diff --git a/app/src/main/java/com/anytypeio/anytype/di/common/ComponentManager.kt b/app/src/main/java/com/anytypeio/anytype/di/common/ComponentManager.kt index d05ca3efad..528f4a1742 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/common/ComponentManager.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/common/ComponentManager.kt @@ -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(private val builder: () -> T) { private var instance: T? = null diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/history/VersionHistoryDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/history/VersionHistoryDI.kt new file mode 100644 index 0000000000..02094c309b --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/history/VersionHistoryDI.kt @@ -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 +} + diff --git a/app/src/main/java/com/anytypeio/anytype/di/main/MainComponent.kt b/app/src/main/java/com/anytypeio/anytype/di/main/MainComponent.kt index e0c0cee94c..b67852990d 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/main/MainComponent.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/main/MainComponent.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/editor/sheets/ObjectMenuBaseFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/editor/sheets/ObjectMenuBaseFragment.kt index 7707de084f..73a321ed94 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/editor/sheets/ObjectMenuBaseFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/editor/sheets/ObjectMenuBaseFragment.kt @@ -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 + ) + ) + } + } } } diff --git a/app/src/main/java/com/anytypeio/anytype/ui/history/VersionHistoryFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/history/VersionHistoryFragment.kt new file mode 100644 index 0000000000..d60bbbd8c3 --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/ui/history/VersionHistoryFragment.kt @@ -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 { 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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/search/GlobalSearchScreen.kt b/app/src/main/java/com/anytypeio/anytype/ui/search/GlobalSearchScreen.kt index d2de9ca4be..259e0906c4 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/search/GlobalSearchScreen.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/search/GlobalSearchScreen.kt @@ -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) diff --git a/app/src/main/res/navigation/graph.xml b/app/src/main/res/navigation/graph.xml index 5d2f0deb8e..5a2403ec4c 100644 --- a/app/src/main/res/navigation/graph.xml +++ b/app/src/main/res/navigation/graph.xml @@ -276,6 +276,10 @@ android:id="@+id/galleryInstallationScreen" android:name="com.anytypeio.anytype.ui.gallery.GalleryInstallationFragment" /> + + , - val authorId: Id, - val authorName: String, - val timestamp: Long, + val spaceMember: Id, + val spaceMemberName: String, + val timestamp: TimeInSeconds, val groupId: Long ) diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/primitives/Primitives.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/primitives/Primitives.kt index 088f4224f5..efed7c5803 100644 --- a/core-models/src/main/java/com/anytypeio/anytype/core_models/primitives/Primitives.kt +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/primitives/Primitives.kt @@ -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 diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/history/VersionHistoryMainScreen.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/history/VersionHistoryMainScreen.kt new file mode 100644 index 0000000000..b1b8ffb2e9 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/history/VersionHistoryMainScreen.kt @@ -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 = {} + ) +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/ObjectIconCompose.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/ObjectIconCompose.kt index a1c188d159..cca4caaa26 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/ObjectIconCompose.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/ObjectIconCompose.kt @@ -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) ) diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/history/GetVersions.kt b/domain/src/main/java/com/anytypeio/anytype/domain/history/GetVersions.kt index 6b6fdbcfaa..4080311c41 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/history/GetVersions.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/history/GetVersions.kt @@ -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 ) } \ No newline at end of file diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/misc/DateProvider.kt b/domain/src/main/java/com/anytypeio/anytype/domain/misc/DateProvider.kt index b430e051cc..48b7b60abb 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/misc/DateProvider.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/misc/DateProvider.kt @@ -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 } interface DateTypeNameProvider { diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 2c5db9960b..3a17e9d81e 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -1733,4 +1733,6 @@ Please provide specific details of your needs here. No access to the space Unrecognized error + Version history + \ No newline at end of file diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt index 357c3d392b..97aeb99be5 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt @@ -2707,7 +2707,7 @@ class Middleware @Inject constructor( @Throws fun getVersions(command: Command.VersionHistory.GetVersions): List { val request = Rpc.History.GetVersions.Request( - lastVersionId = command.lastVersion, + lastVersionId = command.lastVersion.orEmpty(), objectId = command.objectId, limit = command.limit diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/mappers/ToCoreModelMappers.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/mappers/ToCoreModelMappers.kt index bb9321a866..e3514bba61 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/mappers/ToCoreModelMappers.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/mappers/ToCoreModelMappers.kt @@ -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 ) } diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/history/VersionHistoryVMFactory.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/history/VersionHistoryVMFactory.kt new file mode 100644 index 0000000000..f7e3c63cec --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/history/VersionHistoryVMFactory.kt @@ -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 create(modelClass: Class): T { + return VersionHistoryViewModel( + vmParams = vmParams, + getVersions = getVersions, + objectSearch = objectSearch, + dateProvider = dateProvider, + localeProvider = localeProvider, + analytics = analytics, + urlBuilder = urlBuilder + ) as T + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/history/VersionHistoryViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/history/VersionHistoryViewModel.kt new file mode 100644 index 0000000000..559946e470 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/history/VersionHistoryViewModel.kt @@ -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.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) { + 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, members: List) { + val groupedItems = groupItems(versions, members) + _viewState.value = VersionHistoryState.Success(groups = groupedItems) + } + + private fun groupItems( + versions: List, + spaceMembers: List, + ): List { + + 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>() + var currentGroup = mutableListOf() + + 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>.toGroupItems( + spaceMembers: List, + latestVersionTime: String + ): List { + 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) : 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, + val items: List, + 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 + ) +} \ No newline at end of file diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/objects/menu/ObjectMenuViewModelBase.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/objects/menu/ObjectMenuViewModelBase.kt index 3d5ed99d84..a477fafbb1 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/objects/menu/ObjectMenuViewModelBase.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/objects/menu/ObjectMenuViewModelBase.kt @@ -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, diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/search/ObjectSearchConstants.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/search/ObjectSearchConstants.kt index 8fe5e6f897..6efd7636a1 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/search/ObjectSearchConstants.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/search/ObjectSearchConstants.kt @@ -1200,6 +1200,7 @@ object ObjectSearchConstants { Relations.ICON_IMAGE, Relations.PARTICIPANT_STATUS, Relations.PARTICIPANT_PERMISSIONS, + Relations.LAYOUT ) val spaceViewKeys = listOf( diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/widgets/collection/DateProviderImpl.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/widgets/collection/DateProviderImpl.kt index 78cbf2b9ea..b155ee4d1e 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/widgets/collection/DateProviderImpl.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/widgets/collection/DateProviderImpl.kt @@ -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 { + 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("", "") + } + } } diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/history/StubVersionModels.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/history/StubVersionModels.kt new file mode 100644 index 0000000000..0ac95f0bc7 --- /dev/null +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/history/StubVersionModels.kt @@ -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 = 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, + objectView: ObjectView? +): DiffVersionResponse { + return DiffVersionResponse( + historyEvents = historyEvents, + objectView = objectView + ) +} \ No newline at end of file diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/history/VersionHistoryViewModelTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/history/VersionHistoryViewModelTest.kt new file mode 100644 index 0000000000..9ddf6e07bf --- /dev/null +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/history/VersionHistoryViewModelTest.kt @@ -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(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) { + 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 + ) + } +} \ No newline at end of file diff --git a/test/core-models-stub/src/main/java/com/anytypeio/anytype/core_models/Object.kt b/test/core-models-stub/src/main/java/com/anytypeio/anytype/core_models/Object.kt index c39e02b46c..a89ac80856 100644 --- a/test/core-models-stub/src/main/java/com/anytypeio/anytype/core_models/Object.kt +++ b/test/core-models-stub/src/main/java/com/anytypeio/anytype/core_models/Object.kt @@ -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 = 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 = 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() ) )