mirror of
https://github.com/anyproto/anytype-kotlin.git
synced 2025-06-08 05:47:05 +09:00
DROID-2663 Version history | Ui + logic, part 1 (#1437)
This commit is contained in:
parent
7bd9f99586
commit
8f3af5035e
25 changed files with 1327 additions and 22 deletions
|
@ -50,6 +50,7 @@ import com.anytypeio.anytype.di.feature.ViewerSortModule
|
|||
import com.anytypeio.anytype.di.feature.auth.DaggerDeletedAccountComponent
|
||||
import com.anytypeio.anytype.di.feature.cover.UnsplashModule
|
||||
import com.anytypeio.anytype.di.feature.gallery.DaggerGalleryInstallationComponent
|
||||
import com.anytypeio.anytype.di.feature.history.DaggerVersionHistoryComponent
|
||||
import com.anytypeio.anytype.di.feature.home.DaggerHomeScreenComponent
|
||||
import com.anytypeio.anytype.di.feature.library.DaggerLibraryComponent
|
||||
import com.anytypeio.anytype.di.feature.membership.DaggerMembershipComponent
|
||||
|
@ -100,6 +101,7 @@ import com.anytypeio.anytype.di.feature.widgets.SelectWidgetTypeModule
|
|||
import com.anytypeio.anytype.di.main.MainComponent
|
||||
import com.anytypeio.anytype.gallery_experience.viewmodel.GalleryInstallationViewModel
|
||||
import com.anytypeio.anytype.presentation.editor.EditorViewModel
|
||||
import com.anytypeio.anytype.presentation.history.VersionHistoryViewModel
|
||||
import com.anytypeio.anytype.presentation.library.LibraryViewModel
|
||||
import com.anytypeio.anytype.presentation.multiplayer.RequestJoinSpaceViewModel
|
||||
import com.anytypeio.anytype.presentation.multiplayer.ShareSpaceViewModel
|
||||
|
@ -1033,6 +1035,12 @@ class ComponentManager(
|
|||
.build()
|
||||
}
|
||||
|
||||
val versionHistoryComponent =
|
||||
ComponentMapWithParam { vmParams: VersionHistoryViewModel.VmParams ->
|
||||
DaggerVersionHistoryComponent.factory()
|
||||
.create(findComponentDependencies(), vmParams)
|
||||
}
|
||||
|
||||
class Component<T>(private val builder: () -> T) {
|
||||
|
||||
private var instance: T? = null
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package com.anytypeio.anytype.di.feature.history
|
||||
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.anytypeio.anytype.analytics.base.Analytics
|
||||
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
|
||||
import com.anytypeio.anytype.di.common.ComponentDependencies
|
||||
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
|
||||
import com.anytypeio.anytype.domain.block.repo.BlockRepository
|
||||
import com.anytypeio.anytype.domain.misc.DateProvider
|
||||
import com.anytypeio.anytype.domain.misc.LocaleProvider
|
||||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.presentation.history.VersionHistoryVMFactory
|
||||
import com.anytypeio.anytype.presentation.history.VersionHistoryViewModel
|
||||
import com.anytypeio.anytype.ui.history.VersionHistoryFragment
|
||||
import dagger.Binds
|
||||
import dagger.BindsInstance
|
||||
import dagger.Component
|
||||
import dagger.Module
|
||||
|
||||
@Component(
|
||||
dependencies = [VersionHistoryComponentDependencies::class],
|
||||
modules = [
|
||||
VersionHistoryModule::class,
|
||||
VersionHistoryModule.Declarations::class
|
||||
]
|
||||
)
|
||||
@PerScreen
|
||||
interface VersionHistoryComponent {
|
||||
|
||||
@Component.Factory
|
||||
interface Factory {
|
||||
fun create(
|
||||
dependencies: VersionHistoryComponentDependencies,
|
||||
@BindsInstance vmParams: VersionHistoryViewModel.VmParams
|
||||
): VersionHistoryComponent
|
||||
}
|
||||
|
||||
fun inject(fragment: VersionHistoryFragment)
|
||||
}
|
||||
|
||||
@Module
|
||||
object VersionHistoryModule {
|
||||
|
||||
@Module
|
||||
interface Declarations {
|
||||
|
||||
@PerScreen
|
||||
@Binds
|
||||
fun bindViewModelFactory(
|
||||
factory: VersionHistoryVMFactory
|
||||
): ViewModelProvider.Factory
|
||||
}
|
||||
}
|
||||
|
||||
interface VersionHistoryComponentDependencies : ComponentDependencies {
|
||||
fun analytics(): Analytics
|
||||
fun blockRepository(): BlockRepository
|
||||
fun appCoroutineDispatchers(): AppCoroutineDispatchers
|
||||
fun dateProvider(): DateProvider
|
||||
fun localeProvider(): LocaleProvider
|
||||
fun provideUrlBuilder(): UrlBuilder
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import com.anytypeio.anytype.di.feature.PersonalizationSettingsSubComponent
|
|||
import com.anytypeio.anytype.di.feature.SplashDependencies
|
||||
import com.anytypeio.anytype.di.feature.auth.DeletedAccountDependencies
|
||||
import com.anytypeio.anytype.di.feature.gallery.GalleryInstallationComponentDependencies
|
||||
import com.anytypeio.anytype.di.feature.history.VersionHistoryComponentDependencies
|
||||
import com.anytypeio.anytype.di.feature.home.HomeScreenDependencies
|
||||
import com.anytypeio.anytype.di.feature.library.LibraryDependencies
|
||||
import com.anytypeio.anytype.di.feature.multiplayer.RequestJoinSpaceDependencies
|
||||
|
@ -124,7 +125,8 @@ interface MainComponent :
|
|||
GalleryInstallationComponentDependencies,
|
||||
NotificationDependencies,
|
||||
GlobalSearchDependencies,
|
||||
MembershipUpdateComponentDependencies
|
||||
MembershipUpdateComponentDependencies,
|
||||
VersionHistoryComponentDependencies
|
||||
{
|
||||
|
||||
fun inject(app: AndroidApplication)
|
||||
|
@ -345,4 +347,9 @@ abstract class ComponentDependenciesModule {
|
|||
@IntoMap
|
||||
@ComponentDependenciesKey(MembershipUpdateComponentDependencies::class)
|
||||
abstract fun provideMembershipUpdateComponentDependencies(component: MainComponent): ComponentDependencies
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ComponentDependenciesKey(VersionHistoryComponentDependencies::class)
|
||||
abstract fun provideVersionHistoryComponentDependencies(component: MainComponent): ComponentDependencies
|
||||
}
|
|
@ -33,6 +33,7 @@ import com.anytypeio.anytype.ui.editor.cover.SelectCoverObjectFragment
|
|||
import com.anytypeio.anytype.ui.editor.cover.SelectCoverObjectSetFragment
|
||||
import com.anytypeio.anytype.ui.editor.layout.ObjectLayoutFragment
|
||||
import com.anytypeio.anytype.ui.editor.modals.IconPickerFragmentBase
|
||||
import com.anytypeio.anytype.ui.history.VersionHistoryFragment
|
||||
import com.anytypeio.anytype.ui.linking.BacklinkAction
|
||||
import com.anytypeio.anytype.ui.linking.BacklinkOrAddToObjectFragment
|
||||
import com.anytypeio.anytype.ui.moving.OnMoveToAction
|
||||
|
@ -80,7 +81,7 @@ abstract class ObjectMenuBaseFragment :
|
|||
|
||||
override fun onStart() {
|
||||
click(binding.objectDiagnostics) { vm.onDiagnosticsClicked(ctx = ctx) }
|
||||
click(binding.optionHistory) { vm.onHistoryClicked() }
|
||||
click(binding.optionHistory) { vm.onHistoryClicked(ctx = ctx, space = space) }
|
||||
click(binding.optionLayout) { vm.onLayoutClicked(ctx = ctx, space = space) }
|
||||
click(binding.optionIcon) { vm.onIconClicked(ctx = ctx, space = space) }
|
||||
click(binding.optionRelations) { vm.onRelationsClicked() }
|
||||
|
@ -166,6 +167,18 @@ abstract class ObjectMenuBaseFragment :
|
|||
}
|
||||
startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
|
||||
is ObjectMenuViewModelBase.Command.OpenHistoryScreen -> {
|
||||
runCatching {
|
||||
findNavController().navigate(
|
||||
R.id.versionHistoryScreen,
|
||||
VersionHistoryFragment.args(
|
||||
ctx = ctx,
|
||||
spaceId = space
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
package com.anytypeio.anytype.ui.history
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.anytypeio.anytype.core_models.Id
|
||||
import com.anytypeio.anytype.core_ui.features.history.VersionHistoryScreen
|
||||
import com.anytypeio.anytype.core_utils.ext.argString
|
||||
import com.anytypeio.anytype.core_utils.ext.setupBottomSheetBehavior
|
||||
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
|
||||
import com.anytypeio.anytype.di.common.componentManager
|
||||
import com.anytypeio.anytype.presentation.history.VersionHistoryVMFactory
|
||||
import com.anytypeio.anytype.presentation.history.VersionHistoryViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
class VersionHistoryFragment : BaseBottomSheetComposeFragment() {
|
||||
|
||||
private val ctx get() = argString(CTX_ARG)
|
||||
private val spaceId get() = argString(SPACE_ID_ARG)
|
||||
|
||||
@Inject
|
||||
lateinit var factory: VersionHistoryVMFactory
|
||||
private val vm by viewModels<VersionHistoryViewModel> { factory }
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
VersionHistoryScreen(
|
||||
state = vm.viewState.collectAsStateWithLifecycle().value,
|
||||
onGroupClick = vm::onGroupClicked,
|
||||
onItemClick = vm::onGroupItemClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setupBottomSheetBehavior(74)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
vm.onStart()
|
||||
}
|
||||
|
||||
override fun injectDependencies() {
|
||||
val vmParams = VersionHistoryViewModel.VmParams(
|
||||
objectId = ctx,
|
||||
spaceId = spaceId
|
||||
)
|
||||
componentManager().versionHistoryComponent.get(
|
||||
param = vmParams,
|
||||
key = ctx + spaceId
|
||||
).inject(this)
|
||||
}
|
||||
|
||||
override fun releaseDependencies() {
|
||||
componentManager().versionHistoryComponent.release(ctx + spaceId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CTX_ARG = "anytype.ui.history.ctx_arg"
|
||||
const val SPACE_ID_ARG = "anytype.ui.history.space_id_arg"
|
||||
|
||||
fun args(ctx: Id, spaceId: Id) = bundleOf(
|
||||
CTX_ARG to ctx,
|
||||
SPACE_ID_ARG to spaceId
|
||||
)
|
||||
}
|
||||
}
|
|
@ -68,7 +68,9 @@ import androidx.compose.ui.text.withStyle
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.anytypeio.anytype.R
|
||||
import com.anytypeio.anytype.core_models.ObjectType
|
||||
import com.anytypeio.anytype.core_models.ThemeColor
|
||||
|
@ -666,10 +668,11 @@ fun GlobalSearchObjectIcon(
|
|||
icon: ObjectIcon,
|
||||
modifier: Modifier,
|
||||
iconSize: Dp = 48.dp,
|
||||
onTaskIconClicked: (Boolean) -> Unit = {}
|
||||
onTaskIconClicked: (Boolean) -> Unit = {},
|
||||
fontSize: TextUnit = 28.sp
|
||||
) {
|
||||
when (icon) {
|
||||
is ObjectIcon.Profile.Avatar -> DefaultProfileAvatarIcon(modifier, iconSize, icon)
|
||||
is ObjectIcon.Profile.Avatar -> DefaultProfileAvatarIcon(modifier, iconSize, icon, fontSize)
|
||||
is ObjectIcon.Profile.Image -> defaultProfileIconImage(icon, modifier, iconSize)
|
||||
is ObjectIcon.Basic.Emoji -> DefaultEmojiObjectIcon(modifier, iconSize, icon)
|
||||
is ObjectIcon.Basic.Image -> DefaultObjectImageIcon(icon.hash, modifier, iconSize)
|
||||
|
|
|
@ -276,6 +276,10 @@
|
|||
android:id="@+id/galleryInstallationScreen"
|
||||
android:name="com.anytypeio.anytype.ui.gallery.GalleryInstallationFragment" />
|
||||
|
||||
<dialog
|
||||
android:id="@+id/versionHistoryScreen"
|
||||
android:name="com.anytypeio.anytype.ui.history.VersionHistoryFragment" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/splashScreen"
|
||||
android:name="com.anytypeio.anytype.ui.splash.SplashFragment"
|
||||
|
|
|
@ -549,7 +549,7 @@ sealed class Command {
|
|||
sealed class VersionHistory {
|
||||
data class GetVersions(
|
||||
val objectId: Id,
|
||||
val lastVersion: Id,
|
||||
val lastVersion: Id?,
|
||||
val limit: Int
|
||||
) : VersionHistory()
|
||||
|
||||
|
|
|
@ -3,13 +3,14 @@ package com.anytypeio.anytype.core_models.history
|
|||
import com.anytypeio.anytype.core_models.Event
|
||||
import com.anytypeio.anytype.core_models.Id
|
||||
import com.anytypeio.anytype.core_models.ObjectView
|
||||
import com.anytypeio.anytype.core_models.primitives.TimeInSeconds
|
||||
|
||||
data class Version(
|
||||
val id: Id,
|
||||
val previousIds: List<Id>,
|
||||
val authorId: Id,
|
||||
val authorName: String,
|
||||
val timestamp: Long,
|
||||
val spaceMember: Id,
|
||||
val spaceMemberName: String,
|
||||
val timestamp: TimeInSeconds,
|
||||
val groupId: Long
|
||||
)
|
||||
|
||||
|
|
|
@ -21,4 +21,9 @@ value class RelationId(val id: String)
|
|||
@JvmInline
|
||||
value class RelationKey(val key: String)
|
||||
|
||||
@JvmInline
|
||||
value class TimeInSeconds(val time: Long) {
|
||||
val inMillis get() = time * 1000
|
||||
}
|
||||
|
||||
typealias Space = SpaceId
|
||||
|
|
|
@ -0,0 +1,357 @@
|
|||
package com.anytypeio.anytype.core_ui.features.history
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.anytypeio.anytype.core_models.primitives.TimeInSeconds
|
||||
import com.anytypeio.anytype.core_ui.R
|
||||
import com.anytypeio.anytype.core_ui.foundation.Dragger
|
||||
import com.anytypeio.anytype.core_ui.foundation.Header
|
||||
import com.anytypeio.anytype.core_ui.views.Caption1Regular
|
||||
import com.anytypeio.anytype.core_ui.views.PreviewTitle2Medium
|
||||
import com.anytypeio.anytype.core_ui.views.PreviewTitle2Regular
|
||||
import com.anytypeio.anytype.core_ui.widgets.ListWidgetObjectIcon
|
||||
import com.anytypeio.anytype.presentation.history.VersionHistoryGroup
|
||||
import com.anytypeio.anytype.presentation.history.VersionHistoryState
|
||||
import com.anytypeio.anytype.presentation.objects.ObjectIcon
|
||||
|
||||
@Composable
|
||||
fun VersionHistoryScreen(
|
||||
state: VersionHistoryState,
|
||||
onGroupClick: (VersionHistoryGroup) -> Unit,
|
||||
onItemClick: (VersionHistoryGroup.Item) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(rememberNestedScrollInteropConnection())
|
||||
) {
|
||||
Dragger(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 6.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
Header(text = stringResource(id = R.string.version_history_title))
|
||||
when (state) {
|
||||
is VersionHistoryState.Error.GetVersions -> TODO()
|
||||
VersionHistoryState.Error.NoVersions -> TODO()
|
||||
is VersionHistoryState.Error.SpaceMembers -> TODO()
|
||||
VersionHistoryState.Loading -> VersionHistoryLoading()
|
||||
is VersionHistoryState.Success -> {
|
||||
VersionHistorySuccessState(
|
||||
state = state,
|
||||
onGroupClick = onGroupClick,
|
||||
onItemClick = onItemClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VersionHistoryLoading() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(24.dp),
|
||||
color = colorResource(R.color.shape_secondary),
|
||||
trackColor = colorResource(R.color.shape_primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VersionHistorySuccessState(
|
||||
state: VersionHistoryState.Success,
|
||||
onGroupClick: (VersionHistoryGroup) -> Unit,
|
||||
onItemClick: (VersionHistoryGroup.Item) -> Unit
|
||||
) {
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 20.dp,
|
||||
top = 0.dp,
|
||||
end = 20.dp,
|
||||
bottom = 56.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(
|
||||
count = state.groups.size,
|
||||
key = { idx -> state.groups[idx].id }
|
||||
) { idx ->
|
||||
val group = state.groups[idx]
|
||||
if (group.isExpanded) {
|
||||
GroupItemExpanded(
|
||||
group = group,
|
||||
onGroupClick = onGroupClick,
|
||||
onItemClick = onItemClick
|
||||
)
|
||||
} else {
|
||||
GroupItemCollapsed(group = group, onGroupClick = onGroupClick)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupItemExpanded(
|
||||
group: VersionHistoryGroup,
|
||||
onGroupClick: (VersionHistoryGroup) -> Unit,
|
||||
onItemClick: (VersionHistoryGroup.Item) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors().copy(
|
||||
containerColor = colorResource(id = R.color.background_secondary),
|
||||
),
|
||||
border = BorderStroke(
|
||||
width = 0.5.dp, color = colorResource(id = R.color.shape_primary)
|
||||
),
|
||||
onClick = { onGroupClick(group) }
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, top = 12.dp, bottom = 4.dp)
|
||||
.wrapContentSize(),
|
||||
text = group.title,
|
||||
style = PreviewTitle2Regular,
|
||||
color = colorResource(id = R.color.text_secondary)
|
||||
)
|
||||
group.items.forEach {
|
||||
GroupItem(item = it, onItemClick = onItemClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupItem(
|
||||
item: VersionHistoryGroup.Item,
|
||||
onItemClick: (VersionHistoryGroup.Item) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.clickable { onItemClick(item) },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = item.timeFormatted,
|
||||
style = PreviewTitle2Medium,
|
||||
color = colorResource(id = R.color.text_primary)
|
||||
)
|
||||
Text(
|
||||
text = item.spaceMemberName,
|
||||
style = Caption1Regular,
|
||||
color = colorResource(id = R.color.text_secondary)
|
||||
)
|
||||
}
|
||||
if (item.icon != null) {
|
||||
ListWidgetObjectIcon(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
icon = item.icon!!,
|
||||
iconSize = 24.dp,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupItemCollapsed(
|
||||
group: VersionHistoryGroup,
|
||||
onGroupClick: (VersionHistoryGroup) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors().copy(
|
||||
containerColor = colorResource(id = R.color.background_secondary),
|
||||
),
|
||||
border = BorderStroke(
|
||||
width = 0.5.dp, color = colorResource(id = R.color.shape_primary)
|
||||
),
|
||||
onClick = { onGroupClick(group) }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.wrapContentSize(),
|
||||
text = group.title,
|
||||
style = PreviewTitle2Regular,
|
||||
color = colorResource(id = R.color.text_secondary)
|
||||
)
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
space = (-4).dp,
|
||||
alignment = Alignment.End
|
||||
)
|
||||
) {
|
||||
items(
|
||||
count = group.icons.size,
|
||||
) { idx ->
|
||||
val icon = group.icons[idx]
|
||||
ListWidgetObjectIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
icon = icon,
|
||||
iconSize = 24.dp,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(
|
||||
showBackground = true,
|
||||
backgroundColor = 0xFFFFFF,
|
||||
uiMode = Configuration.UI_MODE_NIGHT_NO,
|
||||
name = "Light Mode"
|
||||
)
|
||||
@Preview(
|
||||
showBackground = true,
|
||||
backgroundColor = 0x000000,
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
private fun SpaceListScreenPreview() {
|
||||
VersionHistoryScreen(
|
||||
state = VersionHistoryState.Success(
|
||||
groups = listOf(
|
||||
VersionHistoryGroup(
|
||||
id = "1",
|
||||
title = "Today",
|
||||
isExpanded = true,
|
||||
items = listOf(
|
||||
VersionHistoryGroup.Item(
|
||||
id = "1",
|
||||
timeFormatted = "12:00 PM",
|
||||
spaceMemberName = "John Doe",
|
||||
icon = ObjectIcon.Profile.Avatar("A"),
|
||||
spaceMember = "1",
|
||||
timeStamp = TimeInSeconds(23423423L),
|
||||
versions = emptyList()
|
||||
|
||||
),
|
||||
VersionHistoryGroup.Item(
|
||||
id = "2",
|
||||
timeFormatted = "12:10 PM",
|
||||
spaceMemberName = "Alice Doe",
|
||||
icon = ObjectIcon.Profile.Avatar("B"),
|
||||
spaceMember = "1",
|
||||
timeStamp = TimeInSeconds(23423423L),
|
||||
versions = emptyList()
|
||||
),
|
||||
VersionHistoryGroup.Item(
|
||||
id = "3",
|
||||
timeFormatted = "12:20 PM",
|
||||
spaceMemberName = "Bob Doe",
|
||||
icon = ObjectIcon.Profile.Avatar("C"),
|
||||
spaceMember = "1",
|
||||
timeStamp = TimeInSeconds(23423423L),
|
||||
versions = emptyList()
|
||||
),
|
||||
),
|
||||
icons = listOf(ObjectIcon.Profile.Avatar("A"), ObjectIcon.Profile.Avatar("B"))
|
||||
),
|
||||
VersionHistoryGroup(
|
||||
id = "2",
|
||||
title = "Yesterday",
|
||||
items = emptyList(),
|
||||
icons = listOf(ObjectIcon.Profile.Avatar("C"), ObjectIcon.Profile.Avatar("D"))
|
||||
),
|
||||
VersionHistoryGroup(
|
||||
id = "3",
|
||||
title = "Yesterday",
|
||||
items = emptyList(),
|
||||
icons = listOf(ObjectIcon.Profile.Avatar("C"), ObjectIcon.Profile.Avatar("D"))
|
||||
)
|
||||
)
|
||||
),
|
||||
onGroupClick = {},
|
||||
onItemClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(
|
||||
showBackground = true,
|
||||
backgroundColor = 0xFFFFFF,
|
||||
uiMode = Configuration.UI_MODE_NIGHT_NO,
|
||||
name = "Light Mode"
|
||||
)
|
||||
@Preview(
|
||||
showBackground = true,
|
||||
backgroundColor = 0x000000,
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
private fun SpaceListScreenPreviewLoading() {
|
||||
VersionHistoryScreen(
|
||||
state = VersionHistoryState.Loading,
|
||||
onGroupClick = {},
|
||||
onItemClick = {}
|
||||
)
|
||||
}
|
|
@ -3,7 +3,6 @@ package com.anytypeio.anytype.core_ui.widgets
|
|||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
@ -19,6 +18,7 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
@ -34,10 +34,11 @@ fun ListWidgetObjectIcon(
|
|||
icon: ObjectIcon,
|
||||
modifier: Modifier,
|
||||
iconSize: Dp = 48.dp,
|
||||
onTaskIconClicked: (Boolean) -> Unit = {}
|
||||
onTaskIconClicked: (Boolean) -> Unit = {},
|
||||
fontSize: TextUnit = 28.sp
|
||||
) {
|
||||
when (icon) {
|
||||
is ObjectIcon.Profile.Avatar -> DefaultProfileAvatarIcon(modifier, iconSize, icon)
|
||||
is ObjectIcon.Profile.Avatar -> DefaultProfileAvatarIcon(modifier, iconSize, icon, fontSize)
|
||||
is ObjectIcon.Profile.Image -> defaultProfileIconImage(icon, modifier, iconSize)
|
||||
is ObjectIcon.Basic.Emoji -> DefaultEmojiObjectIcon(modifier, iconSize, icon)
|
||||
is ObjectIcon.Basic.Image -> DefaultObjectImageIcon(icon.hash, modifier, iconSize)
|
||||
|
@ -119,7 +120,8 @@ fun DefaultObjectBookmarkIcon(
|
|||
fun DefaultProfileAvatarIcon(
|
||||
modifier: Modifier,
|
||||
iconSize: Dp,
|
||||
icon: ObjectIcon.Profile.Avatar
|
||||
icon: ObjectIcon.Profile.Avatar,
|
||||
fontSize: TextUnit
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
@ -137,7 +139,7 @@ fun DefaultProfileAvatarIcon(
|
|||
.uppercase(),
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
style = TextStyle(
|
||||
fontSize = 28.sp,
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorResource(id = R.color.text_white)
|
||||
)
|
||||
|
|
|
@ -24,7 +24,7 @@ class GetVersions @Inject constructor(
|
|||
|
||||
data class Params(
|
||||
val objectId: Id,
|
||||
val lastVersion: Id,
|
||||
val limit: Int
|
||||
val lastVersion: Id? = null,
|
||||
val limit: Int = 200
|
||||
)
|
||||
}
|
|
@ -2,6 +2,7 @@ package com.anytypeio.anytype.domain.misc
|
|||
|
||||
import com.anytypeio.anytype.core_models.TimeInMillis
|
||||
import com.anytypeio.anytype.core_models.TimeInSeconds
|
||||
import java.text.DateFormat
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
|
||||
|
@ -21,6 +22,12 @@ interface DateProvider {
|
|||
fun adjustToStartOfDayInUserTimeZone(timestamp: TimeInSeconds): TimeInMillis
|
||||
fun adjustFromStartOfDayInUserTimeZoneToUTC(timestamp: TimeInMillis, zoneId: ZoneId): TimeInSeconds
|
||||
fun formatToDateString(timestamp: Long, pattern: String, locale: Locale): String
|
||||
fun formatTimestampToDateAndTime(
|
||||
timestamp: TimeInMillis,
|
||||
locale: Locale,
|
||||
dateStyle: Int = DateFormat.MEDIUM,
|
||||
timeStyle: Int = DateFormat.SHORT
|
||||
): Pair<String, String>
|
||||
}
|
||||
|
||||
interface DateTypeNameProvider {
|
||||
|
|
|
@ -1733,4 +1733,6 @@ Please provide specific details of your needs here.</string>
|
|||
<string name="sync_status_network_error">No access to the space</string>
|
||||
<string name="sync_status_unrecognized">Unrecognized error</string>
|
||||
|
||||
<string name="version_history_title">Version history</string>
|
||||
|
||||
</resources>
|
|
@ -2707,7 +2707,7 @@ class Middleware @Inject constructor(
|
|||
@Throws
|
||||
fun getVersions(command: Command.VersionHistory.GetVersions): List<Version> {
|
||||
val request = Rpc.History.GetVersions.Request(
|
||||
lastVersionId = command.lastVersion,
|
||||
lastVersionId = command.lastVersion.orEmpty(),
|
||||
objectId = command.objectId,
|
||||
limit = command.limit
|
||||
|
||||
|
|
|
@ -59,6 +59,7 @@ import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncError
|
|||
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncNetwork
|
||||
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncStatus
|
||||
import com.anytypeio.anytype.core_models.primitives.SpaceId
|
||||
import com.anytypeio.anytype.core_models.primitives.TimeInSeconds
|
||||
import com.anytypeio.anytype.core_models.restrictions.DataViewRestriction
|
||||
import com.anytypeio.anytype.core_models.restrictions.DataViewRestrictions
|
||||
import com.anytypeio.anytype.core_models.restrictions.ObjectRestriction
|
||||
|
@ -1105,9 +1106,9 @@ fun Rpc.History.Version.toCoreModel(): Version {
|
|||
return Version(
|
||||
id = id,
|
||||
previousIds = previousIds,
|
||||
authorId = authorId,
|
||||
authorName = authorName,
|
||||
timestamp = time,
|
||||
spaceMember = authorId,
|
||||
spaceMemberName = authorName,
|
||||
timestamp = TimeInSeconds(time),
|
||||
groupId = groupId
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
package com.anytypeio.anytype.presentation.history
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.anytypeio.anytype.analytics.base.Analytics
|
||||
import com.anytypeio.anytype.domain.history.GetVersions
|
||||
import com.anytypeio.anytype.domain.misc.DateProvider
|
||||
import com.anytypeio.anytype.domain.misc.LocaleProvider
|
||||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.domain.search.SearchObjects
|
||||
import com.anytypeio.anytype.presentation.history.VersionHistoryViewModel.VmParams
|
||||
import javax.inject.Inject
|
||||
|
||||
class VersionHistoryVMFactory @Inject constructor(
|
||||
private val vmParams: VmParams,
|
||||
private val getVersions: GetVersions,
|
||||
private val objectSearch: SearchObjects,
|
||||
private val dateProvider: DateProvider,
|
||||
private val localeProvider: LocaleProvider,
|
||||
private val analytics: Analytics,
|
||||
private val urlBuilder: UrlBuilder
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return VersionHistoryViewModel(
|
||||
vmParams = vmParams,
|
||||
getVersions = getVersions,
|
||||
objectSearch = objectSearch,
|
||||
dateProvider = dateProvider,
|
||||
localeProvider = localeProvider,
|
||||
analytics = analytics,
|
||||
urlBuilder = urlBuilder
|
||||
) as T
|
||||
}
|
||||
}
|
|
@ -0,0 +1,252 @@
|
|||
package com.anytypeio.anytype.presentation.history
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.anytypeio.anytype.analytics.base.Analytics
|
||||
import com.anytypeio.anytype.core_models.Id
|
||||
import com.anytypeio.anytype.core_models.ObjectWrapper
|
||||
import com.anytypeio.anytype.core_models.history.Version
|
||||
import com.anytypeio.anytype.core_models.primitives.TimeInSeconds
|
||||
import com.anytypeio.anytype.domain.base.fold
|
||||
import com.anytypeio.anytype.domain.history.GetVersions
|
||||
import com.anytypeio.anytype.domain.misc.DateProvider
|
||||
import com.anytypeio.anytype.domain.misc.LocaleProvider
|
||||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.domain.search.SearchObjects
|
||||
import com.anytypeio.anytype.presentation.objects.ObjectIcon
|
||||
import com.anytypeio.anytype.presentation.search.ObjectSearchConstants
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class VersionHistoryViewModel(
|
||||
private val vmParams: VmParams,
|
||||
private val analytics: Analytics,
|
||||
private val getVersions: GetVersions,
|
||||
private val objectSearch: SearchObjects,
|
||||
private val dateProvider: DateProvider,
|
||||
private val localeProvider: LocaleProvider,
|
||||
private val urlBuilder: UrlBuilder
|
||||
|
||||
) : ViewModel() {
|
||||
|
||||
private val _viewState = MutableStateFlow<VersionHistoryState>(VersionHistoryState.Loading)
|
||||
val viewState = _viewState
|
||||
|
||||
init {
|
||||
Timber.d("VersionHistoryViewModel created")
|
||||
getSpaceMembers()
|
||||
}
|
||||
|
||||
fun onStart() {
|
||||
Timber.d("VersionHistoryViewModel started")
|
||||
}
|
||||
|
||||
fun onGroupClicked(group: VersionHistoryGroup) {
|
||||
val expanded = group.isExpanded
|
||||
val newGroup = group.copy(isExpanded = !expanded)
|
||||
val newGroups = viewState.value.let { state ->
|
||||
if (state is VersionHistoryState.Success) {
|
||||
state.groups.map { if (it.id == group.id) newGroup else it }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
_viewState.value = VersionHistoryState.Success(newGroups)
|
||||
}
|
||||
|
||||
fun onGroupItemClicked(item: VersionHistoryGroup.Item) {
|
||||
|
||||
}
|
||||
|
||||
private fun getSpaceMembers() {
|
||||
viewModelScope.launch {
|
||||
val filters =
|
||||
ObjectSearchConstants.filterParticipants(spaces = listOf(vmParams.spaceId))
|
||||
objectSearch(
|
||||
SearchObjects.Params(
|
||||
filters = filters,
|
||||
keys = ObjectSearchConstants.spaceMemberKeys
|
||||
)
|
||||
).process(
|
||||
failure = {
|
||||
Timber.e(it, "Error while fetching new member")
|
||||
_viewState.value = VersionHistoryState.Error.SpaceMembers(it.message.orEmpty())
|
||||
},
|
||||
success = { members ->
|
||||
if (members.isEmpty()) {
|
||||
_viewState.value =
|
||||
VersionHistoryState.Error.SpaceMembers("No members found")
|
||||
} else {
|
||||
getHistoryVersions(vmParams.objectId, members)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getHistoryVersions(objectId: String, members: List<ObjectWrapper.Basic>) {
|
||||
viewModelScope.launch {
|
||||
val params = GetVersions.Params(objectId = objectId)
|
||||
getVersions.async(params).fold(
|
||||
onSuccess = { versions ->
|
||||
if (versions.isEmpty()) {
|
||||
_viewState.value = VersionHistoryState.Error.NoVersions
|
||||
} else {
|
||||
handleVersionsSuccess(versions, members)
|
||||
}
|
||||
},
|
||||
onFailure = {
|
||||
_viewState.value = VersionHistoryState.Error.GetVersions(it.message.orEmpty())
|
||||
},
|
||||
onLoading = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVersionsSuccess(versions: List<Version>, members: List<ObjectWrapper.Basic>) {
|
||||
val groupedItems = groupItems(versions, members)
|
||||
_viewState.value = VersionHistoryState.Success(groups = groupedItems)
|
||||
}
|
||||
|
||||
private fun groupItems(
|
||||
versions: List<Version>,
|
||||
spaceMembers: List<ObjectWrapper.Basic>,
|
||||
): List<VersionHistoryGroup> {
|
||||
|
||||
val locale = localeProvider.locale()
|
||||
|
||||
// Group by day
|
||||
val versionsByDay = versions.groupBy { version ->
|
||||
val formattedDate = dateProvider.formatToDateString(
|
||||
timestamp = (version.timestamp.inMillis),
|
||||
pattern = GROUP_BY_DAY_FORMAT,
|
||||
locale = locale
|
||||
)
|
||||
formattedDate
|
||||
}
|
||||
|
||||
// Sort days descending
|
||||
val sortedDays = versionsByDay.keys.sortedBy { it }
|
||||
|
||||
// Within each day, sort all versions by timestamp descending
|
||||
val sortedVersionsByDay = sortedDays.associateWith { day ->
|
||||
val versionByDay = versionsByDay[day] ?: return@associateWith emptyList()
|
||||
versionByDay.sortedByDescending { it.timestamp.time }
|
||||
}
|
||||
|
||||
// Group by space member sequentially within each day
|
||||
val groupedBySpaceMember = sortedVersionsByDay.mapValues { (_, versions) ->
|
||||
val grouped = mutableListOf<MutableList<Version>>()
|
||||
var currentGroup = mutableListOf<Version>()
|
||||
|
||||
for (version in versions) {
|
||||
if (currentGroup.isEmpty() || currentGroup.last().spaceMember == version.spaceMember) {
|
||||
currentGroup.add(version)
|
||||
} else {
|
||||
grouped.add(currentGroup)
|
||||
currentGroup = mutableListOf(version)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentGroup.isNotEmpty()) {
|
||||
grouped.add(currentGroup)
|
||||
}
|
||||
|
||||
grouped
|
||||
}
|
||||
|
||||
val groups = groupedBySpaceMember.mapNotNull { (_, spaceMemberVersions) ->
|
||||
if (spaceMemberVersions.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val spaceMemberLatestVersion =
|
||||
spaceMemberVersions.firstOrNull()?.firstOrNull() ?: return@mapNotNull null
|
||||
val (latestVersionDate, latestVersionTime) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = spaceMemberLatestVersion.timestamp.inMillis,
|
||||
locale = locale
|
||||
)
|
||||
val groupItems = spaceMemberVersions.toGroupItems(
|
||||
spaceMembers = spaceMembers,
|
||||
latestVersionTime = latestVersionTime
|
||||
)
|
||||
|
||||
VersionHistoryGroup(
|
||||
id = spaceMemberLatestVersion.id,
|
||||
title = latestVersionDate,
|
||||
icons = groupItems.mapNotNull { it.icon },
|
||||
items = groupItems
|
||||
)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
private fun List<List<Version>>.toGroupItems(
|
||||
spaceMembers: List<ObjectWrapper.Basic>,
|
||||
latestVersionTime: String
|
||||
): List<VersionHistoryGroup.Item> {
|
||||
return mapNotNull { versions ->
|
||||
val latestVersion = versions.firstOrNull() ?: return@mapNotNull null
|
||||
val spaceMemberId = latestVersion.spaceMember
|
||||
val spaceMember = spaceMembers.find { it.id == spaceMemberId }
|
||||
?: return@mapNotNull null
|
||||
|
||||
val icon = ObjectIcon.from(
|
||||
obj = spaceMember,
|
||||
layout = spaceMember.layout,
|
||||
builder = urlBuilder
|
||||
)
|
||||
|
||||
VersionHistoryGroup.Item(
|
||||
id = latestVersion.id,
|
||||
spaceMember = spaceMemberId,
|
||||
spaceMemberName = spaceMember.name.orEmpty(),
|
||||
timeStamp = latestVersion.timestamp,
|
||||
icon = icon,
|
||||
timeFormatted = latestVersionTime,
|
||||
versions = versions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class VmParams(
|
||||
val objectId: Id,
|
||||
val spaceId: Id
|
||||
)
|
||||
|
||||
sealed class Command {
|
||||
data class OpenVersion(val versionId: Id) : Command()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val GROUP_BY_DAY_FORMAT = "d MM yyyy"
|
||||
}
|
||||
}
|
||||
|
||||
sealed class VersionHistoryState {
|
||||
data object Loading : VersionHistoryState()
|
||||
data class Success(val groups: List<VersionHistoryGroup>) : VersionHistoryState()
|
||||
sealed class Error : VersionHistoryState() {
|
||||
data class SpaceMembers(val message: String) : Error()
|
||||
data class GetVersions(val message: String) : Error()
|
||||
data object NoVersions : Error()
|
||||
}
|
||||
}
|
||||
|
||||
data class VersionHistoryGroup(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val icons: List<ObjectIcon>,
|
||||
val items: List<Item>,
|
||||
val isExpanded: Boolean = false
|
||||
) {
|
||||
data class Item(
|
||||
val id: Id,
|
||||
val spaceMember: Id,
|
||||
val spaceMemberName: String,
|
||||
val timeFormatted: String,
|
||||
val timeStamp: TimeInSeconds,
|
||||
val icon: ObjectIcon?,
|
||||
val versions: List<Version>
|
||||
)
|
||||
}
|
|
@ -87,8 +87,10 @@ abstract class ObjectMenuViewModelBase(
|
|||
abstract fun onLayoutClicked(ctx: Id, space: Id)
|
||||
abstract fun onRelationsClicked()
|
||||
|
||||
fun onHistoryClicked() {
|
||||
throw IllegalStateException("History isn't supported yet")
|
||||
fun onHistoryClicked(ctx: Id, space: Id) {
|
||||
viewModelScope.launch {
|
||||
commands.emit(Command.OpenHistoryScreen(ctx, space))
|
||||
}
|
||||
}
|
||||
|
||||
fun onStop() {
|
||||
|
@ -435,6 +437,7 @@ abstract class ObjectMenuViewModelBase(
|
|||
data class ShareDeeplinkToObject(val link: String): Command()
|
||||
data class ShareDebugTree(val uri: Uri) : Command()
|
||||
data class ShareDebugGoroutines(val path: String) : Command()
|
||||
data class OpenHistoryScreen(val objectId: Id, val spaceId: Id) : Command()
|
||||
data class OpenSnackbar(
|
||||
val id: Id,
|
||||
val space: Id,
|
||||
|
|
|
@ -1200,6 +1200,7 @@ object ObjectSearchConstants {
|
|||
Relations.ICON_IMAGE,
|
||||
Relations.PARTICIPANT_STATUS,
|
||||
Relations.PARTICIPANT_PERMISSIONS,
|
||||
Relations.LAYOUT
|
||||
)
|
||||
|
||||
val spaceViewKeys = listOf(
|
||||
|
|
|
@ -3,8 +3,10 @@ package com.anytypeio.anytype.presentation.widgets.collection
|
|||
import android.text.format.DateUtils
|
||||
import com.anytypeio.anytype.core_models.TimeInMillis
|
||||
import com.anytypeio.anytype.core_models.TimeInSeconds
|
||||
import com.anytypeio.anytype.core_utils.date.Milliseconds
|
||||
import com.anytypeio.anytype.domain.misc.DateProvider
|
||||
import com.anytypeio.anytype.domain.misc.DateType
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
@ -136,6 +138,27 @@ class DateProviderImpl @Inject constructor() : DateProvider {
|
|||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun formatTimestampToDateAndTime(
|
||||
timestamp: TimeInMillis,
|
||||
locale: Locale,
|
||||
dateStyle: Int,
|
||||
timeStyle: Int
|
||||
): Pair<String, String> {
|
||||
return try {
|
||||
val datePattern = (DateFormat.getDateInstance(dateStyle, locale) as SimpleDateFormat).toPattern()
|
||||
val timePattern = (DateFormat.getTimeInstance(timeStyle, locale) as SimpleDateFormat).toPattern()
|
||||
val dateFormatter = SimpleDateFormat(datePattern, locale)
|
||||
val timeFormatter = SimpleDateFormat(timePattern, locale)
|
||||
val date = Date(timestamp)
|
||||
val dateString = dateFormatter.format(date)
|
||||
val timeString = timeFormatter.format(date)
|
||||
Pair(dateString, timeString)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error formatting timestamp to date and time string")
|
||||
Pair("", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
package com.anytypeio.anytype.presentation.history
|
||||
|
||||
import com.anytypeio.anytype.core_models.Event
|
||||
import com.anytypeio.anytype.core_models.Id
|
||||
import com.anytypeio.anytype.core_models.ObjectView
|
||||
import com.anytypeio.anytype.core_models.history.DiffVersionResponse
|
||||
import com.anytypeio.anytype.core_models.history.ShowVersionResponse
|
||||
import com.anytypeio.anytype.core_models.history.Version
|
||||
import com.anytypeio.anytype.core_models.primitives.TimeInSeconds
|
||||
import kotlin.random.Random
|
||||
import net.bytebuddy.utility.RandomString
|
||||
|
||||
fun StubVersion(
|
||||
id: Id = "versionId - ${RandomString.make()}",
|
||||
previousIds: List<Id> = emptyList(),
|
||||
authorId: Id = "authorId - ${RandomString.make()}",
|
||||
authorName: String = "",
|
||||
timestamp: TimeInSeconds,
|
||||
groupId: Long = Random(100).nextLong()
|
||||
): Version {
|
||||
return Version(
|
||||
id = id,
|
||||
previousIds = previousIds,
|
||||
spaceMember = authorId,
|
||||
spaceMemberName = authorName,
|
||||
timestamp = timestamp,
|
||||
groupId = groupId
|
||||
)
|
||||
}
|
||||
|
||||
fun StubShowVersionResponse(
|
||||
objectView: ObjectView?,
|
||||
version: Version?,
|
||||
traceId: Id
|
||||
): ShowVersionResponse {
|
||||
return ShowVersionResponse(
|
||||
objectView = objectView,
|
||||
version = version,
|
||||
traceId = traceId
|
||||
)
|
||||
}
|
||||
|
||||
fun StubDiffVersionResponse(
|
||||
historyEvents: List<Event.Command>,
|
||||
objectView: ObjectView?
|
||||
): DiffVersionResponse {
|
||||
return DiffVersionResponse(
|
||||
historyEvents = historyEvents,
|
||||
objectView = objectView
|
||||
)
|
||||
}
|
|
@ -0,0 +1,360 @@
|
|||
package com.anytypeio.anytype.presentation.history
|
||||
|
||||
import android.util.Log
|
||||
import app.cash.turbine.turbineScope
|
||||
import com.anytypeio.anytype.analytics.base.Analytics
|
||||
import com.anytypeio.anytype.core_models.StubSpaceMember
|
||||
import com.anytypeio.anytype.core_models.history.Version
|
||||
import com.anytypeio.anytype.core_models.multiplayer.ParticipantStatus
|
||||
import com.anytypeio.anytype.core_models.multiplayer.SpaceMemberPermissions
|
||||
import com.anytypeio.anytype.core_models.primitives.TimeInSeconds
|
||||
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
|
||||
import com.anytypeio.anytype.domain.base.Either
|
||||
import com.anytypeio.anytype.domain.base.Resultat
|
||||
import com.anytypeio.anytype.domain.history.GetVersions
|
||||
import com.anytypeio.anytype.domain.misc.DateProvider
|
||||
import com.anytypeio.anytype.domain.misc.LocaleProvider
|
||||
import com.anytypeio.anytype.domain.misc.UrlBuilder
|
||||
import com.anytypeio.anytype.domain.search.SearchObjects
|
||||
import com.anytypeio.anytype.domain.workspace.SpaceManager
|
||||
import com.anytypeio.anytype.presentation.objects.ObjectIcon
|
||||
import com.anytypeio.anytype.presentation.search.ObjectSearchConstants
|
||||
import com.anytypeio.anytype.presentation.widgets.collection.DateProviderImpl
|
||||
import java.util.Locale
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestCoroutineScheduler
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import net.bytebuddy.utility.RandomString
|
||||
import net.lachlanmckee.timberjunit.TimberTestRule
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mockito.Mock
|
||||
import org.mockito.MockitoAnnotations
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stub
|
||||
|
||||
class VersionHistoryViewModelTest {
|
||||
|
||||
private val objectId = "objectId-${RandomString.make()}"
|
||||
private val spaceId = "spaceId-${RandomString.make()}"
|
||||
private val vmParams = VersionHistoryViewModel.VmParams(
|
||||
objectId = objectId,
|
||||
spaceId = "spaceId-${RandomString.make()}"
|
||||
)
|
||||
|
||||
private val dispatcher = StandardTestDispatcher(TestCoroutineScheduler())
|
||||
|
||||
@get:Rule
|
||||
val timberTestRule: TimberTestRule = TimberTestRule.builder()
|
||||
.minPriority(Log.DEBUG)
|
||||
.showThread(true)
|
||||
.showTimestamp(false)
|
||||
.onlyLogWhenTestFails(false)
|
||||
.build()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val dispatchers = AppCoroutineDispatchers(
|
||||
io = dispatcher,
|
||||
main = dispatcher,
|
||||
computation = dispatcher
|
||||
).also { Dispatchers.setMain(dispatcher) }
|
||||
|
||||
@Mock
|
||||
lateinit var analytics: Analytics
|
||||
|
||||
@Mock
|
||||
lateinit var getVersions: GetVersions
|
||||
|
||||
@Mock
|
||||
lateinit var urlBuilder: UrlBuilder
|
||||
|
||||
@Mock
|
||||
lateinit var objectSearch: SearchObjects
|
||||
|
||||
private val dateProvider: DateProvider = DateProviderImpl()
|
||||
|
||||
@Mock
|
||||
lateinit var localeProvider: LocaleProvider
|
||||
|
||||
lateinit var spaceManager: SpaceManager
|
||||
|
||||
lateinit var vm: VersionHistoryViewModel
|
||||
|
||||
private val user1 = StubSpaceMember(
|
||||
space = spaceId,
|
||||
name = "user1",
|
||||
iconEmoji = "👩🎤",
|
||||
identity = "identity1",
|
||||
memberStatus = ParticipantStatus.ACTIVE,
|
||||
memberPermissions = listOf(SpaceMemberPermissions.OWNER)
|
||||
)
|
||||
|
||||
private val user2 = StubSpaceMember(
|
||||
space = spaceId,
|
||||
name = "user2",
|
||||
iconEmoji = "👨🎤",
|
||||
identity = "identity2",
|
||||
memberStatus = ParticipantStatus.ACTIVE,
|
||||
memberPermissions = listOf(SpaceMemberPermissions.WRITER)
|
||||
)
|
||||
|
||||
private val user3 = StubSpaceMember(
|
||||
space = spaceId,
|
||||
name = "user3",
|
||||
iconEmoji = "👩🎤",
|
||||
identity = "identity3",
|
||||
memberStatus = ParticipantStatus.ACTIVE,
|
||||
memberPermissions = listOf(SpaceMemberPermissions.READER)
|
||||
)
|
||||
|
||||
private val user4 = StubSpaceMember(
|
||||
space = spaceId,
|
||||
name = "user4",
|
||||
iconEmoji = "👩🎤",
|
||||
identity = "identity4",
|
||||
memberStatus = ParticipantStatus.REMOVED,
|
||||
memberPermissions = listOf(SpaceMemberPermissions.READER)
|
||||
)
|
||||
|
||||
/**
|
||||
* Timeline, GMT+0200
|
||||
* Start : Sat Jan 01 2022 00:00:03 GMT+0100 1640991603 User1
|
||||
* Sat Jan 01 2022 00:00:02 GMT+0100 1640991602 User4
|
||||
* Sat Jan 01 2022 00:00:01 GMT+0100 1640991601 User3
|
||||
* Sat Jan 01 2022 00:00:00 GMT+0100 1640991600 User1
|
||||
* Fri Dec 31 2021 23:59:59 GMT+0100 1640991599 User1
|
||||
* Fri Dec 31 2021 23:59:58 GMT+0100 1640991598 User1
|
||||
* Fri Dec 31 2021 23:59:57 GMT+0100 1640991597 User2
|
||||
*/
|
||||
|
||||
private val timestamp0 = TimeInSeconds(1640991603L)
|
||||
private val timestamp1 = TimeInSeconds(1640991602L)
|
||||
private val timestamp2 = TimeInSeconds(1640991601L)
|
||||
private val timestamp3 = TimeInSeconds(1640991600L)
|
||||
private val timestamp4 = TimeInSeconds(1640991599L)
|
||||
private val timestamp5 = TimeInSeconds(1640991598L)
|
||||
private val timestamp6 = TimeInSeconds(1640991597L)
|
||||
|
||||
private val versions = listOf(
|
||||
StubVersion(
|
||||
authorId = user1.id,
|
||||
timestamp = timestamp0
|
||||
),
|
||||
StubVersion(
|
||||
authorId = user4.id,
|
||||
timestamp = timestamp1
|
||||
),
|
||||
StubVersion(
|
||||
authorId = user3.id,
|
||||
timestamp = timestamp2
|
||||
),
|
||||
StubVersion(
|
||||
authorId = user1.id,
|
||||
timestamp = timestamp3
|
||||
),
|
||||
StubVersion(
|
||||
authorId = user1.id,
|
||||
timestamp = timestamp4
|
||||
),
|
||||
StubVersion(
|
||||
authorId = user1.id,
|
||||
timestamp = timestamp5
|
||||
),
|
||||
StubVersion(
|
||||
authorId = user2.id,
|
||||
timestamp = timestamp6
|
||||
)
|
||||
)
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
MockitoAnnotations.openMocks(this)
|
||||
spaceManager = mock(verboseLogging = true)
|
||||
stubLocale(locale = Locale.CANADA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should has proper date`() = runTest {
|
||||
turbineScope {
|
||||
|
||||
stubVersions(stubbedVersions = versions)
|
||||
stubSpaceMembers()
|
||||
vm = buildViewModel()
|
||||
|
||||
val locale = localeProvider.locale()
|
||||
|
||||
val (date0, time0) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = versions[0].timestamp.inMillis,
|
||||
locale = locale
|
||||
)
|
||||
val (date1, time1) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = versions[1].timestamp.inMillis,
|
||||
locale = locale
|
||||
)
|
||||
val (date2, time2) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = versions[2].timestamp.inMillis,
|
||||
locale = locale
|
||||
)
|
||||
val (date3, time3) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = versions[3].timestamp.inMillis,
|
||||
locale = locale
|
||||
)
|
||||
val (date4, time4) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = versions[4].timestamp.inMillis,
|
||||
locale = locale
|
||||
)
|
||||
val (date5, time5) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = versions[5].timestamp.inMillis,
|
||||
locale = locale
|
||||
)
|
||||
val (date6, time6) = dateProvider.formatTimestampToDateAndTime(
|
||||
timestamp = versions[6].timestamp.inMillis,
|
||||
locale = locale
|
||||
)
|
||||
|
||||
val viewStateFlow = vm.viewState.testIn(backgroundScope)
|
||||
|
||||
assertIs<VersionHistoryState.Loading>(viewStateFlow.awaitItem())
|
||||
|
||||
val expected = VersionHistoryState.Success(
|
||||
groups = buildList {
|
||||
add(
|
||||
VersionHistoryGroup(
|
||||
id = versions[0].id,
|
||||
title = date0,
|
||||
icons = listOf(ObjectIcon.None, ObjectIcon.None, ObjectIcon.None, ObjectIcon.None),
|
||||
items = buildList {
|
||||
add(
|
||||
VersionHistoryGroup.Item(
|
||||
id = versions[0].id,
|
||||
spaceMember = user1.id,
|
||||
spaceMemberName = user1.name!!,
|
||||
timeStamp = versions[0].timestamp,
|
||||
icon = ObjectIcon.None,
|
||||
versions = listOf(versions[0]),
|
||||
timeFormatted = time0
|
||||
)
|
||||
)
|
||||
add(
|
||||
VersionHistoryGroup.Item(
|
||||
id = versions[1].id,
|
||||
spaceMember = user4.id,
|
||||
spaceMemberName = user4.name!!,
|
||||
timeStamp = versions[1].timestamp,
|
||||
icon = ObjectIcon.None,
|
||||
versions = listOf(versions[1]),
|
||||
timeFormatted = time1
|
||||
)
|
||||
)
|
||||
add(
|
||||
VersionHistoryGroup.Item(
|
||||
id = versions[2].id,
|
||||
spaceMember = user3.id,
|
||||
spaceMemberName = user3.name!!,
|
||||
timeStamp = versions[2].timestamp,
|
||||
icon = ObjectIcon.None,
|
||||
versions = listOf(versions[2]),
|
||||
timeFormatted = time2
|
||||
)
|
||||
)
|
||||
add(
|
||||
VersionHistoryGroup.Item(
|
||||
id = versions[3].id,
|
||||
spaceMember = user1.id,
|
||||
spaceMemberName = user1.name!!,
|
||||
timeStamp = versions[3].timestamp,
|
||||
icon = ObjectIcon.None,
|
||||
versions = listOf(versions[3]),
|
||||
timeFormatted = time3
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
add(
|
||||
VersionHistoryGroup(
|
||||
id = versions[4].id,
|
||||
title = date4,
|
||||
icons = listOf(ObjectIcon.None, ObjectIcon.None),
|
||||
items = buildList {
|
||||
add(
|
||||
VersionHistoryGroup.Item(
|
||||
id = versions[4].id,
|
||||
spaceMember = user1.id,
|
||||
spaceMemberName = user1.name!!,
|
||||
timeStamp = versions[4].timestamp,
|
||||
icon = ObjectIcon.None,
|
||||
versions = listOf(versions[4], versions[5]),
|
||||
timeFormatted = time4
|
||||
)
|
||||
)
|
||||
add(
|
||||
VersionHistoryGroup.Item(
|
||||
id = versions[6].id,
|
||||
spaceMember = user2.id,
|
||||
spaceMemberName = user2.name!!,
|
||||
timeStamp = versions[6].timestamp,
|
||||
icon = ObjectIcon.None,
|
||||
versions = listOf(versions[6]),
|
||||
timeFormatted = time6
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
expected = expected,
|
||||
actual = viewStateFlow.awaitItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stubSpaceMembers() {
|
||||
val filters =
|
||||
ObjectSearchConstants.filterParticipants(spaces = listOf(vmParams.spaceId))
|
||||
val params = SearchObjects.Params(
|
||||
filters = filters,
|
||||
keys = ObjectSearchConstants.spaceMemberKeys
|
||||
)
|
||||
objectSearch.stub {
|
||||
onBlocking { invoke(params) } doReturn Either.Right(listOf(user1, user2, user3, user4))
|
||||
}
|
||||
}
|
||||
|
||||
private fun stubVersions(stubbedVersions: List<Version>) {
|
||||
val params = GetVersions.Params(
|
||||
objectId = objectId
|
||||
)
|
||||
getVersions.stub {
|
||||
onBlocking { async(params) } doReturn Resultat.success(stubbedVersions)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stubLocale(locale: Locale) {
|
||||
localeProvider.stub {
|
||||
on { locale() } doReturn locale
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildViewModel(): VersionHistoryViewModel {
|
||||
return VersionHistoryViewModel(
|
||||
analytics = analytics,
|
||||
getVersions = getVersions,
|
||||
objectSearch = objectSearch,
|
||||
dateProvider = dateProvider,
|
||||
localeProvider = localeProvider,
|
||||
vmParams = vmParams,
|
||||
urlBuilder = urlBuilder
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
package com.anytypeio.anytype.core_models
|
||||
|
||||
import com.anytypeio.anytype.core_models.multiplayer.ParticipantStatus
|
||||
import com.anytypeio.anytype.core_models.multiplayer.SpaceMemberPermissions
|
||||
import com.anytypeio.anytype.core_models.restrictions.DataViewRestrictions
|
||||
import com.anytypeio.anytype.core_models.restrictions.ObjectRestriction
|
||||
import com.anytypeio.anytype.test_utils.MockDataFactory
|
||||
|
@ -19,7 +21,8 @@ fun StubObject(
|
|||
isReadOnly: Boolean? = null,
|
||||
isHidden: Boolean? = null,
|
||||
links: List<Id> = emptyList(),
|
||||
targetObjectType: Id? = null
|
||||
targetObjectType: Id? = null,
|
||||
identity: Id? = null
|
||||
): ObjectWrapper.Basic = ObjectWrapper.Basic(
|
||||
map = mapOf(
|
||||
Relations.ID to id,
|
||||
|
@ -37,6 +40,27 @@ fun StubObject(
|
|||
Relations.LINKS to links,
|
||||
Relations.TARGET_OBJECT_TYPE to targetObjectType,
|
||||
Relations.UNIQUE_KEY to uniqueKey,
|
||||
Relations.IDENTITY to identity
|
||||
)
|
||||
)
|
||||
|
||||
fun StubSpaceMember(
|
||||
id: String = MockDataFactory.randomUuid(),
|
||||
space: Id = MockDataFactory.randomUuid(),
|
||||
name: String = MockDataFactory.randomString(),
|
||||
iconEmoji: String? = null,
|
||||
identity: Id? = null,
|
||||
memberPermissions: List<SpaceMemberPermissions> = emptyList(),
|
||||
memberStatus: ParticipantStatus = ParticipantStatus.ACTIVE
|
||||
): ObjectWrapper.Basic = ObjectWrapper.Basic(
|
||||
map = mapOf(
|
||||
Relations.ID to id,
|
||||
Relations.SPACE_ID to space,
|
||||
Relations.NAME to name,
|
||||
Relations.ICON_EMOJI to iconEmoji,
|
||||
Relations.IDENTITY to identity,
|
||||
Relations.PARTICIPANT_PERMISSIONS to memberPermissions,
|
||||
Relations.PARTICIPANT_STATUS to memberStatus.code.toDouble()
|
||||
)
|
||||
)
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue