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 d89f33e316..df0711b4df 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 @@ -96,7 +96,7 @@ import com.anytypeio.anytype.di.feature.settings.DaggerFilesStorageComponent import com.anytypeio.anytype.di.feature.settings.DaggerSpacesStorageComponent import com.anytypeio.anytype.di.feature.settings.LogoutWarningModule import com.anytypeio.anytype.di.feature.settings.ProfileModule -import com.anytypeio.anytype.di.feature.sharing.DaggerAddToAnytypeComponent +import com.anytypeio.anytype.di.feature.sharing.DaggerSharingComponent import com.anytypeio.anytype.di.feature.spaces.DaggerCreateSpaceComponent import com.anytypeio.anytype.di.feature.spaces.DaggerSpaceListComponent import com.anytypeio.anytype.di.feature.spaces.DaggerSpaceSettingsComponent @@ -129,7 +129,6 @@ import com.anytypeio.anytype.presentation.relations.RelationAddViewModelBase import com.anytypeio.anytype.presentation.relations.RelationListViewModel import com.anytypeio.anytype.feature_properties.space.SpacePropertiesViewModel import com.anytypeio.anytype.presentation.publishtoweb.MySitesViewModel -import com.anytypeio.anytype.presentation.home.HomeScreenViewModel import com.anytypeio.anytype.presentation.home.HomeScreenVmParams import com.anytypeio.anytype.presentation.widgets.CreateChatObjectViewModel import com.anytypeio.anytype.presentation.publishtoweb.PublishToWebViewModel @@ -885,8 +884,8 @@ class ComponentManager( .create(findComponentDependencies()) } - val addToAnytypeComponent = Component { - DaggerAddToAnytypeComponent + val sharingComponent = Component { + DaggerSharingComponent .factory() .create(findComponentDependencies()) } diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/sharing/AddToAnytypeDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/sharing/AddToAnytypeDI.kt deleted file mode 100644 index 16295522ff..0000000000 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/sharing/AddToAnytypeDI.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.anytypeio.anytype.di.feature.sharing - -import androidx.lifecycle.ViewModelProvider -import com.anytypeio.anytype.analytics.base.Analytics -import com.anytypeio.anytype.core_utils.di.scope.PerDialog -import com.anytypeio.anytype.data.auth.event.EventProcessDropFilesDateChannel -import com.anytypeio.anytype.data.auth.event.EventProcessDropFilesRemoteChannel -import com.anytypeio.anytype.di.common.ComponentDependencies -import com.anytypeio.anytype.domain.account.AwaitAccountStartManager -import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers -import com.anytypeio.anytype.domain.block.repo.BlockRepository -import com.anytypeio.anytype.domain.config.ConfigStorage -import com.anytypeio.anytype.domain.config.UserSettingsRepository -import com.anytypeio.anytype.domain.device.FileSharer -import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer -import com.anytypeio.anytype.domain.misc.UrlBuilder -import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer -import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider -import com.anytypeio.anytype.domain.workspace.EventProcessDropFilesChannel -import com.anytypeio.anytype.domain.workspace.SpaceManager -import com.anytypeio.anytype.middleware.EventProxy -import com.anytypeio.anytype.middleware.interactor.EventProcessDropFilesMiddlewareChannel -import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate -import com.anytypeio.anytype.presentation.sharing.AddToAnytypeViewModel -import com.anytypeio.anytype.ui.sharing.SharingFragment -import dagger.Binds -import dagger.Component -import dagger.Module -import dagger.Provides - -@Component( - dependencies = [AddToAnytypeDependencies::class], - modules = [ - AddToAnytypeModule::class, - AddToAnytypeModule.Declarations::class - ] -) -@PerDialog -interface AddToAnytypeComponent { - @Component.Factory - interface Factory { - fun create(dependency: AddToAnytypeDependencies): AddToAnytypeComponent - } - - fun inject(fragment: SharingFragment) -} - -@Module -object AddToAnytypeModule { - - @Provides - @PerDialog - fun provideEventProcessRemoteChannel( - proxy: EventProxy - ): EventProcessDropFilesRemoteChannel = EventProcessDropFilesMiddlewareChannel(events = proxy) - - @Provides - @PerDialog - fun provideEventProcessDateChannel( - channel: EventProcessDropFilesRemoteChannel - ): EventProcessDropFilesChannel = EventProcessDropFilesDateChannel(channel = channel) - - @Module - interface Declarations { - @PerDialog - @Binds - fun factory(factory: AddToAnytypeViewModel.Factory): ViewModelProvider.Factory - } -} - -interface AddToAnytypeDependencies : ComponentDependencies { - fun blockRepo(): BlockRepository - fun spaceManager(): SpaceManager - fun dispatchers(): AppCoroutineDispatchers - fun userSettings(): UserSettingsRepository - fun container(): StorelessSubscriptionContainer - fun urlBuilder(): UrlBuilder - fun awaitAccountStartedManager(): AwaitAccountStartManager - fun analytics(): Analytics - fun fileSharer(): FileSharer - fun permissions(): UserPermissionProvider - fun analyticSpaceHelper(): AnalyticSpaceHelperDelegate - fun eventProxy(): EventProxy - fun spaceViewSubscriptionContainer(): SpaceViewSubscriptionContainer -} \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/sharing/SharingDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/sharing/SharingDI.kt new file mode 100644 index 0000000000..9bb4cc45fe --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/sharing/SharingDI.kt @@ -0,0 +1,177 @@ +package com.anytypeio.anytype.di.feature.sharing + +import androidx.lifecycle.ViewModelProvider +import com.anytypeio.anytype.analytics.base.Analytics +import com.anytypeio.anytype.core_utils.di.scope.PerModal +import com.anytypeio.anytype.di.common.ComponentDependencies +import com.anytypeio.anytype.domain.account.AwaitAccountStartManager +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.block.repo.BlockRepository +import com.anytypeio.anytype.domain.chats.AddChatMessage +import com.anytypeio.anytype.domain.block.interactor.CreateBlock +import com.anytypeio.anytype.domain.config.UserSettingsRepository +import com.anytypeio.anytype.domain.device.FileSharer +import com.anytypeio.anytype.domain.media.UploadFile +import com.anytypeio.anytype.domain.misc.UrlBuilder +import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer +import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider +import com.anytypeio.anytype.domain.objects.CreateBookmarkObject +import com.anytypeio.anytype.domain.objects.CreateObjectFromUrl +import com.anytypeio.anytype.domain.objects.CreatePrefilledNote +import com.anytypeio.anytype.domain.page.AddBackLinkToObject +import com.anytypeio.anytype.domain.page.CloseObject +import com.anytypeio.anytype.domain.page.OpenPage +import com.anytypeio.anytype.domain.primitives.FieldParser +import com.anytypeio.anytype.domain.search.SearchObjects +import com.anytypeio.anytype.domain.workspace.SpaceManager +import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate +import com.anytypeio.anytype.presentation.sharing.SharingViewModel +import dagger.Binds +import dagger.Component +import dagger.Module +import dagger.Provides + +/** + * DI Component for the redesigned sharing extension. + * Provides dependencies for SharingViewModel which handles three flows: + * - Flow 1: Chat Space (direct message sending) + * - Flow 2: Data Space without chat (object creation) + * - Flow 3: Data Space with chat (hybrid) + */ +@Component( + dependencies = [SharingDependencies::class], + modules = [ + SharingModule::class, + SharingModule.Declarations::class + ] +) +@PerModal +interface SharingComponent { + @Component.Factory + interface Factory { + fun create(dependency: SharingDependencies): SharingComponent + } + + /** + * Provides the ViewModel factory for creating SharingViewModel instances. + * Used by MainActivity's Compose overlay for the pure Compose modal sheet. + */ + fun viewModelFactory(): ViewModelProvider.Factory +} + +@Module +object SharingModule { + + @Provides + @PerModal + fun provideCreateBookmarkObject( + repo: BlockRepository, + dispatchers: AppCoroutineDispatchers + ): CreateBookmarkObject = CreateBookmarkObject(repo) + + @Provides + @PerModal + fun provideCreatePrefilledNote( + repo: BlockRepository, + dispatchers: AppCoroutineDispatchers + ): CreatePrefilledNote = CreatePrefilledNote(repo, dispatchers) + + @Provides + @PerModal + fun provideCreateObjectFromUrl( + repo: BlockRepository, + dispatchers: AppCoroutineDispatchers + ): CreateObjectFromUrl = CreateObjectFromUrl(repo, dispatchers) + + @Provides + @PerModal + fun provideAddChatMessage( + repo: BlockRepository, + dispatchers: AppCoroutineDispatchers + ): AddChatMessage = AddChatMessage(repo, dispatchers) + + @Provides + @PerModal + fun provideUploadFile( + repo: BlockRepository, + dispatchers: AppCoroutineDispatchers + ): UploadFile = UploadFile(repo, dispatchers) + + @Provides + @PerModal + fun provideSearchObjects( + repo: BlockRepository + ): SearchObjects = SearchObjects(repo) + + @Provides + @PerModal + fun provideOpenPage( + repo: BlockRepository, + settings: UserSettingsRepository, + dispatchers: AppCoroutineDispatchers + ): OpenPage = OpenPage( + repo = repo, + settings = settings, + dispatchers = dispatchers + ) + + @Provides + @PerModal + fun provideCloseObject( + repo: BlockRepository, + dispatchers: AppCoroutineDispatchers + ): CloseObject = CloseObject( + repo = repo, + dispatchers = dispatchers + ) + + @Provides + @PerModal + fun provideCreateBlock( + repo: BlockRepository, + dispatchers: AppCoroutineDispatchers + ): CreateBlock = CreateBlock( + repo = repo, + dispatchers = dispatchers + ) + + @Provides + @PerModal + fun provideAddBackLinkToObject( + openPage: OpenPage, + createBlock: CreateBlock, + closeObject: CloseObject, + dispatchers: AppCoroutineDispatchers + ): AddBackLinkToObject = AddBackLinkToObject( + openPage = openPage, + createBlock = createBlock, + closeObject = closeObject, + dispatchers = dispatchers + ) + + @Module + interface Declarations { + @PerModal + @Binds + fun factory(factory: SharingViewModel.Factory): ViewModelProvider.Factory + } +} + +/** + * Dependencies required by the SharingComponent. + * These are provided by the parent component (MainComponent). + */ +interface SharingDependencies : ComponentDependencies { + fun blockRepo(): BlockRepository + fun spaceManager(): SpaceManager + fun dispatchers(): AppCoroutineDispatchers + fun urlBuilder(): UrlBuilder + fun awaitAccountStartedManager(): AwaitAccountStartManager + fun analytics(): Analytics + fun fileSharer(): FileSharer + fun permissions(): UserPermissionProvider + fun analyticSpaceHelper(): AnalyticSpaceHelperDelegate + fun spaceViewSubscriptionContainer(): SpaceViewSubscriptionContainer + fun fieldParser(): FieldParser + fun userSettingsRepository(): UserSettingsRepository +} 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 7c638a3cdd..b31da732db 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 @@ -57,7 +57,7 @@ import com.anytypeio.anytype.di.feature.settings.FilesStorageDependencies import com.anytypeio.anytype.di.feature.settings.LogoutWarningSubComponent import com.anytypeio.anytype.di.feature.settings.ProfileSubComponent import com.anytypeio.anytype.di.feature.settings.SpacesStorageDependencies -import com.anytypeio.anytype.di.feature.sharing.AddToAnytypeDependencies +import com.anytypeio.anytype.di.feature.sharing.SharingDependencies import com.anytypeio.anytype.di.feature.spaces.CreateSpaceDependencies import com.anytypeio.anytype.di.feature.spaces.SpaceListDependencies import com.anytypeio.anytype.di.feature.spaces.SpaceSettingsDependencies @@ -123,7 +123,6 @@ interface MainComponent : SelectObjectTypeDependencies, SpacesStorageDependencies, AppPreferencesDependencies, - AddToAnytypeDependencies, ShareSpaceDependencies, SpaceJoinRequestDependencies, RequestJoinSpaceDependencies, @@ -154,7 +153,8 @@ interface MainComponent : PublishToWebDependencies, MySitesDependencies, MediaDependencies, - CreateChatObjectDependencies + CreateChatObjectDependencies, + SharingDependencies { fun inject(app: AndroidApplication) @@ -293,11 +293,6 @@ abstract class ComponentDependenciesModule { @ComponentDependenciesKey(AppPreferencesDependencies::class) abstract fun providePreferencesDependencies(component: MainComponent): ComponentDependencies - @Binds - @IntoMap - @ComponentDependenciesKey(AddToAnytypeDependencies::class) - abstract fun provideAddToAnytypeDependencies(component: MainComponent): ComponentDependencies - @Binds @IntoMap @ComponentDependenciesKey(ShareSpaceDependencies::class) @@ -452,4 +447,9 @@ abstract class ComponentDependenciesModule { @IntoMap @ComponentDependenciesKey(CreateChatObjectDependencies::class) abstract fun createChatObjectDependencies(component: MainComponent): ComponentDependencies + + @Binds + @IntoMap + @ComponentDependenciesKey(SharingDependencies::class) + abstract fun sharingDependencies(component: MainComponent): ComponentDependencies } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt b/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt index c6d64322de..51f03d7df1 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/main/MainActivity.kt @@ -10,15 +10,25 @@ import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.platform.ComposeView +import com.anytypeio.anytype.core_ui.features.sharing.SharingModalSheet +import com.anytypeio.anytype.presentation.sharing.IntentToSharedContentConverter +import com.anytypeio.anytype.presentation.sharing.SharingCommand +import com.anytypeio.anytype.presentation.sharing.SharingViewModel import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavOptions @@ -39,7 +49,10 @@ import com.anytypeio.anytype.core_ui.extensions.getGradientDrawableResource import com.anytypeio.anytype.core_utils.ext.Mimetype import com.anytypeio.anytype.core_utils.ext.parseActionSendMultipleUris import com.anytypeio.anytype.core_utils.ext.parseActionSendUri +import com.anytypeio.anytype.core_utils.ext.showSnackbar import com.anytypeio.anytype.core_utils.ext.toast +import com.anytypeio.anytype.presentation.sharing.SharedContent +import com.google.android.material.snackbar.Snackbar import com.anytypeio.anytype.core_utils.intents.ActivityCustomTabsHelper import com.anytypeio.anytype.core_utils.tools.FeatureToggles import com.anytypeio.anytype.device.AnytypePushService @@ -72,7 +85,6 @@ import com.anytypeio.anytype.ui.payments.MembershipFragment import com.anytypeio.anytype.ui.primitives.ObjectTypeFragment import com.anytypeio.anytype.ui.profile.ParticipantFragment import com.anytypeio.anytype.ui.sets.ObjectSetFragment -import com.anytypeio.anytype.ui.sharing.SharingFragment import com.anytypeio.anytype.ui.vault.SpacesIntroductionScreen import com.anytypeio.anytype.ui_settings.appearance.ThemeApplicator import javax.inject.Inject @@ -162,42 +174,6 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr ) ) } - is Command.Sharing.Text -> { - SharingFragment.text(command.data).show( - supportFragmentManager, - SHARE_DIALOG_LABEL - ) - } - is Command.Sharing.Image -> { - SharingFragment.image(command.uri).show( - supportFragmentManager, - SHARE_DIALOG_LABEL - ) - } - is Command.Sharing.Images -> { - SharingFragment.images(command.uris).show( - supportFragmentManager, - SHARE_DIALOG_LABEL - ) - } - is Command.Sharing.Videos -> { - SharingFragment.videos(command.uris).show( - supportFragmentManager, - SHARE_DIALOG_LABEL - ) - } - is Command.Sharing.Files -> { - SharingFragment.files(command.uris).show( - supportFragmentManager, - SHARE_DIALOG_LABEL - ) - } - is Command.Sharing.File -> { - SharingFragment.file(command.uri).show( - supportFragmentManager, - SHARE_DIALOG_LABEL - ) - } is Command.Error -> { toast(command.msg) } @@ -568,87 +544,24 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr } /** - * Main activity is responsible only for checking new deep links. - * Launch deep links are handled by SplashFragment. + * Single entry point for all share intents. + * Deep links are checked here, all other content is routed to SharingFragment. + * SharingFragment handles MIME type detection internally. */ private fun proceedWithShareIntent(intent: Intent, checkDeepLink: Boolean = false) { - if (BuildConfig.DEBUG) Timber.d("Proceeding with share intent: $intent") - when { - intent.type == Mimetype.MIME_TEXT_PLAIN.value -> { - handleTextShare( - intent = intent, - checkDeepLink = checkDeepLink - ) - } - intent.type?.startsWith(SHARE_IMAGE_INTENT_PATTERN) == true -> { - proceedWithImageShareIntent(intent) - } - intent.type?.startsWith(SHARE_VIDEO_INTENT_PATTERN) == true -> { - proceedWithVideoShareIntent(intent) - } - intent.type?.startsWith(SHARE_FILE_INTENT_PATTERN) == true -> { - proceedWithFileShareIntent(intent) - } - intent.type == Mimetype.MIME_FILE_ALL.value -> { - proceedWithFileShareIntent(intent) - } - else -> Timber.e("Unexpected scenario: ${intent.type}") - } - } + if (BuildConfig.DEBUG) Timber.d("Proceeding with share intent: type=${intent.type}, action=${intent.action}") - private fun handleTextShare(intent: Intent, checkDeepLink: Boolean) { - val raw = intent.getStringExtra(Intent.EXTRA_TEXT) ?: intent.dataString ?: return - - when { - checkDeepLink && DefaultDeepLinkResolver.isDeepLink(raw) -> { + // Check for deep links in text content first + if (checkDeepLink && intent.type == Mimetype.MIME_TEXT_PLAIN.value) { + val raw = intent.getStringExtra(Intent.EXTRA_TEXT) ?: intent.dataString + if (raw != null && DefaultDeepLinkResolver.isDeepLink(raw)) { vm.handleNewDeepLink(DefaultDeepLinkResolver.resolve(raw)) - } - raw.isNotEmpty() && !DefaultDeepLinkResolver.isDeepLink(raw) -> { - vm.onIntentTextShare(raw) - } - else -> { - Timber.d("handleTextShare, skip handle intent :$raw") - } - } - } - - private fun proceedWithFileShareIntent(intent: Intent) { - if (intent.action == Intent.ACTION_SEND_MULTIPLE) { - vm.onIntentMultipleFilesShare(intent.parseActionSendMultipleUris()) - } else { - val uri = intent.parseActionSendUri() - if (uri != null) { - vm.onIntentMultipleFilesShare(listOf(uri)) - } else { - toast("Could not parse URI") - } - } - } - - private fun proceedWithImageShareIntent(intent: Intent) { - if (intent.action == Intent.ACTION_SEND_MULTIPLE) { - vm.onIntentMultipleImageShare(uris = intent.parseActionSendMultipleUris()) - } else { - val uri = intent.parseActionSendUri() - if (uri != null) { - vm.onIntentMultipleImageShare(listOf(uri)) - } else { - toast("Could not parse URI") + return } } - } - private fun proceedWithVideoShareIntent(intent: Intent) { - if (intent.action == Intent.ACTION_SEND_MULTIPLE) { - vm.onIntentMultipleVideoShare(uris = intent.parseActionSendMultipleUris()) - } else { - val uri = intent.parseActionSendUri() - if (uri != null) { - vm.onIntentMultipleVideoShare(listOf(uri)) - } else { - toast("Could not parse URI") - } - } + // Single entry point: pass intent to SharingFragment via ViewModel + vm.onShareIntent(intent) } private fun proceedWithNotificationIntent(intent: Intent) { @@ -797,14 +710,20 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr override fun nav(): AppNavigation = navigator /** - * Sets up feature introductions using Compose. - * Shows SpacesIntroductionScreen only after account starts and only to existing users. + * Sets up Compose overlays for modals and introductions. + * Handles: + * - SpacesIntroductionScreen for existing users + * - SharingModalSheet for share intents */ + @OptIn(ExperimentalMaterial3Api::class) private fun setupFeatureIntroductions() { findViewById(R.id.composeOverlay).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val showSpacesIntroduction by vm.showSpacesIntroduction.collectAsState() + val sharingIntent by vm.sharingIntent.collectAsState() + + // Spaces Introduction Screen if (showSpacesIntroduction != null) { SpacesIntroductionScreen( onDismiss = { @@ -818,10 +737,151 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr } ) } + + // Sharing Modal Sheet + sharingIntent?.let { intent -> + SharingModalHost( + intent = intent, + onDismiss = { + vm.onSharingDismissed() + } + ) + } } } } + /** + * Composable host for the sharing modal that manages SharingViewModel lifecycle. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun SharingModalHost( + intent: Intent, + onDismiss: () -> Unit + ) { + // Get or create SharingComponent and ViewModel + val sharingComponent = remember { + componentManager().sharingComponent.get() + } + + // Create ViewModel using the factory from the component + val viewModel = remember { + val factory = sharingComponent.viewModelFactory() + factory.create(SharingViewModel::class.java) + } + + // Convert intent to SharedContent and pass to ViewModel + LaunchedEffect(intent) { + val sharedContent = IntentToSharedContentConverter.convert(intent) + viewModel.onSharedDataReceived(sharedContent) + } + + // Collect screen state + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + + // Handle commands from ViewModel + LaunchedEffect(Unit) { + viewModel.commands.collect { command -> + when (command) { + is SharingCommand.Dismiss -> { + onDismiss() + } + is SharingCommand.ShowToast -> { + toast(command.message) + } + is SharingCommand.ShowSnackbarWithOpenAction -> { + // Dismiss modal and show Snackbar + onDismiss() + val message = getSnackbarMessage( + contentType = command.contentType, + destinationName = command.destinationName, + spaceName = command.spaceName + ) + findViewById(android.R.id.content)?.showSnackbar( + msg = message, + length = Snackbar.LENGTH_INDEFINITE, + actionMessage = getString(R.string.button_ok), + action = { + onDismiss() + } + ) + } + } + } + } + + // Clean up when the composable leaves composition + DisposableEffect(Unit) { + onDispose { + componentManager().sharingComponent.release() + } + } + + // Render the modal sheet + SharingModalSheet( + state = screenState, + onSpaceSelected = viewModel::onSpaceSelected, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onCommentChanged = viewModel::onCommentChanged, + onSendClicked = viewModel::onSendClicked, + onObjectSelected = viewModel::onObjectSelected, + onBackPressed = viewModel::onBackPressed, + onDismiss = onDismiss, + onRetryClicked = viewModel::onRetryClicked + ) + } + + /** + * Returns content-specific Snackbar message based on the shared content type. + * If spaceName is provided, uses "linked to" format; otherwise uses "added to" format. + */ + private fun getSnackbarMessage( + contentType: SharedContent, + destinationName: String, + spaceName: String? + ): String { + return if (spaceName != null) { + // Linked to object format: "Content linked to 'ObjectName' in 'SpaceName'" + val stringResId = when (contentType) { + is SharedContent.Text -> R.string.sharing_snackbar_text_linked + is SharedContent.Url -> R.string.sharing_snackbar_link_linked + is SharedContent.SingleMedia -> when (contentType.type) { + SharedContent.MediaType.IMAGE -> R.string.sharing_snackbar_image_linked + SharedContent.MediaType.VIDEO -> R.string.sharing_snackbar_video_linked + SharedContent.MediaType.AUDIO -> R.string.sharing_snackbar_audio_linked + SharedContent.MediaType.PDF -> R.string.sharing_snackbar_pdf_linked + SharedContent.MediaType.FILE -> R.string.sharing_snackbar_file_linked + } + is SharedContent.MultipleMedia -> when (contentType.type) { + SharedContent.MediaType.IMAGE -> R.string.sharing_snackbar_images_linked + else -> R.string.sharing_snackbar_files_linked + } + is SharedContent.Mixed -> R.string.sharing_snackbar_content_linked + } + getString(stringResId, destinationName, spaceName) + } else { + // Added to space/chat format: "Content added to 'Name'" + val stringResId = when (contentType) { + is SharedContent.Text -> R.string.sharing_snackbar_text_added + is SharedContent.Url -> R.string.sharing_snackbar_link_added + is SharedContent.SingleMedia -> when (contentType.type) { + SharedContent.MediaType.IMAGE -> R.string.sharing_snackbar_image_added + SharedContent.MediaType.VIDEO -> R.string.sharing_snackbar_video_added + SharedContent.MediaType.AUDIO -> R.string.sharing_snackbar_audio_added + SharedContent.MediaType.PDF -> R.string.sharing_snackbar_pdf_added + SharedContent.MediaType.FILE -> R.string.sharing_snackbar_file_added + } + is SharedContent.MultipleMedia -> when (contentType.type) { + SharedContent.MediaType.IMAGE -> R.string.sharing_snackbar_images_added + else -> R.string.sharing_snackbar_files_added + } + is SharedContent.Mixed -> R.string.sharing_snackbar_content_added + } + getString(stringResId, destinationName) + } + } + fun inject() { componentManager().mainEntryComponent.get().inject(this) } @@ -829,11 +889,4 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr fun release() { componentManager().mainEntryComponent.release() } - - companion object { - const val SHARE_DIALOG_LABEL = "anytype.dialog.share.label" - const val SHARE_IMAGE_INTENT_PATTERN = "image/" - const val SHARE_VIDEO_INTENT_PATTERN = "video/" - const val SHARE_FILE_INTENT_PATTERN = "application/" - } } diff --git a/app/src/main/java/com/anytypeio/anytype/ui/sharing/Sharing.kt b/app/src/main/java/com/anytypeio/anytype/ui/sharing/Sharing.kt deleted file mode 100644 index ef22268c0d..0000000000 --- a/app/src/main/java/com/anytypeio/anytype/ui/sharing/Sharing.kt +++ /dev/null @@ -1,644 +0,0 @@ -package com.anytypeio.anytype.ui.sharing - -import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -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.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Divider -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.ProgressIndicatorDefaults -import androidx.compose.material.Text -import androidx.compose.material3.DropdownMenu -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 -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.anytypeio.anytype.R -import com.anytypeio.anytype.core_models.Id -import com.anytypeio.anytype.core_models.ObjectWrapper -import com.anytypeio.anytype.core_ui.widgets.objectIcon.SpaceIconView -import com.anytypeio.anytype.core_ui.foundation.noRippleClickable -import com.anytypeio.anytype.core_ui.views.BodyRegular -import com.anytypeio.anytype.core_ui.views.ButtonPrimary -import com.anytypeio.anytype.core_ui.views.ButtonPrimaryLoading -import com.anytypeio.anytype.core_ui.views.ButtonSecondary -import com.anytypeio.anytype.core_ui.views.ButtonSize -import com.anytypeio.anytype.core_ui.views.Caption1Medium -import com.anytypeio.anytype.core_ui.views.Title2 -import com.anytypeio.anytype.core_utils.ui.MultipleEventCutter -import com.anytypeio.anytype.core_utils.ui.get -import com.anytypeio.anytype.presentation.sharing.AddToAnytypeViewModel -import com.anytypeio.anytype.presentation.sharing.AddToAnytypeViewModel.SpaceView -import com.anytypeio.anytype.presentation.spaces.SpaceIconView - -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Light Mode") -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode") -@Composable -fun AddToAnytypeScreenUrlPreview() { - AddToAnytypeScreen( - data = SharingData.Url("https://en.wikipedia.org/wiki/Walter_Benjamin"), - onCancelClicked = {}, - onAddClicked = {}, - spaces = listOf( - SpaceView( - obj = ObjectWrapper.SpaceView(map = mapOf("name" to "Space 1")), - isSelected = true, - icon = SpaceIconView.DataSpace.Placeholder() - ) - ), - onSelectSpaceClicked = {}, - onOpenClicked = {}, - content = "https://en.wikipedia.org/wiki/Walter_Benjamin", - progressState = AddToAnytypeViewModel.ProgressState.Done(""), - //progressState = AddToAnytypeViewModel.ProgressState.Error(" I understand that contributing to this repository will require me to agree with the CLA I understand that contributing to this repository will require me to agree with the CLA\n") - //progressState = AddToAnytypeViewModel.ProgressState.Progress(processId = "dasda", progress = 0.8f) - ) -} - -@Preview -@Composable -fun AddToAnytypeScreenNotePreview() { - AddToAnytypeScreen( - data = SharingData.Text("The Work of Art in the Age of its Technological Reproducibility"), - onCancelClicked = {}, - onAddClicked = {}, - spaces = listOf( - SpaceView( - obj = ObjectWrapper.SpaceView(map = mapOf()), - isSelected = false, - icon = SpaceIconView.DataSpace.Placeholder() - ) - ), - onSelectSpaceClicked = {}, - content = "", - progressState = AddToAnytypeViewModel.ProgressState.Progress( - processId = "dasda", - progress = 0.8f, - wrapperObjId = "" - ), - onOpenClicked = {}, - ) -} - -@Composable -fun AddToAnytypeScreen( - content: String, - spaces: List, - data: SharingData, - progressState: AddToAnytypeViewModel.ProgressState, - onCancelClicked: () -> Unit, - onAddClicked: (SaveAsOption) -> Unit, - onSelectSpaceClicked: (SpaceView) -> Unit, - onOpenClicked: (Id) -> Unit -) { - var isSaveAsMenuExpanded by remember { mutableStateOf(false) } - val items = when (data) { - is SharingData.Url -> listOf(SAVE_AS_NOTE, SAVE_AS_BOOKMARK) - is SharingData.Image -> listOf(SAVE_AS_IMAGE) - is SharingData.File -> listOf(SAVE_AS_FILE) - is SharingData.Images -> listOf(SAVE_AS_IMAGES) - is SharingData.Files -> listOf(SAVE_AS_FILES) - is SharingData.Text -> listOf(SAVE_AS_NOTE) - is SharingData.Videos -> listOf(SAVE_AS_VIDEOS) - } - var selectedIndex by remember { - mutableStateOf( - when (data) { - is SharingData.Url -> SAVE_AS_BOOKMARK - is SharingData.Image -> SAVE_AS_IMAGE - is SharingData.File -> SAVE_AS_FILE - is SharingData.Images -> SAVE_AS_IMAGES - is SharingData.Files -> SAVE_AS_FILES - is SharingData.Text -> SAVE_AS_NOTE - is SharingData.Videos -> SAVE_AS_VIDEOS - } - ) - } - - Column(modifier = Modifier.fillMaxWidth()) { - val throttler = remember { - MultipleEventCutter.Companion.get(interval = DROPDOWN_MENU_VISIBILITY_WINDOW_INTERVAL) - } - Header() - DataSection(content) - Column( - modifier = Modifier - .fillMaxWidth() - .noRippleClickable { - throttler.processEvent { - isSaveAsMenuExpanded = !isSaveAsMenuExpanded - } - } - ) { - Text( - text = stringResource(R.string.sharing_menu_save_as_section_name), - modifier = Modifier - .padding(top = 14.dp, start = 20.dp), - style = Caption1Medium, - color = colorResource(id = R.color.text_secondary) - ) - - Text( - text = when (selectedIndex) { - SAVE_AS_BOOKMARK -> stringResource(id = R.string.sharing_menu_save_as_bookmark_option) - SAVE_AS_IMAGE -> stringResource(id = R.string.sharing_menu_save_as_image_option) - SAVE_AS_FILE -> stringResource(id = R.string.sharing_menu_save_as_file_option) - SAVE_AS_IMAGES -> stringResource(id = R.string.sharing_menu_save_as_images_option) - SAVE_AS_FILES -> stringResource(id = R.string.sharing_menu_save_as_files_option) - SAVE_AS_VIDEOS -> stringResource(id = R.string.sharing_menu_save_as_videos_option) - else -> stringResource(id = R.string.sharing_menu_save_as_note_option) - }, - modifier = Modifier - .padding(top = 6.dp, start = 20.dp, bottom = 14.dp), - style = BodyRegular, - color = colorResource(id = R.color.text_primary) - ) - if (items.size > 1) { - DropdownMenu( - expanded = isSaveAsMenuExpanded, - onDismissRequest = { - throttler.processEvent { - isSaveAsMenuExpanded = false - } - }, - modifier = Modifier.background( - color = colorResource(id = R.color.background_secondary) - ) - ) { - items.forEachIndexed { index, s -> - DropdownMenuItem( - onClick = { - selectedIndex = index - isSaveAsMenuExpanded = false - } - ) { - when (s) { - SAVE_AS_BOOKMARK -> { - Text( - text = stringResource(id = R.string.sharing_menu_save_as_bookmark_option), - style = BodyRegular, - color = colorResource(id = R.color.text_primary) - ) - } - - SAVE_AS_NOTE -> { - Text( - text = stringResource(id = R.string.sharing_menu_save_as_note_option), - style = BodyRegular, - color = colorResource(id = R.color.text_primary) - ) - } - - else -> { - // Draw nothing - } - } - } - if (index != items.lastIndex) { - Divider( - thickness = 0.5.dp, - color = colorResource(id = R.color.shape_primary) - ) - } - } - } - } - } - com.anytypeio.anytype.core_ui.foundation.Divider(paddingEnd = 20.dp, paddingStart = 20.dp) - val selected = spaces.firstOrNull { it.isSelected } - if (selected != null) { - CurrentSpaceSection( - name = selected.obj.name.orEmpty().ifEmpty { - stringResource(R.string.untitled) - }, - spaces = spaces, - onSelectSpaceClicked = onSelectSpaceClicked, - icon = selected.icon - ) - } else { - CurrentSpaceSection( - name = stringResource(id = R.string.three_dots_text_placeholder), - spaces = spaces, - onSelectSpaceClicked = onSelectSpaceClicked - ) - } - DefaultLinearProgressIndicator(progressState = progressState) - when (progressState) { - is AddToAnytypeViewModel.ProgressState.Done -> { - ButtonsDone( - progressState = progressState, - onCancelClicked = onCancelClicked, - onOpenClicked = onOpenClicked - ) - } - is AddToAnytypeViewModel.ProgressState.Error -> { - Buttons( - onCancelClicked = onCancelClicked, - selectedIndex = selectedIndex, - progressState = progressState, - onAddClicked = onAddClicked - ) - } - AddToAnytypeViewModel.ProgressState.Init -> { - Buttons( - onCancelClicked = onCancelClicked, - selectedIndex = selectedIndex, - progressState = progressState, - onAddClicked = onAddClicked - ) - } - is AddToAnytypeViewModel.ProgressState.Progress -> { - ButtonsProgress(onCancelClicked = onCancelClicked) - } - } - } -} - -@Composable -private fun DefaultLinearProgressIndicator(progressState: AddToAnytypeViewModel.ProgressState) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(46.dp), - contentAlignment = Alignment.Center - ) { - val visible = progressState is AddToAnytypeViewModel.ProgressState.Progress - AnimatedVisibility( - visible = visible, - modifier = Modifier, - enter = fadeIn() + slideInVertically { it }, - exit = fadeOut() + slideOutVertically { it } - ) { - if (progressState is AddToAnytypeViewModel.ProgressState.Progress) { - Indicator(progress = progressState.progress) - } - } - val doneVisibility = progressState is AddToAnytypeViewModel.ProgressState.Done - AnimatedVisibility( - visible = doneVisibility, - modifier = Modifier, - enter = fadeIn() + slideInVertically { it }, - exit = fadeOut() + slideOutVertically { it } - ) { - Text( - text = stringResource(id = R.string.sharing_menu_add_to_anytype_success), - style = Caption1Medium, - color = colorResource(id = R.color.palette_system_green), - modifier = Modifier.padding(top = 4.dp, start = 20.dp, end = 20.dp) - ) - } - val errorVisible = progressState is AddToAnytypeViewModel.ProgressState.Error - AnimatedVisibility( - visible = errorVisible, - modifier = Modifier, - enter = fadeIn() + slideInVertically { it }, - exit = fadeOut() + slideOutVertically { it } - ) { - if (progressState is AddToAnytypeViewModel.ProgressState.Error) { - Text( - text = stringResource( - id = R.string.sharing_menu_add_to_anytype_error, - progressState.error - ), - style = Caption1Medium, - maxLines = 2, - color = colorResource(id = R.color.palette_dark_red), - modifier = Modifier.padding(horizontal = 20.dp) - ) - } - } - } -} - -@Composable -private fun Indicator(progress: Float) { - val animatedProgress = animateFloatAsState( - targetValue = progress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - label = "" - ).value - LinearProgressIndicator( - progress = animatedProgress, - color = colorResource(id = R.color.text_primary), - modifier = Modifier - .height(6.dp) - .fillMaxWidth() - .padding(horizontal = 20.dp), - backgroundColor = colorResource(id = R.color.shape_tertiary), - strokeCap = StrokeCap.Round - ) -} - -@Composable -private fun DataSection(content: String) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - .border( - width = 1.dp, - color = colorResource(id = R.color.shape_primary), - shape = RoundedCornerShape(10.dp) - ) - ) { - Text( - text = stringResource(R.string.sharing_menu_data), - style = Caption1Medium, - color = colorResource(id = R.color.text_secondary), - modifier = Modifier - .padding( - top = 10.dp, - start = 16.dp, - end = 16.dp - ) - ) - Text( - text = content, - style = BodyRegular, - color = colorResource(id = R.color.text_primary), - modifier = Modifier - .padding( - top = 30.dp, - start = 16.dp, - end = 16.dp, - bottom = 10.dp - ) - .verticalScroll(rememberScrollState()), - maxLines = 5 - ) - } -} - -@Composable -private fun ButtonsDone( - progressState: AddToAnytypeViewModel.ProgressState.Done, - onCancelClicked: () -> Unit, - onOpenClicked: (Id) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(68.dp) - .padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ButtonSecondary( - onClick = onCancelClicked, - size = ButtonSize.Large, - text = stringResource(id = R.string.cancel), - modifier = Modifier.weight(1.0f) - ) - Spacer(modifier = Modifier.width(12.dp)) - ButtonPrimary( - onClick = { - onOpenClicked(progressState.wrapperObjId) - }, - size = ButtonSize.Large, - text = stringResource(id = R.string.sharing_menu_btn_open), - modifier = Modifier.weight(1.0f), - ) - } -} - -@Composable -private fun ButtonsProgress( - onCancelClicked: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(68.dp) - .padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ButtonSecondary( - onClick = onCancelClicked, - size = ButtonSize.Large, - text = stringResource(id = R.string.cancel), - modifier = Modifier.weight(1.0f) - ) - Spacer(modifier = Modifier.width(12.dp)) - ButtonPrimaryLoading( - size = ButtonSize.Large, - text = stringResource(id = R.string.sharing_menu_btn_add), - modifierBox = Modifier.weight(1.0f), - modifierButton = Modifier.fillMaxWidth(), - loading = true - ) - } -} - -@Composable -private fun Buttons( - onCancelClicked: () -> Unit, - onAddClicked: (SaveAsOption) -> Unit, - selectedIndex: Int, - progressState: AddToAnytypeViewModel.ProgressState -) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(68.dp) - .padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ButtonSecondary( - onClick = onCancelClicked, - size = ButtonSize.Large, - text = stringResource(id = R.string.cancel), - modifier = Modifier.weight(1.0f) - ) - Spacer(modifier = Modifier.width(12.dp)) - ButtonPrimaryLoading( - onClick = { onAddClicked(selectedIndex) }, - size = ButtonSize.Large, - text = stringResource(id = R.string.sharing_menu_btn_add), - modifierBox = Modifier.weight(1.0f), - modifierButton = Modifier.fillMaxWidth(), - loading = progressState is AddToAnytypeViewModel.ProgressState.Progress - ) - } -} - -@Composable -private fun CurrentSpaceSection( - icon: SpaceIconView? = null, - name: String, - spaces: List, - onSelectSpaceClicked: (SpaceView) -> Unit -) { - var isSpaceSelectMenuExpanded by remember { mutableStateOf(false) } - val throttler = remember { - MultipleEventCutter.Companion.get(interval = DROPDOWN_MENU_VISIBILITY_WINDOW_INTERVAL) - } - Column( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .noRippleClickable { - throttler.processEvent { - isSpaceSelectMenuExpanded = true - } - } - ) { - Text( - text = stringResource(R.string.space), - modifier = Modifier - .padding(top = 14.dp, start = 20.dp), - style = Caption1Medium, - color = colorResource(id = R.color.text_secondary) - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, top = 6.dp, bottom = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val hasIcon = icon is SpaceIconView.DataSpace || icon is SpaceIconView.ChatSpace - if (icon != null && hasIcon) { - SpaceIconView( - icon = icon, - modifier = Modifier.padding(end = 8.dp), - mainSize = 20.dp, - onSpaceIconClick = { - // Do nothing. - } - ) - } - Text( - text = name, - modifier = Modifier, - style = BodyRegular, - color = colorResource(id = R.color.text_primary) - ) - } - DropdownMenu( - expanded = isSpaceSelectMenuExpanded, - onDismissRequest = { - throttler.processEvent { - isSpaceSelectMenuExpanded = false - } - }, - modifier = Modifier.background( - color = colorResource(id = R.color.background_secondary) - ) - ) { - spaces.forEachIndexed { index, view -> - DropdownMenuItem( - onClick = { - onSelectSpaceClicked(view) - isSpaceSelectMenuExpanded = false - } - ) { - Text( - text = view.obj.name.orEmpty(), - style = BodyRegular, - color = colorResource(id = R.color.text_primary), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (index != spaces.lastIndex) { - Divider( - thickness = 0.5.dp, - color = colorResource(id = R.color.shape_primary) - ) - } - } - } - } - com.anytypeio.anytype.core_ui.foundation.Divider(paddingEnd = 20.dp, paddingStart = 20.dp) -} - -@Composable -private fun Header() { - Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - ) { - Text( - text = stringResource(R.string.sharing_menu_add_to_anytype_header_title), - color = colorResource(id = R.color.text_primary), - modifier = Modifier.align(Alignment.Center), - style = Title2 - ) - } -} - -const val SAVE_AS_NOTE = 0 -const val SAVE_AS_BOOKMARK = 1 -const val SAVE_AS_IMAGE = 2 -const val SAVE_AS_FILE = 3 -const val SAVE_AS_IMAGES = 4 -const val SAVE_AS_FILES = 5 -const val SAVE_AS_VIDEOS = 6 -typealias SaveAsOption = Int - -sealed class SharingData { - abstract val data: String - - data class Url(val url: String) : SharingData() { - override val data: String - get() = url - } - - data class Text(val raw: String) : SharingData() { - override val data: String - get() = raw - } - - data class Image(val uri: String) : SharingData() { - override val data: String - get() = uri - } - - data class Images(val uris: List) : SharingData() { - override val data: String - get() = uris.toString() - } - - data class Files(val uris: List) : SharingData() { - override val data: String - get() = uris.toString() - } - - data class File(val uri: String) : SharingData() { - override val data: String - get() = uri - } - - data class Videos(val uris: List) : SharingData() { - override val data: String - get() = uris.toString() - } -} - -const val DROPDOWN_MENU_VISIBILITY_WINDOW_INTERVAL = 150L \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/sharing/SharingFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/sharing/SharingFragment.kt deleted file mode 100644 index c381478cfa..0000000000 --- a/app/src/main/java/com/anytypeio/anytype/ui/sharing/SharingFragment.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.anytypeio.anytype.ui.sharing - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.webkit.URLUtil -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.os.bundleOf -import androidx.fragment.app.viewModels -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.fragment.findNavController -import com.anytypeio.anytype.R -import com.anytypeio.anytype.core_utils.ext.arg -import com.anytypeio.anytype.core_utils.ext.argStringList -import com.anytypeio.anytype.core_utils.ext.toast -import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment -import com.anytypeio.anytype.di.common.componentManager -import com.anytypeio.anytype.presentation.home.OpenObjectNavigation -import com.anytypeio.anytype.presentation.sharing.AddToAnytypeViewModel -import com.anytypeio.anytype.ui.editor.EditorFragment -import com.anytypeio.anytype.ui.settings.typography -import javax.inject.Inject -import kotlinx.coroutines.flow.map - -class SharingFragment : BaseBottomSheetComposeFragment() { - - private val sharedData: SharingData - get() { - val args = requireArguments() - return if (args.containsKey(SHARING_TEXT_KEY)) { - val result = arg(SHARING_TEXT_KEY) - if (URLUtil.isValidUrl(result)) { - SharingData.Url(result) - } else { - SharingData.Text(result) - } - } else if (args.containsKey(SHARING_IMAGE_KEY)) { - val result = arg(SHARING_IMAGE_KEY) - SharingData.Image(uri = result) - } else if (args.containsKey(SHARING_FILE_KEY)) { - val result = arg(SHARING_FILE_KEY) - SharingData.File(uri = result) - } else if (args.containsKey(SHARING_MULTIPLE_IMAGES_KEY)) { - val result = argStringList(SHARING_MULTIPLE_IMAGES_KEY) - SharingData.Images(uris = result) - } else if (args.containsKey(SHARING_MULTIPLE_FILES_KEY)) { - val result = argStringList(SHARING_MULTIPLE_FILES_KEY) - SharingData.Files(uris = result) - } else if (args.containsKey(SHARING_MULTIPLE_VIDEOS_KEY)) { - val result = argStringList(SHARING_MULTIPLE_VIDEOS_KEY) - SharingData.Videos(uris = result) - } - else { - throw IllegalStateException("Unexpcted shared data") - } - } - - @Inject - lateinit var factory: AddToAnytypeViewModel.Factory - - private val vm by viewModels { factory } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - MaterialTheme( - typography = typography - ) { - AddToAnytypeScreen( - content = vm.state.map { state -> - when (state) { - is AddToAnytypeViewModel.ViewState.Default -> state.content - AddToAnytypeViewModel.ViewState.Init -> "" - } - }.collectAsState(initial = "").value, - data = sharedData, - onAddClicked = { option -> - when(option) { - SAVE_AS_BOOKMARK -> vm.onCreateBookmark(url = sharedData.data) - SAVE_AS_NOTE -> vm.onCreateNote(sharedData.data) - SAVE_AS_FILE -> { - vm.onShareFiles(uris = listOf(sharedData.data),) - } - SAVE_AS_FILES -> { - val data = sharedData - if (data is SharingData.Files) { - vm.onShareFiles(uris = data.uris) - } else { - toast("Unexpected data format") - } - } - SAVE_AS_IMAGES, SAVE_AS_IMAGE, SAVE_AS_VIDEOS -> { - when (val data = sharedData) { - is SharingData.Image -> vm.onShareFiles(uris = listOf(data.uri)) - is SharingData.Images -> vm.onShareFiles(uris = data.uris) - is SharingData.Videos -> vm.onShareFiles(uris = data.uris) - else -> { - toast("Unexpected data format") - } - } - } - } - }, - onCancelClicked = { - vm.onCancelClicked().also { - dismiss() - } - }, - spaces = vm.spaceViews.collectAsStateWithLifecycle().value, - onSelectSpaceClicked = { vm.onSelectSpaceClicked(it) }, - progressState = vm.progressState.collectAsStateWithLifecycle().value, - onOpenClicked = vm::proceedWithNavigation, - ) - LaunchedEffect(Unit) { - vm.navigation.collect { nav -> - when(nav) { - is OpenObjectNavigation.OpenEditor -> { - dismiss() - findNavController().navigate( - R.id.objectNavigation, - EditorFragment.args( - ctx = nav.target, - space = nav.space - ) - ) - } - else -> { - // Do nothing. - } - } - } - } - LaunchedEffect(Unit) { - vm.toasts.collect { toast -> - toast(toast) - } - } - LaunchedEffect(Unit) { - vm.commands.collect { command -> - proceedWithCommand(command) - } - } - } - } - } - - override fun onStart() { - super.onStart() - when(val data = sharedData) { - is SharingData.File -> { - vm.onSharedMediaData(listOf(data.uri)) - } - is SharingData.Files -> { - vm.onSharedMediaData(data.uris) - } - is SharingData.Image -> { - vm.onSharedMediaData(listOf(data.uri)) - } - is SharingData.Images -> { - vm.onSharedMediaData(data.uris) - } - is SharingData.Text -> { - vm.onSharedTextData(data.raw) - } - is SharingData.Url -> { - vm.onSharedTextData(data.url) - } - is SharingData.Videos -> { - vm.onSharedMediaData(data.uris) - } - } - } - - private fun proceedWithCommand(command: AddToAnytypeViewModel.Command) { - when (command) { - AddToAnytypeViewModel.Command.Dismiss -> { - dismiss() - } - is AddToAnytypeViewModel.Command.ObjectAddToSpaceToast -> { - val name = command.spaceName ?: resources.getString(R.string.untitled) - val msg = resources.getString(R.string.sharing_menu_toast_object_added, name) - toast(msg = msg) - } - } - } - - override fun injectDependencies() { - componentManager().addToAnytypeComponent.get().inject(this) - } - - override fun releaseDependencies() { - componentManager().addToAnytypeComponent.release() - } - - companion object { - private const val SHARING_TEXT_KEY = "arg.sharing.text-key" - private const val SHARING_IMAGE_KEY = "arg.sharing.image-key" - private const val SHARING_FILE_KEY = "arg.sharing.file-key" - private const val SHARING_MULTIPLE_IMAGES_KEY = "arg.sharing.multiple-images-key" - private const val SHARING_MULTIPLE_VIDEOS_KEY = "arg.sharing.multiple-videos-key" - private const val SHARING_MULTIPLE_FILES_KEY = "arg.sharing.multiple-files-key" - - fun text(data: String) : SharingFragment = SharingFragment().apply { - arguments = bundleOf(SHARING_TEXT_KEY to data) - } - - fun image(uri: String) : SharingFragment = SharingFragment().apply { - arguments = bundleOf(SHARING_IMAGE_KEY to uri) - } - - fun images(uris: List) : SharingFragment = SharingFragment().apply { - arguments = bundleOf(SHARING_MULTIPLE_IMAGES_KEY to ArrayList(uris)) - } - - fun videos(uris: List) : SharingFragment = SharingFragment().apply { - arguments = bundleOf(SHARING_MULTIPLE_VIDEOS_KEY to ArrayList(uris)) - } - - fun files(uris: List) : SharingFragment = SharingFragment().apply { - arguments = bundleOf(SHARING_MULTIPLE_FILES_KEY to ArrayList(uris)) - } - - fun file(uri: String) : SharingFragment = SharingFragment().apply { - arguments = bundleOf(SHARING_FILE_KEY to uri) - } - } -} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SelectDestinationObjectScreen.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SelectDestinationObjectScreen.kt new file mode 100644 index 0000000000..7e44ab0477 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SelectDestinationObjectScreen.kt @@ -0,0 +1,425 @@ +package com.anytypeio.anytype.core_ui.features.sharing + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.core_ui.common.DefaultPreviews +import com.anytypeio.anytype.core_ui.foundation.Divider +import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable +import com.anytypeio.anytype.core_ui.views.BodyRegular +import com.anytypeio.anytype.core_ui.views.ButtonOnboardingPrimaryLarge +import com.anytypeio.anytype.core_ui.views.ButtonSize +import com.anytypeio.anytype.core_ui.views.Caption1Medium +import com.anytypeio.anytype.core_ui.views.Caption1Regular +import com.anytypeio.anytype.core_ui.views.Title1 +import com.anytypeio.anytype.core_ui.views.Title2 +import com.anytypeio.anytype.core_ui.widgets.ListWidgetObjectIcon +import com.anytypeio.anytype.core_ui.widgets.SearchField +import com.anytypeio.anytype.presentation.objects.ObjectIcon + +/** + * Data model for destination object items in the list + */ +data class DestinationObjectItem( + val id: String, + val name: String, + val icon: ObjectIcon, + val typeName: String, + val isSelected: Boolean = false, + val isChatOption: Boolean = false +) + +/** + * Screen for selecting destination objects within a space. + * Supports multi-selection of up to 5 destinations. + * Used for Flow 2 (Data Space without chat) and Flow 3 (Data Space with chat). + * + * @param spaceName Name of the selected space + * @param objects List of objects to display + * @param chatObjects List of chat objects (CHAT_DERIVED layout) in the space + * @param searchQuery Current search query + * @param selectedObjectIds Set of selected object IDs (multi-select) + * @param commentText Current comment text (shown when any chat is selected) + * @param showCommentInput Whether to show the comment input field (true when any chat selected) + * @param onSearchQueryChanged Callback when search query changes + * @param onObjectSelected Callback when an object is selected/deselected + * @param onCommentChanged Callback when comment text changes + * @param onSendClicked Callback when Send/Save button is clicked + * @param onBackPressed Callback when back button is pressed + */ +@Composable +fun BoxScope.SelectDestinationObjectScreen( + spaceName: String, + objects: List, + chatObjects: List = emptyList(), + searchQuery: String, + selectedObjectIds: Set, + commentText: String, + showCommentInput: Boolean, + onSearchQueryChanged: (String) -> Unit, + onObjectSelected: (DestinationObjectItem) -> Unit, + onCommentChanged: (String) -> Unit, + onSendClicked: () -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(color = colorResource(id = R.color.background_primary)) + ) { + // Header with back button + HeaderSectionWithBack( + spaceName = spaceName, + onBackPressed = onBackPressed + ) + + // Search bar + SearchField( + horizontalPadding = 20.dp, + query = searchQuery, + onQueryChanged = onSearchQueryChanged, + enabled = true, + onFocused = {} + ) + + Spacer(modifier = Modifier.height(22.dp)) + + // Object list + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + item { + Text( + text = stringResource(R.string.sharing_select_dest), + style = Caption1Medium, + color = colorResource(id = R.color.text_secondary), + modifier = Modifier.padding(start = 20.dp, bottom = 8.dp) + ) + } + item { + Divider() + } + // Chat objects section (if any chats exist in the space) + if (chatObjects.isNotEmpty()) { + items( + items = chatObjects, + key = { "chat_${it.id}" } + ) { chat -> + ObjectListItem( + item = chat, + isSelected = chat.id in selectedObjectIds, + onClick = { onObjectSelected(chat) } + ) + } + item(key = "chats_divider") { + Divider() + } + } + + // Empty state + if (objects.isEmpty() && searchQuery.isNotEmpty()) { + item { + EmptySearchState( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + ) + } + } + + // Object items + items( + items = objects, + key = { it.id } + ) { obj -> + ObjectListItem( + item = obj, + isSelected = obj.id in selectedObjectIds, + onClick = { onObjectSelected(obj) } + ) + Divider() + } + + item { + Spacer(modifier = Modifier.height(200.dp)) + } + } + } + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .background( + color = colorResource(R.color.background_primary), + shape = RoundedCornerShape(20.dp) + ) + .navigationBarsPadding() + .align(Alignment.BottomCenter) + ) { + // Comment input field (shown above the list when chat is selected) + if (showCommentInput) { + CommentInputField( + commentText = commentText, + onCommentChanged = onCommentChanged, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + // Bottom button + ButtonOnboardingPrimaryLarge( + text = if (showCommentInput) { + stringResource(R.string.send) + } else { + stringResource(R.string.save) + }, + onClick = onSendClicked, + size = ButtonSize.Large, + modifierBox = Modifier + .fillMaxWidth() + ) + } +} + +@Composable +private fun HeaderSectionWithBack( + spaceName: String, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier + .size(48.dp) + .noRippleThrottledClickable { + onBackPressed() + }, + contentScale = ContentScale.Inside, + painter = painterResource(R.drawable.ic_back_24), + contentDescription = "Back", + ) + + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = spaceName.ifEmpty { stringResource(R.string.untitled) }, + style = Title1, + color = colorResource(id = R.color.text_primary), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Spacer to balance the back button + Spacer(modifier = Modifier.size(48.dp)) + } +} + +@Composable +private fun ObjectListItem( + item: DestinationObjectItem, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Object icon + ListWidgetObjectIcon( + icon = item.icon, + modifier = Modifier.size(48.dp), + iconSize = 48.dp + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = item.name.ifEmpty { stringResource(R.string.untitled) }, + style = Title2, + color = colorResource(id = R.color.text_primary), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (item.typeName.isNotEmpty()) { + Text( + text = item.typeName, + style = Caption1Regular, + color = colorResource(id = R.color.text_secondary), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Selection indicator + if (isSelected) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_checkbox_checked), + contentDescription = "Selected", + ) + } else { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_checkbox_unchecked), + contentDescription = "Not selected", + ) + } + } +} + +@Composable +private fun EmptySearchState( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_doc_search), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.sharing_no_objects_found), + style = BodyRegular, + color = colorResource(id = R.color.text_secondary), + textAlign = TextAlign.Center + ) + } +} + +// ============================================ +// PREVIEW +// ============================================ + +@DefaultPreviews +@Composable +private fun SelectDestinationObjectScreenPreview() { + val sampleObjects = listOf( + DestinationObjectItem( + id = "1", + name = "Meeting Notes", + icon = ObjectIcon.Basic.Emoji("📝"), + typeName = "Note" + ), + DestinationObjectItem( + id = "2", + name = "Project Ideas", + icon = ObjectIcon.Basic.Emoji("💡"), + typeName = "Page" + ), + DestinationObjectItem( + id = "3", + name = "Weekly Review", + icon = ObjectIcon.Basic.Emoji("📅"), + typeName = "Task" + ) + ) + + val sampleChatObjects = listOf( + DestinationObjectItem( + id = "chat1", + name = "Team Chat", + icon = ObjectIcon.Basic.Emoji("💬"), + typeName = "Chat", + isChatOption = true + ) + ) + + Box { + SelectDestinationObjectScreen( + spaceName = "Work Space", + objects = sampleObjects, + chatObjects = sampleChatObjects, + searchQuery = "", + selectedObjectIds = emptySet(), + commentText = "", + showCommentInput = false, + onSearchQueryChanged = {}, + onObjectSelected = {}, + onCommentChanged = {}, + onSendClicked = {}, + onBackPressed = {} + ) + } +} + +@DefaultPreviews +@Composable +private fun SelectDestinationObjectScreenWithChatSelectedPreview() { + val chatObject = DestinationObjectItem( + id = "chat", + name = "Team Chat", + icon = ObjectIcon.Basic.Emoji("💬"), + typeName = "Chat", + isChatOption = true + ) + + Box { + SelectDestinationObjectScreen( + spaceName = "Work Space", + objects = emptyList(), + chatObjects = listOf(chatObject), + searchQuery = "", + selectedObjectIds = setOf(chatObject.id), + commentText = "Check this out!", + showCommentInput = true, + onSearchQueryChanged = {}, + onObjectSelected = {}, + onCommentChanged = {}, + onSendClicked = {}, + onBackPressed = {} + ) + } +} diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SelectSpaceScreen.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SelectSpaceScreen.kt new file mode 100644 index 0000000000..ff603b404f --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SelectSpaceScreen.kt @@ -0,0 +1,510 @@ +package com.anytypeio.anytype.core_ui.features.sharing + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Text +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.anytypeio.anytype.core_models.SystemColor +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.core_ui.common.DefaultPreviews +import com.anytypeio.anytype.core_ui.foundation.DefaultSearchBar +import com.anytypeio.anytype.core_ui.foundation.noRippleClickable +import com.anytypeio.anytype.core_ui.views.BodyBold +import com.anytypeio.anytype.core_ui.views.BodyRegular +import com.anytypeio.anytype.core_ui.views.BodySemiBold +import com.anytypeio.anytype.core_ui.views.ButtonOnboardingPrimaryLarge +import com.anytypeio.anytype.core_ui.views.ButtonSize +import com.anytypeio.anytype.core_ui.views.Relations3 +import com.anytypeio.anytype.core_ui.widgets.objectIcon.SpaceIconView +import com.anytypeio.anytype.presentation.spaces.SpaceIconView as SpaceIcon + +/** + * Data model for selectable space items in the grid + */ +data class SelectableSpaceItem( + val id: String, + val icon: SpaceIcon, + val name: String, + val isSelected: Boolean = false, + val isChatSpace: Boolean = false +) + +/** + * Full screen for space selection with search, grid layout, and comment section + * + * @param spaces List of selectable space items + * @param searchQuery Current search query text + * @param commentText Current comment text + * @param onSearchQueryChanged Callback when search query changes + * @param onCommentChanged Callback when comment text changes + * @param onSpaceSelected Callback when a space is selected + * @param onSendClicked Callback when Send button is clicked + * @param modifier Modifier for the screen + */ +@Composable +fun SelectSpaceScreen( + spaces: List, + searchQuery: String, + commentText: String, + onSearchQueryChanged: (String) -> Unit, + onCommentChanged: (String) -> Unit, + onSpaceSelected: (SelectableSpaceItem) -> Unit, + onSendClicked: () -> Unit, + modifier: Modifier = Modifier +) { + // Check if any chat space is selected + val hasSelectedChatSpace = spaces.any { it.isSelected && it.isChatSpace } + + Column( + modifier = modifier + .fillMaxSize() + ) { + // Search bar - always visible + DefaultSearchBar( + value = searchQuery, + onQueryChanged = onSearchQueryChanged, + hint = R.string.search, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + + // Space Grid - shows ALL spaces, scrollable + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + items( + items = spaces, + key = { it.id } + ) { space -> + SpaceGridItem( + icon = space.icon, + name = space.name, + isSelected = space.isSelected, + onClick = { onSpaceSelected(space) } + ) + } + } + + // Comment section - appears at bottom when chat space is selected + if (hasSelectedChatSpace) { + CommentSection( + commentText = commentText, + onCommentChanged = onCommentChanged, + onSendClicked = onSendClicked, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +fun SelectSpaceScreenHeader(modifier: Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.select_space), + style = BodyBold, + color = colorResource(id = R.color.text_primary), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp) + ) + } +} + +/** + * Individual space item in the grid + * + * @param icon Space icon to display + * @param name Space name + * @param isSelected Whether this space is currently selected + * @param onClick Callback when item is clicked + * @param modifier Modifier for the item + */ +@Composable +private fun SpaceGridItem( + icon: SpaceIcon, + name: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .noRippleClickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .height(86.dp) + .width(92.dp), + contentAlignment = Alignment.TopCenter + ) { + SpaceIconView( + icon = icon, + mainSize = 80.dp, + onSpaceIconClick = onClick + ) + + if (isSelected) { + Box( + modifier = Modifier + .size(24.dp) + .align(Alignment.BottomEnd) + .clip(CircleShape) + .background(color = colorResource(id = R.color.glyph_active)), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_checked_24), + contentDescription = "Selected", + modifier = Modifier.size(24.dp) + ) + } + } + } + + // Space Name + Text( + text = name.ifEmpty { stringResource(R.string.untitled) }, + style = Relations3, + color = colorResource(id = R.color.text_primary), + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .height(30.dp) + .padding(horizontal = 4.dp) + ) + } +} + +/** + * Comment section with text field and Send button + * + * @param commentText Current comment text + * @param onCommentChanged Callback when comment changes + * @param onSendClicked Callback when Send button is clicked + * @param modifier Modifier for the section + */ +@Composable +private fun CommentSection( + commentText: String, + onCommentChanged: (String) -> Unit, + onSendClicked: () -> Unit, + modifier: Modifier = Modifier +) { + var innerValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) + } + + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + Column( + modifier = modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // Comment Text Field + OutlinedTextField( + value = innerValue, + onValueChange = { + innerValue = it + onCommentChanged(it.text) + }, + textStyle = BodySemiBold.copy( + color = colorResource(id = R.color.text_primary) + ), + singleLine = true, + enabled = true, + colors = TextFieldDefaults.colors( + disabledTextColor = colorResource(id = R.color.text_primary), + cursorColor = colorResource(id = R.color.color_accent), + focusedContainerColor = colorResource(id = R.color.shape_transparent_secondary), + unfocusedContainerColor = colorResource(id = R.color.shape_transparent_secondary), + errorContainerColor = colorResource(id = R.color.shape_transparent_secondary), + focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + errorIndicatorColor = androidx.compose.ui.graphics.Color.Transparent + ), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 0.dp, top = 12.dp) + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions { + keyboardController?.hide() + focusManager.clearFocus() + }, + shape = RoundedCornerShape(size = 26.dp), + placeholder = { + Text( + modifier = Modifier.padding(start = 1.dp), + text = stringResource(id = R.string.add_a_comment), + style = BodyRegular, + color = colorResource(id = R.color.text_tertiary) + ) + } + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Send Button + ButtonOnboardingPrimaryLarge( + text = stringResource(R.string.send), + onClick = onSendClicked, + size = ButtonSize.Large, + modifierBox = Modifier.fillMaxWidth() + ) + } +} + +/** + * Standalone comment input field that can be reused across screens. + * Uses Material3 OutlinedTextField with proper keyboard handling. + * + * @param commentText Current comment text (for display, state managed via innerValue) + * @param onCommentChanged Callback when comment text changes + * @param modifier Modifier for the text field + */ +@Composable +internal fun CommentInputField( + commentText: String, + onCommentChanged: (String) -> Unit, + modifier: Modifier = Modifier +) { + var innerValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(commentText)) + } + + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + OutlinedTextField( + value = innerValue, + onValueChange = { + innerValue = it + onCommentChanged(it.text) + }, + textStyle = BodySemiBold.copy( + color = colorResource(id = R.color.text_primary) + ), + singleLine = true, + enabled = true, + colors = TextFieldDefaults.colors( + disabledTextColor = colorResource(id = R.color.text_primary), + cursorColor = colorResource(id = R.color.color_accent), + focusedContainerColor = colorResource(id = R.color.shape_transparent_secondary), + unfocusedContainerColor = colorResource(id = R.color.shape_transparent_secondary), + errorContainerColor = colorResource(id = R.color.shape_transparent_secondary), + focusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent, + errorIndicatorColor = androidx.compose.ui.graphics.Color.Transparent + ), + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions { + keyboardController?.hide() + focusManager.clearFocus() + }, + shape = RoundedCornerShape(size = 26.dp), + placeholder = { + Text( + modifier = Modifier.padding(start = 1.dp), + text = stringResource(id = R.string.add_a_comment), + style = BodyRegular, + color = colorResource(id = R.color.text_tertiary) + ) + } + ) +} + +/** + * Empty state when user has no spaces + * + * @param modifier Modifier for the empty state + */ +@Composable +private fun EmptySpaceState( + modifier: Modifier = Modifier +) { + +} + +// ============================================ +// PREVIEW +// ============================================ + +@DefaultPreviews +@Composable +private fun SelectSpaceScreenPreview() { + val sampleSpaces = listOf( + SelectableSpaceItem( + id = "1", + icon = SpaceIcon.DataSpace.Placeholder( + name = "B&O Museum", + color = SystemColor.PINK + ), + name = "B&O Museum", + isSelected = true, + isChatSpace = true + ), + SelectableSpaceItem( + id = "2", + icon = SpaceIcon.DataSpace.Placeholder( + name = "Imaginary Space", + color = SystemColor.YELLOW + ), + name = "Imaginary Space", + isSelected = false + ), + SelectableSpaceItem( + id = "3", + icon = SpaceIcon.DataSpace.Placeholder( + name = "Berlin Reading Club for Expats", + color = SystemColor.BLUE + ), + name = "Berlin Reading Club for Expats", + isSelected = false + ), + SelectableSpaceItem( + id = "4", + icon = SpaceIcon.DataSpace.Placeholder( + name = "Box for Cards", + color = SystemColor.RED + ), + name = "Box for Cards", + isSelected = false + ), + SelectableSpaceItem( + id = "5", + icon = SpaceIcon.DataSpace.Placeholder( + name = "Anytype Design", + color = SystemColor.TEAL + ), + name = "Anytype Design", + isSelected = false + ), + SelectableSpaceItem( + id = "6", + icon = SpaceIcon.DataSpace.Placeholder( + name = "Space Name", + color = SystemColor.RED + ), + name = "Space Name", + isSelected = false + ), + SelectableSpaceItem( + id = "7", + icon = SpaceIcon.DataSpace.Placeholder( + name = "Go Team", + color = SystemColor.AMBER + ), + name = "Go Team", + isSelected = false + ), + SelectableSpaceItem( + id = "8", + icon = SpaceIcon.DataSpace.Placeholder( + name = "The New Yorker", + color = SystemColor.PURPLE + ), + name = "The New Yorker", + isSelected = false + ), + SelectableSpaceItem( + id = "9", + icon = SpaceIcon.DataSpace.Placeholder( + name = "Diary", + color = SystemColor.SKY + ), + name = "Diary", + isSelected = false + ) + ) + + Box { + SelectSpaceScreen( + spaces = sampleSpaces, + searchQuery = "", + commentText = "", + onSearchQueryChanged = {}, + onCommentChanged = {}, + onSpaceSelected = {}, + onSendClicked = {} + ) + } +} + +@DefaultPreviews +@Composable +private fun SelectSpaceScreenEmptyPreview() { + Box { + SelectSpaceScreen( + spaces = emptyList(), + searchQuery = "", + commentText = "", + onSearchQueryChanged = {}, + onCommentChanged = {}, + onSpaceSelected = {}, + onSendClicked = {} + ) + } +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingModalSheet.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingModalSheet.kt new file mode 100644 index 0000000000..b540699274 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingModalSheet.kt @@ -0,0 +1,88 @@ +package com.anytypeio.anytype.core_ui.features.sharing + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.presentation.sharing.SelectableObjectView +import com.anytypeio.anytype.presentation.sharing.SelectableSpaceView +import com.anytypeio.anytype.presentation.sharing.SharingScreenState +import kotlinx.coroutines.launch + +/** + * Pure Compose ModalBottomSheet wrapper for the sharing extension screen. + * + * This composable replaces the legacy [BaseBottomSheetComposeFragment] approach with + * a modern Material3 ModalBottomSheet, providing: + * - Better state management (single source of truth) + * - Simpler lifecycle (no Fragment complexity) + * - Modern animations and gestures + * - Easier testing with Compose testing framework + * + * @param state The current screen state from SharingViewModel + * @param onSpaceSelected Callback when a space is selected in the grid + * @param onSearchQueryChanged Callback when search query changes + * @param onCommentChanged Callback when comment text changes + * @param onSendClicked Callback when send button is clicked + * @param onObjectSelected Callback when an object is selected in destination list + * @param onBackPressed Callback when back navigation is triggered + * @param onDismiss Callback when the sheet is dismissed (swipe down or back press) + * @param onRetryClicked Callback when retry button is clicked on error screen + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SharingModalSheet( + state: SharingScreenState, + onSpaceSelected: (SelectableSpaceView) -> Unit, + onSearchQueryChanged: (String) -> Unit, + onCommentChanged: (String) -> Unit, + onSendClicked: () -> Unit, + onObjectSelected: (SelectableObjectView) -> Unit, + onBackPressed: () -> Unit, + onDismiss: () -> Unit, + onRetryClicked: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + val scope = rememberCoroutineScope() + + ModalBottomSheet( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding(), + onDismissRequest = { onDismiss() }, + sheetState = sheetState, + containerColor = colorResource(R.color.background_primary), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + dragHandle = null + ) { + SharingScreen( + modifier = Modifier.fillMaxSize(), + state = state, + onSpaceSelected = onSpaceSelected, + onSearchQueryChanged = onSearchQueryChanged, + onCommentChanged = onCommentChanged, + onSendClicked = onSendClicked, + onObjectSelected = onObjectSelected, + onBackPressed = { onBackPressed() }, + onCancelClicked = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + onDismiss() + } + } + }, + onRetryClicked = onRetryClicked + ) + } +} diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingNoSpacesScreen.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingNoSpacesScreen.kt new file mode 100644 index 0000000000..5e500d39b1 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingNoSpacesScreen.kt @@ -0,0 +1,57 @@ +package com.anytypeio.anytype.core_ui.features.sharing + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.core_ui.common.DefaultPreviews +import com.anytypeio.anytype.core_ui.views.BodyRegular + +@Composable +fun SharingNoSpacesScreen() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Coffee icon + Image( + painter = painterResource(id = R.drawable.ic_popup_coffee_56), + contentDescription = null, + modifier = Modifier.size(56.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Empty state message + Text( + text = stringResource(R.string.you_dont_have_any_spaces_yet), + style = BodyRegular, + color = colorResource(id = R.color.text_primary), + textAlign = TextAlign.Center + ) + } +} + + +@DefaultPreviews +@Composable +private fun SharingNoSpacesScreenPreview() { + MaterialTheme { + SharingNoSpacesScreen() + } +} \ No newline at end of file diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingScreen.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingScreen.kt new file mode 100644 index 0000000000..4edd7c1c1c --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingScreen.kt @@ -0,0 +1,543 @@ +package com.anytypeio.anytype.core_ui.features.sharing + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.multiplayer.SpaceUxType +import com.anytypeio.anytype.core_ui.R +import com.anytypeio.anytype.core_ui.foundation.Dragger +import com.anytypeio.anytype.core_ui.views.BodyRegular +import com.anytypeio.anytype.core_ui.views.ButtonPrimary +import com.anytypeio.anytype.core_ui.views.ButtonSecondary +import com.anytypeio.anytype.core_ui.views.ButtonSize +import com.anytypeio.anytype.core_ui.views.HeadlineHeading +import com.anytypeio.anytype.presentation.objects.ObjectIcon +import com.anytypeio.anytype.presentation.sharing.SelectableObjectView +import com.anytypeio.anytype.presentation.sharing.SelectableSpaceView +import com.anytypeio.anytype.presentation.sharing.SharedContent +import com.anytypeio.anytype.presentation.sharing.SharingFlowType +import com.anytypeio.anytype.presentation.sharing.SharingScreenState +import com.anytypeio.anytype.presentation.spaces.SpaceIconView + +/** + * Main sharing screen that orchestrates the different UI states. + * Acts as a state machine, rendering the appropriate screen based on [SharingScreenState]. + */ +@Composable +fun ColumnScope.SharingScreen( + modifier: Modifier, + state: SharingScreenState, + onSpaceSelected: (SelectableSpaceView) -> Unit, + onSearchQueryChanged: (String) -> Unit, + onCommentChanged: (String) -> Unit, + onSendClicked: () -> Unit, + onObjectSelected: (SelectableObjectView) -> Unit, + onBackPressed: () -> Unit, + onCancelClicked: () -> Unit, + onRetryClicked: () -> Unit, +) { + + Dragger( + modifier = Modifier + .padding(vertical = 6.dp) + .align(Alignment.CenterHorizontally) + ) + + when (state) { + + SharingScreenState.Loading -> { + SelectSpaceScreenHeader( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colorResource(id = R.color.glyph_active), + modifier = Modifier.size(48.dp) + ) + } + } + + SharingScreenState.NoSpaces -> { + SelectSpaceScreenHeader( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + SharingNoSpacesScreen() + } + + is SharingScreenState.SpaceSelection -> { + // Check if any chat space is selected to enable comment input and send + val hasChatSpaceSelected = state.spaces.any { + it.isSelected && it.flowType == SharingFlowType.CHAT + } + + val sendAction: () -> Unit = if (hasChatSpaceSelected) onSendClicked else { + {} + } + val commentAction: (String) -> Unit = + if (hasChatSpaceSelected) onCommentChanged else { + { _: String -> } + } + + SelectSpaceScreenHeader( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + + SelectSpaceScreen( + spaces = state.spaces.map { it.toSelectableSpaceItem() }, + searchQuery = state.searchQuery, + commentText = if (hasChatSpaceSelected) state.commentText else "", + onSearchQueryChanged = onSearchQueryChanged, + onCommentChanged = commentAction, + onSpaceSelected = { item -> + state.spaces.find { it.id == item.id }?.let { spaceView -> + onSpaceSelected(spaceView) + } + }, + onSendClicked = sendAction + ) + } + + is SharingScreenState.Error -> { + SelectSpaceScreenHeader( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + ErrorScreen( + message = state.message, + canRetry = state.canRetry, + onRetryClicked = onRetryClicked, + onCancelClicked = onCancelClicked + ) + } + + is SharingScreenState.ObjectSelection -> { + Box( + modifier = Modifier.fillMaxSize() + ) { + SelectDestinationObjectScreen( + spaceName = state.space.name, + objects = state.objects.map { it.toDestinationObjectItem() }, + chatObjects = state.chatObjects.map { it.toDestinationObjectItem() }, + searchQuery = state.searchQuery, + selectedObjectIds = state.selectedObjectIds, + commentText = state.commentText, + showCommentInput = state.hasAnyChatSelected, + onSearchQueryChanged = onSearchQueryChanged, + onObjectSelected = { item -> + if (item.isChatOption) { + // Find the chat object from the dynamically discovered chatObjects + state.chatObjects.find { it.id == item.id }?.let { chatObj -> + onObjectSelected(chatObj) + } + } else { + state.objects.find { it.id == item.id }?.let { objView -> + onObjectSelected(objView) + } + } + }, + onCommentChanged = onCommentChanged, + onSendClicked = onSendClicked, + onBackPressed = onBackPressed + ) + } + } + + is SharingScreenState.Sending -> { + SelectSpaceScreenHeader( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + SendingScreen( + progress = state.progress, + message = state.message + ) + } + + is SharingScreenState.Success -> { + SelectSpaceScreenHeader( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + SuccessScreen( + spaceName = state.spaceName, + canOpenObject = state.canOpenObject, + onDoneClicked = onCancelClicked + ) + } + } +} + +@Composable +private fun SendingScreen( + progress: Float, + message: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(colorResource(id = R.color.background_primary)), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + color = colorResource(id = R.color.glyph_active), + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = message, + style = BodyRegular, + color = colorResource(id = R.color.text_secondary) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LinearProgressIndicator( + progress = progress, + color = colorResource(id = R.color.glyph_active), + backgroundColor = colorResource(id = R.color.shape_primary), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp) + ) + } +} + +@Composable +private fun SuccessScreen( + spaceName: String, + canOpenObject: Boolean, + onDoneClicked: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(colorResource(id = R.color.background_primary)) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_tick_24), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.sharing_success), + style = HeadlineHeading, + color = colorResource(id = R.color.text_primary), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.sharing_added_to_space, spaceName), + style = BodyRegular, + color = colorResource(id = R.color.text_secondary), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + ButtonPrimary( + text = stringResource(R.string.done), + onClick = onDoneClicked, + size = ButtonSize.Large, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun ErrorScreen( + message: String, + canRetry: Boolean, + onRetryClicked: () -> Unit, + onCancelClicked: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(colorResource(id = R.color.background_primary)) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_popup_alert_56), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.sharing_error), + style = HeadlineHeading, + color = colorResource(id = R.color.text_primary), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = message, + style = BodyRegular, + color = colorResource(id = R.color.text_secondary), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (canRetry) { + ButtonPrimary( + text = stringResource(R.string.retry), + onClick = onRetryClicked, + size = ButtonSize.Large, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + } + + ButtonSecondary( + text = stringResource(R.string.cancel), + onClick = onCancelClicked, + size = ButtonSize.Large, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * Extension function to convert SelectableSpaceView to SelectableSpaceItem for the UI. + */ +private fun SelectableSpaceView.toSelectableSpaceItem(): SelectableSpaceItem { + return SelectableSpaceItem( + id = id, + icon = icon, + name = name, + isSelected = isSelected, + isChatSpace = flowType == SharingFlowType.CHAT + ) +} + +/** + * Extension function to convert SelectableObjectView to DestinationObjectItem for the UI. + */ +private fun SelectableObjectView.toDestinationObjectItem(): DestinationObjectItem { + return DestinationObjectItem( + id = id, + name = name, + icon = icon, + typeName = typeName, + isSelected = isSelected, + isChatOption = isChatOption + ) +} + +// --- PREVIEWS --- +@Preview(name = "Space Selection State", showBackground = true) +@Composable +private fun SharingScreenPreview_SpaceSelection() { + val mockSpaces = listOf( + StubSelectableSpaceView(id = "1", name = "Personal Space", isSelected = false), + StubSelectableSpaceView(id = "2", name = "Team Projects", isSelected = true), + StubSelectableSpaceView(id = "3", name = "", isSelected = false) + ) + + Column { + SharingScreen( + modifier = Modifier + .fillMaxSize() + .background( + color = colorResource(com.anytypeio.anytype.core_ui.R.color.background_primary), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ), + state = SharingScreenState.SpaceSelection( + spaces = mockSpaces, + searchQuery = "", + sharedContent = SharedContent.Url("fdsfsd") + ), + onSpaceSelected = {}, + onSearchQueryChanged = {}, + onCommentChanged = {}, + onSendClicked = {}, + onObjectSelected = {}, + onBackPressed = {}, + onCancelClicked = {}, + onRetryClicked = {} + ) + } +} + +@Preview(name = "Object Selection State", showBackground = true) +@Composable +private fun SharingScreenPreview_ObjectSelection() { + val mockSpace = StubSelectableSpaceView(id = "2", name = "Team Projects", isSelected = true) + val mockObjects = listOf( + SelectableObjectView( + id = "obj1", + name = "Q1 Roadmap", + typeName = "Page", + icon = ObjectIcon.TypeIcon.Default.DEFAULT + ), + SelectableObjectView(id = "obj2", name = "Sprint Planning", typeName = "Board"), + SelectableObjectView(id = "obj3", name = "Design Mockups", typeName = "Collection") + ) + + Column { + SharingScreen( + modifier = Modifier.fillMaxWidth(), + state = SharingScreenState.ObjectSelection( + space = mockSpace, + objects = mockObjects, + searchQuery = "", + selectedObjectIds = setOf(mockObjects[1].id), + sharedContent = SharedContent.Url("www.google.com") + ), + onSpaceSelected = {}, + onSearchQueryChanged = {}, + onCommentChanged = {}, + onSendClicked = {}, + onObjectSelected = {}, + onBackPressed = {}, + onCancelClicked = {}, + onRetryClicked = {} + ) + } +} + +@Preview(name = "Sending State", showBackground = true) +@Composable +private fun SharingScreenPreview_Sending() { + Column { + SharingScreen( + modifier = Modifier.fillMaxWidth(), + state = SharingScreenState.Sending( + progress = 0.6f, + message = "Encrypting and sending..." + ), + onSpaceSelected = {}, + onSearchQueryChanged = {}, + onCommentChanged = {}, + onSendClicked = {}, + onObjectSelected = {}, + onBackPressed = {}, + onCancelClicked = {}, + onRetryClicked = {} + ) + } +} + +@Preview(name = "Success State", showBackground = true) +@Composable +private fun SharingScreenPreview_Success() { + Column { + SharingScreen( + modifier = Modifier.fillMaxWidth(), + state = SharingScreenState.Success( + spaceName = "Team Projects", + canOpenObject = true + ), + onSpaceSelected = {}, + onSearchQueryChanged = {}, + onCommentChanged = {}, + onSendClicked = {}, + onObjectSelected = {}, + onBackPressed = {}, + onCancelClicked = {}, + onRetryClicked = {} + ) + } +} + +@Preview(name = "Error State", showBackground = true) +@Composable +private fun SharingScreenPreview_Error() { + Column { + SharingScreen( + modifier = Modifier.fillMaxWidth(), + state = SharingScreenState.Error( + message = "Failed to connect. Please check your network and try again.", + canRetry = true + ), + onSpaceSelected = {}, + onSearchQueryChanged = {}, + onCommentChanged = {}, + onSendClicked = {}, + onObjectSelected = {}, + onBackPressed = {}, + onCancelClicked = {}, + onRetryClicked = {} + ) + } +} + +/** + * Creates a stub instance of SelectableSpaceView for testing. + */ +fun StubSelectableSpaceView( + id: Id = "stub-id", + targetSpaceId: Id = "stub-target-id", + name: String = "Stub Space", + icon: SpaceIconView = SpaceIconView.DataSpace.Placeholder(name = "K"), + uxType: SpaceUxType? = null, + chatId: Id? = null, + isSelected: Boolean = false +): SelectableSpaceView { + return SelectableSpaceView( + id = id, + targetSpaceId = targetSpaceId, + name = name, + icon = icon, + uxType = uxType, + chatId = chatId, + isSelected = isSelected + ) +} diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/SearchField.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/SearchField.kt index 2252c32208..bd74361705 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/SearchField.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/SearchField.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.anytypeio.anytype.core_ui.R import com.anytypeio.anytype.core_ui.foundation.noRippleClickable @@ -41,6 +42,7 @@ import com.anytypeio.anytype.core_ui.views.BodyRegular @OptIn(ExperimentalMaterialApi::class) @Composable fun SearchField( + horizontalPadding: Dp = 16.dp, query: String = "", enabled: Boolean = true, onQueryChanged: (String) -> Unit, @@ -62,7 +64,7 @@ fun SearchField( } Box( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = horizontalPadding) .fillMaxWidth() .height(40.dp) .clip(RoundedCornerShape(20.dp)) diff --git a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/objectIcon/ImageIconView.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/objectIcon/ImageIconView.kt index 65eb26b7a8..6835f14708 100644 --- a/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/objectIcon/ImageIconView.kt +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/widgets/objectIcon/ImageIconView.kt @@ -2,7 +2,6 @@ package com.anytypeio.anytype.core_ui.widgets.objectIcon import androidx.compose.foundation.Image import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState diff --git a/core-ui/src/main/res/drawable/ic_checkbox_checked.xml b/core-ui/src/main/res/drawable/ic_checkbox_checked.xml index 61198b0121..1452476cee 100644 --- a/core-ui/src/main/res/drawable/ic_checkbox_checked.xml +++ b/core-ui/src/main/res/drawable/ic_checkbox_checked.xml @@ -5,7 +5,7 @@ android:viewportHeight="24"> + android:fillColor="@color/control_accent"/> Add Open + + Image added to \'%1$s\' + Images added to \'%1$s\' + Video added to \'%1$s\' + Audio added to \'%1$s\' + PDF added to \'%1$s\' + File added to \'%1$s\' + Files added to \'%1$s\' + Text added to \'%1$s\' + Link added to \'%1$s\' + Content added to \'%1$s\' + + + Image linked to \'%1$s\' in \'%2$s\' + Images linked to \'%1$s\' in \'%2$s\' + Video linked to \'%1$s\' in \'%2$s\' + Audio linked to \'%1$s\' in \'%2$s\' + PDF linked to \'%1$s\' in \'%2$s\' + File linked to \'%1$s\' in \'%2$s\' + Files linked to \'%1$s\' in \'%2$s\' + Text linked to \'%1$s\' in \'%2$s\' + Link linked to \'%1$s\' in \'%2$s\' + Content linked to \'%1$s\' in \'%2$s\' + @@ -2365,6 +2389,9 @@ Please provide specific details of your needs here. Move chat to Bin? This chat and all its attachments will be moved to Bin. No one will be able to send new messages. You can restore it from Bin until it\'s permanently cleared. Upload Image + Select Channel + Add a comment... + Send Receive all Mute all Chat specific notifications @@ -2376,4 +2403,20 @@ Please provide specific details of your needs here. Object restored. Connect + + Select destination + Send to chat + Select destination + No objects found + Success! + Content added to %1$s + Something went wrong + Send to %1$s + Send to %1$d chats + You can select up to %1$d destinations + Sending to + Clear selection + Save + You don\'t have any spaces yet + diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/main/MainViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/main/MainViewModel.kt index a58d41ea12..96b6dde720 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/main/MainViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/main/MainViewModel.kt @@ -124,6 +124,13 @@ class MainViewModel( val wallpaperState: MutableStateFlow = MutableStateFlow(WallpaperResult.None) val showSpacesIntroduction: MutableStateFlow = MutableStateFlow(null) + /** + * State for the sharing modal sheet. + * When non-null, MainActivity should show the SharingModalSheet with this Intent. + * When null, the modal should be hidden. + */ + val sharingIntent: MutableStateFlow = MutableStateFlow(null) + init { subscribeToActiveSpaceWallpaper() viewModelScope.launch { @@ -350,67 +357,33 @@ class MainViewModel( } } - fun onIntentTextShare(data: String) { - viewModelScope.launch { - checkAuthorizationStatus.async(Unit).fold( - onFailure = { e -> Timber.e(e, "Error while checking auth status") }, - onSuccess = { (status, account) -> - if (status == AuthStatus.AUTHORIZED) { - commands.emit(Command.Sharing.Text(data)) - } - } - ) - } - } - - fun onIntentMultipleFilesShare(uris: List) { - Timber.d("onIntentFileShare: $uris") - viewModelScope.launch { - checkAuthorizationStatus.async(Unit).fold( - onFailure = { e -> Timber.e(e, "Error while checking auth status") }, - onSuccess = { (status, account) -> - if (status == AuthStatus.AUTHORIZED) { - if (uris.size == 1) { - commands.emit(Command.Sharing.File(uris.first())) - } else { - commands.emit(Command.Sharing.Files(uris)) - } - } - } - ) - } - } - - fun onIntentMultipleImageShare(uris: List) { - Timber.d("onIntentImageShare: $uris") + /** + * Single entry point for all share intents. + * Shows the sharing modal sheet with the given intent. + * + * @param intent The share intent from Android system + */ + fun onShareIntent(intent: android.content.Intent) { + Timber.d("onShareIntent: type=${intent.type}, action=${intent.action}") viewModelScope.launch { checkAuthorizationStatus.async(Unit).fold( onFailure = { e -> Timber.e(e, "Error while checking auth status") }, - onSuccess = { (status, account) -> + onSuccess = { (status, _) -> if (status == AuthStatus.AUTHORIZED) { - if (uris.size == 1) { - commands.emit(Command.Sharing.Image(uris.first())) - } else { - commands.emit(Command.Sharing.Images(uris)) - } + // Set intent to show the sharing modal sheet + sharingIntent.value = intent } } ) } } - fun onIntentMultipleVideoShare(uris: List) { - Timber.d("onIntentVideoShare: $uris") - viewModelScope.launch { - checkAuthorizationStatus.async(Unit).fold( - onFailure = { e -> Timber.e(e, "Error while checking auth status") }, - onSuccess = { (status, account) -> - if (status == AuthStatus.AUTHORIZED) { - commands.emit(Command.Sharing.Videos(uris)) - } - } - ) - } + /** + * Called when the sharing modal is dismissed. + */ + fun onSharingDismissed() { + Timber.d("onSharingDismissed") + sharingIntent.value = null } fun onInterceptNotificationAction(action: NotificationAction) { @@ -795,14 +768,6 @@ class MainViewModel( data object LogoutDueToAccountDeletion : Command() class OpenCreateNewType(val type: Id) : Command() data class Error(val msg: String) : Command() - sealed class Sharing : Command() { - data class Text(val data: String) : Sharing() - data class Image(val uri: String) : Sharing() - data class Images(val uris: List) : Sharing() - data class Videos(val uris: List) : Sharing() - data class File(val uri: String) : Sharing() - data class Files(val uris: List) : Sharing() - } data object Notifications : Command() data object RequestNotificationPermission : Command() diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/AddToAnytypeViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/AddToAnytypeViewModel.kt deleted file mode 100644 index 9b22ca6f06..0000000000 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/AddToAnytypeViewModel.kt +++ /dev/null @@ -1,541 +0,0 @@ -package com.anytypeio.anytype.presentation.sharing - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.anytypeio.anytype.analytics.base.Analytics -import com.anytypeio.anytype.analytics.base.EventsDictionary -import com.anytypeio.anytype.analytics.base.EventsDictionary.CLICK_ONBOARDING_TOOLTIP_ID_SHARING_EXTENSION -import com.anytypeio.anytype.analytics.base.EventsDictionary.CLICK_ONBOARDING_TOOLTIP_TYPE_CLOSE -import com.anytypeio.anytype.analytics.base.EventsDictionary.CLICK_ONBOARDING_TOOLTIP_TYPE_SHARING_MENU -import com.anytypeio.anytype.analytics.base.EventsPropertiesKey -import com.anytypeio.anytype.analytics.event.EventAnalytics -import com.anytypeio.anytype.analytics.props.Props -import com.anytypeio.anytype.core_models.Id -import com.anytypeio.anytype.core_models.MarketplaceObjectTypeIds -import com.anytypeio.anytype.core_models.NO_VALUE -import com.anytypeio.anytype.core_models.ObjectOrigin -import com.anytypeio.anytype.core_models.ObjectWrapper -import com.anytypeio.anytype.core_models.Process.Event -import com.anytypeio.anytype.core_models.Process.State -import com.anytypeio.anytype.core_models.Relations -import com.anytypeio.anytype.core_models.ext.EMPTY_STRING_VALUE -import com.anytypeio.anytype.core_models.primitives.SpaceId -import com.anytypeio.anytype.core_utils.ext.msg -import com.anytypeio.anytype.domain.account.AwaitAccountStartManager -import com.anytypeio.anytype.domain.base.fold -import com.anytypeio.anytype.domain.config.ConfigStorage -import com.anytypeio.anytype.domain.device.FileSharer -import com.anytypeio.anytype.domain.download.ProcessCancel -import com.anytypeio.anytype.domain.media.FileDrop -import com.anytypeio.anytype.domain.misc.UrlBuilder -import com.anytypeio.anytype.domain.multiplayer.Permissions -import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer -import com.anytypeio.anytype.domain.objects.CreateBookmarkObject -import com.anytypeio.anytype.domain.objects.CreatePrefilledNote -import com.anytypeio.anytype.domain.spaces.GetSpaceViews -import com.anytypeio.anytype.domain.workspace.EventProcessDropFilesChannel -import com.anytypeio.anytype.domain.workspace.SpaceManager -import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate -import com.anytypeio.anytype.presentation.common.BaseViewModel -import com.anytypeio.anytype.presentation.extension.sendAnalyticsObjectCreateEvent -import com.anytypeio.anytype.presentation.home.OpenObjectNavigation -import com.anytypeio.anytype.presentation.spaces.SpaceGradientProvider -import com.anytypeio.anytype.presentation.spaces.SpaceIconView -import com.anytypeio.anytype.presentation.spaces.spaceIcon -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onSubscription -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.launch -import timber.log.Timber - -class AddToAnytypeViewModel( - private val createBookmarkObject: CreateBookmarkObject, - private val createPrefilledNote: CreatePrefilledNote, - private val spaceManager: SpaceManager, - private val urlBuilder: UrlBuilder, - private val awaitAccountStartManager: AwaitAccountStartManager, - private val analytics: Analytics, - private val fileSharer: FileSharer, - private val permissions: Permissions, - private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, - private val fileDrop: FileDrop, - private val eventProcessChannel: EventProcessDropFilesChannel, - private val spaceViewSubscriptionContainer: SpaceViewSubscriptionContainer -) : BaseViewModel(), AnalyticSpaceHelperDelegate by analyticSpaceHelperDelegate { - - private val selectedSpaceId = MutableStateFlow(NO_VALUE) - - val navigation = MutableSharedFlow() - val spaceViews = MutableStateFlow>(emptyList()) - val commands = MutableSharedFlow() - val progressState = MutableStateFlow(ProgressState.Init) - - val state = MutableStateFlow(ViewState.Init) - - private var progressJob: Job? = null - - init { - viewModelScope.launch { - analytics.registerEvent( - EventAnalytics.Anytype( - name = EventsDictionary.CLICK_ONBOARDING_TOOLTIP, - props = Props( - mapOf( - EventsPropertiesKey.id to CLICK_ONBOARDING_TOOLTIP_ID_SHARING_EXTENSION, - EventsPropertiesKey.type to CLICK_ONBOARDING_TOOLTIP_TYPE_SHARING_MENU, - ) - ) - ) - ) - } - viewModelScope.launch { - selectedSpaceId.value = spaceManager.get() - } - viewModelScope.launch { - awaitAccountStartManager - .awaitStart() - .flatMapLatest { - combine( - spaceViewSubscriptionContainer.observe().map { items -> items.distinctBy { it.id } }, - selectedSpaceId, - permissions.all() - ) { spaces, selected, currPermissions -> - val isSelectedSpaceAvailable = if (selected.isEmpty()) { - false - } else { - currPermissions[selected]?.isOwnerOrEditor() == true - } - spaces.filter { wrapper -> - val space = wrapper.targetSpaceId - if (space.isNullOrEmpty()) - false - else { - currPermissions[space]?.isOwnerOrEditor() == true - } - }.mapIndexed { index, space -> - SpaceView( - obj = space, - icon = space.spaceIcon(urlBuilder), - isSelected = if (!isSelectedSpaceAvailable) { - index == 0 - } else { - space.targetSpaceId == selected - } - ) - } - } - }.catch { - Timber.e(it, "Error while searching for spaces") - }.collect { views -> - spaceViews.value = views - } - } - } - - private fun subscribeToEventProcessChannel( - wrapperObjId: Id, - filePaths: List, - targetSpaceId: String - ) { - if (progressJob?.isActive == true) { - Timber.d("Progress job is already active") - progressJob?.cancel() - } - progressJob = viewModelScope.launch { - eventProcessChannel.observe() - .shareIn( - viewModelScope, - replay = 0, - started = SharingStarted.WhileSubscribed() - ) - .onSubscription { - proceedWithFilesDrop( - wrapperObjId = wrapperObjId, - filePaths = filePaths, - targetSpaceId = targetSpaceId - ) - } - .collect { events -> - events.forEach { event -> - when (event) { - is Event.DropFiles.New -> { - val currentProgressState = progressState.value - if (currentProgressState is ProgressState.Init - && event.process.state == State.RUNNING - ) { - progressState.value = ProgressState.Progress( - processId = event.process.id, - progress = 0f, - wrapperObjId = wrapperObjId - ) - } else { - //some process is already running - } - } - - is Event.DropFiles.Update -> { - val currentProgressState = progressState.value - val newProcess = event.process - if (currentProgressState is ProgressState.Progress - && currentProgressState.processId == event.process.id - && newProcess.state == State.RUNNING - ) { - val progress = newProcess.progress - val total = progress?.total - val done = progress?.done - progressState.value = - if (total != null && total != 0L && done != null) { - currentProgressState.copy(progress = done.toFloat() / total) - } else { - currentProgressState.copy(progress = 0f) - } - } - } - - is Event.DropFiles.Done -> { - val currentProgressState = progressState.value - val newProcess = event.process - if (currentProgressState is ProgressState.Progress - && event.process.state == State.DONE - && newProcess.id == currentProgressState.processId - ) { - progressState.value = currentProgressState.copy(progress = 1f) - delay(300) - progressState.value = ProgressState.Done( - wrapperObjId = currentProgressState.wrapperObjId - ) - } - } - } - } - } - } - } - - fun onShareFiles(uris: List, wrapperObjTitle: String? = null) { - viewModelScope.launch(Dispatchers.IO) { - val targetSpaceView = spaceViews.value.firstOrNull { view -> - view.isSelected - } - val targetSpaceId = targetSpaceView?.obj?.targetSpaceId - - if (targetSpaceView != null && targetSpaceId != null) { - val paths = uris.mapNotNull { uri -> - try { - fileSharer.getPath(uri) - } catch (e: Exception) { - Timber.e(e, "Error getting path for URI: $uri") - null - } - } - - when (paths.size) { - 0 -> sendToast("Could not get file paths") - else -> proceedWithCreatingWrapperObject( - filePaths = paths, - targetSpaceId = targetSpaceId, - wrapperObjTitle = wrapperObjTitle, - ) - } - } - } - } - - private suspend fun proceedWithCreatingWrapperObject( - filePaths: List, - targetSpaceId: String, - wrapperObjTitle: String? = null - ) { - val startTime = System.currentTimeMillis() - createPrefilledNote.async( - CreatePrefilledNote.Params( - text = wrapperObjTitle ?: EMPTY_STRING_VALUE, - space = targetSpaceId, - details = mapOf( - Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble(), - ), - ) - ).fold( - onSuccess = { wrapperObjId -> - viewModelScope.sendAnalyticsObjectCreateEvent( - analytics = analytics, - objType = MarketplaceObjectTypeIds.PAGE, - route = EventsDictionary.Routes.sharingExtension, - startTime = startTime, - spaceParams = provideParams(spaceManager.get()) - ) - subscribeToEventProcessChannel( - wrapperObjId = wrapperObjId, - filePaths = filePaths, - targetSpaceId = targetSpaceId - ) - }, - onFailure = { - Timber.d(it, "Error while creating page") - sendToast("Error while creating page: ${it.msg()}") - } - ) - } - - private suspend fun proceedWithFilesDrop( - wrapperObjId: Id, - filePaths: List, - targetSpaceId: String, - ) { - val params = FileDrop.Params( - ctx = wrapperObjId, - space = SpaceId(targetSpaceId), - localFilePaths = filePaths - ) - fileDrop.async(params).fold( - onSuccess = { _ -> Timber.d("Files dropped successfully") }, - onFailure = { e -> - Timber.e(e, "Error while dropping files").also { - sendToast(e.msg()) - } - } - ) - } - - fun proceedWithNavigation(wrapperObjId: Id) { - val targetSpaceView = spaceViews.value.firstOrNull { view -> - view.isSelected - } - val targetSpaceId = targetSpaceView?.obj?.targetSpaceId - viewModelScope.launch { - Timber.d("proceedWithNavigation: $wrapperObjId, $targetSpaceId") - if (targetSpaceId == spaceManager.get()) { - Timber.d("proceedWithNavigation: OpenEditor") - delay(300) - navigation.emit( - OpenObjectNavigation.OpenEditor( - target = wrapperObjId, - space = targetSpaceId - ) - ) - } else { - Timber.d("proceedWithNavigation: ObjectAddToSpaceToast") - delay(300) - with(commands) { - emit(Command.ObjectAddToSpaceToast(targetSpaceView?.obj?.name)) - emit(Command.Dismiss) - } - } - } - } - - fun onCreateBookmark(url: String) { - viewModelScope.launch { - val targetSpaceView = spaceViews.value.firstOrNull { view -> - view.isSelected - } - val targetSpaceId = targetSpaceView?.obj?.targetSpaceId - if (targetSpaceView != null && targetSpaceId != null) { - val startTime = System.currentTimeMillis() - createBookmarkObject( - CreateBookmarkObject.Params( - space = targetSpaceId, - url = url, - details = mapOf( - Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble() - ) - ) - ).process( - success = { obj -> - sendAnalyticsObjectCreateEvent( - analytics = analytics, - objType = MarketplaceObjectTypeIds.BOOKMARK, - route = EventsDictionary.Routes.sharingExtension, - startTime = startTime, - spaceParams = provideParams(spaceManager.get()) - ) - if (targetSpaceId == spaceManager.get()) { - navigation.emit( - OpenObjectNavigation.OpenEditor( - target = obj, - space = targetSpaceId - ) - ) - } else { - with(commands) { - emit(Command.ObjectAddToSpaceToast(targetSpaceView.obj.name)) - emit(Command.Dismiss) - } - } - }, - failure = { - Timber.d(it, "Error while creating bookmark") - sendToast("Error while creating bookmark: ${it.msg()}") - } - ) - } - } - } - - fun onCreateNote(text: String) { - viewModelScope.launch { - val targetSpaceView = spaceViews.value.firstOrNull { view -> - view.isSelected - } - val targetSpaceId = targetSpaceView?.obj?.targetSpaceId - if (targetSpaceView != null && targetSpaceId != null) { - val startTime = System.currentTimeMillis() - createPrefilledNote.async( - CreatePrefilledNote.Params( - text = text, - space = targetSpaceId, - details = mapOf( - Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble() - ) - ) - ).fold( - onSuccess = { result -> - sendAnalyticsObjectCreateEvent( - analytics = analytics, - objType = MarketplaceObjectTypeIds.NOTE, - route = EventsDictionary.Routes.sharingExtension, - startTime = startTime, - spaceParams = provideParams(spaceManager.get()) - ) - if (targetSpaceId == spaceManager.get()) { - navigation.emit( - OpenObjectNavigation.OpenEditor( - target = result, - space = targetSpaceId - ) - ) - } else { - with(commands) { - emit(Command.ObjectAddToSpaceToast(targetSpaceView.obj.name)) - emit(Command.Dismiss) - } - } - }, - onFailure = { - Timber.d(it, "Error while creating note") - sendToast("Error while creating note: ${it.msg()}") - } - ) - } - } - } - - fun onSelectSpaceClicked(view: SpaceView) { - Timber.d("onSelectSpaceClicked: ${view.obj.targetSpaceId}") - viewModelScope.launch { - val targetSpaceId = view.obj.targetSpaceId - if (targetSpaceId != null) { - selectedSpaceId.value = targetSpaceId - } - } - } - - fun onCancelClicked() { - viewModelScope.launch { - analytics.registerEvent( - EventAnalytics.Anytype( - name = EventsDictionary.CLICK_ONBOARDING_TOOLTIP, - props = Props( - mapOf( - EventsPropertiesKey.id to CLICK_ONBOARDING_TOOLTIP_ID_SHARING_EXTENSION, - EventsPropertiesKey.type to CLICK_ONBOARDING_TOOLTIP_TYPE_CLOSE, - ) - ) - ) - ) - } - } - - fun onSharedMediaData(uris: List) { - viewModelScope.launch { - state.value = ViewState.Default( - uris.mapNotNull { uri -> - fileSharer.getDisplayName(uri) - }.joinToString(separator = FILE_NAME_SEPARATOR) - ) - } - } - - fun onSharedTextData(text: String) { - viewModelScope.launch { - state.value = ViewState.Default(text) - } - } - - class Factory @Inject constructor( - private val createBookmarkObject: CreateBookmarkObject, - private val createPrefilledNote: CreatePrefilledNote, - private val spaceManager: SpaceManager, - private val urlBuilder: UrlBuilder, - private val awaitAccountStartManager: AwaitAccountStartManager, - private val analytics: Analytics, - private val fileSharer: FileSharer, - private val permissions: Permissions, - private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, - private val fileDrop: FileDrop, - private val eventProcessChannel: EventProcessDropFilesChannel, - private val processCancel: ProcessCancel, - private val spaceViewSubscriptionContainer: SpaceViewSubscriptionContainer - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return AddToAnytypeViewModel( - createBookmarkObject = createBookmarkObject, - spaceManager = spaceManager, - createPrefilledNote = createPrefilledNote, - urlBuilder = urlBuilder, - awaitAccountStartManager = awaitAccountStartManager, - analytics = analytics, - fileSharer = fileSharer, - permissions = permissions, - analyticSpaceHelperDelegate = analyticSpaceHelperDelegate, - fileDrop = fileDrop, - eventProcessChannel = eventProcessChannel, - spaceViewSubscriptionContainer = spaceViewSubscriptionContainer - ) as T - } - } - - data class SpaceView( - val obj: ObjectWrapper.SpaceView, - val isSelected: Boolean, - val icon: SpaceIconView - ) - - sealed class Command { - data object Dismiss : Command() - data class ObjectAddToSpaceToast( - val spaceName: String? - ) : Command() - } - - sealed class ViewState { - data object Init : ViewState() - data class Default(val content: String) : ViewState() - } - - sealed class ProgressState { - data object Init : ProgressState() - data class Progress(val wrapperObjId: Id, val processId: Id, val progress: Float) : - ProgressState() - - data class Done(val wrapperObjId: Id) : ProgressState() - data class Error(val error: String) : ProgressState() - } - - companion object { - const val FILE_NAME_SEPARATOR = ", " - } -} \ No newline at end of file diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/IntentToSharedContentConverter.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/IntentToSharedContentConverter.kt new file mode 100644 index 0000000000..6295392953 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/IntentToSharedContentConverter.kt @@ -0,0 +1,127 @@ +package com.anytypeio.anytype.presentation.sharing + +import android.content.Intent +import android.webkit.URLUtil +import com.anytypeio.anytype.core_utils.ext.parseActionSendMultipleUris +import com.anytypeio.anytype.core_utils.ext.parseActionSendUri +import timber.log.Timber + +/** + * Utility object for converting Android share intents to [SharedContent]. + * + * This extracts the intent parsing logic that was previously in SharingFragment, + * allowing it to be used from anywhere (ViewModel, Activity, etc.) without + * requiring a Fragment context. + * + * Supports all MIME types: text, images, videos, audio, PDF, and generic files. + */ +object IntentToSharedContentConverter { + + // MIME type constants + private const val MIME_TEXT_PLAIN = "text/plain" + private const val MIME_TEXT_PREFIX = "text/" + private const val MIME_IMAGE_PREFIX = "image/" + private const val MIME_VIDEO_PREFIX = "video/" + private const val MIME_AUDIO_PREFIX = "audio/" + private const val MIME_APPLICATION_PREFIX = "application/" + private const val MIME_PDF = "application/pdf" + + /** + * Converts an Android Intent to [SharedContent]. + * Handles all MIME types: text, images, videos, audio, PDF, and files. + * + * @param intent The share intent from Android system + * @return The parsed [SharedContent] representing the shared data + */ + fun convert(intent: Intent): SharedContent { + val mimeType = intent.type + + Timber.d("Converting intent to SharedContent. MIME type: $mimeType, action: ${intent.action}") + + return when { + // No MIME type - try to extract text + mimeType == null -> { + val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "" + if (URLUtil.isValidUrl(text)) { + SharedContent.Url(text) + } else { + SharedContent.Text(text) + } + } + + // Text content (plain text, URLs) + mimeType == MIME_TEXT_PLAIN -> { + val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "" + if (URLUtil.isValidUrl(text)) { + SharedContent.Url(text) + } else { + SharedContent.Text(text) + } + } + + // Images + mimeType.startsWith(MIME_IMAGE_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.IMAGE) + } + + // Videos + mimeType.startsWith(MIME_VIDEO_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.VIDEO) + } + + // Audio (music, voice memos, podcasts) + mimeType.startsWith(MIME_AUDIO_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.AUDIO) + } + + // PDF specifically + mimeType == MIME_PDF -> { + parseMediaIntent(intent, SharedContent.MediaType.PDF) + } + + // Other application files (zip, doc, etc.) + mimeType.startsWith(MIME_APPLICATION_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.FILE) + } + + // Other text types (html, csv, xml) - treat as file + mimeType.startsWith(MIME_TEXT_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.FILE) + } + + // Fallback for unknown types + else -> { + Timber.w("Unknown MIME type: $mimeType, treating as generic file") + parseMediaIntent(intent, SharedContent.MediaType.FILE) + } + } + } + + /** + * Parses media content from an Intent, handling both single and multiple items. + * + * @param intent The share intent + * @param type The media type classification + * @return [SharedContent.SingleMedia] for single items, [SharedContent.MultipleMedia] for multiple + */ + private fun parseMediaIntent(intent: Intent, type: SharedContent.MediaType): SharedContent { + return if (intent.action == Intent.ACTION_SEND_MULTIPLE) { + val uris = intent.parseActionSendMultipleUris() + if (uris.isNotEmpty()) { + SharedContent.MultipleMedia(uris = uris, type = type) + } else { + Timber.w("No URIs found in ACTION_SEND_MULTIPLE intent") + SharedContent.Text("") + } + } else { + val uri = intent.parseActionSendUri() + if (uri != null) { + SharedContent.SingleMedia(uri = uri, type = type) + } else { + // Fallback: try to get text content + val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: "" + SharedContent.Text(text) + } + } + } +} diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharedContent.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharedContent.kt new file mode 100644 index 0000000000..a6857a1cab --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharedContent.kt @@ -0,0 +1,78 @@ +package com.anytypeio.anytype.presentation.sharing + +/** + * Represents the different types of content that can be shared to Anytype. + * This sealed class handles all sharing scenarios including text, URLs, media, and mixed content. + */ +sealed class SharedContent { + + /** + * Plain text content shared from another app. + * @property text The shared text content + */ + data class Text(val text: String) : SharedContent() + + /** + * A URL/link shared from another app. + * @property url The shared URL + */ + data class Url(val url: String) : SharedContent() + + /** + * A single media file (image, video, or generic file). + * @property uri The content URI of the media file + * @property type The type of media + */ + data class SingleMedia( + val uri: String, + val type: MediaType + ) : SharedContent() + + /** + * Multiple media files of the same type. + * @property uris List of content URIs for the media files + * @property type The type of media (all files share the same type) + */ + data class MultipleMedia( + val uris: List, + val type: MediaType + ) : SharedContent() + + /** + * Mixed content containing any combination of text, URL, and media. + * Used when user shares multiple different types of content at once. + * @property text Optional text content + * @property url Optional URL content + * @property mediaUris List of media file URIs + */ + data class Mixed( + val text: String? = null, + val url: String? = null, + val mediaUris: List = emptyList() + ) : SharedContent() + + /** + * Media type classification for shared files. + */ + enum class MediaType { + IMAGE, + VIDEO, + FILE, + PDF, + AUDIO + } + + companion object { + /** + * Maximum character limit for chat messages. + * Text exceeding this limit will be truncated. + */ + const val MAX_CHAT_MESSAGE_LENGTH = 2000 + + /** + * Maximum number of attachments allowed per chat message. + * Media will be batched into multiple messages if this limit is exceeded. + */ + const val MAX_ATTACHMENTS_PER_MESSAGE = 10 + } +} diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingModels.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingModels.kt new file mode 100644 index 0000000000..fe6d43ca8a --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingModels.kt @@ -0,0 +1,78 @@ +package com.anytypeio.anytype.presentation.sharing + +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.multiplayer.SpaceUxType +import com.anytypeio.anytype.presentation.objects.ObjectIcon +import com.anytypeio.anytype.presentation.spaces.SpaceIconView + +/** + * Represents a space that can be selected in the sharing flow. + * Supports both single and multi-selection depending on the space type. + * + * @property id The unique identifier of the space + * @property targetSpaceId The target space ID for operations + * @property name Display name of the space + * @property icon The space icon to display + * @property uxType The UX type determining the sharing flow + * @property chatId The chat ID if this space has chat functionality (null for pure data spaces) + * @property isSelected Whether this space is currently selected + */ +data class SelectableSpaceView( + val id: Id, + val targetSpaceId: Id, + val name: String, + val icon: SpaceIconView, + val uxType: SpaceUxType?, + val chatId: Id?, + val isSelected: Boolean = false +) { + /** + * Determines which sharing flow should be used for this space. + */ + val flowType: SharingFlowType + get() = when (uxType) { + SpaceUxType.CHAT, SpaceUxType.ONE_TO_ONE -> SharingFlowType.CHAT + else -> SharingFlowType.DATA // DATA, STREAM, or null + } +} + +/** + * Represents an object that can be selected as a destination in the sharing flow. + * Used in Data Space flow for selecting target objects or chats. + * + * @property id The unique identifier of the object + * @property name Display name of the object + * @property icon The object icon to display + * @property typeName Human-readable type name (e.g., "Page", "Note", "Chat") + * @property isSelected Whether this object is currently selected + * @property isChatOption True if this represents a chat object (CHAT_DERIVED layout) + */ +data class SelectableObjectView( + val id: Id, + val name: String, + val icon: ObjectIcon = ObjectIcon.None, + val typeName: String, + val isSelected: Boolean = false, + val isChatOption: Boolean = false +) + +/** + * Defines the two sharing flows based on space type. + */ +enum class SharingFlowType { + /** + * Flow 1: Pure chat space (SpaceUxType.CHAT or ONE_TO_ONE). + * - Content is sent directly as chat messages + * - Multi-select spaces allowed + * - Comment becomes message or caption + */ + CHAT, + + /** + * Flow 2: Data space (SpaceUxType.DATA or STREAM). + * - Content is created as objects in the space + * - Single space selection + * - Dynamically discovers chat objects for "Send to chat" option + */ + DATA +} diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingScreenState.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingScreenState.kt new file mode 100644 index 0000000000..71ed086290 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingScreenState.kt @@ -0,0 +1,144 @@ +package com.anytypeio.anytype.presentation.sharing + +import com.anytypeio.anytype.core_models.Id + +/** + * Represents the different screen states in the sharing extension flow. + * This sealed class implements a state machine pattern for navigating between: + * - Space selection (initial screen) + * - Object selection (for data spaces - Flow 2 & 3) + * - Progress and result states + */ +sealed class SharingScreenState { + + /** + * Initial loading state while spaces are being fetched. + */ + data object Loading : SharingScreenState() + + /** + * Empty state when no spaces are available. + */ + data object NoSpaces : SharingScreenState() + + /** + * Initial screen showing the grid of available spaces. + * For chat/one-to-one spaces, shows comment input inline when selected. + * + * @property spaces List of spaces available for selection + * @property searchQuery Current search filter text + * @property sharedContent The content being shared + * @property commentText Comment text for chat spaces (shown when chat space is selected) + */ + data class SpaceSelection( + val spaces: List, + val searchQuery: String = "", + val sharedContent: SharedContent, + val commentText: String = "" + ) : SharingScreenState() + + /** + * Object selection screen shown after selecting a data space. + * Displays both regular objects and chat objects (discovered dynamically). + * Supports multi-selection of up to [MAX_SELECTION_COUNT] destinations. + * + * @property space The selected data space + * @property objects List of regular objects in the space for selection + * @property chatObjects List of chat objects (CHAT_DERIVED layout) in the space + * @property searchQuery Current search filter text + * @property selectedObjectIds Set of selected destination object IDs (empty = create as new) + * @property commentText Comment text for chat destinations + * @property sharedContent The content being shared + */ + data class ObjectSelection( + val space: SelectableSpaceView, + val objects: List, + val chatObjects: List = emptyList(), + val searchQuery: String = "", + val selectedObjectIds: Set = emptySet(), + val commentText: String = "", + val sharedContent: SharedContent + ) : SharingScreenState() { + + /** + * Returns true if any selected item is a chat. + * Used to determine whether to show the comment input field. + */ + val hasAnyChatSelected: Boolean + get() = chatObjects.any { it.id in selectedObjectIds } + + companion object { + const val MAX_SELECTION_COUNT = 5 + } + } + + /** + * Progress state while content is being uploaded/sent. + * + * @property progress Upload progress from 0.0 to 1.0 + * @property message Current status message to display + */ + data class Sending( + val progress: Float = 0f, + val message: String = "" + ) : SharingScreenState() + + /** + * Success state after content has been successfully shared. + * + * @property createdObjectId ID of the created object (null for chat messages) + * @property spaceName Name of the space where content was shared + * @property canOpenObject Whether the created object can be opened + */ + data class Success( + val createdObjectId: Id? = null, + val spaceName: String, + val canOpenObject: Boolean = false + ) : SharingScreenState() + + /** + * Error state when sharing fails. + * + * @property message Error message to display + * @property canRetry Whether the operation can be retried + */ + data class Error( + val message: String, + val canRetry: Boolean = true + ) : SharingScreenState() +} + +/** + * Commands emitted by the ViewModel for UI-level actions. + */ +sealed class SharingCommand { + /** + * Dismiss the sharing bottom sheet. + */ + data object Dismiss : SharingCommand() + + /** + * Show a toast message. + */ + data class ShowToast(val message: String) : SharingCommand() + + /** + * Show Snackbar with message and "Open" action. + * Used after successfully adding content to a chat, space, or linking to an object. + * + * @property contentType The type of content that was shared (for message formatting) + * @property destinationName The name of the destination (chat/object/space name) + * @property spaceName Optional space name for context (used when linking to an object) + * @property objectId The ID of the object/chat to navigate to when "Open" is clicked + * @property spaceId The space ID containing the target + * @property isChat Whether the target is a chat (determines navigation destination) + */ + data class ShowSnackbarWithOpenAction( + val contentType: SharedContent, + val destinationName: String, + val spaceName: String? = null, + val objectId: Id, + val spaceId: Id, + val isChat: Boolean + ) : SharingCommand() +} diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingViewModel.kt new file mode 100644 index 0000000000..f6b5fb2959 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingViewModel.kt @@ -0,0 +1,1270 @@ +package com.anytypeio.anytype.presentation.sharing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.anytypeio.anytype.analytics.base.Analytics +import com.anytypeio.anytype.analytics.base.EventsDictionary +import com.anytypeio.anytype.core_models.Block +import com.anytypeio.anytype.core_models.Command +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.DVSortType +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.MarketplaceObjectTypeIds +import com.anytypeio.anytype.core_models.ObjectOrigin +import com.anytypeio.anytype.core_models.ObjectType +import com.anytypeio.anytype.core_models.RelationFormat +import com.anytypeio.anytype.core_models.Relations +import com.anytypeio.anytype.core_models.chats.Chat +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.core_utils.ext.msg +import com.anytypeio.anytype.domain.account.AwaitAccountStartManager +import com.anytypeio.anytype.domain.base.fold +import com.anytypeio.anytype.domain.chats.AddChatMessage +import com.anytypeio.anytype.domain.device.FileSharer +import com.anytypeio.anytype.domain.media.UploadFile +import com.anytypeio.anytype.domain.misc.UrlBuilder +import com.anytypeio.anytype.domain.multiplayer.Permissions +import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer +import com.anytypeio.anytype.domain.objects.CreateBookmarkObject +import com.anytypeio.anytype.domain.objects.CreateObjectFromUrl +import com.anytypeio.anytype.domain.objects.CreatePrefilledNote +import com.anytypeio.anytype.domain.page.AddBackLinkToObject +import com.anytypeio.anytype.core_models.ObjectWrapper +import com.anytypeio.anytype.core_models.ext.mapToObjectWrapperType +import com.anytypeio.anytype.domain.primitives.FieldParser +import com.anytypeio.anytype.domain.search.SearchObjects +import com.anytypeio.anytype.domain.workspace.SpaceManager +import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate +import com.anytypeio.anytype.presentation.common.BaseViewModel +import com.anytypeio.anytype.presentation.extension.sendAnalyticsObjectCreateEvent +import com.anytypeio.anytype.presentation.mapper.objectIcon +import com.anytypeio.anytype.presentation.search.ObjectSearchConstants +import com.anytypeio.anytype.presentation.search.buildDeletedFilter +import com.anytypeio.anytype.presentation.search.buildLayoutFilter +import com.anytypeio.anytype.presentation.search.buildTemplateFilter +import com.anytypeio.anytype.presentation.spaces.spaceIcon +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * ViewModel for the redesigned sharing extension. + * Handles three distinct flows: + * - Flow 1: Chat Space - Direct message sending with multi-select + * - Flow 2: Data Space (no chat) - Object creation with optional linking + * - Flow 3: Data Space (with chat) - Hybrid with "Send to chat" option + */ +class SharingViewModel( + private val createBookmarkObject: CreateBookmarkObject, + private val createPrefilledNote: CreatePrefilledNote, + private val createObjectFromUrl: CreateObjectFromUrl, + private val spaceManager: SpaceManager, + private val urlBuilder: UrlBuilder, + private val awaitAccountStartManager: AwaitAccountStartManager, + private val analytics: Analytics, + private val fileSharer: FileSharer, + private val permissions: Permissions, + private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, + private val spaceViewSubscriptionContainer: SpaceViewSubscriptionContainer, + private val addChatMessage: AddChatMessage, + private val uploadFile: UploadFile, + private val searchObjects: SearchObjects, + private val fieldParser: FieldParser, + private val addBackLinkToObject: AddBackLinkToObject +) : BaseViewModel(), AnalyticSpaceHelperDelegate by analyticSpaceHelperDelegate { + + private val _screenState = MutableStateFlow(SharingScreenState.Loading) + val screenState = _screenState.asStateFlow() + + private val _commands = MutableSharedFlow() + val commands = _commands.asSharedFlow() + + // Internal state + private var sharedContent: SharedContent? = null + private val allSpaces = mutableListOf() + private var selectedChatSpace: SelectableSpaceView? = null + private var selectedDataSpace: SelectableSpaceView? = null + private val selectedDestinationObjectIds = mutableSetOf() + private var spaceSearchQuery: String = "" + private var objectSearchQuery: String = "" + private var commentText: String = "" + private var cachedTypesMap: Map = emptyMap() + + init { + loadSpaces() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun loadSpaces() { + viewModelScope.launch { + awaitAccountStartManager + .awaitStart() + .flatMapLatest { + combine( + spaceViewSubscriptionContainer.observe().map { items -> items.distinctBy { it.id } }, + permissions.all() + ) { spaces, currPermissions -> + spaces.filter { wrapper -> + val space = wrapper.targetSpaceId + if (space.isNullOrEmpty()) { + false + } else { + currPermissions[space]?.isOwnerOrEditor() == true + } + }.mapNotNull { spaceView -> + val targetSpaceId = spaceView.targetSpaceId ?: return@mapNotNull null + SelectableSpaceView( + id = spaceView.id, + targetSpaceId = targetSpaceId, + name = spaceView.name.orEmpty(), + icon = spaceView.spaceIcon(urlBuilder), + uxType = spaceView.spaceUxType, + chatId = spaceView.chatId, + isSelected = false + ) + } + } + } + .catch { e -> + Timber.e(e, "Error while loading spaces") + _screenState.value = SharingScreenState.Error( + message = e.msg(), + canRetry = true + ) + } + .collect { spaces -> + allSpaces.clear() + allSpaces.addAll(spaces) + updateSpaceSelectionState() + } + } + } + + /** + * Called when shared data is received from the intent. + */ + fun onSharedDataReceived(content: SharedContent) { + this.sharedContent = content + updateSpaceSelectionState() + } + + /** + * Called when a space is tapped in the grid. + * + * Behavior depends on space type: + * - Chat/One-to-One: Toggle selection, stay on SpaceSelection with comment input + * - Data spaces: Navigate immediately to ObjectSelection (chats discovered dynamically) + */ + fun onSpaceSelected(space: SelectableSpaceView) { + Timber.d("onSpaceSelected: ${space.name}, flowType: ${space.flowType}") + when (space.flowType) { + SharingFlowType.CHAT -> { + // Single-select for chat spaces - toggle selection + selectedChatSpace = if (selectedChatSpace?.id == space.id) { + null // Deselect if clicking the same space + } else { + space // Select the new space + } + // Stay on SpaceSelection - comment input will appear when space is selected + updateSpaceSelectionState() + } + SharingFlowType.DATA -> { + // Clear any chat space selection when switching to data space + selectedChatSpace = null + // Single select for data spaces - navigate to object selection + // Chat objects are discovered dynamically via search + selectedDataSpace = space + navigateToObjectSelection() + } + } + } + + /** + * Called when search query changes (for both space and object search). + */ + fun onSearchQueryChanged(query: String) { + when (val state = _screenState.value) { + is SharingScreenState.SpaceSelection -> { + spaceSearchQuery = query + updateSpaceSelectionState() + } + is SharingScreenState.ObjectSelection -> { + objectSearchQuery = query + searchObjectsAndChatsInSpace(state.space) + } + else -> { /* ignore */ } + } + } + + /** + * Called when comment text changes. + */ + fun onCommentChanged(text: String) { + // Enforce 2000 character limit + val limitedText = text.take(SharedContent.MAX_CHAT_MESSAGE_LENGTH) + commentText = limitedText + + when (val state = _screenState.value) { + is SharingScreenState.SpaceSelection -> { + // Support comment in SpaceSelection when chat spaces are selected + _screenState.value = state.copy(commentText = limitedText) + } + is SharingScreenState.ObjectSelection -> { + _screenState.value = state.copy(commentText = limitedText) + } + else -> { /* ignore */ } + } + } + + /** + * Called when retry button is clicked on error screen. + * Attempts to reload spaces and retry the last operation. + */ + fun onRetryClicked() { + val content = sharedContent + if (content != null) { + // Reset to loading state and reload spaces + _screenState.value = SharingScreenState.Loading + loadSpaces() + } + } + + /** + * Called when an object is selected in the destination list. + * Single selection mode - selecting a new item deselects the previous one. + */ + fun onObjectSelected(obj: SelectableObjectView) { + val currentState = _screenState.value + if (currentState !is SharingScreenState.ObjectSelection) return + + val wasSelected = obj.id in selectedDestinationObjectIds + + // Clear all selections first (single selection mode) + selectedDestinationObjectIds.clear() + + // If it wasn't selected before, select it now + if (!wasSelected) { + selectedDestinationObjectIds.add(obj.id) + } + // If it was selected, clicking again deselects (nothing to add) + + // Update state with new selection + val updatedObjects = currentState.objects.map { + it.copy(isSelected = it.id in selectedDestinationObjectIds) + } + val updatedChatObjects = currentState.chatObjects.map { + it.copy(isSelected = it.id in selectedDestinationObjectIds) + } + + _screenState.value = currentState.copy( + objects = updatedObjects, + chatObjects = updatedChatObjects, + selectedObjectIds = selectedDestinationObjectIds.toSet() + ) + } + + /** + * Called when Send/Save button is clicked. + * Handles multi-select: sends to all selected chats and saves to all selected objects. + */ + fun onSendClicked() { + viewModelScope.launch { + when (val state = _screenState.value) { + is SharingScreenState.SpaceSelection -> { + // Handle send from SpaceSelection when a chat space is selected + if (selectedChatSpace != null) { + sendToChat() + } + } + is SharingScreenState.ObjectSelection -> { + if (state.selectedObjectIds.isEmpty()) { + // No selection - create new object (existing behavior) + createObjectInSpace() + } else { + // Process all selected destinations + sendToMultipleDestinations(state) + } + } + else -> { /* ignore */ } + } + } + } + + /** + * Sends content to multiple selected destinations (chats and/or objects). + * Chat destinations receive the shared content with comment. + * Object destinations get the shared content linked to the destination object. + */ + private suspend fun sendToMultipleDestinations(state: SharingScreenState.ObjectSelection) { + val content = sharedContent ?: return + val spaceId = SpaceId(state.space.targetSpaceId) + + // Partition selected items into chats and objects + val selectedChats = state.chatObjects.filter { it.id in state.selectedObjectIds } + val selectedObjects = state.objects.filter { it.id in state.selectedObjectIds } + + _screenState.value = SharingScreenState.Sending(progress = 0f, message = "Sending...") + + try { + // Send to chats (with comment) + selectedChats.forEach { chat -> + sendContentToChatObject(chat.id, state.space, content) + } + + // Link to objects - create new objects and add links to destination + if (selectedObjects.isNotEmpty()) { + val newObjectIds = createObjectFromContent(content, state.space.targetSpaceId) + // Link each created object to each destination object + newObjectIds.forEach { newObjectId -> + selectedObjects.forEach { destObject -> + linkObjectToDestination( + objectToLink = newObjectId, + destinationObjectId = destObject.id, + spaceId = spaceId + ) + } + } + } + + _screenState.value = SharingScreenState.Success( + createdObjectId = null, + spaceName = state.space.name, + canOpenObject = false + ) + + // Show Snackbar with option to open the destination + val firstSelectedChat = selectedChats.firstOrNull() + val firstSelectedObject = selectedObjects.firstOrNull() + + when { + // Chat selected - show Snackbar to open chat + firstSelectedChat != null -> { + _commands.emit( + SharingCommand.ShowSnackbarWithOpenAction( + contentType = content, + destinationName = firstSelectedChat.name, + spaceName = null, + objectId = firstSelectedChat.id, + spaceId = state.space.targetSpaceId, + isChat = true + ) + ) + } + // Object selected - show Snackbar to open linked object + firstSelectedObject != null -> { + _commands.emit( + SharingCommand.ShowSnackbarWithOpenAction( + contentType = content, + destinationName = firstSelectedObject.name, + spaceName = state.space.name, + objectId = firstSelectedObject.id, + spaceId = state.space.targetSpaceId, + isChat = false + ) + ) + } + } + _commands.emit(SharingCommand.Dismiss) + + } catch (e: Exception) { + Timber.e(e, "Error sending to multiple destinations") + _screenState.value = SharingScreenState.Error( + message = e.msg(), + canRetry = true + ) + } + } + + /** + * Creates new objects from shared content. + * Returns the list of created object IDs. + * For media content, uploads files directly (files are first-class objects in Anytype). + */ + private suspend fun createObjectFromContent(content: SharedContent, targetSpaceId: Id): List { + return when (content) { + is SharedContent.Text -> { + listOfNotNull(createNoteAndGetId(content.text, targetSpaceId)) + } + is SharedContent.Url -> { + listOfNotNull(createBookmarkAndGetId(content.url, targetSpaceId)) + } + is SharedContent.SingleMedia -> { + // Upload file directly - files are objects in Anytype + listOfNotNull(uploadMediaAndGetId(content.uri, content.type, targetSpaceId)) + } + is SharedContent.MultipleMedia -> { + // Upload ALL files + content.uris.mapNotNull { uri -> + uploadMediaAndGetId(uri, content.type, targetSpaceId) + } + } + is SharedContent.Mixed -> { + val noteText = content.text ?: content.url ?: "" + listOfNotNull(createNoteAndGetId(noteText, targetSpaceId)) + } + } + } + + /** + * Creates a note and returns its ID, or null on failure. + */ + private suspend fun createNoteAndGetId(text: String, targetSpaceId: Id): Id? { + var result: Id? = null + createPrefilledNote.async( + CreatePrefilledNote.Params( + text = text, + space = targetSpaceId, + details = mapOf( + Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble() + ) + ) + ).fold( + onSuccess = { objectId -> + viewModelScope.sendAnalyticsObjectCreateEvent( + analytics = analytics, + objType = MarketplaceObjectTypeIds.NOTE, + route = EventsDictionary.Routes.sharingExtension, + startTime = System.currentTimeMillis(), + spaceParams = provideParams(targetSpaceId) + ) + result = objectId + }, + onFailure = { e -> + Timber.e(e, "Error creating note") + } + ) + return result + } + + /** + * Creates a bookmark and returns its ID, or null on failure. + */ + private suspend fun createBookmarkAndGetId(url: String, targetSpaceId: Id): Id? { + var result: Id? = null + createBookmarkObject( + CreateBookmarkObject.Params( + space = targetSpaceId, + url = url, + details = mapOf( + Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble() + ) + ) + ).process( + success = { objectId -> + viewModelScope.sendAnalyticsObjectCreateEvent( + analytics = analytics, + objType = MarketplaceObjectTypeIds.BOOKMARK, + route = EventsDictionary.Routes.sharingExtension, + startTime = System.currentTimeMillis(), + spaceParams = provideParams(targetSpaceId) + ) + result = objectId + }, + failure = { e -> + Timber.e(e, "Error creating bookmark") + } + ) + return result + } + + /** + * Uploads a media file and returns its ID, or null on failure. + * Files in Anytype are first-class objects with their own IDs. + */ + private suspend fun uploadMediaAndGetId( + uri: String, + type: SharedContent.MediaType, + targetSpaceId: Id + ): Id? { + var result: Id? = null + uploadMediaFile( + uri = uri, + type = type, + spaceId = SpaceId(targetSpaceId) + ) { fileId -> + result = fileId + } + return result + } + + /** + * Links a newly created object to a destination object. + * Opens the destination, adds a link block to the new object, then closes. + */ + private suspend fun linkObjectToDestination( + objectToLink: Id, + destinationObjectId: Id, + spaceId: SpaceId + ) { + try { + addBackLinkToObject.async( + AddBackLinkToObject.Params( + objectToLink = objectToLink, + objectToPlaceLink = destinationObjectId, + saveAsLastOpened = false, + spaceId = spaceId + ) + ) + Timber.d("Successfully linked object $objectToLink to destination $destinationObjectId") + } catch (e: Exception) { + Timber.e(e, "Error linking object to destination") + // Don't throw - the object was created successfully, link is optional + } + } + + /** + * Sends content to a chat object (CHAT_DERIVED layout) within a data space. + */ + private suspend fun sendContentToChatObject(chatObjectId: Id, space: SelectableSpaceView, content: SharedContent) { + val spaceId = SpaceId(space.targetSpaceId) + sendContentToChat(chatObjectId, spaceId, content) + } + + /** + * Called when back button is pressed. + * @return true if the back press was handled, false otherwise + */ + fun onBackPressed(): Boolean { + return when (_screenState.value) { + is SharingScreenState.ObjectSelection -> { + // Go back to space selection + selectedDataSpace = null + selectedDestinationObjectIds.clear() + objectSearchQuery = "" + commentText = "" + cachedTypesMap = emptyMap() + updateSpaceSelectionState() + true + } + else -> false + } + } + + // region Private Methods + + private fun updateSpaceSelectionState() { + val content = sharedContent ?: return + + val filteredSpaces = if (spaceSearchQuery.isBlank()) { + allSpaces + } else { + allSpaces.filter { + it.name.contains(spaceSearchQuery, ignoreCase = true) + } + } + + val spacesWithSelection = filteredSpaces.map { space -> + space.copy(isSelected = selectedChatSpace?.id == space.id) + } + + _screenState.value = SharingScreenState.SpaceSelection( + spaces = spacesWithSelection, + searchQuery = spaceSearchQuery, + sharedContent = content, + commentText = commentText + ) + } + + private fun navigateToObjectSelection() { + val space = selectedDataSpace ?: return + val content = sharedContent ?: return + + // Show loading state initially + _screenState.value = SharingScreenState.ObjectSelection( + space = space, + objects = emptyList(), + chatObjects = emptyList(), + searchQuery = objectSearchQuery, + selectedObjectIds = selectedDestinationObjectIds.toSet(), + commentText = commentText, + sharedContent = content + ) + + // Fetch types once when entering the space, then search + viewModelScope.launch { + val spaceId = SpaceId(space.targetSpaceId) + cachedTypesMap = fetchObjectTypesForSpace(spaceId) + searchObjectsAndChatsInSpace(space) + } + } + + /** + * Searches for both regular objects and chat objects in the given space. + * Uses cached types map (fetched once in navigateToObjectSelection). + * Preserves selection state across search queries. + */ + private fun searchObjectsAndChatsInSpace(space: SelectableSpaceView) { + viewModelScope.launch { + val spaceId = SpaceId(space.targetSpaceId) + + // Search regular objects and chats using cached types map + val objects = searchRegularObjects(spaceId, cachedTypesMap) + val chats = searchChatObjects(spaceId, cachedTypesMap) + + val currentState = _screenState.value + if (currentState is SharingScreenState.ObjectSelection) { + _screenState.value = currentState.copy( + objects = objects, + chatObjects = chats, + searchQuery = objectSearchQuery, + selectedObjectIds = selectedDestinationObjectIds.toSet() + ) + } + } + } + + /** + * Fetches all object types from the given space. + * Returns a map of type ID -> ObjectWrapper.Type for quick lookup. + */ + private suspend fun fetchObjectTypesForSpace(spaceId: SpaceId): Map { + val filters = buildList { + addAll(buildDeletedFilter()) + add(buildTemplateFilter()) + add( + DVFilter( + relation = Relations.LAYOUT, + condition = DVFilterCondition.EQUAL, + value = ObjectType.Layout.OBJECT_TYPE.code.toDouble() + ) + ) + add( + DVFilter( + relation = Relations.UNIQUE_KEY, + condition = DVFilterCondition.NOT_EMPTY + ) + ) + } + + val keys = ObjectSearchConstants.defaultKeysObjectType + + val params = SearchObjects.Params( + space = spaceId, + filters = filters, + sorts = emptyList(), + keys = keys, + limit = 0 + ) + + return try { + val results = searchObjects(params).getOrNull() ?: emptyList() + results.mapNotNull { obj -> + obj.map.mapToObjectWrapperType()?.let { type -> + type.id to type + } + }.toMap() + } catch (e: Exception) { + Timber.e(e, "Error fetching object types for space") + emptyMap() + } + } + + /** + * Searches for regular objects (non-chat) in the space. + */ + private suspend fun searchRegularObjects( + spaceId: SpaceId, + typesMap: Map + ): List { + val filters = buildObjectSearchFilters() + val sorts = listOf( + DVSort( + relationKey = Relations.LAST_OPENED_DATE, + type = DVSortType.DESC, + relationFormat = RelationFormat.DATE, + includeTime = true + ) + ) + + val params = SearchObjects.Params( + space = spaceId, + filters = filters, + sorts = sorts, + fulltext = objectSearchQuery, + keys = ObjectSearchConstants.defaultKeys, + limit = 200 + ) + + return try { + val objects = searchObjects(params).getOrNull() ?: emptyList() + objects.map { obj -> + // Get type from the map using object's type relation + val typeId = obj.type.firstOrNull() + val objType = typeId?.let { typesMap[it] } + SelectableObjectView( + id = obj.id, + name = fieldParser.getObjectPluralName(obj), + icon = obj.objectIcon( + builder = urlBuilder, + objType = objType + ), + typeName = objType?.name.orEmpty(), + isSelected = obj.id in selectedDestinationObjectIds + ) + } + } catch (e: Exception) { + Timber.e(e, "Error searching objects in space") + emptyList() + } + } + + /** + * Finds the chat object ID in a space by searching for CHAT_DERIVED layout objects. + * Used as fallback when SpaceView.chatId is not available (e.g., for 1-1 spaces). + * + * @param spaceId The space to search in + * @return The chat ID if found, null otherwise + */ + private suspend fun findChatIdInSpace(spaceId: SpaceId): Id? { + val filters = buildList { + add( + DVFilter( + relation = Relations.LAYOUT, + condition = DVFilterCondition.EQUAL, + value = ObjectType.Layout.CHAT_DERIVED.code.toDouble() + ) + ) + addAll(buildDeletedFilter()) + add(buildTemplateFilter()) + } + + val params = SearchObjects.Params( + space = spaceId, + filters = filters, + sorts = emptyList(), + keys = listOf(Relations.ID), + limit = 1 + ) + + return try { + searchObjects(params).getOrNull()?.firstOrNull()?.id + } catch (e: Exception) { + Timber.e(e, "Error searching for chat in space") + null + } + } + + /** + * Searches for chat objects (CHAT_DERIVED layout) in the space. + */ + private suspend fun searchChatObjects( + spaceId: SpaceId, + typesMap: Map + ): List { + val filters = buildList { + addAll(buildDeletedFilter()) + add(buildTemplateFilter()) + add( + DVFilter( + relation = Relations.LAYOUT, + condition = DVFilterCondition.EQUAL, + value = ObjectType.Layout.CHAT_DERIVED.code.toDouble() + ) + ) + } + + val sorts = listOf( + DVSort( + relationKey = Relations.LAST_MODIFIED_DATE, + type = DVSortType.DESC, + relationFormat = RelationFormat.DATE, + includeTime = true + ) + ) + + val params = SearchObjects.Params( + space = spaceId, + filters = filters, + sorts = sorts, + fulltext = objectSearchQuery, + keys = listOf( + Relations.ID, + Relations.NAME, + Relations.TYPE, + Relations.ICON_EMOJI, + Relations.ICON_IMAGE, + Relations.ICON_OPTION + ) + ) + + return try { + val objects = searchObjects(params).getOrNull() ?: emptyList() + objects.map { obj -> + val typeId = obj.type.firstOrNull() + val objType = typeId?.let { typesMap[it] } + SelectableObjectView( + id = obj.id, + name = fieldParser.getObjectPluralName(obj), + icon = obj.objectIcon( + builder = urlBuilder, + objType = objType + ), + typeName = objType?.name ?: "Chat", + isSelected = obj.id in selectedDestinationObjectIds, + isChatOption = true + ) + } + } catch (e: Exception) { + Timber.e(e, "Error searching chat objects in space") + emptyList() + } + } + + private fun buildObjectSearchFilters(): List = buildList { + addAll(buildDeletedFilter()) + add(buildTemplateFilter()) + add( + buildLayoutFilter( + layouts = listOf( + ObjectType.Layout.BASIC, + ObjectType.Layout.NOTE, + ObjectType.Layout.PROFILE, + ObjectType.Layout.TODO + ) + ) + ) + } + + // endregion + + // region Chat Sending + + /** + * Sends content to the currently selected chat space. + * Used when a chat/one-to-one space is selected from the space grid. + */ + private suspend fun sendToChat() { + val space = selectedChatSpace ?: return + sendToChat(space) + } + + /** + * Sends content to a specific chat space. + * Used both from space selection and object selection flows. + * Falls back to searching for chat objects if chatId is not directly available. + */ + private suspend fun sendToChat(space: SelectableSpaceView) { + val content = sharedContent ?: return + val spaceId = SpaceId(space.targetSpaceId) + + // Try to get chatId from SpaceView, fallback to search for 1-1 spaces + val chatId = space.chatId ?: findChatIdInSpace(spaceId) + if (chatId == null) { + Timber.e("No chat found in space ${space.name}") + _screenState.value = SharingScreenState.Error( + message = "No chat found in this space", + canRetry = false + ) + return + } + + _screenState.value = SharingScreenState.Sending(progress = 0f, message = "Sending...") + + try { + sendContentToChat(chatId, spaceId, content) + + _screenState.value = SharingScreenState.Success( + createdObjectId = null, + spaceName = space.name, + canOpenObject = false + ) + + _commands.emit( + SharingCommand.Dismiss + ) + + } catch (e: Exception) { + Timber.e(e, "Error sending to chat") + _screenState.value = SharingScreenState.Error( + message = e.msg(), + canRetry = true + ) + } + } + + @Throws + private suspend fun sendContentToChat(chatId: Id, spaceId: SpaceId, content: SharedContent) { + when (content) { + is SharedContent.Text -> { + // Send comment FIRST as separate message if provided + if (commentText.isNotBlank()) { + sendChatMessage(chatId, commentText, emptyList()) + } + + // Then send the shared text (truncated if > 2000 chars) + val truncatedText = content.text.take(SharedContent.MAX_CHAT_MESSAGE_LENGTH) + sendChatMessage(chatId, truncatedText, emptyList()) + } + + is SharedContent.Url -> { + // Create bookmark object and send as attachment + createBookmarkForChat(url = content.url, spaceId = spaceId, chatId = chatId) + } + + is SharedContent.SingleMedia -> { + // Upload and send with caption + uploadMediaFile( + uri = content.uri, + type = content.type, + spaceId = spaceId + ) { fileId -> + val attachment = createMediaAttachment(fileId, content.type) + sendChatMessage(chatId, commentText, listOf(attachment)) + } + } + + is SharedContent.MultipleMedia -> { + // Batch into groups of 10 + val batches = content.uris.chunked(SharedContent.MAX_ATTACHMENTS_PER_MESSAGE) + batches.forEachIndexed { index, batch -> + val attachments = mutableListOf() + batch.forEach { uri -> + uploadMediaFile(uri, content.type, spaceId) { fileId -> + attachments.add(createMediaAttachment(fileId, content.type)) + } + } + + // Add caption only to first message + val caption = if (index == 0) commentText else "" + sendChatMessage(chatId, caption, attachments) + } + } + + is SharedContent.Mixed -> { + // Send comment as separate preceding message + if (commentText.isNotBlank()) { + sendChatMessage(chatId, commentText, emptyList()) + } + + // Build and batch attachments + val attachments = mutableListOf() + + // Add media attachments + content.mediaUris.forEach { uri -> + uploadMediaFile(uri, SharedContent.MediaType.FILE, spaceId) { fileId -> + attachments.add(createMediaAttachment(fileId, SharedContent.MediaType.FILE)) + } + } + + // Batch and send + attachments.chunked(SharedContent.MAX_ATTACHMENTS_PER_MESSAGE).forEach { batch -> + sendChatMessage(chatId, "", batch) + } + + // Send text as separate message if present + content.text?.let { text -> + sendChatMessage(chatId, text.take(SharedContent.MAX_CHAT_MESSAGE_LENGTH), emptyList()) + } + } + } + } + + @Throws + private suspend fun sendChatMessage( + chatId: Id, + text: String, + attachments: List + ) { + addChatMessage.async( + Command.ChatCommand.AddMessage( + chat = chatId, + message = Chat.Message.new( + text = text, + attachments = attachments, + marks = emptyList() + ) + ) + ).fold( + onSuccess = { (messageId, _) -> + Timber.d("Message sent successfully: $messageId") + }, + onFailure = { e -> + Timber.e(e, "Error sending chat message") + throw e + } + ) + } + + private suspend fun createBookmarkForChat(url: String, spaceId: SpaceId, chatId: String) { + val params = CreateObjectFromUrl.Params( + url = url, + space = spaceId + ) + return createObjectFromUrl.async(params).fold( + onSuccess = { obj -> + // Send comment FIRST as separate message if provided (per spec) + if (commentText.isNotBlank()) { + sendChatMessage(chatId, commentText, emptyList()) + } + + // Then send the bookmark as attachment + val bookmarkId = obj.id + val attachment = Chat.Message.Attachment( + target = bookmarkId, + type = Chat.Message.Attachment.Type.Link + ) + sendChatMessage(chatId, "", listOf(attachment)) + }, + onFailure = { e -> + Timber.e(e, "Error creating bookmark from URL") + } + ) + } + + private suspend fun uploadMediaFile( + uri: String, + type: SharedContent.MediaType, + spaceId: SpaceId, + onSuccess: suspend (Id) -> Unit + ) { + val path = try { + fileSharer.getPath(uri) + } catch (e: Exception) { + Timber.e(e, "Error getting path for URI: $uri") + return + } + + if (path == null) { + Timber.e("Path is null for URI: $uri") + return + } + + val fileType = when (type) { + SharedContent.MediaType.IMAGE -> Block.Content.File.Type.IMAGE + SharedContent.MediaType.VIDEO -> Block.Content.File.Type.VIDEO + SharedContent.MediaType.FILE -> Block.Content.File.Type.NONE + SharedContent.MediaType.PDF -> Block.Content.File.Type.PDF + SharedContent.MediaType.AUDIO -> Block.Content.File.Type.AUDIO + } + + uploadFile.async( + UploadFile.Params( + space = spaceId, + path = path, + type = fileType + ) + ).fold( + onSuccess = { file -> onSuccess(file.id) }, + onFailure = { e -> + Timber.e(e, "Error uploading file") + } + ) + + } + + private fun createMediaAttachment( + fileId: Id, + type: SharedContent.MediaType + ): Chat.Message.Attachment { + val attachmentType = when (type) { + SharedContent.MediaType.IMAGE -> Chat.Message.Attachment.Type.Image + SharedContent.MediaType.VIDEO -> Chat.Message.Attachment.Type.File + SharedContent.MediaType.FILE -> Chat.Message.Attachment.Type.File + SharedContent.MediaType.PDF -> Chat.Message.Attachment.Type.File + SharedContent.MediaType.AUDIO -> Chat.Message.Attachment.Type.File + } + return Chat.Message.Attachment(target = fileId, type = attachmentType) + } + + // endregion + + // region Object Creation + + private suspend fun createObjectInSpace() { + val content = sharedContent ?: return + val space = selectedDataSpace ?: return + + _screenState.value = SharingScreenState.Sending(progress = 0f, message = "Creating...") + + val targetSpaceId = space.targetSpaceId + + when (content) { + is SharedContent.Text -> { + proceedWithNoteCreation(content.text, targetSpaceId) { objectId -> + handleObjectCreationSuccess(objectId, space, targetSpaceId) + } + } + is SharedContent.Url -> { + proceedWithBookmarkCreation(content.url, targetSpaceId) { objectId -> + handleObjectCreationSuccess(objectId, space, targetSpaceId) + } + } + is SharedContent.SingleMedia -> { + val title = fileSharer.getDisplayName(content.uri) ?: "" + proceedWithNoteCreation(title, targetSpaceId) { objectId -> + // TODO: Drop files into the object using FileDrop + handleObjectCreationSuccess(objectId, space, targetSpaceId) + } + } + is SharedContent.MultipleMedia -> { + val title = content.uris.mapNotNull { fileSharer.getDisplayName(it) }.joinToString(", ") + proceedWithNoteCreation(title, targetSpaceId) { objectId -> + // TODO: Drop files into the object using FileDrop + handleObjectCreationSuccess(objectId, space, targetSpaceId) + } + } + is SharedContent.Mixed -> { + val noteText = content.text ?: content.url ?: "" + proceedWithNoteCreation(noteText, targetSpaceId) { objectId -> + // TODO: Drop media files into the object + handleObjectCreationSuccess(objectId, space, targetSpaceId) + } + } + } + } + + private suspend fun proceedWithNoteCreation( + text: String, + targetSpaceId: Id, + onSuccess: suspend (Id) -> Unit + ) { + createPrefilledNote.async( + CreatePrefilledNote.Params( + text = text, + space = targetSpaceId, + details = mapOf( + Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble() + ) + ) + ).fold( + onSuccess = { objectId -> + viewModelScope.sendAnalyticsObjectCreateEvent( + analytics = analytics, + objType = MarketplaceObjectTypeIds.NOTE, + route = EventsDictionary.Routes.sharingExtension, + startTime = System.currentTimeMillis(), + spaceParams = provideParams(spaceManager.get()) + ) + onSuccess(objectId) + }, + onFailure = { e -> + Timber.e(e, "Error creating note") + _screenState.value = SharingScreenState.Error( + message = e.msg(), + canRetry = true + ) + } + ) + } + + private suspend fun proceedWithBookmarkCreation( + url: String, + targetSpaceId: Id, + onSuccess: suspend (Id) -> Unit + ) { + createBookmarkObject( + CreateBookmarkObject.Params( + space = targetSpaceId, + url = url, + details = mapOf( + Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble() + ) + ) + ).process( + success = { objectId -> + viewModelScope.sendAnalyticsObjectCreateEvent( + analytics = analytics, + objType = MarketplaceObjectTypeIds.BOOKMARK, + route = EventsDictionary.Routes.sharingExtension, + startTime = System.currentTimeMillis(), + spaceParams = provideParams(spaceManager.get()) + ) + onSuccess(objectId) + }, + failure = { e -> + Timber.e(e, "Error creating bookmark") + _screenState.value = SharingScreenState.Error( + message = e.msg(), + canRetry = true + ) + } + ) + } + + private suspend fun handleObjectCreationSuccess( + objectId: Id, + space: SelectableSpaceView, + targetSpaceId: Id + ) { + val content = sharedContent ?: return + val currentSpaceId = spaceManager.get() + + _screenState.value = SharingScreenState.Success( + createdObjectId = objectId, + spaceName = space.name, + canOpenObject = targetSpaceId == currentSpaceId + ) + + // Show Snackbar with "Open" action to navigate to the created object + _commands.emit( + SharingCommand.ShowSnackbarWithOpenAction( + contentType = content, + destinationName = space.name, + spaceName = null, // No extra space context needed for "added to space" + objectId = objectId, + spaceId = targetSpaceId, + isChat = false + ) + ) + _commands.emit(SharingCommand.Dismiss) + } + + // endregion + + // region Factory + + class Factory @Inject constructor( + private val createBookmarkObject: CreateBookmarkObject, + private val createPrefilledNote: CreatePrefilledNote, + private val createObjectFromUrl: CreateObjectFromUrl, + private val spaceManager: SpaceManager, + private val urlBuilder: UrlBuilder, + private val awaitAccountStartManager: AwaitAccountStartManager, + private val analytics: Analytics, + private val fileSharer: FileSharer, + private val permissions: Permissions, + private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, + private val spaceViewSubscriptionContainer: SpaceViewSubscriptionContainer, + private val addChatMessage: AddChatMessage, + private val uploadFile: UploadFile, + private val searchObjects: SearchObjects, + private val fieldParser: FieldParser, + private val addBackLinkToObject: AddBackLinkToObject + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return SharingViewModel( + createBookmarkObject = createBookmarkObject, + createPrefilledNote = createPrefilledNote, + createObjectFromUrl = createObjectFromUrl, + spaceManager = spaceManager, + urlBuilder = urlBuilder, + awaitAccountStartManager = awaitAccountStartManager, + analytics = analytics, + fileSharer = fileSharer, + permissions = permissions, + analyticSpaceHelperDelegate = analyticSpaceHelperDelegate, + spaceViewSubscriptionContainer = spaceViewSubscriptionContainer, + addChatMessage = addChatMessage, + uploadFile = uploadFile, + searchObjects = searchObjects, + fieldParser = fieldParser, + addBackLinkToObject = addBackLinkToObject + ) as T + } + } + + // endregion +} \ No newline at end of file