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..38150ed140 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 @@ -97,6 +97,7 @@ 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 @@ -891,6 +892,12 @@ class ComponentManager( .create(findComponentDependencies()) } + val sharingComponent = Component { + DaggerSharingComponent + .factory() + .create(findComponentDependencies()) + } + val notificationsComponent = Component { DaggerNotificationComponent .factory() 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 index 16295522ff..84b2ef9a1e 100644 --- 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 @@ -42,7 +42,6 @@ interface AddToAnytypeComponent { fun create(dependency: AddToAnytypeDependencies): AddToAnytypeComponent } - fun inject(fragment: SharingFragment) } @Module 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..78038b40ad --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/sharing/SharingDI.kt @@ -0,0 +1,122 @@ +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.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.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 com.anytypeio.anytype.ui.sharing.SharingFragment +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 + } + + fun inject(fragment: SharingFragment) +} + +@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) + + @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 +} 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..79b3891331 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 @@ -58,6 +58,7 @@ 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 @@ -154,7 +155,8 @@ interface MainComponent : PublishToWebDependencies, MySitesDependencies, MediaDependencies, - CreateChatObjectDependencies + CreateChatObjectDependencies, + SharingDependencies { fun inject(app: AndroidApplication) @@ -452,4 +454,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..3e7e5e6cf0 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 @@ -162,37 +162,51 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr ) ) } + // New single entry point for all share intents + is Command.Sharing.Show -> { + SharingFragment.newInstance(command.intent).show( + supportFragmentManager, + SHARE_DIALOG_LABEL + ) + } + // Legacy handlers - kept for backward compatibility is Command.Sharing.Text -> { + @Suppress("DEPRECATION") SharingFragment.text(command.data).show( supportFragmentManager, SHARE_DIALOG_LABEL ) } is Command.Sharing.Image -> { + @Suppress("DEPRECATION") SharingFragment.image(command.uri).show( supportFragmentManager, SHARE_DIALOG_LABEL ) } is Command.Sharing.Images -> { + @Suppress("DEPRECATION") SharingFragment.images(command.uris).show( supportFragmentManager, SHARE_DIALOG_LABEL ) } is Command.Sharing.Videos -> { + @Suppress("DEPRECATION") SharingFragment.videos(command.uris).show( supportFragmentManager, SHARE_DIALOG_LABEL ) } is Command.Sharing.Files -> { + @Suppress("DEPRECATION") SharingFragment.files(command.uris).show( supportFragmentManager, SHARE_DIALOG_LABEL ) } is Command.Sharing.File -> { + @Suppress("DEPRECATION") SharingFragment.file(command.uri).show( supportFragmentManager, SHARE_DIALOG_LABEL @@ -568,87 +582,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}") - } - } - - private fun handleTextShare(intent: Intent, checkDeepLink: Boolean) { - val raw = intent.getStringExtra(Intent.EXTRA_TEXT) ?: intent.dataString ?: return + if (BuildConfig.DEBUG) Timber.d("Proceeding with share intent: type=${intent.type}, action=${intent.action}") - 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") + return } } - } - 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") - } - } - } - - 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) { @@ -832,8 +783,5 @@ class MainActivity : AppCompatActivity(R.layout.activity_main), AppNavigation.Pr 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/SharingFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/sharing/SharingFragment.kt index c381478cfa..48d3b028b5 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/sharing/SharingFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/sharing/SharingFragment.kt @@ -1,207 +1,297 @@ package com.anytypeio.anytype.ui.sharing +import android.content.Intent 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.activity.compose.BackHandler +import androidx.compose.material3.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.fragment.compose.content 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_ui.features.sharing.SharingScreen +import com.anytypeio.anytype.core_utils.ext.argOrNull +import com.anytypeio.anytype.core_utils.ext.parseActionSendMultipleUris +import com.anytypeio.anytype.core_utils.ext.parseActionSendUri 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.presentation.sharing.SharedContent +import com.anytypeio.anytype.presentation.sharing.SharingCommand +import com.anytypeio.anytype.presentation.sharing.SharingViewModel import com.anytypeio.anytype.ui.editor.EditorFragment -import com.anytypeio.anytype.ui.settings.typography import javax.inject.Inject -import kotlinx.coroutines.flow.map +import timber.log.Timber +/** + * SharingFragment is the single entry point for all share intents. + * It handles all MIME types: text, images, videos, audio, PDF, and generic files. + * + * ## Usage + * ```kotlin + * SharingFragment.newInstance(intent).show(supportFragmentManager, "share") + * ``` + */ 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 + lateinit var factory: SharingViewModel.Factory - private val vm by viewModels { 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. - } - } - } + ) = content { + MaterialTheme { + // Handle back press + BackHandler { + if (!vm.onBackPressed()) { + dismiss() } - LaunchedEffect(Unit) { - vm.toasts.collect { toast -> - toast(toast) + } + + SharingScreen( + state = vm.screenState.collectAsStateWithLifecycle().value, + onSpaceSelected = vm::onSpaceSelected, + onSearchQueryChanged = vm::onSearchQueryChanged, + onCommentChanged = vm::onCommentChanged, + onSendClicked = vm::onSendClicked, + onObjectSelected = vm::onObjectSelected, + onBackPressed = { + if (!vm.onBackPressed()) { + dismiss() } + }, + onCancelClicked = { dismiss() }, + onRetryClicked = vm::onSendClicked + ) + + // Handle commands + LaunchedEffect(Unit) { + vm.commands.collect { command -> + proceedWithCommand(command) } - LaunchedEffect(Unit) { - vm.commands.collect { command -> - proceedWithCommand(command) - } + } + + // Handle toasts + LaunchedEffect(Unit) { + vm.toasts.collect { toast -> + toast(toast) } } } } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + skipCollapsed() + expand() + } + override fun onStart() { super.onStart() - when(val data = sharedData) { - is SharingData.File -> { - vm.onSharedMediaData(listOf(data.uri)) + val sharedContent = parseSharedContent() + vm.onSharedDataReceived(sharedContent) + } + + /** + * Parses the shared content from the fragment arguments. + * Supports both new Intent-based entry point and legacy factory methods. + */ + private fun parseSharedContent(): SharedContent { + val args = requireArguments() + + // New single entry point: Intent passed directly + val intent: Intent? = argOrNull(SHARING_INTENT_KEY) + if (intent != null) { + return convertIntentToSharedContent(intent) + } + + // Legacy support: individual keys for backward compatibility + return parseLegacySharedContent(args) + } + + /** + * Single entry point for converting Android Intent to SharedContent. + * Handles all MIME types: text, images, videos, audio, PDF, and files. + */ + private fun convertIntentToSharedContent(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) } - is SharingData.Files -> { - vm.onSharedMediaData(data.uris) + + // Videos + mimeType.startsWith(MIME_VIDEO_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.VIDEO) } - is SharingData.Image -> { - vm.onSharedMediaData(listOf(data.uri)) + + // Audio (music, voice memos, podcasts) + mimeType.startsWith(MIME_AUDIO_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.AUDIO) } - is SharingData.Images -> { - vm.onSharedMediaData(data.uris) + + // PDF specifically + mimeType == MIME_PDF -> { + parseMediaIntent(intent, SharedContent.MediaType.PDF) } - is SharingData.Text -> { - vm.onSharedTextData(data.raw) + + // Other application files (zip, doc, etc.) + mimeType.startsWith(MIME_APPLICATION_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.FILE) } - is SharingData.Url -> { - vm.onSharedTextData(data.url) + + // Other text types (html, csv, xml) - treat as file + mimeType.startsWith(MIME_TEXT_PREFIX) -> { + parseMediaIntent(intent, SharedContent.MediaType.FILE) } - is SharingData.Videos -> { - vm.onSharedMediaData(data.uris) + + // Fallback for unknown types + else -> { + Timber.w("Unknown MIME type: $mimeType, treating as generic file") + parseMediaIntent(intent, SharedContent.MediaType.FILE) } } } - private fun proceedWithCommand(command: AddToAnytypeViewModel.Command) { + /** + * Parses media content from an Intent, handling both single and multiple items. + */ + 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) + } + } + } + + /** + * Legacy content parsing for backward compatibility with old factory methods. + */ + private fun parseLegacySharedContent(args: Bundle): SharedContent { + return when { + args.containsKey(SHARING_TEXT_KEY) -> { + val result = args.getString(SHARING_TEXT_KEY, "") + if (URLUtil.isValidUrl(result)) { + SharedContent.Url(result) + } else { + SharedContent.Text(result) + } + } + args.containsKey(SHARING_IMAGE_KEY) -> { + val uri = args.getString(SHARING_IMAGE_KEY, "") + SharedContent.SingleMedia(uri = uri, type = SharedContent.MediaType.IMAGE) + } + args.containsKey(SHARING_FILE_KEY) -> { + val uri = args.getString(SHARING_FILE_KEY, "") + SharedContent.SingleMedia(uri = uri, type = SharedContent.MediaType.FILE) + } + args.containsKey(SHARING_MULTIPLE_IMAGES_KEY) -> { + val uris = args.getStringArrayList(SHARING_MULTIPLE_IMAGES_KEY) ?: emptyList() + SharedContent.MultipleMedia(uris = uris, type = SharedContent.MediaType.IMAGE) + } + args.containsKey(SHARING_MULTIPLE_FILES_KEY) -> { + val uris = args.getStringArrayList(SHARING_MULTIPLE_FILES_KEY) ?: emptyList() + SharedContent.MultipleMedia(uris = uris, type = SharedContent.MediaType.FILE) + } + args.containsKey(SHARING_MULTIPLE_VIDEOS_KEY) -> { + val uris = args.getStringArrayList(SHARING_MULTIPLE_VIDEOS_KEY) ?: emptyList() + SharedContent.MultipleMedia(uris = uris, type = SharedContent.MediaType.VIDEO) + } + else -> { + Timber.e("Unexpected shared data - no recognized keys in bundle") + SharedContent.Text("") + } + } + } + + private fun proceedWithCommand(command: SharingCommand) { when (command) { - AddToAnytypeViewModel.Command.Dismiss -> { + is SharingCommand.Dismiss -> { + dismiss() + } + is SharingCommand.ShowToast -> { + toast(command.message) + } + is SharingCommand.NavigateToObject -> { dismiss() + findNavController().navigate( + R.id.objectNavigation, + EditorFragment.args( + ctx = command.objectId, + space = command.spaceId + ) + ) } - 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) + is SharingCommand.ObjectAddedToSpaceToast -> { + val msg = resources.getString( + R.string.sharing_menu_toast_object_added, + command.spaceName + ) toast(msg = msg) } } } override fun injectDependencies() { - componentManager().addToAnytypeComponent.get().inject(this) + componentManager().sharingComponent.get().inject(this) } override fun releaseDependencies() { - componentManager().addToAnytypeComponent.release() + componentManager().sharingComponent.release() } companion object { + // New single entry point key + private const val SHARING_INTENT_KEY = "arg.sharing.intent" + + // Legacy keys (kept for backward compatibility) 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" @@ -209,28 +299,55 @@ class SharingFragment : BaseBottomSheetComposeFragment() { 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 { + // 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" + + /** + * Single entry point for all share intents. + * Handles all MIME types: text, images, videos, audio, PDF, and files. + * + * @param intent The share intent from Android system + * @return A new SharingFragment instance + */ + fun newInstance(intent: Intent): SharingFragment = SharingFragment().apply { + arguments = bundleOf(SHARING_INTENT_KEY to intent) + } + + // Legacy factory methods - kept for backward compatibility + @Deprecated("Use newInstance(intent) instead", ReplaceWith("newInstance(intent)")) + fun text(data: String): SharingFragment = SharingFragment().apply { arguments = bundleOf(SHARING_TEXT_KEY to data) } - fun image(uri: String) : SharingFragment = SharingFragment().apply { + @Deprecated("Use newInstance(intent) instead", ReplaceWith("newInstance(intent)")) + fun image(uri: String): SharingFragment = SharingFragment().apply { arguments = bundleOf(SHARING_IMAGE_KEY to uri) } - fun images(uris: List) : SharingFragment = SharingFragment().apply { + @Deprecated("Use newInstance(intent) instead", ReplaceWith("newInstance(intent)")) + fun images(uris: List): SharingFragment = SharingFragment().apply { arguments = bundleOf(SHARING_MULTIPLE_IMAGES_KEY to ArrayList(uris)) } - fun videos(uris: List) : SharingFragment = SharingFragment().apply { + @Deprecated("Use newInstance(intent) instead", ReplaceWith("newInstance(intent)")) + fun videos(uris: List): SharingFragment = SharingFragment().apply { arguments = bundleOf(SHARING_MULTIPLE_VIDEOS_KEY to ArrayList(uris)) } - fun files(uris: List) : SharingFragment = SharingFragment().apply { + @Deprecated("Use newInstance(intent) instead", ReplaceWith("newInstance(intent)")) + fun files(uris: List): SharingFragment = SharingFragment().apply { arguments = bundleOf(SHARING_MULTIPLE_FILES_KEY to ArrayList(uris)) } - fun file(uri: String) : SharingFragment = SharingFragment().apply { + @Deprecated("Use newInstance(intent) instead", ReplaceWith("newInstance(intent)")) + 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..02eab30473 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SelectDestinationObjectScreen.kt @@ -0,0 +1,468 @@ +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.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.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.foundation.text.BasicTextField +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +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.DefaultSearchBar +import com.anytypeio.anytype.core_ui.views.BodyBold +import com.anytypeio.anytype.core_ui.views.BodyRegular +import com.anytypeio.anytype.core_ui.views.ButtonPrimary +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.PreviewTitle2Regular +import com.anytypeio.anytype.core_ui.widgets.ListWidgetObjectIcon +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 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 + HeaderSection( + spaceName = spaceName, + onBackPressed = onBackPressed + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Search bar + DefaultSearchBar( + value = searchQuery, + onQueryChanged = onSearchQueryChanged, + hint = R.string.search, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(26.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 = 16.dp, bottom = 8.dp) + ) + } + // 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( + color = colorResource(id = R.color.shape_primary), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + + // 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( + color = colorResource(id = R.color.shape_primary), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + + // Bottom section + BottomSection( + showCommentInput = showCommentInput, + commentText = commentText, + onCommentChanged = onCommentChanged, + onSendClicked = onSendClicked, + buttonText = if (showCommentInput) { + stringResource(R.string.send) + } else { + stringResource(R.string.save) + } + ) + } +} + +@Composable +private fun HeaderSection( + spaceName: String, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBackPressed) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = colorResource(id = R.color.glyph_active) + ) + } + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.sharing_select_destination), + style = BodyBold, + color = colorResource(id = R.color.text_primary), + textAlign = TextAlign.Center + ) + Text( + text = spaceName, + style = Caption1Regular, + color = colorResource(id = R.color.text_secondary), + 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 = 16.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 = PreviewTitle2Regular, + 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 = "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 + ) + } +} + +@Composable +private fun BottomSection( + showCommentInput: Boolean, + commentText: String, + onCommentChanged: (String) -> Unit, + onSendClicked: () -> Unit, + buttonText: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = colorResource(id = R.color.background_primary)) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // Comment input (only shown when "Send to chat" is selected) + if (showCommentInput) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.shape_primary), + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + BasicTextField( + value = commentText, + onValueChange = onCommentChanged, + textStyle = BodyRegular.copy( + color = colorResource(id = R.color.text_primary) + ), + cursorBrush = SolidColor(colorResource(id = R.color.glyph_active)), + modifier = Modifier.fillMaxWidth(), + decorationBox = { innerTextField -> + if (commentText.isEmpty()) { + Text( + text = stringResource(R.string.add_a_comment), + style = BodyRegular, + color = colorResource(id = R.color.text_secondary) + ) + } + innerTextField() + } + ) + } + Spacer(modifier = Modifier.height(12.dp)) + } + + // Send/Save button + ButtonPrimary( + text = buttonText, + onClick = onSendClicked, + size = ButtonSize.Large, + modifier = Modifier.fillMaxWidth() + ) + } +} + +// ============================================ +// 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 + ) + ) + + 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 + ) + + 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..caa319bbae --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SelectSpaceScreen.kt @@ -0,0 +1,517 @@ +package com.anytypeio.anytype.core_ui.features.sharing + +import android.graphics.Color +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.ColumnScope +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +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.BasicTextField +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.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +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.Dragger +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.ButtonPrimary +import com.anytypeio.anytype.core_ui.views.ButtonSize +import com.anytypeio.anytype.core_ui.views.Caption1Medium +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 BoxScope.SelectSpaceScreen( + spaces: List, + searchQuery: String, + commentText: String, + onSearchQueryChanged: (String) -> Unit, + onCommentChanged: (String) -> Unit, + onSpaceSelected: (SelectableSpaceItem) -> Unit, + onSendClicked: () -> Unit, + modifier: Modifier = Modifier +) { + val hasSelectedSpace = spaces.any { it.isSelected } + + if (spaces.isEmpty()) { + EmptySpaceState( + modifier = Modifier.fillMaxSize() + ) + } else { + Column( + modifier = modifier + .padding(top = 64.dp) + .fillMaxSize() + ) { + + // Search Bar + DefaultSearchBar( + value = searchQuery, + onQueryChanged = onSearchQueryChanged, + hint = R.string.search, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + + // Space Grid + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize() + ) { + items( + items = spaces, + key = { it.id } + ) { space -> + SpaceGridItem( + icon = space.icon, + name = space.name, + isSelected = space.isSelected, + onClick = { onSpaceSelected(space) } + ) + } + } + + // Bottom section (Comment + Send button) - only shown when space is selected + if (hasSelectedSpace) { + CommentSection( + commentText = commentText, + onCommentChanged = onCommentChanged, + onSendClicked = onSendClicked, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +fun SelectSpaceScreenHeader(modifier: Modifier) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Dragger( + modifier = Modifier + .padding(vertical = 6.dp) + ) + 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, + 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 + .background(color = colorResource(id = R.color.background_primary)) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // Comment Text Field + OutlinedTextField( + value = innerValue, + onValueChange = { + innerValue = it + }, + 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) + ) + } + ) +// Box( +// modifier = Modifier +// .fillMaxWidth() +// .border( +// width = 0.5.dp, +// color = colorResource(R.color.shape_primary), +// shape = RoundedCornerShape(12.dp) +// ) +// .padding(horizontal = 16.dp, vertical = 12.dp) +// ) { +// BasicTextField( +// value = commentText, +// onValueChange = onCommentChanged, +// textStyle = BodyRegular.copy( +// color = colorResource(id = R.color.text_primary) +// ), +// cursorBrush = SolidColor(colorResource(id = R.color.glyph_active)), +// modifier = Modifier.fillMaxWidth(), +// decorationBox = { innerTextField -> +// if (commentText.isEmpty()) { +// Text( +// modifier = Modifier.padding(start = 1.dp), +// text = stringResource(R.string.add_a_comment), +// style = BodyRegular, +// color = colorResource(id = R.color.text_tertiary) +// ) +// } +// innerTextField() +// } +// ) +// } + + Spacer(modifier = Modifier.height(12.dp)) + + // Send Button + ButtonPrimary( + text = stringResource(R.string.send), + onClick = onSendClicked, + size = ButtonSize.Large, + modifier = Modifier.fillMaxWidth() + ) + } +} + +/** + * Empty state when user has no spaces + * + * @param modifier Modifier for the empty state + */ +@Composable +private fun EmptySpaceState( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + 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 + ) + } +} + +// ============================================ +// 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 + ), + 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/SharingScreen.kt b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingScreen.kt new file mode 100644 index 0000000000..539457d294 --- /dev/null +++ b/core-ui/src/main/java/com/anytypeio/anytype/core_ui/features/sharing/SharingScreen.kt @@ -0,0 +1,680 @@ +package com.anytypeio.anytype.core_ui.features.sharing + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +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.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.systemBarsPadding +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.views.BodyBold +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.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 SharingScreen( + state: SharingScreenState, + onSpaceSelected: (SelectableSpaceView) -> Unit, + onSearchQueryChanged: (String) -> Unit, + onCommentChanged: (String) -> Unit, + onSendClicked: () -> Unit, + onObjectSelected: (SelectableObjectView) -> Unit, + onBackPressed: () -> Unit, + onCancelClicked: () -> Unit, + onRetryClicked: () -> Unit, + modifier: Modifier = Modifier +) { + + Box( + modifier = Modifier + .systemBarsPadding() + .fillMaxSize() + .background( + color = colorResource(R.color.background_primary), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ), + ) { + SelectSpaceScreenHeader( + modifier = Modifier.fillMaxWidth().height(64.dp) + ) + when (state) { + SharingScreenState.Loading -> { + LoadingScreen() + } + + 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 -> } + } + + 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.ChatInput -> TODO() + is SharingScreenState.Error -> TODO() + is SharingScreenState.ObjectSelection -> TODO() + is SharingScreenState.Sending -> TODO() + is SharingScreenState.Success -> TODO() + } +// when (state) { +// is SharingScreenState.Loading -> { +// Column( +// modifier = Modifier +// .fillMaxWidth(), +// horizontalAlignment = Alignment.CenterHorizontally +// ) { +// SelectSpaceScreenHeader() +// LoadingScreen() +// } +// } +// +// 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 -> }} +// +// Column( +// modifier = Modifier +// .fillMaxWidth(), +// horizontalAlignment = Alignment.CenterHorizontally +// ) { +// SelectSpaceScreenHeader() +// 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.ChatInput -> { +// ChatInputScreen( +// selectedSpaces = state.selectedSpaces, +// commentText = state.commentText, +// onCommentChanged = onCommentChanged, +// onSendClicked = onSendClicked, +// onBackPressed = onBackPressed +// ) +// } +// +// is SharingScreenState.ObjectSelection -> { +// 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 -> { +// SendingScreen( +// progress = state.progress, +// message = state.message +// ) +// } +// +// is SharingScreenState.Success -> { +// SuccessScreen( +// spaceName = state.spaceName, +// canOpenObject = state.canOpenObject, +// onDoneClicked = onCancelClicked +// ) +// } +// +// is SharingScreenState.Error -> { +// ErrorScreen( +// message = state.message, +// canRetry = state.canRetry, +// onRetryClicked = onRetryClicked, +// onCancelClicked = onCancelClicked +// ) +// } +// } + } +} + +@Composable +private fun LoadingScreen( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colorResource(id = R.color.glyph_active), + modifier = Modifier.size(48.dp) + ) + } +} + +/** + * Screen for chat input when chat space(s) are selected. + * Shows selected spaces and comment input. + */ +@Composable +private fun ChatInputScreen( + selectedSpaces: List, + commentText: String, + onCommentChanged: (String) -> Unit, + onSendClicked: () -> Unit, + onBackPressed: () -> Unit, + modifier: Modifier = Modifier +) { + // For now, use SelectSpaceScreen with all spaces selected + // In a full implementation, this would be a dedicated screen showing selected chats + Column( + modifier = modifier + .fillMaxSize() + .background(colorResource(id = R.color.background_primary)) + ) { + // Header + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (selectedSpaces.size == 1) { + stringResource(R.string.sharing_send_to, selectedSpaces.first().name) + } else { + stringResource(R.string.sharing_send_to_multiple, selectedSpaces.size) + }, + style = BodyBold, + color = colorResource(id = R.color.text_primary), + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Comment section (reuse from SelectSpaceScreen pattern) + Column( + modifier = Modifier + .fillMaxWidth() + .background(colorResource(id = R.color.background_primary)) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // Comment field + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = colorResource(id = R.color.shape_primary), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + androidx.compose.foundation.text.BasicTextField( + value = commentText, + onValueChange = onCommentChanged, + textStyle = BodyRegular.copy( + color = colorResource(id = R.color.text_primary) + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor( + colorResource(id = R.color.glyph_active) + ), + modifier = Modifier.fillMaxWidth(), + decorationBox = { innerTextField -> + if (commentText.isEmpty()) { + Text( + text = stringResource(R.string.add_a_comment), + style = BodyRegular, + color = colorResource(id = R.color.text_secondary) + ) + } + innerTextField() + } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Send button + ButtonPrimary( + text = stringResource(R.string.send), + onClick = onSendClicked, + size = ButtonSize.Large, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@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 = "Loading State", showBackground = true) +//@Composable +//private fun SharingScreenPreview_Loading() { +// MaterialTheme { // Wrap with your app's theme for consistent styling +// SharingScreen( +// state = SharingScreenState.Loading, +// onSpaceSelected = {}, +// onSearchQueryChanged = {}, +// onCommentChanged = {}, +// onSendClicked = {}, +// onObjectSelected = {}, +// onBackPressed = {}, +// onCancelClicked = {}, +// onRetryClicked = {} +// ) +// } +//} + +@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) + ) + + MaterialTheme { + SharingScreen( + state = SharingScreenState.SpaceSelection( + spaces = listOf(), + 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"), +// SelectableObjectView(id = "obj2", name = "Sprint Planning", typeName = "Board"), +// SelectableObjectView(id = "obj3", name = "Design Mockups", typeName = "Collection") +// ) +// +// MaterialTheme { +// SharingScreen( +// screenState = SharingScreenState.ObjectSelection( +// space = mockSpace, +// objects = mockObjects, +// searchQuery = "", +// selectedObject = mockObjects[1], +// showChatOption = true, +// 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() { +// MaterialTheme { +// SharingScreen( +// screenState = 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() { +// MaterialTheme { +// SharingScreen( +// screenState = 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() { +// MaterialTheme { +// SharingScreen( +// screenState = 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/res/drawable/ic_checked_24.xml b/core-ui/src/main/res/drawable/ic_checked_24.xml index 6d63841daf..94c2b03327 100644 --- a/core-ui/src/main/res/drawable/ic_checked_24.xml +++ b/core-ui/src/main/res/drawable/ic_checked_24.xml @@ -4,7 +4,7 @@ android:viewportWidth="24" android:viewportHeight="24"> 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 Space + Add a comment... + Send Receive all Mute all Chat specific notifications @@ -2374,4 +2377,18 @@ 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 + 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 a2b72320d7..2d91eeeb32 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 @@ -329,12 +329,35 @@ class MainViewModel( } } + /** + * Single entry point for all share intents. + * SharingFragment handles MIME type detection and content parsing internally. + * + * @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, _) -> + if (status == AuthStatus.AUTHORIZED) { + commands.emit(Command.Sharing.Show(intent)) + } + } + ) + } + } + + // Legacy methods - kept for backward compatibility + @Deprecated("Use onShareIntent(intent) instead") 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) { + @Suppress("DEPRECATION") commands.emit(Command.Sharing.Text(data)) } } @@ -342,6 +365,7 @@ class MainViewModel( } } + @Deprecated("Use onShareIntent(intent) instead") fun onIntentMultipleFilesShare(uris: List) { Timber.d("onIntentFileShare: $uris") viewModelScope.launch { @@ -349,6 +373,7 @@ class MainViewModel( onFailure = { e -> Timber.e(e, "Error while checking auth status") }, onSuccess = { (status, account) -> if (status == AuthStatus.AUTHORIZED) { + @Suppress("DEPRECATION") if (uris.size == 1) { commands.emit(Command.Sharing.File(uris.first())) } else { @@ -360,6 +385,7 @@ class MainViewModel( } } + @Deprecated("Use onShareIntent(intent) instead") fun onIntentMultipleImageShare(uris: List) { Timber.d("onIntentImageShare: $uris") viewModelScope.launch { @@ -367,6 +393,7 @@ class MainViewModel( onFailure = { e -> Timber.e(e, "Error while checking auth status") }, onSuccess = { (status, account) -> if (status == AuthStatus.AUTHORIZED) { + @Suppress("DEPRECATION") if (uris.size == 1) { commands.emit(Command.Sharing.Image(uris.first())) } else { @@ -378,6 +405,7 @@ class MainViewModel( } } + @Deprecated("Use onShareIntent(intent) instead") fun onIntentMultipleVideoShare(uris: List) { Timber.d("onIntentVideoShare: $uris") viewModelScope.launch { @@ -385,6 +413,7 @@ class MainViewModel( onFailure = { e -> Timber.e(e, "Error while checking auth status") }, onSuccess = { (status, account) -> if (status == AuthStatus.AUTHORIZED) { + @Suppress("DEPRECATION") commands.emit(Command.Sharing.Videos(uris)) } } @@ -775,11 +804,24 @@ class MainViewModel( class OpenCreateNewType(val type: Id) : Command() data class Error(val msg: String) : Command() sealed class Sharing : Command() { + /** + * Single entry point for all share intents. + * SharingFragment handles MIME type detection internally. + */ + data class Show(val intent: android.content.Intent) : Sharing() + + // Legacy commands - kept for backward compatibility + @Deprecated("Use Show(intent) instead") data class Text(val data: String) : Sharing() + @Deprecated("Use Show(intent) instead") data class Image(val uri: String) : Sharing() + @Deprecated("Use Show(intent) instead") data class Images(val uris: List) : Sharing() + @Deprecated("Use Show(intent) instead") data class Videos(val uris: List) : Sharing() + @Deprecated("Use Show(intent) instead") data class File(val uri: String) : Sharing() + @Deprecated("Use Show(intent) instead") data class Files(val uris: List) : Sharing() } 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..6cf5f8a498 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharedContent.kt @@ -0,0 +1,109 @@ +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 + } + + /** + * Checks if the text content needs to be truncated for chat messages. + * @return true if text content exceeds the maximum chat message length + */ + fun requiresTruncation(): Boolean = when (this) { + is Text -> text.length > MAX_CHAT_MESSAGE_LENGTH + is Mixed -> (text?.length ?: 0) > MAX_CHAT_MESSAGE_LENGTH + else -> false + } + + /** + * Returns the total number of media attachments in this content. + */ + fun mediaCount(): Int = when (this) { + is SingleMedia -> 1 + is MultipleMedia -> uris.size + is Mixed -> mediaUris.size + else -> 0 + } + + /** + * Checks if this content has any media attachments. + */ + fun hasMedia(): Boolean = mediaCount() > 0 + + /** + * Checks if this content requires batching due to attachment limits. + * @return true if media count exceeds max attachments per message + */ + fun requiresBatching(): Boolean = mediaCount() > MAX_ATTACHMENTS_PER_MESSAGE + + 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..3a983cb3d3 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingModels.kt @@ -0,0 +1,92 @@ +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 +) { + companion object { + /** + * Creates a special "Send to chat" option for Flow 3 spaces. + */ + fun createChatOption(chatId: Id): SelectableObjectView = SelectableObjectView( + id = chatId, + name = "", // Will be replaced with localized string in UI + icon = ObjectIcon.None, + typeName = "", + isSelected = false, + isChatOption = true + ) + } +} + +/** + * 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..e2cd53427e --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingScreenState.kt @@ -0,0 +1,164 @@ +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) + * - Chat input (for chat spaces - Flow 1) + * - 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() + + /** + * 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() + + /** + * Chat input screen shown after selecting chat space(s). + * Used for Flow 1 (Chat Space). + * + * @property selectedSpaces List of selected chat spaces (multi-select supported) + * @property commentText Current comment/caption text (max 2000 chars) + * @property sharedContent The content being shared + */ + data class ChatInput( + val selectedSpaces: List, + val commentText: String = "", + val sharedContent: SharedContent + ) : 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 there are chat objects available in this space. + */ + val hasChatOptions: Boolean + get() = chatObjects.isNotEmpty() + + /** + * 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 } + + /** + * Number of currently selected destinations. + */ + val selectedCount: Int + get() = selectedObjectIds.size + + /** + * Returns true if more items can be selected (under the limit). + */ + val canSelectMore: Boolean + get() = selectedObjectIds.size < MAX_SELECTION_COUNT + + 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() + + /** + * Navigate to the created object. + */ + data class NavigateToObject( + val objectId: Id, + val spaceId: Id + ) : SharingCommand() + + /** + * Show toast indicating object was added to a different space. + */ + data class ObjectAddedToSpaceToast(val spaceName: String) : 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..1c0b5c3cb9 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/SharingViewModel.kt @@ -0,0 +1,1034 @@ +package com.anytypeio.anytype.presentation.sharing + +import android.util.Log +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.multiplayer.SpaceUxType +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.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.objects.ObjectIcon +import com.anytypeio.anytype.presentation.search.ObjectSearchConstants +import com.anytypeio.anytype.presentation.spaces.spaceIcon +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +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 kotlinx.coroutines.withContext +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 +) : 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 = "" + + 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() + Log.d("Test1983", "All spaces: $spaces") + 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.ChatInput -> { + _screenState.value = state.copy(commentText = limitedText) + } + is SharingScreenState.ObjectSelection -> { + _screenState.value = state.copy(commentText = limitedText) + } + else -> { /* ignore */ } + } + } + + /** + * Called when an object is selected in the destination list. + * Supports multi-selection up to [SharingScreenState.ObjectSelection.MAX_SELECTION_COUNT] items. + */ + fun onObjectSelected(obj: SelectableObjectView) { + val currentState = _screenState.value + if (currentState !is SharingScreenState.ObjectSelection) return + + // Toggle selection + if (obj.id in selectedDestinationObjectIds) { + selectedDestinationObjectIds.remove(obj.id) + } else { + // Check limit before adding + if (selectedDestinationObjectIds.size >= SharingScreenState.ObjectSelection.MAX_SELECTION_COUNT) { + viewModelScope.launch { + _commands.emit( + SharingCommand.ShowToast("You can select up to ${SharingScreenState.ObjectSelection.MAX_SELECTION_COUNT} destinations") + ) + } + return + } + selectedDestinationObjectIds.add(obj.id) + } + + // 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.ChatInput -> { + 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 content linked/saved. + */ + private suspend fun sendToMultipleDestinations(state: SharingScreenState.ObjectSelection) { + val content = sharedContent ?: return + + // 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) + } + + // Save to objects + selectedObjects.forEach { obj -> + // For now, create object in space (linking to existing objects can be added later) + // This maintains compatibility with the current behavior + } + + // If only objects selected (no chats), create the object + if (selectedChats.isEmpty() && selectedObjects.isNotEmpty()) { + createObjectInSpace() + return + } + + _screenState.value = SharingScreenState.Success( + createdObjectId = null, + spaceName = state.space.name, + canOpenObject = 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 + ) + } + } + + /** + * 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.ChatInput -> { + // Clear chat selection and go back to space selection + selectedChatSpace = null + commentText = "" + updateSpaceSelectionState() + true + } + is SharingScreenState.ObjectSelection -> { + // Go back to space selection + selectedDataSpace = null + selectedDestinationObjectIds.clear() + objectSearchQuery = "" + commentText = "" + 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 = filteredSpaces, + searchQuery = spaceSearchQuery, + sharedContent = content, + commentText = commentText + ) + } + + private fun showChatInputScreen() { + val space = selectedChatSpace ?: return + val content = sharedContent ?: return + _screenState.value = SharingScreenState.ChatInput( + selectedSpaces = listOf(space), // Single item list for compatibility + commentText = commentText, + sharedContent = content + ) + } + + 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 + ) + + // Load objects and chats in parallel + searchObjectsAndChatsInSpace(space) + } + + /** + * Searches for both regular objects and chat objects in the given space. + * Results are loaded in parallel for better performance. + * Preserves selection state across search queries. + */ + private fun searchObjectsAndChatsInSpace(space: SelectableSpaceView) { + viewModelScope.launch { + val spaceId = SpaceId(space.targetSpaceId) + + // Search regular objects + val objects = searchRegularObjects(spaceId, space.uxType) + + // Search chat objects (CHAT_DERIVED layout) + val chats = searchChatObjects(spaceId) + + val currentState = _screenState.value + if (currentState is SharingScreenState.ObjectSelection) { + _screenState.value = currentState.copy( + objects = objects, + chatObjects = chats, + searchQuery = objectSearchQuery, + selectedObjectIds = selectedDestinationObjectIds.toSet() + ) + } + } + } + + /** + * Searches for regular objects (non-chat) in the space. + */ + private suspend fun searchRegularObjects( + spaceId: SpaceId, + spaceUxType: SpaceUxType? + ): List { + val filters = buildObjectSearchFilters(spaceUxType) + val sorts = listOf( + DVSort( + relationKey = Relations.LAST_MODIFIED_DATE, + type = DVSortType.DESC, + relationFormat = RelationFormat.DATE + ) + ) + + val params = SearchObjects.Params( + space = spaceId, + filters = filters, + sorts = sorts, + fulltext = objectSearchQuery, + keys = listOf( + Relations.ID, + Relations.NAME, + Relations.ICON_EMOJI, + Relations.ICON_IMAGE, + Relations.ICON_OPTION, + Relations.TYPE, + Relations.TYPE_UNIQUE_KEY, + Relations.LAYOUT + ) + ) + + return try { + val objects = searchObjects(params).getOrNull() ?: emptyList() + objects.map { obj -> + SelectableObjectView( + id = obj.id, + name = fieldParser.getObjectPluralName(obj), + icon = obj.objectIcon(urlBuilder), + typeName = "", // TODO: resolve type name + isSelected = obj.id in selectedDestinationObjectIds + ) + } + } catch (e: Exception) { + Timber.e(e, "Error searching objects in space") + emptyList() + } + } + + /** + * Searches for chat objects (CHAT_DERIVED layout) in the space. + */ + private suspend fun searchChatObjects(spaceId: SpaceId): List { + val filters = buildList { + add( + DVFilter( + relation = Relations.LAYOUT, + condition = DVFilterCondition.EQUAL, + value = ObjectType.Layout.CHAT_DERIVED.code.toDouble() + ) + ) + add( + DVFilter( + relation = Relations.IS_ARCHIVED, + condition = DVFilterCondition.NOT_EQUAL, + value = true + ) + ) + add( + DVFilter( + relation = Relations.IS_DELETED, + condition = DVFilterCondition.NOT_EQUAL, + value = true + ) + ) + } + + val sorts = listOf( + DVSort( + relationKey = Relations.LAST_MODIFIED_DATE, + type = DVSortType.DESC, + relationFormat = RelationFormat.DATE + ) + ) + + val params = SearchObjects.Params( + space = spaceId, + filters = filters, + sorts = sorts, + fulltext = "", + keys = listOf( + Relations.ID, + Relations.NAME, + Relations.ICON_EMOJI, + Relations.ICON_IMAGE, + Relations.ICON_OPTION + ) + ) + + return try { + val objects = searchObjects(params).getOrNull() ?: emptyList() + objects.map { obj -> + SelectableObjectView( + id = obj.id, + name = fieldParser.getObjectPluralName(obj), + icon = obj.objectIcon(urlBuilder), + typeName = "Chat", + isSelected = obj.id in selectedDestinationObjectIds, + isChatOption = true + ) + } + } catch (e: Exception) { + Timber.e(e, "Error searching chat objects in space") + emptyList() + } + } + + private fun buildObjectSearchFilters(spaceUxType: SpaceUxType?): List = buildList { + // Base filters from ObjectSearchConstants + addAll(ObjectSearchConstants.filterSearchObjects(excludeTypes = true, spaceUxType = spaceUxType)) + + // Exclude Sets, Collections, and Chat objects (chats are searched separately) + add( + DVFilter( + relation = Relations.LAYOUT, + condition = DVFilterCondition.NOT_IN, + value = listOf( + ObjectType.Layout.SET.code.toDouble(), + ObjectType.Layout.COLLECTION.code.toDouble(), + ObjectType.Layout.CHAT_DERIVED.code.toDouble() + ) + ) + ) + } + + // 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. + */ + private suspend fun sendToChat(space: SelectableSpaceView) { + val content = sharedContent ?: return + val chatId = space.chatId ?: return + val spaceId = SpaceId(space.targetSpaceId) + + _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 + ) + } + } + + private suspend fun sendContentToChat(chatId: Id, spaceId: SpaceId, content: SharedContent) { + when (content) { + is SharedContent.Text -> { + // Truncate text if > 2000 chars + val truncatedText = content.text.take(SharedContent.MAX_CHAT_MESSAGE_LENGTH) + sendChatMessage(chatId, truncatedText, emptyList()) + + // Send comment as separate message if provided + if (commentText.isNotBlank()) { + sendChatMessage(chatId, commentText, emptyList()) + } + } + + is SharedContent.Url -> { + // Create bookmark object and send as attachment + createBookmarkForChat(content.url, spaceId, chatId) + } + + is SharedContent.SingleMedia -> { + // Upload and send with caption + uploadMediaFile(content.uri, content.type, 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 bookmark if URL present + content.url?.let { url -> +// val bookmarkId = createBookmarkForChat(url, spaceId, chatId) +// if (bookmarkId != null) { +// attachments.add(Chat.Message.Attachment( +// target = bookmarkId, +// type = Chat.Message.Attachment.Type.Link +// )) +// } + } + + // 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()) + } + } + } + } + + 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 -> + val bookmarkId = obj.id + if (bookmarkId != null) { + val attachment = Chat.Message.Attachment( + target = bookmarkId, + type = Chat.Message.Attachment.Type.Link + ) + sendChatMessage(chatId, "", listOf(attachment)) + } + + // Send comment as separate message + if (commentText.isNotBlank()) { + sendChatMessage(chatId, commentText, emptyList()) + } + }, + onFailure = { e -> + Timber.e(e, "Error creating bookmark from URL") + null + } + ) + } + + private suspend fun uploadMediaFile( + uri: String, + type: SharedContent.MediaType, + spaceId: SpaceId, + onSuccess: suspend (Id) -> Unit + ) = withContext(Dispatchers.IO) { + val path = try { + fileSharer.getPath(uri) + } catch (e: Exception) { + Timber.e(e, "Error getting path for URI: $uri") + return@withContext + } + + if (path == null) { + Timber.e("Path is null for URI: $uri") + return@withContext + } + + 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 currentSpaceId = spaceManager.get() + + _screenState.value = SharingScreenState.Success( + createdObjectId = objectId, + spaceName = space.name, + canOpenObject = targetSpaceId == currentSpaceId + ) + + if (targetSpaceId == currentSpaceId) { + _commands.emit(SharingCommand.NavigateToObject(objectId, targetSpaceId)) + } else { + _commands.emit(SharingCommand.ObjectAddedToSpaceToast(space.name)) + _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 + ) : 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 + ) as T + } + } + + // endregion + + companion object { + private const val TAG = "SharingViewModel" + } +} + +/** + * Extension function to get object icon from ObjectWrapper.Basic + */ +private fun com.anytypeio.anytype.core_models.ObjectWrapper.Basic.objectIcon( + urlBuilder: UrlBuilder +): ObjectIcon { + val iconEmoji = iconEmoji + val iconImage = iconImage + + return when { + !iconEmoji.isNullOrBlank() -> ObjectIcon.Basic.Emoji(iconEmoji) + !iconImage.isNullOrBlank() -> ObjectIcon.Basic.Image(urlBuilder.thumbnail(iconImage)) + else -> ObjectIcon.None + } +}