From e6acc965ffb287c0b5cd1c0e32488a3bf63cd82c Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Tue, 16 Jan 2024 16:10:52 +0100 Subject: [PATCH] DROID-2146 Objects | Enhancement | Allow pinning and unpinning types in the new object-creation panel (#771) --- .../di/feature/objects/SelectObjectTypeDI.kt | 2 + .../anytypeio/anytype/di/main/DataModule.kt | 6 +- .../creation/SelectObjectTypeFragment.kt | 18 +++ .../creation/SelectObjectTypeScreen.kt | 123 +++++++++++++++--- app/src/main/res/values/strings.xml | 3 + .../data/auth/repo/UserSettingsCache.kt | 3 + .../auth/repo/UserSettingsDataRepository.kt | 12 ++ .../anytype/domain/base/Interactor.kt | 2 + .../domain/config/UserSettingsRepository.kt | 4 + .../domain/types/GetPinnedObjectTypes.kt | 27 ++++ .../domain/types/SetPinnedObjectTypes.kt | 26 ++++ .../domain/wallpaper/RestoreWallpaper.kt | 2 + gradle/libs.versions.toml | 2 + persistence/build.gradle | 8 ++ .../persistence/preferences/Preferences.kt | 23 ++++ .../repo/DefaultUserSettingsCache.kt | 75 ++++++++++- persistence/src/main/proto/preferences.proto | 14 ++ .../persistence/UserSettingsCacheTest.kt | 25 ++-- .../objects/SelectObjectTypeViewModel.kt | 123 ++++++++++++++++-- 19 files changed, 461 insertions(+), 37 deletions(-) create mode 100644 domain/src/main/java/com/anytypeio/anytype/domain/types/GetPinnedObjectTypes.kt create mode 100644 domain/src/main/java/com/anytypeio/anytype/domain/types/SetPinnedObjectTypes.kt create mode 100644 persistence/src/main/java/com/anytypeio/anytype/persistence/preferences/Preferences.kt create mode 100644 persistence/src/main/proto/preferences.proto diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/objects/SelectObjectTypeDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/objects/SelectObjectTypeDI.kt index f2bfc119ed..d3a53d8516 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/objects/SelectObjectTypeDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/objects/SelectObjectTypeDI.kt @@ -6,6 +6,7 @@ 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.config.UserSettingsRepository import com.anytypeio.anytype.domain.workspace.SpaceManager import com.anytypeio.anytype.presentation.objects.SelectObjectTypeViewModel import com.anytypeio.anytype.ui.objects.creation.SelectObjectTypeFragment @@ -48,4 +49,5 @@ interface SelectObjectTypeDependencies : ComponentDependencies { fun analytics(): Analytics fun dispatchers(): AppCoroutineDispatchers fun spaceManager(): SpaceManager + fun userSettingsRepo(): UserSettingsRepository } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/di/main/DataModule.kt b/app/src/main/java/com/anytypeio/anytype/di/main/DataModule.kt index d55a07651c..f876068760 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/main/DataModule.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/main/DataModule.kt @@ -277,7 +277,11 @@ object DataModule { @Singleton fun provideUserSettingsCache( @Named("default") prefs: SharedPreferences, - ): UserSettingsCache = DefaultUserSettingsCache(prefs) + context: Context + ): UserSettingsCache = DefaultUserSettingsCache( + prefs = prefs, + context = context + ) @JvmStatic @Provides diff --git a/app/src/main/java/com/anytypeio/anytype/ui/objects/creation/SelectObjectTypeFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/objects/creation/SelectObjectTypeFragment.kt index de6e5ef366..9f6e2403e5 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/objects/creation/SelectObjectTypeFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/objects/creation/SelectObjectTypeFragment.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.fragment.app.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import com.anytypeio.anytype.R import com.anytypeio.anytype.core_models.Key import com.anytypeio.anytype.core_models.ObjectWrapper @@ -23,6 +24,8 @@ import com.anytypeio.anytype.presentation.objects.Command import com.anytypeio.anytype.presentation.objects.SelectObjectTypeViewModel import com.anytypeio.anytype.ui.settings.typography import javax.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch class SelectObjectTypeFragment : BaseBottomSheetComposeFragment() { @@ -48,6 +51,20 @@ class SelectObjectTypeFragment : BaseBottomSheetComposeFragment() { SelectObjectTypeScreen( state = vm.viewState.collectAsStateWithLifecycle().value, onTypeClicked = vm::onTypeClicked, + onPinOnTopClicked = { + lifecycleScope.launch { + // Workaround to prevent dropdown-menu flickering + delay(DROP_DOWN_MENU_ACTION_DELAY) + vm.onPinTypeClicked(it) + } + }, + onUnpinTypeClicked = { + lifecycleScope.launch { + // Workaround to prevent dropdown-menu flickering + delay(DROP_DOWN_MENU_ACTION_DELAY) + vm.onUnpinTypeClicked(it) + } + }, onQueryChanged = vm::onQueryChanged, onFocused = { skipCollapsed() @@ -89,6 +106,7 @@ class SelectObjectTypeFragment : BaseBottomSheetComposeFragment() { companion object { const val EXCLUDED_TYPE_KEYS_ARG_KEY = "arg.create-object-of-type.excluded-type-keys" + const val DROP_DOWN_MENU_ACTION_DELAY = 100L fun newInstance( excludedTypeKeys: List, diff --git a/app/src/main/java/com/anytypeio/anytype/ui/objects/creation/SelectObjectTypeScreen.kt b/app/src/main/java/com/anytypeio/anytype/ui/objects/creation/SelectObjectTypeScreen.kt index 604a6ba98a..8f491527ac 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/objects/creation/SelectObjectTypeScreen.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/objects/creation/SelectObjectTypeScreen.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,6 +32,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults @@ -55,6 +57,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import com.anytypeio.anytype.R @@ -70,6 +73,7 @@ import com.anytypeio.anytype.core_ui.views.Title2 import com.anytypeio.anytype.emojifier.Emojifier import com.anytypeio.anytype.presentation.objects.SelectTypeView import com.anytypeio.anytype.presentation.objects.SelectTypeViewState +import kotlinx.coroutines.delay @Preview @Composable @@ -78,13 +82,17 @@ fun PreviewScreen() { onTypeClicked = {}, state = SelectTypeViewState.Loading, onQueryChanged = {}, - onFocused = {} + onFocused = {}, + onUnpinTypeClicked = {}, + onPinOnTopClicked = {} ) } @Composable fun SelectObjectTypeScreen( onTypeClicked: (SelectTypeView.Type) -> Unit, + onUnpinTypeClicked: (SelectTypeView.Type) -> Unit, + onPinOnTopClicked: (SelectTypeView.Type) -> Unit, onQueryChanged: (String) -> Unit, onFocused: () -> Unit, state: SelectTypeViewState @@ -104,18 +112,30 @@ fun SelectObjectTypeScreen( onFocused = onFocused ) Spacer(modifier = Modifier.height(8.dp)) - ScreenContent(state, onTypeClicked) + ScreenContent( + state = state, + onTypeClicked = onTypeClicked, + onPinOnTopClicked = onPinOnTopClicked, + onUnpinTypeClicked = onUnpinTypeClicked + ) } } @Composable private fun ScreenContent( state: SelectTypeViewState, - onTypeClicked: (SelectTypeView.Type) -> Unit + onTypeClicked: (SelectTypeView.Type) -> Unit, + onUnpinTypeClicked: (SelectTypeView.Type) -> Unit, + onPinOnTopClicked: (SelectTypeView.Type) -> Unit ) { when (state) { is SelectTypeViewState.Content -> { - FlowRowContent(state.views, onTypeClicked) + FlowRowContent( + views = state.views, + onTypeClicked = onTypeClicked, + onPinOnTopClicked = onPinOnTopClicked, + onUnpinTypeClicked = onUnpinTypeClicked + ) } SelectTypeViewState.Empty -> { AnimatedVisibility( @@ -149,7 +169,9 @@ private fun ScreenContent( @OptIn(ExperimentalLayoutApi::class) private fun FlowRowContent( views: List, - onTypeClicked: (SelectTypeView.Type) -> Unit + onTypeClicked: (SelectTypeView.Type) -> Unit, + onUnpinTypeClicked: (SelectTypeView.Type) -> Unit, + onPinOnTopClicked: (SelectTypeView.Type) -> Unit ) { FlowRow( modifier = Modifier @@ -162,13 +184,62 @@ private fun FlowRowContent( views.forEach { view -> when (view) { is SelectTypeView.Type -> { - ObjectTypeItem( - name = view.name, - emoji = view.icon, - onItemClicked = throttledClick( - onClick = { onTypeClicked(view) } - ), - modifier = Modifier + val isMenuExpanded = remember { + mutableStateOf(false) + } + Box { + ObjectTypeItem( + name = view.name, + emoji = view.icon, + onItemClicked = throttledClick( + onClick = { onTypeClicked(view) } + ), + onItemLongClicked = { + isMenuExpanded.value = !isMenuExpanded.value + }, + modifier = Modifier + ) + if (view.isPinnable) { + DropdownMenu( + expanded = isMenuExpanded.value, + onDismissRequest = { isMenuExpanded.value = false }, + offset = DpOffset(x = 0.dp, y = 6.dp) + ) { + if (!view.isPinned || !view.isFirstInSection) { + DropdownMenuItem( + onClick = { + isMenuExpanded.value = false + onPinOnTopClicked(view) + } + ) { + Text( + text = stringResource(R.string.any_object_creation_menu_pin_on_top), + style = BodyRegular, + color = colorResource(id = R.color.text_primary) + ) + } + } + if (view.isPinned) { + DropdownMenuItem( + onClick = { + isMenuExpanded.value = false + onUnpinTypeClicked(view) + } + ) { + Text( + text = stringResource(R.string.any_object_creation_menu_unpin), + style = BodyRegular, + color = colorResource(id = R.color.text_primary) + ) + } + } + } + } + } + } + is SelectTypeView.Section.Pinned -> { + Section( + title = stringResource(id = R.string.create_object_section_pinned), ) } is SelectTypeView.Section.Groups -> { @@ -230,6 +301,16 @@ private fun LazyColumnContent( ) } } + is SelectTypeView.Section.Pinned -> { + item( + key = view.javaClass.name, + span = { GridItemSpan(maxLineSpan) } + ) { + Section( + title = stringResource(id = R.string.create_object_section_pinned) + ) + } + } is SelectTypeView.Section.Library -> { item( key = view.javaClass.name, @@ -252,6 +333,9 @@ private fun LazyColumnContent( onTypeClicked(view) } ), + onItemLongClicked = { + + }, modifier = Modifier.animateItemPlacement() ) } @@ -262,12 +346,14 @@ private fun LazyColumnContent( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun ObjectTypeItem( modifier: Modifier, name: String, emoji: String, - onItemClicked: () -> Unit + onItemClicked: () -> Unit, + onItemLongClicked: () -> Unit ) { Row( modifier = modifier @@ -278,7 +364,14 @@ fun ObjectTypeItem( shape = RoundedCornerShape(12.dp) ) .clip(RoundedCornerShape(12.dp)) - .clickable { onItemClicked() }, + .combinedClickable( + onClick = { + onItemClicked() + }, + onLongClick = { + onItemLongClicked() + } + ), verticalAlignment = Alignment.CenterVertically ) { Spacer( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed27889078..1c41e55d24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ 1ba981d1a9afb8af8c81847ef3383a20 b9791dd64a1e9f07a330a4ac9feb1f10 + Pinned + Pin on top + Unpin diff --git a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/UserSettingsCache.kt b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/UserSettingsCache.kt index 8ffd800ca8..713b0a5a90 100644 --- a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/UserSettingsCache.kt +++ b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/UserSettingsCache.kt @@ -6,12 +6,15 @@ import com.anytypeio.anytype.core_models.Wallpaper import com.anytypeio.anytype.core_models.WidgetSession import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.core_models.primitives.TypeId +import kotlinx.coroutines.flow.Flow interface UserSettingsCache { suspend fun setCurrentSpace(space: SpaceId) suspend fun getCurrentSpace(): SpaceId? suspend fun setDefaultObjectType(space: SpaceId, type: TypeId) suspend fun getDefaultObjectType(space: SpaceId): TypeId? + suspend fun setPinnedObjectTypes(space: SpaceId, types: List) + fun getPinnedObjectTypes(space: SpaceId) : Flow> suspend fun setWallpaper(space: Id, wallpaper: Wallpaper) suspend fun getWallpaper(space: Id) : Wallpaper suspend fun setThemeMode(mode: ThemeMode) diff --git a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/UserSettingsDataRepository.kt b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/UserSettingsDataRepository.kt index e2aeb76e19..1535d732d4 100644 --- a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/UserSettingsDataRepository.kt +++ b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/UserSettingsDataRepository.kt @@ -7,6 +7,7 @@ import com.anytypeio.anytype.core_models.WidgetSession import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.core_models.primitives.TypeId import com.anytypeio.anytype.domain.config.UserSettingsRepository +import kotlinx.coroutines.flow.Flow class UserSettingsDataRepository(private val cache: UserSettingsCache) : UserSettingsRepository { @@ -28,6 +29,17 @@ class UserSettingsDataRepository(private val cache: UserSettingsCache) : UserSet space: SpaceId ): TypeId? = cache.getDefaultObjectType(space = space) + override suspend fun setPinnedObjectTypes(space: SpaceId, types: List) { + cache.setPinnedObjectTypes( + space = space, + types = types + ) + } + + override fun getPinnedObjectTypes(space: SpaceId): Flow> { + return cache.getPinnedObjectTypes(space = space) + } + override suspend fun setThemeMode(mode: ThemeMode) { cache.setThemeMode(mode) } diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/base/Interactor.kt b/domain/src/main/java/com/anytypeio/anytype/domain/base/Interactor.kt index 7e4b35791a..b9578dc7fa 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/base/Interactor.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/base/Interactor.kt @@ -73,7 +73,9 @@ abstract class FlowInteractor( private val context: CoroutineContext ) { protected abstract fun build() : Flow + protected abstract fun build(params: P) : Flow fun flow() : Flow = build().flowOn(context) + fun flow(params: P) : Flow = build(params).flowOn(context) } diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/config/UserSettingsRepository.kt b/domain/src/main/java/com/anytypeio/anytype/domain/config/UserSettingsRepository.kt index b2e150bc88..9bb4ed9550 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/config/UserSettingsRepository.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/config/UserSettingsRepository.kt @@ -6,6 +6,7 @@ import com.anytypeio.anytype.core_models.Wallpaper import com.anytypeio.anytype.core_models.WidgetSession import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.core_models.primitives.TypeId +import kotlinx.coroutines.flow.Flow interface UserSettingsRepository { @@ -18,6 +19,9 @@ interface UserSettingsRepository { suspend fun setDefaultObjectType(space: SpaceId, type: TypeId) suspend fun getDefaultObjectType(space: SpaceId): TypeId? + suspend fun setPinnedObjectTypes(space: SpaceId, types: List) + fun getPinnedObjectTypes(space: SpaceId) : Flow> + suspend fun setThemeMode(mode: ThemeMode) suspend fun getThemeMode(): ThemeMode diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/types/GetPinnedObjectTypes.kt b/domain/src/main/java/com/anytypeio/anytype/domain/types/GetPinnedObjectTypes.kt new file mode 100644 index 0000000000..4513d0d282 --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/types/GetPinnedObjectTypes.kt @@ -0,0 +1,27 @@ +package com.anytypeio.anytype.domain.types + +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.core_models.primitives.TypeId +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.base.FlowInteractor +import com.anytypeio.anytype.domain.config.UserSettingsRepository +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emptyFlow + +class GetPinnedObjectTypes @Inject constructor( + private val repo: UserSettingsRepository, + dispatchers: AppCoroutineDispatchers +) : FlowInteractor>(dispatchers.io) { + + override fun build(): Flow> { + throw UnsupportedOperationException() + } + + override fun build(params: Params) = repo.getPinnedObjectTypes( + space = params.space + ).catch { emit(emptyList()) } + + class Params(val space: SpaceId) +} \ No newline at end of file diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/types/SetPinnedObjectTypes.kt b/domain/src/main/java/com/anytypeio/anytype/domain/types/SetPinnedObjectTypes.kt new file mode 100644 index 0000000000..576c634b12 --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/types/SetPinnedObjectTypes.kt @@ -0,0 +1,26 @@ +package com.anytypeio.anytype.domain.types + +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.core_models.primitives.TypeId +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.base.ResultInteractor +import com.anytypeio.anytype.domain.config.UserSettingsRepository +import javax.inject.Inject + +class SetPinnedObjectTypes @Inject constructor( + private val repo: UserSettingsRepository, + dispatchers: AppCoroutineDispatchers +) : ResultInteractor(dispatchers.io) { + + override suspend fun doWork(params: Params) { + repo.setPinnedObjectTypes( + space = params.space, + types = params.types + ) + } + + class Params( + val space: SpaceId, + val types: List + ) +} \ No newline at end of file diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/wallpaper/RestoreWallpaper.kt b/domain/src/main/java/com/anytypeio/anytype/domain/wallpaper/RestoreWallpaper.kt index 1d711ecbaf..2e9e75987d 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/wallpaper/RestoreWallpaper.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/wallpaper/RestoreWallpaper.kt @@ -28,4 +28,6 @@ class RestoreWallpaper( .catch { // Do nothing. } + + override fun build(params: Unit): Flow = build() } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80ee46a6e7..6dc5cab8d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,7 @@ timberVersion = '5.0.1' protobufJavaVersion = '3.9.2' protocVersion = '3.9.0' roomVersion = '2.5.2' +dataStoreVersion = '1.0.0' amplitudeVersion = '2.36.1' coilComposeVersion = '2.2.2' sentryVersion = '6.0.0' @@ -153,6 +154,7 @@ protobufJava = { module = "com.google.protobuf:protobuf-java", version.ref = "pr protobufJavaUtil = { module = "com.google.protobuf:protobuf-java-util", version.ref = "protobufJavaVersion" } protoc = { module = "com.google.protobuf:protoc", version.ref = "protocVersion" } room = { module = "androidx.room:room-runtime", version.ref = "roomVersion" } +dataStore = { module = "androidx.datastore:datastore", version.ref = "dataStoreVersion" } roomKtx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" } annotations = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } roomTesting = { module = "androidx.room:room-testing", version.ref = "roomVersion" } diff --git a/persistence/build.gradle b/persistence/build.gradle index 3ed3b120e1..23d6df199e 100644 --- a/persistence/build.gradle +++ b/persistence/build.gradle @@ -3,6 +3,7 @@ plugins { id "kotlin-android" id "kotlin-kapt" id "kotlinx-serialization" + id "com.squareup.wire" } dependencies { @@ -18,6 +19,7 @@ dependencies { implementation libs.room implementation libs.roomKtx + implementation libs.dataStore kapt libs.annotations @@ -30,6 +32,7 @@ dependencies { testImplementation libs.mockitoKotlin testImplementation libs.robolectric testImplementation libs.archCoreTesting + testImplementation libs.androidXTestCore testImplementation libs.coroutineTesting } @@ -42,4 +45,9 @@ android { jvmToolchain(17) } namespace 'com.anytypeio.anytype.persistence' +} + +wire { + protoPath { srcDir 'src/main/proto' } + kotlin {} } \ No newline at end of file diff --git a/persistence/src/main/java/com/anytypeio/anytype/persistence/preferences/Preferences.kt b/persistence/src/main/java/com/anytypeio/anytype/persistence/preferences/Preferences.kt new file mode 100644 index 0000000000..b4b8ce3a89 --- /dev/null +++ b/persistence/src/main/java/com/anytypeio/anytype/persistence/preferences/Preferences.kt @@ -0,0 +1,23 @@ +package com.anytypeio.anytype.persistence.preferences + +import androidx.datastore.core.Serializer +import com.anytypeio.anytype.persistence.SpacePreferences +import java.io.InputStream +import java.io.OutputStream + +object SpacePrefSerializer : Serializer { + override val defaultValue: SpacePreferences = SpacePreferences() + + override suspend fun readFrom(input: InputStream): SpacePreferences { + return SpacePreferences.ADAPTER.decode(input) + } + + override suspend fun writeTo(t: SpacePreferences, output: OutputStream) { + SpacePreferences.ADAPTER.encode( + stream = output, + value = t + ) + } +} + +const val SPACE_PREFERENCE_FILENAME = "space-preferences.pb" \ No newline at end of file diff --git a/persistence/src/main/java/com/anytypeio/anytype/persistence/repo/DefaultUserSettingsCache.kt b/persistence/src/main/java/com/anytypeio/anytype/persistence/repo/DefaultUserSettingsCache.kt index c7718e0122..18d4d06cad 100644 --- a/persistence/src/main/java/com/anytypeio/anytype/persistence/repo/DefaultUserSettingsCache.kt +++ b/persistence/src/main/java/com/anytypeio/anytype/persistence/repo/DefaultUserSettingsCache.kt @@ -1,6 +1,9 @@ package com.anytypeio.anytype.persistence.repo +import android.content.Context import android.content.SharedPreferences +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_models.NO_VALUE import com.anytypeio.anytype.core_models.ThemeMode @@ -9,6 +12,8 @@ import com.anytypeio.anytype.core_models.WidgetSession import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.core_models.primitives.TypeId import com.anytypeio.anytype.data.auth.repo.UserSettingsCache +import com.anytypeio.anytype.persistence.SpacePreference +import com.anytypeio.anytype.persistence.SpacePreferences import com.anytypeio.anytype.persistence.common.JsonString import com.anytypeio.anytype.persistence.common.deserializeWallpaperSettings import com.anytypeio.anytype.persistence.common.serializeWallpaperSettings @@ -16,8 +21,20 @@ import com.anytypeio.anytype.persistence.common.toJsonString import com.anytypeio.anytype.persistence.common.toStringMap import com.anytypeio.anytype.persistence.model.asSettings import com.anytypeio.anytype.persistence.model.asWallpaper +import com.anytypeio.anytype.persistence.preferences.SPACE_PREFERENCE_FILENAME +import com.anytypeio.anytype.persistence.preferences.SpacePrefSerializer +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map -class DefaultUserSettingsCache(private val prefs: SharedPreferences) : UserSettingsCache { +class DefaultUserSettingsCache( + private val prefs: SharedPreferences, + private val context: Context +) : UserSettingsCache { + + private val Context.spacePrefsStore: DataStore by dataStore( + fileName = SPACE_PREFERENCE_FILENAME, + serializer = SpacePrefSerializer + ) override suspend fun setCurrentSpace(space: SpaceId) { prefs.edit() @@ -46,6 +63,22 @@ class DefaultUserSettingsCache(private val prefs: SharedPreferences) : UserSetti .edit() .putString(DEFAULT_OBJECT_TYPES_KEY, updated.toJsonString()) .apply() + + context + .spacePrefsStore + .updateData { prefs -> + val givenSpacePreferences = prefs.preferences.getOrDefault( + space.id, + SpacePreference() + ) + val updatedSpacePreferences = givenSpacePreferences.copy( + defaultObjectTypeKey = type.id + ) + + val result = prefs.preferences + mapOf(space.id to updatedSpacePreferences) + + prefs.copy(preferences = result) + } } override suspend fun getDefaultObjectType(space: SpaceId): TypeId? { @@ -168,7 +201,41 @@ class DefaultUserSettingsCache(private val prefs: SharedPreferences) : UserSetti .apply() } + override suspend fun setPinnedObjectTypes(space: SpaceId, types: List) { + context.spacePrefsStore.updateData { existingPreferences -> + val givenSpacePreference = existingPreferences + .preferences + .getOrDefault(key = space.id, defaultValue = SpacePreference()) + + val updated = givenSpacePreference.copy( + pinnedObjectTypeKeys = types.map { type -> type.id } + ) + + val result = buildMap { + putAll(existingPreferences.preferences) + put(key = space.id, updated) + } + + SpacePreferences( + preferences = result + ) + } + } + + override fun getPinnedObjectTypes(space: SpaceId): Flow> { + return context.spacePrefsStore + .data + .map { preferences -> + preferences + .preferences[space.id] + ?.pinnedObjectTypeKeys?.map { id -> TypeId(id) } ?: emptyList() + } + } + override suspend fun clear() { + + // Clearing shared preferences + prefs.edit() .remove(DEFAULT_OBJECT_TYPE_ID_KEY) .remove(DEFAULT_OBJECT_TYPE_NAME_KEY) @@ -176,6 +243,12 @@ class DefaultUserSettingsCache(private val prefs: SharedPreferences) : UserSetti .remove(ACTIVE_WIDGETS_VIEWS_KEY) .remove(CURRENT_SPACE_KEY) .apply() + + // Clearing data stores + + context.spacePrefsStore.updateData { + SpacePreferences(emptyMap()) + } } companion object { diff --git a/persistence/src/main/proto/preferences.proto b/persistence/src/main/proto/preferences.proto new file mode 100644 index 0000000000..c7c84548b0 --- /dev/null +++ b/persistence/src/main/proto/preferences.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +option java_package = "com.anytypeio.anytype.persistence"; +option java_multiple_files = true; + +message SpacePreferences { + // maps space id to space preference + map preferences = 1; +} + +message SpacePreference { + optional string defaultObjectTypeKey = 1; + repeated string pinnedObjectTypeKeys = 2; +} \ No newline at end of file diff --git a/persistence/src/test/java/com/anytypeio/anytype/persistence/UserSettingsCacheTest.kt b/persistence/src/test/java/com/anytypeio/anytype/persistence/UserSettingsCacheTest.kt index 3b189915f3..30c5829f68 100644 --- a/persistence/src/test/java/com/anytypeio/anytype/persistence/UserSettingsCacheTest.kt +++ b/persistence/src/test/java/com/anytypeio/anytype/persistence/UserSettingsCacheTest.kt @@ -2,6 +2,7 @@ package com.anytypeio.anytype.persistence import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.core.app.ApplicationProvider import com.anytypeio.anytype.core_models.Wallpaper import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.core_models.primitives.TypeId @@ -32,7 +33,8 @@ class UserSettingsCacheTest { fun `should save and return default wallpaper`() = runTest { val cache = DefaultUserSettingsCache( - prefs = defaultPrefs + prefs = defaultPrefs, + context = ApplicationProvider.getApplicationContext() ) val space = MockDataFactory.randomUuid() @@ -55,7 +57,8 @@ class UserSettingsCacheTest { fun `should save and return gradient wallpaper`() = runTest { val cache = DefaultUserSettingsCache( - prefs = defaultPrefs + prefs = defaultPrefs, + context = ApplicationProvider.getApplicationContext() ) val space = MockDataFactory.randomUuid() @@ -81,7 +84,8 @@ class UserSettingsCacheTest { fun `should not save wallpaper if space id is empty, should return default`() = runTest { val cache = DefaultUserSettingsCache( - prefs = defaultPrefs + prefs = defaultPrefs, + context = ApplicationProvider.getApplicationContext() ) val space = "" @@ -108,7 +112,8 @@ class UserSettingsCacheTest { fun `should save and return solid-color wallpaper`() = runTest { val cache = DefaultUserSettingsCache( - prefs = defaultPrefs + prefs = defaultPrefs, + context = ApplicationProvider.getApplicationContext() ) val space = MockDataFactory.randomUuid() @@ -134,7 +139,8 @@ class UserSettingsCacheTest { fun `should save and return image wallpaper`() = runTest { val cache = DefaultUserSettingsCache( - prefs = defaultPrefs + prefs = defaultPrefs, + context = ApplicationProvider.getApplicationContext() ) val space = MockDataFactory.randomUuid() @@ -160,7 +166,8 @@ class UserSettingsCacheTest { fun `should return default wallpaper`() = runTest { val cache = DefaultUserSettingsCache( - prefs = defaultPrefs + prefs = defaultPrefs, + context = ApplicationProvider.getApplicationContext() ) val space = MockDataFactory.randomUuid() @@ -178,7 +185,8 @@ class UserSettingsCacheTest { fun `should save new default object type for given space`() = runTest { val cache = DefaultUserSettingsCache( - prefs = defaultPrefs + prefs = defaultPrefs, + context = ApplicationProvider.getApplicationContext() ) val space = SpaceId(MockDataFactory.randomUuid()) @@ -210,7 +218,8 @@ class UserSettingsCacheTest { fun `should save default object type for two given spaces`() = runTest { val cache = DefaultUserSettingsCache( - prefs = defaultPrefs + prefs = defaultPrefs, + context = ApplicationProvider.getApplicationContext() ) val space1 = SpaceId(MockDataFactory.randomUuid()) diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/objects/SelectObjectTypeViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/objects/SelectObjectTypeViewModel.kt index a76ed0b342..2555082dc1 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/objects/SelectObjectTypeViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/objects/SelectObjectTypeViewModel.kt @@ -11,17 +11,23 @@ import com.anytypeio.anytype.core_models.ObjectTypeUniqueKeys import com.anytypeio.anytype.core_models.ObjectWrapper import com.anytypeio.anytype.core_models.Relations import com.anytypeio.anytype.core_models.ext.mapToObjectWrapperType +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.core_models.primitives.TypeId import com.anytypeio.anytype.core_models.primitives.TypeKey import com.anytypeio.anytype.domain.base.Resultat import com.anytypeio.anytype.domain.base.fold import com.anytypeio.anytype.domain.block.interactor.sets.GetObjectTypes import com.anytypeio.anytype.domain.spaces.AddObjectToSpace +import com.anytypeio.anytype.domain.types.GetPinnedObjectTypes +import com.anytypeio.anytype.domain.types.SetPinnedObjectTypes import com.anytypeio.anytype.domain.workspace.SpaceManager import com.anytypeio.anytype.presentation.common.BaseViewModel import com.anytypeio.anytype.presentation.search.ObjectSearchConstants import javax.inject.Inject +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -34,6 +40,8 @@ class SelectObjectTypeViewModel( private val getObjectTypes: GetObjectTypes, private val spaceManager: SpaceManager, private val addObjectToSpace: AddObjectToSpace, + private val setPinnedObjectTypes: SetPinnedObjectTypes, + private val getPinnedObjectTypes: GetPinnedObjectTypes ) : BaseViewModel() { val viewState = MutableStateFlow(SelectTypeViewState.Loading) @@ -49,7 +57,7 @@ class SelectObjectTypeViewModel( viewModelScope.launch { space = spaceManager.get() query.onStart { emit(EMPTY_QUERY) }.flatMapLatest { query -> - getObjectTypes.stream( + val types = getObjectTypes.stream( GetObjectTypes.Params( sorts = ObjectSearchConstants.defaultObjectTypeSearchSorts(), filters = ObjectSearchConstants.filterTypes( @@ -65,10 +73,23 @@ class SelectObjectTypeViewModel( keys = ObjectSearchConstants.defaultKeysObjectType, query = query ) - ).filterIsInstance>>().map { result -> + ).filterIsInstance>>() + + combine( + types, + getPinnedObjectTypes.flow(GetPinnedObjectTypes.Params(SpaceId(space))) + ) { result, pinned -> _objectTypes.clear() _objectTypes.addAll(result.getOrNull() ?: emptyList()) + + val pinnedObjectTypesIds = pinned.map { it.id } + val allTypes = (result.getOrNull() ?: emptyList()) + + val pinnedTypes = allTypes + .filter { pinnedObjectTypesIds.contains(it.id) } + .sortedBy { obj -> pinnedObjectTypesIds.indexOf(obj.id) } + val (allUserTypes, allLibraryTypes) = allTypes.partition { type -> type.getValue(Relations.SPACE_ID) == space } @@ -78,33 +99,57 @@ class SelectObjectTypeViewModel( val (groups, objects) = allUserTypes.partition { type -> type.uniqueKey == ObjectTypeUniqueKeys.SET || type.uniqueKey == ObjectTypeUniqueKeys.COLLECTION } + val notPinnedObjects = objects.filter { !pinnedObjectTypesIds.contains(it.id) } buildList { + if (pinnedTypes.isNotEmpty()) { + add( + SelectTypeView.Section.Pinned + ) + addAll( + pinnedTypes.mapIndexed { index, type -> + SelectTypeView.Type( + id = type.id, + typeKey = type.uniqueKey, + name = type.name.orEmpty(), + icon = type.iconEmoji.orEmpty(), + isPinned = true, + isFirstInSection = index == 0 + ) + } + ) + } if (groups.isNotEmpty()) { add( SelectTypeView.Section.Groups ) addAll( - groups.map { type -> + groups.mapIndexed { index, type -> SelectTypeView.Type( id = type.id, typeKey = type.uniqueKey, name = type.name.orEmpty(), - icon = type.iconEmoji.orEmpty() + icon = type.iconEmoji.orEmpty(), + isFirstInSection = index == 0, + isPinnable = false, + isPinned = false, ) } ) } - if (objects.isNotEmpty()) { + if (notPinnedObjects.isNotEmpty()) { add( SelectTypeView.Section.Objects ) addAll( - objects.map { type -> + notPinnedObjects.mapIndexed { index, type -> SelectTypeView.Type( id = type.id, typeKey = type.uniqueKey, name = type.name.orEmpty(), - icon = type.iconEmoji.orEmpty() + icon = type.iconEmoji.orEmpty(), + isPinnable = true, + isFirstInSection = index == 0, + isPinned = false ) } ) @@ -112,13 +157,16 @@ class SelectObjectTypeViewModel( if (filteredLibraryTypes.isNotEmpty()) { add(SelectTypeView.Section.Library) addAll( - filteredLibraryTypes.map { type -> + filteredLibraryTypes.mapIndexed { index, type -> SelectTypeView.Type( id = type.id, typeKey = type.uniqueKey, name = type.name.orEmpty(), icon = type.iconEmoji.orEmpty(), - isFromLibrary = true + isFromLibrary = true, + isPinned = false, + isPinnable = false, + isFirstInSection = index == 0 ) } ) @@ -142,6 +190,49 @@ class SelectObjectTypeViewModel( } } + fun onPinTypeClicked(typeView: SelectTypeView.Type) { + Timber.d("onPinTypeClicked: ${typeView.id}") + val state = viewState.value + if (state is SelectTypeViewState.Content) { + val pinned = buildSet { + add(TypeId(typeView.id)) + state.views.forEach { view -> + if (view is SelectTypeView.Type && view.isPinned) + add(TypeId(view.id)) + } + } + viewModelScope.launch { + setPinnedObjectTypes.async( + SetPinnedObjectTypes.Params( + space = SpaceId(id = space), + types = pinned.toList() + ) + ) + } + } + } + + fun onUnpinTypeClicked(typeView: SelectTypeView.Type) { + Timber.d("onUnpinTypeClicked: ${typeView.id}") + val state = viewState.value + if (state is SelectTypeViewState.Content) { + val pinned = buildSet { + state.views.forEach { view -> + if (view is SelectTypeView.Type && view.isPinned && view.id != typeView.id) + add(TypeId(view.id)) + } + } + viewModelScope.launch { + setPinnedObjectTypes.async( + SetPinnedObjectTypes.Params( + space = SpaceId(id = space), + types = pinned.toList() + ) + ) + } + } + } + fun onTypeClicked(typeView: SelectTypeView.Type) { viewModelScope.launch { if (typeView.isFromLibrary) { @@ -179,7 +270,9 @@ class SelectObjectTypeViewModel( private val params: Params, private val getObjectTypes: GetObjectTypes, private val spaceManager: SpaceManager, - private val addObjectToSpace: AddObjectToSpace + private val addObjectToSpace: AddObjectToSpace, + private val setPinnedObjectTypes: SetPinnedObjectTypes, + private val getPinnedObjectTypes: GetPinnedObjectTypes ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create( @@ -188,7 +281,9 @@ class SelectObjectTypeViewModel( params = params, getObjectTypes = getObjectTypes, spaceManager = spaceManager, - addObjectToSpace = addObjectToSpace + addObjectToSpace = addObjectToSpace, + setPinnedObjectTypes = setPinnedObjectTypes, + getPinnedObjectTypes = getPinnedObjectTypes ) as T } @@ -205,6 +300,7 @@ sealed class SelectTypeViewState{ sealed class SelectTypeView { sealed class Section : SelectTypeView() { + object Pinned : Section() object Objects : Section() object Groups : Section() object Library : Section() @@ -215,7 +311,10 @@ sealed class SelectTypeView { val typeKey: Key, val name: String, val icon: String, - val isFromLibrary: Boolean = false + val isFromLibrary: Boolean = false, + val isPinned: Boolean = false, + val isFirstInSection: Boolean = false, + val isPinnable: Boolean = true ) : SelectTypeView() }