diff --git a/app/build.gradle b/app/build.gradle index bd00f1222a..4685c6b47c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -162,6 +162,7 @@ dependencies { implementation project(':payments') implementation project(':feature-discussions') implementation project(':gallery-experience') + implementation project(':feature-all-content') //Compile time dependencies ksp libs.daggerCompiler @@ -197,6 +198,7 @@ dependencies { implementation libs.lifecycleCompose implementation libs.compose + implementation libs.fragmentCompose implementation libs.composeFoundation implementation libs.composeMaterial implementation libs.composeMaterial3 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 e494702bba..e7823bb3d6 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 @@ -6,6 +6,7 @@ import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.di.feature.CreateBookmarkModule import com.anytypeio.anytype.di.feature.CreateObjectModule +import com.anytypeio.anytype.di.feature.DaggerAllContentComponent import com.anytypeio.anytype.di.feature.DaggerAppPreferencesComponent import com.anytypeio.anytype.di.feature.DaggerBacklinkOrAddToObjectComponent import com.anytypeio.anytype.di.feature.DaggerSplashComponent @@ -99,6 +100,7 @@ import com.anytypeio.anytype.di.feature.wallpaper.WallpaperSelectModule import com.anytypeio.anytype.di.feature.widgets.SelectWidgetSourceModule import com.anytypeio.anytype.di.feature.widgets.SelectWidgetTypeModule import com.anytypeio.anytype.di.main.MainComponent +import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel import com.anytypeio.anytype.gallery_experience.viewmodel.GalleryInstallationViewModel import com.anytypeio.anytype.presentation.editor.EditorViewModel import com.anytypeio.anytype.presentation.history.VersionHistoryViewModel @@ -329,6 +331,12 @@ class ComponentManager( .create(params, findComponentDependencies()) } + val allContentComponent = ComponentWithParams { params: AllContentViewModel.VmParams -> + DaggerAllContentComponent + .factory() + .create(params, findComponentDependencies()) + } + val objectSetComponent = ComponentMapWithParam { param: DefaultComponentParam -> main.objectSetComponentBuilder() .module(ObjectSetModule) diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/AllContentDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/AllContentDI.kt new file mode 100644 index 0000000000..21675932e1 --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/AllContentDI.kt @@ -0,0 +1,121 @@ +package com.anytypeio.anytype.di.feature + +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.all_content.RestoreAllContentState +import com.anytypeio.anytype.domain.all_content.UpdateAllContentState +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.debugging.Logger +import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer +import com.anytypeio.anytype.domain.misc.LocaleProvider +import com.anytypeio.anytype.domain.misc.UrlBuilder +import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes +import com.anytypeio.anytype.domain.objects.StoreOfRelations +import com.anytypeio.anytype.domain.search.SearchObjects +import com.anytypeio.anytype.domain.search.SubscriptionEventChannel +import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel +import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModelFactory +import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate +import com.anytypeio.anytype.ui.allcontent.AllContentFragment +import dagger.Binds +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import javax.inject.Named + +@Component( + dependencies = [AllContentDependencies::class], + modules = [ + AllContentModule::class, + AllContentModule.Declarations::class + ] +) +@PerScreen +interface AllContentComponent { + @Component.Factory + interface Factory { + fun create( + @BindsInstance vmParams: AllContentViewModel.VmParams, + dependencies: AllContentDependencies + ): AllContentComponent + } + + fun inject(fragment: AllContentFragment) +} + +@Module +object AllContentModule { + + @JvmStatic + @Provides + @PerScreen + fun provideStoreLessSubscriptionContainer( + repo: BlockRepository, + channel: SubscriptionEventChannel, + dispatchers: AppCoroutineDispatchers, + logger: Logger + ): StorelessSubscriptionContainer = StorelessSubscriptionContainer.Impl( + repo = repo, + channel = channel, + dispatchers = dispatchers, + logger = logger + ) + + @JvmStatic + @Provides + @PerScreen + fun provideUpdateAllContentState( + dispatchers: AppCoroutineDispatchers, + userSettingsRepository: UserSettingsRepository + ): UpdateAllContentState = UpdateAllContentState( + dispatchers = dispatchers, + settings = userSettingsRepository + ) + + @JvmStatic + @Provides + @PerScreen + fun provideRestoreAllContentState( + dispatchers: AppCoroutineDispatchers, + userSettingsRepository: UserSettingsRepository + ): RestoreAllContentState = RestoreAllContentState( + dispatchers = dispatchers, + settings = userSettingsRepository + ) + + @JvmStatic + @Provides + @PerScreen + fun searchObjects( + repo: BlockRepository + ): SearchObjects = SearchObjects(repo = repo) + + @Module + interface Declarations { + @PerScreen + @Binds + fun bindViewModelFactory( + factory: AllContentViewModelFactory + ): ViewModelProvider.Factory + + } +} + +interface AllContentDependencies : ComponentDependencies { + fun blockRepository(): BlockRepository + fun analytics(): Analytics + fun urlBuilder(): UrlBuilder + fun dispatchers(): AppCoroutineDispatchers + fun storeOfObjectTypes(): StoreOfObjectTypes + fun storeOfRelations(): StoreOfRelations + fun analyticsHelper(): AnalyticSpaceHelperDelegate + fun userSettingsRepository(): UserSettingsRepository + fun subEventChannel(): SubscriptionEventChannel + fun logger(): Logger + fun localeProvider(): LocaleProvider +} \ No newline at end of file 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 1052d3ccb6..732333c076 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 @@ -3,6 +3,7 @@ package com.anytypeio.anytype.di.main import com.anytypeio.anytype.app.AndroidApplication import com.anytypeio.anytype.di.common.ComponentDependencies import com.anytypeio.anytype.di.common.ComponentDependenciesKey +import com.anytypeio.anytype.di.feature.AllContentDependencies import com.anytypeio.anytype.di.feature.AppPreferencesDependencies import com.anytypeio.anytype.di.feature.BacklinkOrAddToObjectDependencies import com.anytypeio.anytype.di.feature.CreateBookmarkSubComponent @@ -126,7 +127,8 @@ interface MainComponent : NotificationDependencies, GlobalSearchDependencies, MembershipUpdateComponentDependencies, - VaultComponentDependencies + VaultComponentDependencies, + AllContentDependencies { fun inject(app: AndroidApplication) @@ -352,4 +354,9 @@ abstract class ComponentDependenciesModule { @IntoMap @ComponentDependenciesKey(VaultComponentDependencies::class) abstract fun provideVaultComponentDependencies(component: MainComponent): ComponentDependencies + + @Binds + @IntoMap + @ComponentDependenciesKey(AllContentDependencies::class) + abstract fun provideAllContentDependencies(component: MainComponent): ComponentDependencies } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/navigation/Navigator.kt b/app/src/main/java/com/anytypeio/anytype/navigation/Navigator.kt index cbee1f9ab5..99db6e8fc5 100644 --- a/app/src/main/java/com/anytypeio/anytype/navigation/Navigator.kt +++ b/app/src/main/java/com/anytypeio/anytype/navigation/Navigator.kt @@ -8,6 +8,7 @@ import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_models.Key import com.anytypeio.anytype.presentation.navigation.AppNavigation import com.anytypeio.anytype.presentation.widgets.collection.Subscription +import com.anytypeio.anytype.ui.allcontent.AllContentFragment import com.anytypeio.anytype.ui.auth.account.DeletedAccountFragment import com.anytypeio.anytype.ui.editor.EditorFragment import com.anytypeio.anytype.ui.editor.EditorModalFragment @@ -239,4 +240,11 @@ class Navigator : AppNavigation { ) ) } + + override fun openAllContent(space: Id) { + navController?.navigate( + resId = R.id.action_open_all_content, + args = AllContentFragment.args(space) + ) + } } diff --git a/app/src/main/java/com/anytypeio/anytype/ui/allcontent/AllContentFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/allcontent/AllContentFragment.kt new file mode 100644 index 0000000000..d71948bf61 --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/ui/allcontent/AllContentFragment.kt @@ -0,0 +1,133 @@ +package com.anytypeio.anytype.ui.allcontent + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.fragment.compose.content +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.anytypeio.anytype.BuildConfig +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.core_utils.ext.argString +import com.anytypeio.anytype.core_utils.ext.subscribe +import com.anytypeio.anytype.core_utils.ext.toast +import com.anytypeio.anytype.core_utils.insets.EDGE_TO_EDGE_MIN_SDK +import com.anytypeio.anytype.core_utils.ui.BaseComposeFragment +import com.anytypeio.anytype.di.common.componentManager +import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel +import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModelFactory +import com.anytypeio.anytype.feature_allcontent.ui.AllContentWrapperScreen +import com.anytypeio.anytype.feature_allcontent.ui.AllContentNavigation.ALL_CONTENT_MAIN +import com.anytypeio.anytype.ui.base.navigation +import com.anytypeio.anytype.ui.settings.typography +import javax.inject.Inject + +class AllContentFragment : BaseComposeFragment() { + + @Inject + lateinit var factory: AllContentViewModelFactory + + private val vm by viewModels { factory } + + private val space get() = argString(ARG_SPACE) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = content { + MaterialTheme( + typography = typography + ) { + AllContentScreenWrapper() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + subscribe(vm.commands) { command -> + when (command) { + is AllContentViewModel.Command.NavigateToEditor -> { + runCatching { + navigation().openDocument( + target = command.id, + space = command.space + ) + } + } + is AllContentViewModel.Command.NavigateToSetOrCollection -> { + runCatching { + navigation().openObjectSet( + target = command.id, + space = command.space, + ) + } + } + is AllContentViewModel.Command.SendToast -> { + toast(command.message) + } + } + } + } + + @Composable + fun AllContentScreenWrapper() { + NavHost( + navController = rememberNavController(), + startDestination = ALL_CONTENT_MAIN + ) { + composable(route = ALL_CONTENT_MAIN) { + AllContentWrapperScreen( + uiState = vm.uiState.collectAsStateWithLifecycle().value, + onTabClick = vm::onTabClicked, + onQueryChanged = vm::onFilterChanged, + uiTabsState = vm.uiTabsState.collectAsStateWithLifecycle().value, + uiTitleState = vm.uiTitleState.collectAsStateWithLifecycle().value, + uiMenuButtonViewState = vm.uiMenuButtonState.collectAsStateWithLifecycle().value, + uiMenuState = vm.uiMenu.collectAsStateWithLifecycle().value, + onSortClick = vm::onSortClicked, + onModeClick = vm::onAllContentModeClicked, + onItemClicked = vm::onItemClicked + ) + } + } + } + + override fun onStop() { + vm.onStop() + super.onStop() + } + + override fun injectDependencies() { + val vmParams = AllContentViewModel.VmParams(spaceId = SpaceId(space)) + componentManager().allContentComponent.get(vmParams).inject(this) + } + + override fun releaseDependencies() { + componentManager().allContentComponent.release() + } + + override fun onApplyWindowRootInsets(view: View) { + if (BuildConfig.USE_EDGE_TO_EDGE && Build.VERSION.SDK_INT >= EDGE_TO_EDGE_MIN_SDK) { + // Do nothing. + } else { + super.onApplyWindowRootInsets(view) + } + } + + companion object { + const val KEYBOARD_HIDE_DELAY = 300L + + const val ARG_SPACE = "arg.all.content.space" + fun args(space: Id): Bundle = bundleOf(ARG_SPACE to space) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/home/HomeScreenFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/home/HomeScreenFragment.kt index fbb907eabb..2225cb4082 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/home/HomeScreenFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/home/HomeScreenFragment.kt @@ -353,14 +353,17 @@ class HomeScreenFragment : BaseComposeFragment() { ) } is Navigation.OpenLibrary -> runCatching { - runCatching { - findNavController().navigate( - R.id.libraryFragment, - args = LibraryFragment.args(destination.space) - ) - }.onFailure { e -> - Timber.e(e, "Error while opening space library from widgets") - } + findNavController().navigate( + R.id.libraryFragment, + args = LibraryFragment.args(destination.space) + ) + }.onFailure { e -> + Timber.e(e, "Error while opening space library from widgets") + } + is Navigation.OpenAllContent -> runCatching { + navigation().openAllContent(space = destination.space) + }.onFailure { e -> + Timber.e(e, "Error while opening all content from widgets") } } } diff --git a/app/src/main/res/anim/enter_from_right.xml b/app/src/main/res/anim/enter_from_right.xml new file mode 100644 index 0000000000..c62efe8750 --- /dev/null +++ b/app/src/main/res/anim/enter_from_right.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/exit_to_left.xml b/app/src/main/res/anim/exit_to_left.xml new file mode 100644 index 0000000000..5867904273 --- /dev/null +++ b/app/src/main/res/anim/exit_to_left.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/graph.xml b/app/src/main/res/navigation/graph.xml index 1efb216f10..a57ba0dd64 100644 --- a/app/src/main/res/navigation/graph.xml +++ b/app/src/main/res/navigation/graph.xml @@ -160,6 +160,12 @@ app:popUpTo="@id/vaultScreen" app:popUpToInclusive="true" /> + + + + colorResource(id = R.color.palette_light_grey) ThemeColor.DEFAULT -> colorResource(id = R.color.palette_light_default) } + +@OptIn(ExperimentalFoundationApi::class) +fun Modifier.bouncingClickable( + enabled: Boolean = true, + pressScaleFactor: Float = 0.97f, + pressAlphaFactor: Float = 0.7f, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + onClick: () -> Unit, +) = composed { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val animationTransition = updateTransition(isPressed, label = "BouncingClickableTransition") + val scaleFactor by animationTransition.animateFloat( + targetValueByState = { pressed -> if (pressed) pressScaleFactor else 1f }, + label = "BouncingClickableScaleFactorTransition", + ) + val opacity by animationTransition.animateFloat( + targetValueByState = { pressed -> if (pressed) pressAlphaFactor else 1f }, + label = "BouncingClickableAlphaTransition", + ) + + this + .graphicsLayer { + scaleX = scaleFactor + scaleY = scaleFactor + alpha = opacity + } + .combinedClickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = onClick, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + ) +} diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/all_content/RestoreAllContentState.kt b/domain/src/main/java/com/anytypeio/anytype/domain/all_content/RestoreAllContentState.kt new file mode 100644 index 0000000000..20df11fd12 --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/all_content/RestoreAllContentState.kt @@ -0,0 +1,29 @@ +package com.anytypeio.anytype.domain.all_content + +import com.anytypeio.anytype.core_models.DVSort +import com.anytypeio.anytype.core_models.primitives.SpaceId +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 RestoreAllContentState @Inject constructor( + private val settings: UserSettingsRepository, + dispatchers: AppCoroutineDispatchers +) : ResultInteractor( + dispatchers.io +) { + + override suspend fun doWork(params: Params): Response { + //todo: implement + return Response(activeSort = null) + } + + data class Params( + val spaceId: SpaceId + ) + + data class Response( + val activeSort: String? + ) +} \ No newline at end of file diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/all_content/UpdateAllContentState.kt b/domain/src/main/java/com/anytypeio/anytype/domain/all_content/UpdateAllContentState.kt new file mode 100644 index 0000000000..6671201de2 --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/all_content/UpdateAllContentState.kt @@ -0,0 +1,24 @@ +package com.anytypeio.anytype.domain.all_content + +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.primitives.SpaceId +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 UpdateAllContentState @Inject constructor( + private val settings: UserSettingsRepository, + dispatchers: AppCoroutineDispatchers +) : ResultInteractor(dispatchers.io) { + + override suspend fun doWork(params: Params) { + //todo: implement + } + + data class Params( + val spaceId: SpaceId, + val query: String, + val relatedObjectId: Id? + ) +} \ No newline at end of file diff --git a/feature-all-content/build.gradle b/feature-all-content/build.gradle new file mode 100644 index 0000000000..31595256b6 --- /dev/null +++ b/feature-all-content/build.gradle @@ -0,0 +1,60 @@ +plugins { + id "com.android.library" + id "kotlin-android" + alias(libs.plugins.compose.compiler) +} + +android { + + defaultConfig { + buildConfigField "boolean", "USE_NEW_WINDOW_INSET_API", "true" + buildConfigField "boolean", "USE_EDGE_TO_EDGE", "true" + } + + buildFeatures { + compose true + } + + namespace 'com.anytypeio.anytype.feature_allcontent' +} + +dependencies { + + implementation project(':domain') + implementation project(':core-ui') + implementation project(':analytics') + implementation project(':core-models') + implementation project(':core-utils') + implementation project(':localization') + implementation project(':presentation') + implementation project(':library-emojifier') + + compileOnly libs.javaxInject + + implementation libs.lifecycleViewModel + implementation libs.lifecycleRuntime + + implementation libs.appcompat + implementation libs.compose + implementation libs.composeFoundation + implementation libs.composeToolingPreview + implementation libs.composeMaterial3 + implementation libs.composeMaterial + implementation libs.navigationCompose + + debugImplementation libs.composeTooling + + implementation libs.timber + + testImplementation project(':test:android-utils') + testImplementation project(':test:utils') + testImplementation project(":test:core-models-stub") + testImplementation libs.junit + testImplementation libs.kotlinTest + testImplementation libs.robolectric + testImplementation libs.androidXTestCore + testImplementation libs.mockitoKotlin + testImplementation libs.coroutineTesting + testImplementation libs.timberJUnit + testImplementation libs.turbine +} \ No newline at end of file diff --git a/feature-all-content/src/main/AndroidManifest.xml b/feature-all-content/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..1d26c87a17 --- /dev/null +++ b/feature-all-content/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentModels.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentModels.kt new file mode 100644 index 0000000000..6ac75a1e89 --- /dev/null +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentModels.kt @@ -0,0 +1,256 @@ +package com.anytypeio.anytype.feature_allcontent.models + +import androidx.compose.runtime.Immutable +import com.anytypeio.anytype.core_models.DVSortType +import com.anytypeio.anytype.core_models.Key +import com.anytypeio.anytype.core_models.MarketplaceObjectTypeIds +import com.anytypeio.anytype.core_models.ObjectType +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.DateParser +import com.anytypeio.anytype.core_models.primitives.RelationKey +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.domain.misc.UrlBuilder +import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel.Companion.DEFAULT_INITIAL_SORT +import com.anytypeio.anytype.presentation.objects.ObjectIcon +import com.anytypeio.anytype.presentation.objects.getProperName +import com.anytypeio.anytype.presentation.objects.getProperType + +//region STATE +sealed class AllContentState { + data object Init : AllContentState() + data class Default( + val activeTab: AllContentTab, + val activeMode: AllContentMode, + val activeSort: AllContentSort, + val filter: String, + val limit: Int + ) : AllContentState() +} + +@Immutable +enum class AllContentTab { + PAGES, LISTS, MEDIA, BOOKMARKS, FILES, TYPES, RELATIONS +} + +sealed class AllContentMode { + data object AllContent : AllContentMode() + data object Unlinked : AllContentMode() +} + +sealed class AllContentMenuMode { + abstract val isSelected: Boolean + + data class AllContent( + override val isSelected: Boolean = false + ) : AllContentMenuMode() + + data class Unlinked( + override val isSelected: Boolean = false + ) : AllContentMenuMode() +} + +sealed class AllContentSort { + abstract val relationKey: RelationKey + abstract val sortType: DVSortType + abstract val canGroupByDate: Boolean + abstract val isSelected: Boolean + + data class ByName( + override val relationKey: RelationKey = RelationKey(Relations.NAME), + override val sortType: DVSortType = DVSortType.ASC, + override val canGroupByDate: Boolean = false, + override val isSelected: Boolean = false + ) : AllContentSort() + + data class ByDateUpdated( + override val relationKey: RelationKey = RelationKey(Relations.LAST_MODIFIED_DATE), + override val sortType: DVSortType = DVSortType.DESC, + override val canGroupByDate: Boolean = true, + override val isSelected: Boolean = false + ) : AllContentSort() + + data class ByDateCreated( + override val relationKey: RelationKey = RelationKey(Relations.CREATED_DATE), + override val sortType: DVSortType = DVSortType.DESC, + override val canGroupByDate: Boolean = true, + override val isSelected: Boolean = false + ) : AllContentSort() +} +//endregion + +//region VIEW STATES + +//TITLE +sealed class UiTitleState { + data object Hidden : UiTitleState() + data object AllContent : UiTitleState() + data object OnlyUnlinked : UiTitleState() +} + +//MENU BUTTON +sealed class MenuButtonViewState { + data object Hidden : MenuButtonViewState() + data object Visible : MenuButtonViewState() +} + +// TABS +@Immutable +sealed class UiTabsState { + data object Hidden : UiTabsState() + + @Immutable + data class Default( + val tabs: List, + val selectedTab: AllContentTab + ) : UiTabsState() +} + +// CONTENT +sealed class UiContentState { + + data object Hidden : UiContentState() + + data object Loading : UiContentState() + + data class Error( + val message: String, + ) : UiContentState() + + @Immutable + data class Content( + val items: List, + ) : UiContentState() +} + +// ITEMS +sealed class UiContentItem { + abstract val id: String + + sealed class Group : UiContentItem() { + data class Today(override val id: String = TODAY_ID) : Group() + data class Yesterday(override val id: String = YESTERDAY_ID) : Group() + data class Previous7Days(override val id: String = PREVIOUS_7_DAYS_ID) : Group() + data class Previous14Days(override val id: String = PREVIOUS_14_DAYS_ID) : Group() + data class Month(override val id: String, val title: String) : Group() + data class MonthAndYear(override val id: String, val title: String) : Group() + } + + data class Item( + override val id: String, + val name: String, + val space: SpaceId, + val type: String? = null, + val typeName: String? = null, + val description: String? = null, + val layout: ObjectType.Layout? = null, + val icon: ObjectIcon = ObjectIcon.None, + val lastModifiedDate: Long = 0L, + val createdDate: Long = 0L, + ) : UiContentItem() + + companion object { + const val TODAY_ID = "TodayId" + const val YESTERDAY_ID = "YesterdayId" + const val PREVIOUS_7_DAYS_ID = "Previous7DaysId" + const val PREVIOUS_14_DAYS_ID = "Previous14DaysId" + } +} + +// MENU +@Immutable +data class UiMenuState( + val mode: List, + val container: MenuSortsItem.Container, + val sorts: List, + val types: List +) { + companion object { + fun empty(): UiMenuState { + return UiMenuState( + mode = emptyList(), + container = MenuSortsItem.Container(AllContentSort.ByName()), + sorts = emptyList(), + types = emptyList() + ) + } + } +} + +sealed class MenuSortsItem { + data class Container(val sort: AllContentSort) : MenuSortsItem() + data class Sort(val sort: AllContentSort) : MenuSortsItem() + data object Spacer : MenuSortsItem() + data class SortType( + val sort: AllContentSort, + val sortType: DVSortType, + val isSelected: Boolean + ) : MenuSortsItem() +} +//endregion + +//region MAPPING +fun AllContentState.Default.toMenuMode(): AllContentMenuMode { + return when (activeMode) { + AllContentMode.AllContent -> AllContentMenuMode.AllContent(isSelected = true) + AllContentMode.Unlinked -> AllContentMenuMode.Unlinked(isSelected = true) + } +} + +fun AllContentMode.view(): UiTitleState { + return when (this) { + AllContentMode.AllContent -> UiTitleState.AllContent + AllContentMode.Unlinked -> UiTitleState.OnlyUnlinked + } +} + +fun Key?.mapRelationKeyToSort(): AllContentSort { + return when (this) { + Relations.CREATED_DATE -> AllContentSort.ByDateCreated() + Relations.LAST_OPENED_DATE -> AllContentSort.ByDateUpdated() + else -> DEFAULT_INITIAL_SORT + } +} + +fun List.toUiContentItems( + space: SpaceId, + urlBuilder: UrlBuilder, + objectTypes: List +): List { + return map { it.toAllContentItem(space, urlBuilder, objectTypes) } +} + +fun ObjectWrapper.Basic.toAllContentItem( + space: SpaceId, + urlBuilder: UrlBuilder, + objectTypes: List +): UiContentItem.Item { + val obj = this + val typeUrl = obj.getProperType() + val isProfile = typeUrl == MarketplaceObjectTypeIds.PROFILE + val layout = layout ?: ObjectType.Layout.BASIC + return UiContentItem.Item( + id = obj.id, + space = space, + name = obj.getProperName(), + description = obj.description, + type = typeUrl, + typeName = objectTypes.firstOrNull { type -> + if (isProfile) { + type.uniqueKey == ObjectTypeUniqueKeys.PROFILE + } else { + type.id == typeUrl + } + }?.name, + layout = layout, + icon = ObjectIcon.from( + obj = obj, + layout = layout, + builder = urlBuilder + ), + lastModifiedDate = DateParser.parseInMillis(obj.lastModifiedDate) ?: 0L, + createdDate = DateParser.parse(obj.getValue(Relations.CREATED_DATE)) ?: 0L + ) +} +//endregion \ No newline at end of file diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentSearchParams.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentSearchParams.kt new file mode 100644 index 0000000000..f8eefa84ac --- /dev/null +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/models/AllContentSearchParams.kt @@ -0,0 +1,229 @@ +package com.anytypeio.anytype.feature_allcontent.models + +import com.anytypeio.anytype.core_models.DVFilter +import com.anytypeio.anytype.core_models.DVFilterCondition +import com.anytypeio.anytype.core_models.DVSort +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.ObjectType +import com.anytypeio.anytype.core_models.ObjectTypeUniqueKeys +import com.anytypeio.anytype.core_models.RelationFormat +import com.anytypeio.anytype.core_models.Relations +import com.anytypeio.anytype.domain.library.StoreSearchParams + +val allContentTabLayouts = mapOf( + AllContentTab.PAGES to listOf( + ObjectType.Layout.BASIC, + ObjectType.Layout.PROFILE, + ObjectType.Layout.TODO, + ObjectType.Layout.NOTE + ), + AllContentTab.LISTS to listOf( + ObjectType.Layout.SET, + ObjectType.Layout.COLLECTION + ), + AllContentTab.FILES to listOf( + ObjectType.Layout.FILE, + ObjectType.Layout.PDF + ), + AllContentTab.MEDIA to listOf( + ObjectType.Layout.IMAGE, + ObjectType.Layout.VIDEO, + ObjectType.Layout.AUDIO + ), + AllContentTab.BOOKMARKS to listOf( + ObjectType.Layout.BOOKMARK + ) +) + +// Function to create subscription params +fun createSubscriptionParams( + spaceId: Id, + activeMode: AllContentMode, + activeTab: AllContentTab, + activeSort: AllContentSort, + limitedObjectIds: List, + limit: Int, + subscriptionId: String +): StoreSearchParams { + val (filters, sorts) = activeTab.filtersForSubscribe( + spaces = listOf(spaceId), + activeSort = activeSort, + limitedObjectIds = limitedObjectIds, + activeMode = activeMode + ) + return StoreSearchParams( + filters = filters, + sorts = sorts, + keys = listOf( + Relations.ID, + Relations.SPACE_ID, + Relations.TARGET_SPACE_ID, + Relations.UNIQUE_KEY, + Relations.NAME, + Relations.ICON_IMAGE, + Relations.ICON_EMOJI, + Relations.ICON_OPTION, + Relations.TYPE, + Relations.LAYOUT, + Relations.IS_ARCHIVED, + Relations.IS_DELETED, + Relations.IS_HIDDEN, + Relations.SNIPPET, + Relations.DONE, + Relations.IDENTITY_PROFILE_LINK, + Relations.RESTRICTIONS, + Relations.SIZE_IN_BYTES, + Relations.FILE_MIME_TYPE, + Relations.FILE_EXT, + Relations.LAST_OPENED_DATE, + Relations.LAST_MODIFIED_DATE, + Relations.CREATED_DATE, + Relations.LINKS, + Relations.BACKLINKS + ), + limit = limit, + subscription = subscriptionId + ) +} + +fun AllContentTab.filtersForSubscribe( + spaces: List, + activeSort: AllContentSort, + limitedObjectIds: List, + activeMode: AllContentMode +): Pair, List> { + val tab = this + when (this) { + AllContentTab.PAGES, + AllContentTab.LISTS, + AllContentTab.FILES, + AllContentTab.MEDIA, + AllContentTab.BOOKMARKS -> { + val filters = buildList { + addAll(buildDeletedFilter()) + add(buildLayoutFilter(layouts = allContentTabLayouts.getValue(tab))) + add(buildSpaceIdFilter(spaces)) + if (tab == AllContentTab.PAGES) { + add(buildTemplateFilter()) + } + if (limitedObjectIds.isNotEmpty()) { + add(buildLimitedObjectIdsFilter(limitedObjectIds = limitedObjectIds)) + } + if (activeMode == AllContentMode.Unlinked) { + addAll(buildUnlinkedObjectFilter()) + } + } + val sorts = listOf(activeSort.toDVSort()) + return filters to sorts + } + + AllContentTab.TYPES -> TODO() + AllContentTab.RELATIONS -> TODO() + } +} + +fun AllContentTab.filtersForSearch( + spaces: List +): List { + val tab = this + when (this) { + AllContentTab.PAGES, + AllContentTab.LISTS, + AllContentTab.FILES, + AllContentTab.MEDIA, + AllContentTab.BOOKMARKS -> { + val filters = buildList { + addAll(buildDeletedFilter()) + add(buildLayoutFilter(layouts = allContentTabLayouts.getValue(tab))) + add(buildSpaceIdFilter(spaces)) + if (tab == AllContentTab.PAGES) { + add(buildTemplateFilter()) + } + } + return filters + } + + AllContentTab.TYPES -> TODO() + AllContentTab.RELATIONS -> TODO() + } +} + +private fun buildLayoutFilter(layouts: List): DVFilter = DVFilter( + relation = Relations.LAYOUT, + condition = DVFilterCondition.IN, + value = layouts.map { it.code.toDouble() } +) + +private fun buildTemplateFilter(): DVFilter = DVFilter( + relation = Relations.TYPE_UNIQUE_KEY, + condition = DVFilterCondition.NOT_EQUAL, + value = ObjectTypeUniqueKeys.TEMPLATE +) + +private fun buildSpaceIdFilter(spaces: List): DVFilter = DVFilter( + relation = Relations.SPACE_ID, + condition = DVFilterCondition.IN, + value = spaces +) + +private fun buildUnlinkedObjectFilter(): List = listOf( + DVFilter( + relation = Relations.LINKS, + condition = DVFilterCondition.EMPTY + ), + DVFilter( + relation = Relations.BACKLINKS, + condition = DVFilterCondition.EMPTY + ) +) + +private fun buildLimitedObjectIdsFilter(limitedObjectIds: List): DVFilter = DVFilter( + relation = Relations.ID, + condition = DVFilterCondition.IN, + value = limitedObjectIds +) + +private fun buildDeletedFilter(): List { + return listOf( + DVFilter( + relation = Relations.IS_ARCHIVED, + condition = DVFilterCondition.NOT_EQUAL, + value = true + ), + DVFilter( + relation = Relations.IS_HIDDEN, + condition = DVFilterCondition.NOT_EQUAL, + value = true + ), + DVFilter( + relation = Relations.IS_DELETED, + condition = DVFilterCondition.NOT_EQUAL, + value = true + ) + ) +} + +fun AllContentSort.toDVSort(): DVSort { + return when (this) { + is AllContentSort.ByDateCreated -> DVSort( + relationKey = relationKey.key, + type = sortType, + relationFormat = RelationFormat.DATE, + includeTime = true, + ) + + is AllContentSort.ByDateUpdated -> DVSort( + relationKey = relationKey.key, + type = sortType, + relationFormat = RelationFormat.DATE, + includeTime = true, + ) + + is AllContentSort.ByName -> DVSort( + relationKey = relationKey.key, + type = sortType, + relationFormat = RelationFormat.LONG_TEXT, + includeTime = false + ) + } +} \ No newline at end of file diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModel.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModel.kt new file mode 100644 index 0000000000..eb08f9561a --- /dev/null +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModel.kt @@ -0,0 +1,523 @@ +package com.anytypeio.anytype.feature_allcontent.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.anytypeio.anytype.analytics.base.Analytics +import com.anytypeio.anytype.core_models.DVSortType +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.ObjectWrapper +import com.anytypeio.anytype.core_models.Relations +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.domain.all_content.RestoreAllContentState +import com.anytypeio.anytype.domain.all_content.UpdateAllContentState +import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer +import com.anytypeio.anytype.domain.misc.LocaleProvider +import com.anytypeio.anytype.domain.misc.UrlBuilder +import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes +import com.anytypeio.anytype.domain.objects.StoreOfRelations +import com.anytypeio.anytype.domain.search.SearchObjects +import com.anytypeio.anytype.feature_allcontent.models.UiContentItem +import com.anytypeio.anytype.feature_allcontent.models.AllContentMenuMode +import com.anytypeio.anytype.feature_allcontent.models.AllContentMode +import com.anytypeio.anytype.feature_allcontent.models.AllContentSort +import com.anytypeio.anytype.feature_allcontent.models.AllContentTab +import com.anytypeio.anytype.feature_allcontent.models.UiTitleState +import com.anytypeio.anytype.feature_allcontent.models.UiContentState +import com.anytypeio.anytype.feature_allcontent.models.MenuButtonViewState +import com.anytypeio.anytype.feature_allcontent.models.MenuSortsItem +import com.anytypeio.anytype.feature_allcontent.models.UiMenuState +import com.anytypeio.anytype.feature_allcontent.models.UiTabsState +import com.anytypeio.anytype.feature_allcontent.models.createSubscriptionParams +import com.anytypeio.anytype.feature_allcontent.models.filtersForSearch +import com.anytypeio.anytype.feature_allcontent.models.mapRelationKeyToSort +import com.anytypeio.anytype.feature_allcontent.models.toUiContentItems +import com.anytypeio.anytype.feature_allcontent.models.view +import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate +import com.anytypeio.anytype.presentation.home.OpenObjectNavigation +import com.anytypeio.anytype.presentation.home.navigation +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import javax.inject.Named +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * ViewState: @see [UiContentState] + * Factory: @see [AllContentViewModelFactory] + * Screen: @see [com.anytypeio.anytype.feature_allcontent.ui.AllContentWrapperScreen] + */ + +class AllContentViewModel( + private val vmParams: VmParams, + private val storeOfObjectTypes: StoreOfObjectTypes, + private val storeOfRelations: StoreOfRelations, + private val urlBuilder: UrlBuilder, + private val analytics: Analytics, + private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, + private val storelessSubscriptionContainer: StorelessSubscriptionContainer, + private val updateAllContentState: UpdateAllContentState, + private val restoreAllContentState: RestoreAllContentState, + private val searchObjects: SearchObjects, + private val localeProvider: LocaleProvider +) : ViewModel() { + + private val _limitedObjectIds: MutableStateFlow> = MutableStateFlow(emptyList()) + + private val _tabsState = MutableStateFlow(DEFAULT_INITIAL_TAB) + private val _modeState = MutableStateFlow(DEFAULT_INITIAL_MODE) + private val _sortState = MutableStateFlow(DEFAULT_INITIAL_SORT) + private val _limitState = MutableStateFlow(DEFAULT_SEARCH_LIMIT) + private val userInput = MutableStateFlow(DEFAULT_QUERY) + + @OptIn(FlowPreview::class) + private val searchQuery = userInput + .take(1) + .onCompletion { + emitAll(userInput.drop(1).debounce(DEFAULT_DEBOUNCE_DURATION).distinctUntilChanged()) + } + + private val _uiTitleState = MutableStateFlow(UiTitleState.Hidden) + val uiTitleState: StateFlow = _uiTitleState.asStateFlow() + + private val _uiMenuButtonState = + MutableStateFlow(MenuButtonViewState.Hidden) + val uiMenuButtonState: StateFlow = _uiMenuButtonState.asStateFlow() + + private val _uiTabsState = MutableStateFlow(UiTabsState.Hidden) + val uiTabsState: StateFlow = _uiTabsState.asStateFlow() + + private val _uiState = MutableStateFlow(UiContentState.Hidden) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiMenu = MutableStateFlow(UiMenuState.empty()) + val uiMenu: StateFlow = _uiMenu.asStateFlow() + + private val _commands = MutableSharedFlow() + val commands: SharedFlow = _commands + + init { + Timber.d("AllContentViewModel init, spaceId:[${vmParams.spaceId.id}]") + setupInitialStateParams() + proceedWithUiTitleStateSetup() + proceedWithUiTabsStateSetup() + proceedWithUiStateSetup() + proceedWithSearchStateSetup() + proceedWithMenuSetup() + } + + private fun proceedWithUiTitleStateSetup() { + viewModelScope.launch { + _modeState.collectLatest { result -> + Timber.d("New mode: [$result]") + _uiTitleState.value = result.view() + _uiMenuButtonState.value = MenuButtonViewState.Visible + } + } + } + + private fun proceedWithUiTabsStateSetup() { + viewModelScope.launch { + _tabsState.collectLatest { result -> + Timber.d("New tab: [$result]") + _uiTabsState.value = UiTabsState.Default( + tabs = AllContentTab.entries, + selectedTab = result + ) + } + } + } + + private fun setupInitialStateParams() { + viewModelScope.launch { + if (vmParams.useHistory) { + runCatching { + val initialParams = restoreAllContentState.run( + RestoreAllContentState.Params(vmParams.spaceId) + ) + if (initialParams.activeSort != null) { + _sortState.value = initialParams.activeSort.mapRelationKeyToSort() + } + }.onFailure { e -> + Timber.e(e, "Error restoring state") + } + } + } + } + + private fun proceedWithSearchStateSetup() { + viewModelScope.launch { + searchQuery.collectLatest { query -> + Timber.d("New query: [$query]") + if (query.isBlank()) { + _limitedObjectIds.value = emptyList() + } else { + val searchParams = createSearchParams( + activeTab = _tabsState.value, + activeQuery = query + ) + searchObjects(searchParams).process( + success = { searchResults -> + Timber.d("Search objects by query:[$query], size: : ${searchResults.size}") + _limitedObjectIds.value = searchResults.map { it.id } + }, + failure = { + Timber.e(it, "Error searching objects by query") + } + ) + } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun proceedWithUiStateSetup() { + viewModelScope.launch { + combine( + _modeState, + _tabsState, + _sortState, + _limitedObjectIds, + _limitState + ) { mode, tab, sort, limitedObjectIds, limit -> + Result(mode, tab, sort, limitedObjectIds, limit) + } + .flatMapLatest { currentState -> + Timber.d("AllContentNewState:$currentState, restart subscription") + loadData(currentState) + }.collect { + _uiState.value = it + } + } + } + + fun subscriptionId() = "all_content_subscription_${vmParams.spaceId.id}" + + private fun loadData( + result: Result + ): Flow = flow { + val loadingStartTime = System.currentTimeMillis() + + emit(UiContentState.Loading) + + val searchParams = createSubscriptionParams( + activeTab = result.tab, + activeSort = result.sort, + limitedObjectIds = result.limitedObjectIds, + limit = result.limit, + subscriptionId = subscriptionId(), + spaceId = vmParams.spaceId.id, + activeMode = result.mode + ) + + val dataFlow = storelessSubscriptionContainer.subscribe(searchParams) + .map { objWrappers -> + val items = mapToUiContentItems( + objectWrappers = objWrappers, + activeSort = result.sort + ) + UiContentState.Content(items = items) + } + .catch { e -> + emit( + UiContentState.Error( + message = e.message ?: "Error loading objects by subscription" + ) + ) + } + + var isFirstEmission = true + + emitAll( + dataFlow.onEach { + if (isFirstEmission) { + val elapsedTime = System.currentTimeMillis() - loadingStartTime + if (elapsedTime < DEFAULT_LOADING_DELAY) { + delay(DEFAULT_LOADING_DELAY - elapsedTime) + } + isFirstEmission = false + } + } + ) + } + + private suspend fun mapToUiContentItems( + objectWrappers: List, + activeSort: AllContentSort + ): List { + val items = objectWrappers.toUiContentItems( + space = vmParams.spaceId, + urlBuilder = urlBuilder, + objectTypes = storeOfObjectTypes.getAll() + ) + return if (activeSort.canGroupByDate) { + groupItemsByDate( + items = items, + activeSort = activeSort + ) + } else { + items + } + } + + private fun groupItemsByDate( + items: List, + activeSort: AllContentSort + ): List { + val groupedItems = mutableListOf() + var currentGroupKey: String? = null + + for (item in items) { + val timestamp = when (activeSort) { + is AllContentSort.ByDateCreated -> item.createdDate + is AllContentSort.ByDateUpdated -> item.lastModifiedDate + is AllContentSort.ByName -> 0L + } + val (groupKey, group) = getDateGroup(timestamp) + + if (currentGroupKey != groupKey) { + groupedItems.add(group) + currentGroupKey = groupKey + } + + groupedItems.add(item) + } + + return groupedItems + } + + private fun getDateGroup(timestamp: Long): Pair { + val zoneId = ZoneId.systemDefault() + val itemDate = Instant.ofEpochSecond(timestamp) + .atZone(zoneId) + .toLocalDate() + val today = LocalDate.now(zoneId) + val daysAgo = ChronoUnit.DAYS.between(itemDate, today) + val todayGroup = UiContentItem.Group.Today() + val yesterdayGroup = UiContentItem.Group.Yesterday() + val previous7DaysGroup = UiContentItem.Group.Previous7Days() + val previous14DaysGroup = UiContentItem.Group.Previous14Days() + return when { + daysAgo == 0L -> todayGroup.id to todayGroup + daysAgo == 1L -> yesterdayGroup.id to yesterdayGroup + daysAgo in 2..7 -> previous7DaysGroup.id to previous7DaysGroup + daysAgo in 8..14 -> previous14DaysGroup.id to previous14DaysGroup + itemDate.year == today.year -> { + val monthName = + itemDate.month.getDisplayName(TextStyle.FULL, localeProvider.locale()) + monthName to UiContentItem.Group.Month(id = monthName, title = monthName) + } + + else -> { + val monthAndYear = "${ + itemDate.month.getDisplayName( + TextStyle.FULL, + localeProvider.locale() + ) + } ${itemDate.year}" + monthAndYear to UiContentItem.Group.MonthAndYear( + id = monthAndYear, + title = monthAndYear + ) + } + } + } + + // Function to create search parameters + private fun createSearchParams( + activeTab: AllContentTab, + activeQuery: String, + ): SearchObjects.Params { + val filters = activeTab.filtersForSearch( + spaces = listOf(vmParams.spaceId.id) + ) + return SearchObjects.Params( + filters = filters, + keys = listOf(Relations.ID), + fulltext = activeQuery + ) + } + + // Function to get the menu mode based on the active mode + private fun getMenuMode(mode: AllContentMode): AllContentMenuMode { + return when (mode) { + AllContentMode.AllContent -> AllContentMenuMode.AllContent(isSelected = true) + AllContentMode.Unlinked -> AllContentMenuMode.Unlinked(isSelected = true) + } + } + + private fun proceedWithMenuSetup() { + viewModelScope.launch { + combine( + _modeState, + _sortState + ) { mode, sort -> + mode to sort + }.collectLatest { (mode, sort) -> + val uiMode = listOf( + AllContentMenuMode.AllContent(isSelected = mode == AllContentMode.AllContent), + AllContentMenuMode.Unlinked(isSelected = mode == AllContentMode.Unlinked) + ) + val container = MenuSortsItem.Container(sort = sort) + val uiSorts = listOf( + MenuSortsItem.Sort( + sort = AllContentSort.ByName(isSelected = sort is AllContentSort.ByName) + ), + MenuSortsItem.Sort( + sort = AllContentSort.ByDateUpdated(isSelected = sort is AllContentSort.ByDateUpdated) + ), + MenuSortsItem.Sort( + sort = AllContentSort.ByDateCreated(isSelected = sort is AllContentSort.ByDateCreated) + ) + ) + val uiSortTypes = listOf( + MenuSortsItem.SortType( + sort = sort, + sortType = DVSortType.ASC, + isSelected = sort.sortType == DVSortType.ASC + ), + MenuSortsItem.SortType( + sort = sort, + sortType = DVSortType.DESC, + isSelected = sort.sortType == DVSortType.DESC + ) + ) + _uiMenu.value = UiMenuState( + mode = uiMode, + container = container, + sorts = uiSorts, + types = uiSortTypes + ) + } + } + } + + fun onTabClicked(tab: AllContentTab) { + Timber.d("onTabClicked: $tab") + if (tab == AllContentTab.TYPES || tab == AllContentTab.RELATIONS) { + viewModelScope.launch { + _commands.emit(Command.SendToast("Not implemented yet")) + } + return + } + _tabsState.value = tab + } + + fun onAllContentModeClicked(mode: AllContentMenuMode) { + Timber.d("onAllContentModeClicked: $mode") + _modeState.value = when (mode) { + is AllContentMenuMode.AllContent -> AllContentMode.AllContent + is AllContentMenuMode.Unlinked -> AllContentMode.Unlinked + } + } + + fun onSortClicked(sort: AllContentSort) { + Timber.d("onSortClicked: $sort") + _sortState.value = sort + } + + fun onFilterChanged(filter: String) { + Timber.d("onFilterChanged: $filter") + userInput.value = filter + } + + fun onLimitUpdated(limit: Int) { + Timber.d("onLimitUpdated: $limit") + _limitState.value = limit + } + + fun onItemClicked(item: UiContentItem.Item) { + Timber.d("onItemClicked: ${item.id}") + val layout = item.layout ?: return + viewModelScope.launch { + when (val navigation = layout.navigation( + target = item.id, + space = vmParams.spaceId.id + )) { + is OpenObjectNavigation.OpenDataView -> { + _commands.emit( + Command.NavigateToSetOrCollection( + id = navigation.target, + space = navigation.space + ) + ) + } + + is OpenObjectNavigation.OpenEditor -> { + _commands.emit( + Command.NavigateToEditor( + id = navigation.target, + space = navigation.space + ) + ) + } + + is OpenObjectNavigation.UnexpectedLayoutError -> { + _commands.emit(Command.SendToast("Unexpected layout: ${navigation.layout}")) + } + } + } + } + + fun onStop() { + Timber.d("onStop") + viewModelScope.launch { + storelessSubscriptionContainer.unsubscribe(listOf(subscriptionId())) + } + } + + data class VmParams( + val spaceId: SpaceId, + val useHistory: Boolean = true + ) + + internal data class Result( + val mode: AllContentMode, + val tab: AllContentTab, + val sort: AllContentSort, + val limitedObjectIds: List, + val limit: Int + ) + + sealed class Command { + data class NavigateToEditor(val id: Id, val space: Id) : Command() + data class NavigateToSetOrCollection(val id: Id, val space: Id) : Command() + data class SendToast(val message: String) : Command() + } + + companion object { + const val DEFAULT_DEBOUNCE_DURATION = 300L + const val DEFAULT_LOADING_DELAY = 250L + + //INITIAL STATE + const val DEFAULT_SEARCH_LIMIT = 50 + val DEFAULT_INITIAL_TAB = AllContentTab.PAGES + val DEFAULT_INITIAL_SORT = AllContentSort.ByDateCreated() + val DEFAULT_INITIAL_MODE = AllContentMode.AllContent + val DEFAULT_QUERY = "" + } +} diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModelFactory.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModelFactory.kt new file mode 100644 index 0000000000..5d7bf6723a --- /dev/null +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/presentation/AllContentViewModelFactory.kt @@ -0,0 +1,47 @@ +package com.anytypeio.anytype.feature_allcontent.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.anytypeio.anytype.analytics.base.Analytics +import com.anytypeio.anytype.domain.all_content.RestoreAllContentState +import com.anytypeio.anytype.domain.all_content.UpdateAllContentState +import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer +import com.anytypeio.anytype.domain.misc.LocaleProvider +import com.anytypeio.anytype.domain.misc.UrlBuilder +import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes +import com.anytypeio.anytype.domain.objects.StoreOfRelations +import com.anytypeio.anytype.domain.search.SearchObjects +import com.anytypeio.anytype.feature_allcontent.presentation.AllContentViewModel.VmParams +import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate +import javax.inject.Inject +import javax.inject.Named + +class AllContentViewModelFactory @Inject constructor( + private val vmParams: VmParams, + private val storeOfObjectTypes: StoreOfObjectTypes, + private val storeOfRelations: StoreOfRelations, + private val urlBuilder: UrlBuilder, + private val analytics: Analytics, + private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, + private val storelessSubscriptionContainer: StorelessSubscriptionContainer, + private val updateAllContentState: UpdateAllContentState, + private val restoreAllContentState: RestoreAllContentState, + private val searchObjects: SearchObjects, + private val localeProvider: LocaleProvider +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + AllContentViewModel( + vmParams = vmParams, + storeOfObjectTypes = storeOfObjectTypes, + storeOfRelations = storeOfRelations, + urlBuilder = urlBuilder, + analytics = analytics, + analyticSpaceHelperDelegate = analyticSpaceHelperDelegate, + storelessSubscriptionContainer = storelessSubscriptionContainer, + restoreAllContentState = restoreAllContentState, + updateAllContentState = updateAllContentState, + searchObjects = searchObjects, + localeProvider = localeProvider + ) as T +} \ No newline at end of file diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentMenu.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentMenu.kt new file mode 100644 index 0000000000..61470ddf72 --- /dev/null +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentMenu.kt @@ -0,0 +1,238 @@ +package com.anytypeio.anytype.feature_allcontent.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter.Companion.tint +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.anytypeio.anytype.core_models.DVSortType +import com.anytypeio.anytype.core_ui.common.DefaultPreviews +import com.anytypeio.anytype.core_ui.views.BodyCalloutRegular +import com.anytypeio.anytype.core_ui.views.UXBody +import com.anytypeio.anytype.feature_allcontent.R +import com.anytypeio.anytype.feature_allcontent.models.AllContentMenuMode +import com.anytypeio.anytype.feature_allcontent.models.AllContentSort +import com.anytypeio.anytype.feature_allcontent.models.MenuSortsItem +import com.anytypeio.anytype.feature_allcontent.models.UiMenuState + +@Composable +fun AllContentMenu( + uiMenuState: UiMenuState, + onModeClick: (AllContentMenuMode) -> Unit, + onSortClick: (AllContentSort) -> Unit +) { + var sortingExpanded by remember { mutableStateOf(false) } + + uiMenuState.mode.forEach { item -> + MenuItem( + title = getModeTitle(item), + isSelected = item.isSelected, + modifier = Modifier.clickable { + onModeClick(item) + } + ) + } + Spacer(modifier = Modifier.height(8.dp)) + SortingBox( + modifier = Modifier + .clickable { + sortingExpanded = !sortingExpanded + }, + subtitle = uiMenuState.container.sort.title() + ) + if (sortingExpanded) { + uiMenuState.sorts.forEach { item -> + MenuItem( + title = item.sort.title(), + isSelected = item.sort.isSelected, + modifier = Modifier + .clickable { + onSortClick(item.sort) + } + ) + } + uiMenuState.types.forEach { item -> + MenuItem( + title = item.sortType.title(item.sort), + isSelected = item.isSelected, + modifier = Modifier + .clickable { + val updatedSort = when (item.sort) { + is AllContentSort.ByName -> item.sort.copy(sortType = item.sortType) + is AllContentSort.ByDateCreated -> item.sort.copy(sortType = item.sortType) + is AllContentSort.ByDateUpdated -> item.sort.copy(sortType = item.sortType) + } + onSortClick(updatedSort) + } + ) + } + } +} + +@Composable +private fun SortingBox(modifier: Modifier, subtitle: String) { + Row( + modifier = modifier + .fillMaxWidth() + .background(colorResource(id = R.color.background_secondary)), + verticalAlignment = CenterVertically + ) { + Image( + modifier = Modifier.size(32.dp), + painter = painterResource(R.drawable.ic_menu_arrow_right), + contentDescription = "", + colorFilter = tint(colorResource(id = R.color.glyph_selected)) + ) + Column( + modifier = Modifier + .wrapContentHeight() + .padding(top = 11.dp, bottom = 10.dp, start = 6.dp) + ) { + Text( + text = stringResource(id = R.string.all_content_sort_by), + modifier = Modifier.wrapContentSize(), + style = UXBody, + color = colorResource(id = R.color.text_primary) + ) + Text( + text = subtitle, + modifier = Modifier.wrapContentSize(), + style = BodyCalloutRegular, + color = colorResource(id = R.color.text_secondary) + ) + } + } +} + +@Composable +private fun MenuItem(modifier: Modifier, title: String, isSelected: Boolean) { + Row( + modifier = modifier + .fillMaxWidth() + .height(44.dp) + .background(colorResource(id = R.color.background_secondary)), + verticalAlignment = CenterVertically, + ) { + Image( + modifier = Modifier + .wrapContentSize() + .padding(start = 12.dp), + painter = painterResource(R.drawable.ic_check_16), + contentDescription = "All Content mode selected", + alpha = if (isSelected) 1f else 0f + ) + Text( + text = title, + modifier = Modifier + .wrapContentSize() + .padding(start = 8.dp), + style = UXBody, + color = colorResource(id = R.color.text_primary) + ) + } +} + +//region RESOURCES +@Composable +private fun getModeTitle(mode: AllContentMenuMode): String = stringResource( + when (mode) { + is AllContentMenuMode.AllContent -> R.string.all_content_title_all_content + is AllContentMenuMode.Unlinked -> R.string.all_content_title_only_unlinked + } +) + +@Composable +private fun AllContentSort.title(): String = stringResource( + when (this) { + is AllContentSort.ByDateCreated -> R.string.all_content_sort_date_created + is AllContentSort.ByDateUpdated -> R.string.all_content_sort_date_updated + is AllContentSort.ByName -> R.string.all_content_sort_name + } +) + +@Composable +private fun DVSortType.title(sort: AllContentSort): String = when (this) { + DVSortType.ASC -> { + when (sort) { + is AllContentSort.ByDateCreated, is AllContentSort.ByDateUpdated -> stringResource( + id = R.string.all_content_sort_date_asc + ) + + is AllContentSort.ByName -> stringResource(id = R.string.all_content_sort_name_asc) + } + } + + DVSortType.DESC -> { + when (sort) { + is AllContentSort.ByDateCreated, is AllContentSort.ByDateUpdated -> stringResource( + id = R.string.all_content_sort_date_desc + ) + + is AllContentSort.ByName -> stringResource(id = R.string.all_content_sort_name_desc) + } + } + + DVSortType.CUSTOM -> "" +} +//endregion + +//region PREVIEW +@DefaultPreviews +@Composable +fun AllContentMenuPreview() { + AllContentMenu( + uiMenuState = UiMenuState( + mode = listOf( + AllContentMenuMode.AllContent(isSelected = true), + AllContentMenuMode.Unlinked(isSelected = false) + ), + sorts = listOf( + MenuSortsItem.Sort( + sort = AllContentSort.ByName(isSelected = true) + ), + MenuSortsItem.Sort( + AllContentSort.ByDateUpdated(isSelected = false) + ), + MenuSortsItem.Sort( + AllContentSort.ByDateCreated(isSelected = false) + ) + ), + types = listOf( + MenuSortsItem.SortType( + sortType = DVSortType.ASC, + isSelected = true, + sort = AllContentSort.ByName(isSelected = true) + ), + MenuSortsItem.SortType( + sortType = DVSortType.DESC, + isSelected = false, + sort = AllContentSort.ByName(isSelected = false) + ), + ), + container = MenuSortsItem.Container(AllContentSort.ByName()) + ), + onModeClick = {}, + onSortClick = {} + ) +} +//endregion \ No newline at end of file diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentScreen.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentScreen.kt new file mode 100644 index 0000000000..1b22b0fdbc --- /dev/null +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentScreen.kt @@ -0,0 +1,417 @@ +package com.anytypeio.anytype.feature_allcontent.ui + +import android.os.Build +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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 com.anytypeio.anytype.core_models.DVSortType +import com.anytypeio.anytype.core_ui.common.DefaultPreviews +import com.anytypeio.anytype.core_ui.foundation.Divider +import com.anytypeio.anytype.core_ui.views.ButtonSize +import com.anytypeio.anytype.core_ui.views.Caption1Regular +import com.anytypeio.anytype.core_ui.views.PreviewTitle2Medium +import com.anytypeio.anytype.core_ui.views.Relations3 +import com.anytypeio.anytype.core_ui.views.UXBody +import com.anytypeio.anytype.core_ui.views.animations.DotsLoadingIndicator +import com.anytypeio.anytype.core_ui.views.animations.FadeAnimationSpecs +import com.anytypeio.anytype.core_ui.widgets.DefaultBasicAvatarIcon +import com.anytypeio.anytype.core_ui.widgets.DefaultEmojiObjectIcon +import com.anytypeio.anytype.core_ui.widgets.DefaultFileObjectImageIcon +import com.anytypeio.anytype.core_ui.widgets.DefaultObjectBookmarkIcon +import com.anytypeio.anytype.core_ui.widgets.DefaultObjectImageIcon +import com.anytypeio.anytype.core_ui.widgets.DefaultProfileAvatarIcon +import com.anytypeio.anytype.core_ui.widgets.DefaultProfileIconImage +import com.anytypeio.anytype.core_ui.widgets.DefaultTaskObjectIcon +import com.anytypeio.anytype.core_utils.insets.EDGE_TO_EDGE_MIN_SDK +import com.anytypeio.anytype.feature_allcontent.BuildConfig +import com.anytypeio.anytype.feature_allcontent.R +import com.anytypeio.anytype.feature_allcontent.models.AllContentMenuMode +import com.anytypeio.anytype.feature_allcontent.models.AllContentSort +import com.anytypeio.anytype.feature_allcontent.models.AllContentTab +import com.anytypeio.anytype.feature_allcontent.models.UiContentState +import com.anytypeio.anytype.feature_allcontent.models.MenuButtonViewState +import com.anytypeio.anytype.feature_allcontent.models.UiContentItem +import com.anytypeio.anytype.feature_allcontent.models.UiMenuState +import com.anytypeio.anytype.feature_allcontent.models.UiTabsState +import com.anytypeio.anytype.feature_allcontent.models.UiTitleState +import com.anytypeio.anytype.presentation.objects.ObjectIcon + +@Composable +fun AllContentWrapperScreen( + uiTitleState: UiTitleState, + uiTabsState: UiTabsState, + uiMenuButtonViewState: MenuButtonViewState, + uiMenuState: UiMenuState, + uiState: UiContentState, + onTabClick: (AllContentTab) -> Unit, + onQueryChanged: (String) -> Unit, + onModeClick: (AllContentMenuMode) -> Unit, + onSortClick: (AllContentSort) -> Unit, + onItemClicked: (UiContentItem.Item) -> Unit +) { + val objects = remember { mutableStateOf>(emptyList()) } + if (uiState is UiContentState.Content) { + objects.value = uiState.items + } + AllContentMainScreen( + uiTitleState = uiTitleState, + uiTabsState = uiTabsState, + uiMenuButtonViewState = uiMenuButtonViewState, + onTabClick = onTabClick, + objects = objects, + isLoading = uiState is UiContentState.Loading, + onQueryChanged = onQueryChanged, + uiMenuState = uiMenuState, + onModeClick = onModeClick, + onSortClick = onSortClick, + onItemClicked = onItemClicked + ) +} + +@Composable +fun AllContentMainScreen( + uiTitleState: UiTitleState, + uiTabsState: UiTabsState, + uiMenuButtonViewState: MenuButtonViewState, + uiMenuState: UiMenuState, + objects: MutableState>, + onTabClick: (AllContentTab) -> Unit, + onQueryChanged: (String) -> Unit, + onModeClick: (AllContentMenuMode) -> Unit, + onSortClick: (AllContentSort) -> Unit, + isLoading: Boolean, + onItemClicked: (UiContentItem.Item) -> Unit +) { + val modifier = Modifier + .background(color = colorResource(id = R.color.background_primary)) + + Scaffold( + modifier = modifier + .fillMaxSize(), + containerColor = colorResource(id = R.color.background_primary), + topBar = { + Column( + modifier = if (BuildConfig.USE_EDGE_TO_EDGE && Build.VERSION.SDK_INT >= EDGE_TO_EDGE_MIN_SDK) + Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .fillMaxWidth() + else + Modifier.fillMaxWidth() + ) { + if (uiTitleState !is UiTitleState.Hidden) { + AllContentTopBarContainer( + titleState = uiTitleState, + menuButtonState = uiMenuButtonViewState, + uiMenuState = uiMenuState, + onSortClick = onSortClick, + onModeClick = onModeClick, + ) + } + + if (uiTabsState is UiTabsState.Default) { + AllContentTabs(tabsViewState = uiTabsState) { tab -> + onTabClick(tab) + } + } + Spacer(modifier = Modifier.size(10.dp)) + AllContentSearchBar(onQueryChanged) + Spacer(modifier = Modifier.size(10.dp)) + Divider(paddingStart = 0.dp, paddingEnd = 0.dp) + } + }, + content = { paddingValues -> + val contentModifier = + if (BuildConfig.USE_EDGE_TO_EDGE && Build.VERSION.SDK_INT >= EDGE_TO_EDGE_MIN_SDK) + Modifier + .windowInsetsPadding(WindowInsets.navigationBars) + .fillMaxSize() + .padding(paddingValues) + else + Modifier + .fillMaxSize() + .padding(paddingValues) + if (isLoading) { + Box(modifier = contentModifier) { + LoadingState() + } + } else { + ContentItems( + modifier = contentModifier, + items = objects.value, + onItemClicked = onItemClicked + ) + } + } + ) +} + +@Composable +private fun ContentItems( + modifier: Modifier, + items: List, + onItemClicked: (UiContentItem.Item) -> Unit +) { + LazyColumn(modifier = modifier) { + items( + count = items.size, + key = { index -> items[index].id }, + contentType = { index -> + when (items[index]) { + is UiContentItem.Group -> "group" + is UiContentItem.Item -> "item" + } + } + ) { index -> + when (val item = items[index]) { + is UiContentItem.Group -> { + Box( + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + contentAlignment = Alignment.BottomStart + ) { + Text( + modifier = Modifier + .wrapContentSize() + .padding(start = 20.dp, bottom = 8.dp), + text = item.title(), + style = Caption1Regular, + color = colorResource(id = R.color.text_secondary), + ) + } + } + + is UiContentItem.Item -> { + Item( + modifier = Modifier + .padding(horizontal = 16.dp) + .bottomBorder() + .animateItem() + .clickable { + onItemClicked(item) + }, + item = item + ) + } + } + } + } +} + +@Composable +private fun BoxScope.LoadingState() { + val loadingAlpha by animateFloatAsState(targetValue = 1f, label = "") + DotsLoadingIndicator( + animating = true, + modifier = Modifier + .graphicsLayer { alpha = loadingAlpha } + .align(Alignment.Center), + animationSpecs = FadeAnimationSpecs(itemCount = 3), + color = colorResource(id = R.color.glyph_active), + size = ButtonSize.Small + ) +} + +@DefaultPreviews +@Composable +fun PreviewLoadingState() { + Box(modifier = Modifier.fillMaxSize()) { + LoadingState() + } +} + +@Composable +private fun Item( + modifier: Modifier, + item: UiContentItem.Item +) { + Row( + modifier = modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .padding(0.dp, 12.dp, 12.dp, 12.dp) + .size(48.dp) + .align(CenterVertically) + ) { + AllContentItemIcon(icon = item.icon, modifier = Modifier) + } + Column( + modifier = Modifier + .align(CenterVertically) + .padding(0.dp, 0.dp, 60.dp, 0.dp) + ) { + + val name = item.name.trim().ifBlank { stringResource(R.string.untitled) } + + Text( + text = name, + style = PreviewTitle2Medium, + color = colorResource(id = R.color.text_primary), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + val description = item.description + if (!description.isNullOrBlank()) { + Text( + text = description, + style = Relations3, + color = colorResource(id = R.color.text_primary), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + val typeName = item.typeName + if (!typeName.isNullOrBlank()) { + Text( + text = typeName, + style = Relations3, + color = colorResource(id = R.color.text_secondary), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +fun AllContentItemIcon( + icon: ObjectIcon, + modifier: Modifier, + iconSize: Dp = 48.dp, + onTaskIconClicked: (Boolean) -> Unit = {}, + avatarBackgroundColor: Int = R.color.shape_secondary, + avatarFontSize: TextUnit = 28.sp, + avatarTextStyle: TextStyle = TextStyle( + fontWeight = FontWeight.SemiBold, + color = colorResource(id = R.color.text_white) + ) +) { + when (icon) { + is ObjectIcon.Profile.Avatar -> DefaultProfileAvatarIcon( + modifier = modifier, + iconSize = iconSize, + icon = icon, + avatarTextStyle = avatarTextStyle, + avatarFontSize = avatarFontSize, + avatarBackgroundColor = avatarBackgroundColor + ) + + 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) + is ObjectIcon.Basic.Avatar -> DefaultBasicAvatarIcon(modifier, iconSize, icon) + is ObjectIcon.Bookmark -> DefaultObjectBookmarkIcon(icon.image, modifier, iconSize) + is ObjectIcon.Task -> DefaultTaskObjectIcon(modifier, iconSize, icon, onTaskIconClicked) + is ObjectIcon.File -> { + DefaultFileObjectImageIcon( + fileName = icon.fileName.orEmpty(), + mime = icon.mime.orEmpty(), + modifier = modifier, + iconSize = iconSize, + extension = icon.extensions + ) + } + + else -> { + // Draw nothing. + } + } +} + +@Composable +private fun BoxScope.ErrorState(message: String) { + Text( + modifier = Modifier + .wrapContentSize() + .align(Alignment.Center), + text = "Error : message", + color = colorResource(id = R.color.palette_system_red), + style = UXBody + ) +} + +@Composable +fun UiContentItem.Group.title(): String { + return when (this) { + is UiContentItem.Group.Today -> stringResource(R.string.allContent_group_today) + is UiContentItem.Group.Yesterday -> stringResource(R.string.allContent_group_yesterday) + is UiContentItem.Group.Previous7Days -> stringResource(R.string.allContent_group_prev_7) + is UiContentItem.Group.Previous14Days -> stringResource(R.string.allContent_group_prev_14) + is UiContentItem.Group.Month -> title + is UiContentItem.Group.MonthAndYear -> title + } +} + +object AllContentNavigation { + const val ALL_CONTENT_MAIN = "all_content_main" +} + +@Composable +fun Modifier.bottomBorder( + strokeWidth: Dp = 0.5.dp, + color: Color = colorResource(R.color.shape_primary) +) = composed( + factory = { + val density = LocalDensity.current + val strokeWidthPx = density.run { strokeWidth.toPx() } + + Modifier.drawBehind { + val width = size.width + val height = size.height - strokeWidthPx / 2 + + drawLine( + color = color, + start = Offset(x = 0f, y = height), + end = Offset(x = width, y = height), + strokeWidth = strokeWidthPx + ) + } + } +) \ No newline at end of file diff --git a/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentTopToolbar.kt b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentTopToolbar.kt new file mode 100644 index 0000000000..6119acc627 --- /dev/null +++ b/feature-all-content/src/main/java/com/anytypeio/anytype/feature_allcontent/ui/AllContentTopToolbar.kt @@ -0,0 +1,411 @@ +package com.anytypeio.anytype.feature_allcontent.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.anytypeio.anytype.core_models.DVSortType +import com.anytypeio.anytype.core_ui.common.DefaultPreviews +import com.anytypeio.anytype.core_ui.extensions.bouncingClickable +import com.anytypeio.anytype.core_ui.foundation.noRippleClickable +import com.anytypeio.anytype.core_ui.views.BodyRegular +import com.anytypeio.anytype.core_ui.views.Title1 +import com.anytypeio.anytype.core_ui.views.Title2 +import com.anytypeio.anytype.feature_allcontent.R +import com.anytypeio.anytype.feature_allcontent.models.AllContentMenuMode +import com.anytypeio.anytype.feature_allcontent.models.AllContentSort +import com.anytypeio.anytype.feature_allcontent.models.AllContentTab +import com.anytypeio.anytype.feature_allcontent.models.MenuButtonViewState +import com.anytypeio.anytype.feature_allcontent.models.MenuSortsItem +import com.anytypeio.anytype.feature_allcontent.models.UiMenuState +import com.anytypeio.anytype.feature_allcontent.models.UiTabsState +import com.anytypeio.anytype.feature_allcontent.models.UiTitleState + +//region AllContentTopBarContainer +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AllContentTopBarContainer( + titleState: UiTitleState, + menuButtonState: MenuButtonViewState, + uiMenuState: UiMenuState, + onModeClick: (AllContentMenuMode) -> Unit, + onSortClick: (AllContentSort) -> Unit +) { + var isMenuExpanded by remember { mutableStateOf(false) } + + CenterAlignedTopAppBar( + modifier = Modifier.fillMaxWidth(), + expandedHeight = 48.dp, + title = { AllContentTitle(state = titleState) }, + actions = { + AllContentMenuButton( + state = menuButtonState, + onClick = { isMenuExpanded = true } + ) + DropdownMenu( + modifier = Modifier.width(252.dp), + expanded = isMenuExpanded, + onDismissRequest = { isMenuExpanded = false }, + shape = RoundedCornerShape(size = 16.dp), + containerColor = colorResource(id = R.color.background_primary), + shadowElevation = 20.dp, + ) { + AllContentMenu( + uiMenuState = uiMenuState, + onModeClick = { + onModeClick(it) + isMenuExpanded = false + }, + onSortClick = { + onSortClick(it) + isMenuExpanded = false + } + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = colorResource(id = R.color.background_primary) + ), + ) +} + +@DefaultPreviews +@Composable +private fun AllContentTopBarContainerPreview() { + AllContentTopBarContainer( + titleState = UiTitleState.OnlyUnlinked, + menuButtonState = MenuButtonViewState.Visible, + uiMenuState = UiMenuState( + mode = listOf( + AllContentMenuMode.AllContent(isSelected = true), + AllContentMenuMode.Unlinked() + ), + container = MenuSortsItem.Container( + sort = AllContentSort.ByName(isSelected = true) + ), + sorts = listOf( + MenuSortsItem.Sort( + sort = AllContentSort.ByName(isSelected = true) + ), + ), + types = listOf( + MenuSortsItem.SortType( + sort = AllContentSort.ByName(isSelected = true), + sortType = DVSortType.DESC, + isSelected = true + ), + MenuSortsItem.SortType( + sort = AllContentSort.ByDateCreated(isSelected = false), + sortType = DVSortType.ASC, + isSelected = false + ), + ) + ), + onModeClick = {}, + onSortClick = {} + ) +} +//endregion + +//region AllContentTitle +@Composable +fun AllContentTitle(state: UiTitleState) { + when (state) { + UiTitleState.Hidden -> return + UiTitleState.AllContent -> { + Text( + modifier = Modifier + .wrapContentSize(), + text = stringResource(id = R.string.all_content_title_all_content), + style = Title1, + color = colorResource(id = R.color.text_primary) + ) + } + + UiTitleState.OnlyUnlinked -> { + Text( + modifier = Modifier + .wrapContentSize(), + text = stringResource(id = R.string.all_content_title_only_unlinked), + style = Title1, + color = colorResource(id = R.color.text_primary) + ) + } + } +} +//endregion + +//region AllContentMenuButton +@Composable +fun AllContentMenuButton(state: MenuButtonViewState, onClick: () -> Unit) { + when (state) { + MenuButtonViewState.Hidden -> return + MenuButtonViewState.Visible -> { + Image( + modifier = Modifier + .padding(end = 12.dp) + .size(32.dp) + .bouncingClickable { onClick() }, + painter = painterResource(id = R.drawable.ic_space_list_dots), + contentDescription = "Menu icon", + contentScale = ContentScale.Inside + ) + } + } +} +//endregion + +//region AllContentTabs +@Composable +fun AllContentTabs( + tabsViewState: UiTabsState.Default, + onClick: (AllContentTab) -> Unit +) { + val scrollState = rememberLazyListState() + var selectedTab by remember { mutableStateOf(tabsViewState.selectedTab) } + + val snapFlingBehavior = rememberSnapFlingBehavior(scrollState) + + LazyRow( + state = scrollState, + flingBehavior = snapFlingBehavior, + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(start = 20.dp, end = 20.dp) + ) { + items( + count = tabsViewState.tabs.size, + key = { index -> tabsViewState.tabs[index].ordinal }, + ) { index -> + val tab = tabsViewState.tabs[index] + AllContentTabText( + tab = tab, + isSelected = tab == selectedTab, + onClick = { + selectedTab = tab + onClick(tab) + } + ) + } + } +} + +@Composable +private fun AllContentTabText( + tab: AllContentTab, + isSelected: Boolean, + onClick: () -> Unit +) { + Text( + modifier = Modifier + .wrapContentSize() + .noRippleClickable { onClick() }, + text = getTabText(tab), + style = Title2, + color = if (isSelected) colorResource(id = R.color.glyph_button) else colorResource(id = R.color.glyph_active), + maxLines = 1 + ) +} + +@Composable +private fun getTabText(tab: AllContentTab): String { + return when (tab) { + AllContentTab.PAGES -> stringResource(id = R.string.all_content_title_tab_pages) + AllContentTab.FILES -> stringResource(id = R.string.all_content_title_tab_files) + AllContentTab.MEDIA -> stringResource(id = R.string.all_content_title_tab_media) + AllContentTab.BOOKMARKS -> stringResource(id = R.string.all_content_title_tab_bookmarks) + AllContentTab.TYPES -> stringResource(id = R.string.all_content_title_tab_objetc_types) + AllContentTab.RELATIONS -> stringResource(id = R.string.all_content_title_tab_relations) + AllContentTab.LISTS -> stringResource(id = R.string.all_content_title_tab_lists) + } +} + +@DefaultPreviews +@Composable +private fun AllContentTabsPreview() { + AllContentTabs( + tabsViewState = UiTabsState.Default( + tabs = listOf( + AllContentTab.PAGES, + AllContentTab.FILES, + AllContentTab.MEDIA, + AllContentTab.BOOKMARKS, + AllContentTab.TYPES, + AllContentTab.RELATIONS + ), + selectedTab = AllContentTab.MEDIA + ), + onClick = {} + ) +} + +//endregion + +//region SearchBar +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun AllContentSearchBar(onQueryChanged: (String) -> Unit) { + + val interactionSource = remember { MutableInteractionSource() } + val focus = LocalFocusManager.current + val focusRequester = FocusRequester() + + val selectionColors = TextSelectionColors( + backgroundColor = colorResource(id = R.color.cursor_color).copy( + alpha = 0.2f + ), + handleColor = colorResource(id = R.color.cursor_color), + ) + + var query by remember { mutableStateOf(TextFieldValue()) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .background( + color = colorResource(id = R.color.shape_transparent), + shape = RoundedCornerShape(10.dp) + ) + .height(40.dp), + verticalAlignment = Alignment.CenterVertically + + ) { + Image( + painter = painterResource(id = R.drawable.ic_search_18), + contentDescription = "Search icon", + modifier = Modifier + .align(Alignment.CenterVertically) + .padding( + start = 11.dp + ) + ) + CompositionLocalProvider(value = LocalTextSelectionColors provides selectionColors) { + + BasicTextField( + value = query, + modifier = Modifier + .weight(1.0f) + .padding(start = 6.dp) + .align(Alignment.CenterVertically) + .focusRequester(focusRequester), + textStyle = BodyRegular.copy( + color = colorResource(id = R.color.text_primary) + ), + onValueChange = { input -> + query = input.also { + onQueryChanged(input.text) + } + }, + singleLine = true, + maxLines = 1, + keyboardActions = KeyboardActions( + onDone = { + focus.clearFocus(true) + } + ), + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.OutlinedTextFieldDecorationBox( + value = query.text, + innerTextField = innerTextField, + enabled = true, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + placeholder = { + Text( + text = stringResource(id = R.string.search), + style = BodyRegular.copy( + color = colorResource(id = R.color.text_tertiary) + ) + ) + }, + colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Transparent, + cursorColor = colorResource(id = R.color.cursor_color), + ), + border = {}, + contentPadding = PaddingValues() + ) + }, + cursorBrush = SolidColor(colorResource(id = R.color.palette_system_blue)), + ) + } + Spacer(Modifier.width(9.dp)) + AnimatedVisibility( + visible = query.text.isNotEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)) + ) { + Image( + painter = painterResource(id = R.drawable.ic_clear_18), + contentDescription = "Clear icon", + modifier = Modifier + .padding(end = 9.dp) + .noRippleClickable { + query = TextFieldValue().also { + onQueryChanged("") + } + } + ) + } + } +} + +@DefaultPreviews +@Composable +private fun AllContentSearchBarPreview() { + AllContentSearchBar() {} +} +//endregion \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0bcbc74b4b..f0c53b3558 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -64,6 +64,7 @@ coilComposeVersion = '2.6.0' sentryVersion = '7.6.0' composeQrCodeVersion = '1.0.1' +fragmentComposeVersion = "1.8.3" [libraries] middleware = { module = "io.anyproto:anytype-heart-android", version.ref = "middlewareVersion" } @@ -151,6 +152,7 @@ navigationCompose = { module = "androidx.navigation:navigation-compose", version appUpdater = { module = "com.github.PLPsiSoft:AndroidAppUpdater", version = "9913ce80da7871c84af24b9adc2bf2414ca294f0" } composeQrCode = { module = "com.lightspark:compose-qr-code", version.ref = "composeQrCodeVersion" } playBilling = { module = "com.android.billingclient:billing", version = "7.0.0" } +fragmentCompose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "fragmentComposeVersion" } [bundles] diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index ab815f2692..38c16d9af1 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -1748,6 +1748,35 @@ Please provide specific details of your needs here. New object My spaces - All content + All objects + Only unlinked + Unlinked objects that do not have a direct link or backlink with other objects in the graph. + + View Bin + + Pages + Lists + Media + Bookmarks + Files + Object Types + Relations + Sort by + + Z → A + A → Z + Newest first + Oldest first + Date updated + Date created + Name + + All objects + + Today + Yesterday + Previous 7 days + Previous 14 days + \ No newline at end of file diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/home/HomeScreenViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/home/HomeScreenViewModel.kt index 453462eaf3..09c64f13d8 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/home/HomeScreenViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/home/HomeScreenViewModel.kt @@ -1035,7 +1035,11 @@ class HomeScreenViewModel( ) } WidgetView.AllContent.ALL_CONTENT_WIDGET_ID -> { - // TODO Proceed with navigation + navigation( + Navigation.OpenAllContent( + space = space + ) + ) } } } @@ -2036,6 +2040,7 @@ class HomeScreenViewModel( data class OpenSet(val ctx: Id, val space: Id, val view: Id?) : Navigation() data class ExpandWidget(val subscription: Subscription, val space: Id) : Navigation() data class OpenLibrary(val space: Id) : Navigation() + data class OpenAllContent(val space: Id) : Navigation() } class Factory @Inject constructor( diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/navigation/AppNavigation.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/navigation/AppNavigation.kt index 77a6d2cf94..5d93f30dc1 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/navigation/AppNavigation.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/navigation/AppNavigation.kt @@ -51,6 +51,8 @@ interface AppNavigation { fun openTemplatesModal(typeId: Id) + fun openAllContent(space: Id) + sealed class Command { data object Exit : Command() diff --git a/settings.gradle b/settings.gradle index 0970583d6d..268288b102 100644 --- a/settings.gradle +++ b/settings.gradle @@ -65,3 +65,4 @@ include ':localization' include ':payments' include ':gallery-experience' include ':feature-discussions' +include ':feature-all-content'