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 257720ad85..c2ef11b206 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 @@ -3,6 +3,8 @@ package com.anytypeio.anytype.di.feature.sharing import androidx.lifecycle.ViewModelProvider import com.anytypeio.anytype.analytics.base.Analytics import com.anytypeio.anytype.core_utils.di.scope.PerDialog +import com.anytypeio.anytype.data.auth.event.EventProcessDropFilesDateChannel +import com.anytypeio.anytype.data.auth.event.EventProcessDropFilesRemoteChannel import com.anytypeio.anytype.di.common.ComponentDependencies import com.anytypeio.anytype.domain.account.AwaitAccountStartManager import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers @@ -13,13 +15,17 @@ import com.anytypeio.anytype.domain.device.FileSharer import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer import com.anytypeio.anytype.domain.misc.UrlBuilder import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider +import com.anytypeio.anytype.domain.workspace.EventProcessDropFilesChannel import com.anytypeio.anytype.domain.workspace.SpaceManager +import com.anytypeio.anytype.middleware.EventProxy +import com.anytypeio.anytype.middleware.interactor.EventProcessDropFilesMiddlewareChannel import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate import com.anytypeio.anytype.presentation.sharing.AddToAnytypeViewModel import com.anytypeio.anytype.ui.sharing.SharingFragment import dagger.Binds import dagger.Component import dagger.Module +import dagger.Provides @Component( dependencies = [AddToAnytypeDependencies::class], @@ -40,6 +46,19 @@ interface AddToAnytypeComponent { @Module object AddToAnytypeModule { + + @Provides + @PerDialog + fun provideEventProcessRemoteChannel( + proxy: EventProxy + ): EventProcessDropFilesRemoteChannel = EventProcessDropFilesMiddlewareChannel(events = proxy) + + @Provides + @PerDialog + fun provideEventProcessDateChannel( + channel: EventProcessDropFilesRemoteChannel + ): EventProcessDropFilesChannel = EventProcessDropFilesDateChannel(channel = channel) + @Module interface Declarations { @PerDialog @@ -61,4 +80,5 @@ interface AddToAnytypeDependencies : ComponentDependencies { fun fileSharer(): FileSharer fun permissions(): UserPermissionProvider fun analyticSpaceHelper(): AnalyticSpaceHelperDelegate + fun eventProxy(): EventProxy } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/sharing/Sharing.kt b/app/src/main/java/com/anytypeio/anytype/ui/sharing/Sharing.kt index 9f28862a47..7025ce82d4 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/sharing/Sharing.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/sharing/Sharing.kt @@ -1,5 +1,12 @@ package com.anytypeio.anytype.ui.sharing +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -12,10 +19,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Divider import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.ProgressIndicatorDefaults import androidx.compose.material.Text import androidx.compose.material3.DropdownMenu import androidx.compose.runtime.Composable @@ -28,6 +40,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource @@ -37,28 +50,44 @@ import androidx.compose.ui.unit.dp import androidx.core.graphics.toColorInt import coil.compose.rememberAsyncImagePainter import com.anytypeio.anytype.R +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.ObjectWrapper import com.anytypeio.anytype.core_ui.foundation.noRippleClickable import com.anytypeio.anytype.core_ui.views.BodyRegular import com.anytypeio.anytype.core_ui.views.ButtonPrimary +import com.anytypeio.anytype.core_ui.views.ButtonPrimaryLoading import com.anytypeio.anytype.core_ui.views.ButtonSecondary import com.anytypeio.anytype.core_ui.views.ButtonSize import com.anytypeio.anytype.core_ui.views.Caption1Medium -import com.anytypeio.anytype.core_ui.views.TitleInter15 +import com.anytypeio.anytype.core_ui.views.Title2 import com.anytypeio.anytype.core_utils.ui.MultipleEventCutter import com.anytypeio.anytype.core_utils.ui.get +import com.anytypeio.anytype.presentation.sharing.AddToAnytypeViewModel import com.anytypeio.anytype.presentation.sharing.AddToAnytypeViewModel.SpaceView import com.anytypeio.anytype.presentation.spaces.SpaceIconView -@Preview +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Light Mode") +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_NO, name = "Dark Mode") @Composable fun AddToAnytypeScreenUrlPreview() { AddToAnytypeScreen( data = SharingData.Url("https://en.wikipedia.org/wiki/Walter_Benjamin"), onCancelClicked = {}, - onDoneClicked = {}, - spaces = emptyList(), + onAddClicked = {}, + spaces = listOf( + SpaceView( + obj = ObjectWrapper.SpaceView(map = mapOf("name" to "Space 1")), + isSelected = true, + icon = SpaceIconView.Gradient(from = "#FF0000", to = "#00FF00") + ) + ), onSelectSpaceClicked = {}, - content = "https://en.wikipedia.org/wiki/Walter_Benjamin" + onOpenClicked = {}, + content = "https://en.wikipedia.org/wiki/Walter_Benjamin", + progressState = AddToAnytypeViewModel.ProgressState.Done(""), + onCancelProcessClicked = {} + //progressState = AddToAnytypeViewModel.ProgressState.Error(" I understand that contributing to this repository will require me to agree with the CLA I understand that contributing to this repository will require me to agree with the CLA\n") + //progressState = AddToAnytypeViewModel.ProgressState.Progress(processId = "dasda", progress = 0.8f) ) } @@ -68,10 +97,23 @@ fun AddToAnytypeScreenNotePreview() { AddToAnytypeScreen( data = SharingData.Text("The Work of Art in the Age of its Technological Reproducibility"), onCancelClicked = {}, - onDoneClicked = {}, - spaces = emptyList(), + onAddClicked = {}, + spaces = listOf( + SpaceView( + obj = ObjectWrapper.SpaceView(map = mapOf()), + isSelected = false, + icon = SpaceIconView.Gradient(from = "#FF0000", to = "#00FF00") + ) + ), onSelectSpaceClicked = {}, - content = "" + content = "", + progressState = AddToAnytypeViewModel.ProgressState.Progress( + processId = "dasda", + progress = 0.8f, + wrapperObjId = "" + ), + onOpenClicked = {}, + onCancelProcessClicked = {} ) } @@ -80,9 +122,12 @@ fun AddToAnytypeScreen( content: String, spaces: List, data: SharingData, + progressState: AddToAnytypeViewModel.ProgressState, onCancelClicked: () -> Unit, - onDoneClicked: (SaveAsOption) -> Unit, - onSelectSpaceClicked: (SpaceView) -> Unit + onCancelProcessClicked: (Id) -> Unit, + onAddClicked: (SaveAsOption) -> Unit, + onSelectSpaceClicked: (SpaceView) -> Unit, + onOpenClicked: (Id) -> Unit ) { var isSaveAsMenuExpanded by remember { mutableStateOf(false) } val items = when (data) { @@ -95,7 +140,7 @@ fun AddToAnytypeScreen( } var selectedIndex by remember { mutableStateOf( - when(data) { + when (data) { is SharingData.Url -> SAVE_AS_BOOKMARK is SharingData.Image -> SAVE_AS_IMAGE is SharingData.File -> SAVE_AS_FILE @@ -112,10 +157,9 @@ fun AddToAnytypeScreen( } Header() DataSection(content) - Box( + Column( modifier = Modifier .fillMaxWidth() - .height(76.dp) .noRippleClickable { throttler.processEvent { isSaveAsMenuExpanded = !isSaveAsMenuExpanded @@ -140,8 +184,7 @@ fun AddToAnytypeScreen( else -> stringResource(id = R.string.sharing_menu_save_as_note_option) }, modifier = Modifier - .align(Alignment.BottomStart) - .padding(bottom = 14.dp, start = 20.dp), + .padding(top = 6.dp, start = 20.dp, bottom = 14.dp), style = BodyRegular, color = colorResource(id = R.color.text_primary) ) @@ -164,7 +207,7 @@ fun AddToAnytypeScreen( isSaveAsMenuExpanded = false } ) { - when(s) { + when (s) { SAVE_AS_BOOKMARK -> { Text( text = stringResource(id = R.string.sharing_menu_save_as_bookmark_option), @@ -172,6 +215,7 @@ fun AddToAnytypeScreen( color = colorResource(id = R.color.text_primary) ) } + SAVE_AS_NOTE -> { Text( text = stringResource(id = R.string.sharing_menu_save_as_note_option), @@ -179,6 +223,7 @@ fun AddToAnytypeScreen( color = colorResource(id = R.color.text_primary) ) } + else -> { // Draw nothing } @@ -194,6 +239,7 @@ fun AddToAnytypeScreen( } } } + com.anytypeio.anytype.core_ui.foundation.Divider(paddingEnd = 20.dp, paddingStart = 20.dp) val selected = spaces.firstOrNull { it.isSelected } if (selected != null) { CurrentSpaceSection( @@ -209,11 +255,116 @@ fun AddToAnytypeScreen( onSelectSpaceClicked = onSelectSpaceClicked ) } - Spacer(modifier = Modifier.height(20.dp)) - Buttons(onCancelClicked, onDoneClicked, selectedIndex) + DefaultLinearProgressIndicator(progressState = progressState) + when (progressState) { + is AddToAnytypeViewModel.ProgressState.Done -> { + ButtonsDone( + progressState = progressState, + onCancelClicked = onCancelClicked, + onOpenClicked = onOpenClicked + ) + } + is AddToAnytypeViewModel.ProgressState.Error -> { + Buttons( + onCancelClicked = onCancelClicked, + selectedIndex = selectedIndex, + progressState = progressState, + onAddClicked = onAddClicked + ) + } + AddToAnytypeViewModel.ProgressState.Init -> { + Buttons( + onCancelClicked = onCancelClicked, + selectedIndex = selectedIndex, + progressState = progressState, + onAddClicked = onAddClicked + ) + } + is AddToAnytypeViewModel.ProgressState.Progress -> { + ButtonsProgress( + onCancelProcessClicked = onCancelProcessClicked, + progressState = progressState, + ) + } + } } } +@Composable +private fun DefaultLinearProgressIndicator(progressState: AddToAnytypeViewModel.ProgressState) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(46.dp), + contentAlignment = Alignment.Center + ) { + val visible = progressState is AddToAnytypeViewModel.ProgressState.Progress + AnimatedVisibility( + visible = visible, + modifier = Modifier, + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it } + ) { + if (progressState is AddToAnytypeViewModel.ProgressState.Progress) { + Indicator(progress = progressState.progress) + } + } + val doneVisibility = progressState is AddToAnytypeViewModel.ProgressState.Done + AnimatedVisibility( + visible = doneVisibility, + modifier = Modifier, + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it } + ) { + Text( + text = stringResource(id = R.string.sharing_menu_add_to_anytype_success), + style = Caption1Medium, + color = colorResource(id = R.color.palette_system_green), + modifier = Modifier.padding(top = 4.dp, start = 20.dp, end = 20.dp) + ) + } + val errorVisible = progressState is AddToAnytypeViewModel.ProgressState.Error + AnimatedVisibility( + visible = errorVisible, + modifier = Modifier, + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it } + ) { + if (progressState is AddToAnytypeViewModel.ProgressState.Error) { + Text( + text = stringResource( + id = R.string.sharing_menu_add_to_anytype_error, + progressState.error + ), + style = Caption1Medium, + maxLines = 2, + color = colorResource(id = R.color.palette_dark_red), + modifier = Modifier.padding(horizontal = 20.dp) + ) + } + } + } +} + +@Composable +private fun Indicator(progress: Float) { + val animatedProgress = animateFloatAsState( + targetValue = progress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "" + ).value + LinearProgressIndicator( + progress = animatedProgress, + color = colorResource(id = R.color.text_primary), + modifier = Modifier + .height(6.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp), + backgroundColor = colorResource(id = R.color.shape_tertiary), + strokeCap = StrokeCap.Round + ) +} + @Composable private fun DataSection(content: String) { Box( @@ -241,22 +392,24 @@ private fun DataSection(content: String) { text = content, style = BodyRegular, color = colorResource(id = R.color.text_primary), - modifier = Modifier.padding( - top = 30.dp, - start = 16.dp, - end = 16.dp, - bottom = 10.dp - ), + modifier = Modifier + .padding( + top = 30.dp, + start = 16.dp, + end = 16.dp, + bottom = 10.dp + ) + .verticalScroll(rememberScrollState()), maxLines = 5 ) } } @Composable -private fun Buttons( +private fun ButtonsDone( + progressState: AddToAnytypeViewModel.ProgressState.Done, onCancelClicked: () -> Unit, - onDoneClicked: (SaveAsOption) -> Unit, - selectedIndex: Int + onOpenClicked: (Id) -> Unit ) { Row( modifier = Modifier @@ -273,11 +426,74 @@ private fun Buttons( ) Spacer(modifier = Modifier.width(12.dp)) ButtonPrimary( - onClick = { onDoneClicked(selectedIndex) }, + onClick = { + onOpenClicked(progressState.wrapperObjId) + }, size = ButtonSize.Large, - text = stringResource(id = R.string.done), + text = stringResource(id = R.string.sharing_menu_btn_open), + modifier = Modifier.weight(1.0f), + ) + } +} + +@Composable +private fun ButtonsProgress( + onCancelProcessClicked: (Id) -> Unit, + progressState: AddToAnytypeViewModel.ProgressState.Progress +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(68.dp) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ButtonSecondary( + onClick = { onCancelProcessClicked(progressState.processId) }, + size = ButtonSize.Large, + text = stringResource(id = R.string.cancel), modifier = Modifier.weight(1.0f) ) + Spacer(modifier = Modifier.width(12.dp)) + ButtonPrimaryLoading( + size = ButtonSize.Large, + text = stringResource(id = R.string.sharing_menu_btn_add), + modifierBox = Modifier.weight(1.0f), + modifierButton = Modifier.fillMaxWidth(), + loading = true + ) + } +} + +@Composable +private fun Buttons( + onCancelClicked: () -> Unit, + onAddClicked: (SaveAsOption) -> Unit, + selectedIndex: Int, + progressState: AddToAnytypeViewModel.ProgressState +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(68.dp) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ButtonSecondary( + onClick = onCancelClicked, + size = ButtonSize.Large, + text = stringResource(id = R.string.cancel), + modifier = Modifier.weight(1.0f) + ) + Spacer(modifier = Modifier.width(12.dp)) + ButtonPrimaryLoading( + onClick = { onAddClicked(selectedIndex) }, + size = ButtonSize.Large, + text = stringResource(id = R.string.sharing_menu_btn_add), + modifierBox = Modifier.weight(1.0f), + modifierButton = Modifier.fillMaxWidth(), + loading = progressState is AddToAnytypeViewModel.ProgressState.Progress + ) } } @@ -292,10 +508,10 @@ private fun CurrentSpaceSection( val throttler = remember { MultipleEventCutter.Companion.get(interval = DROPDOWN_MENU_VISIBILITY_WINDOW_INTERVAL) } - Box( + Column( modifier = Modifier .fillMaxWidth() - .height(76.dp) + .wrapContentHeight() .noRippleClickable { throttler.processEvent { isSpaceSelectMenuExpanded = true @@ -309,29 +525,26 @@ private fun CurrentSpaceSection( style = Caption1Medium, color = colorResource(id = R.color.text_secondary) ) - val hasIcon = icon is SpaceIconView.Gradient || icon is SpaceIconView.Image - if (icon != null && hasIcon) { - SmallSpaceIcon( - icon = icon, - modifier = Modifier - .padding( - start = 20.dp, - bottom = 17.dp - ) - .align(Alignment.BottomStart) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, top = 6.dp, bottom = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val hasIcon = icon is SpaceIconView.Gradient || icon is SpaceIconView.Image + if (icon != null && hasIcon) { + SmallSpaceIcon( + icon = icon, + modifier = Modifier.padding(end = 8.dp) + ) + } + Text( + text = name, + modifier = Modifier, + style = BodyRegular, + color = colorResource(id = R.color.text_primary) ) } - Text( - text = name, - modifier = Modifier - .align(Alignment.BottomStart) - .padding( - bottom = 14.dp, - start = if (hasIcon) 44.dp else 20.dp - ), - style = BodyRegular, - color = colorResource(id = R.color.text_primary) - ) DropdownMenu( expanded = isSpaceSelectMenuExpanded, onDismissRequest = { @@ -365,6 +578,7 @@ private fun CurrentSpaceSection( } } } + com.anytypeio.anytype.core_ui.foundation.Divider(paddingEnd = 20.dp, paddingStart = 20.dp) } @Composable @@ -378,7 +592,7 @@ private fun Header() { text = stringResource(R.string.sharing_menu_add_to_anytype_header_title), color = colorResource(id = R.color.text_primary), modifier = Modifier.align(Alignment.Center), - style = TitleInter15 + style = Title2 ) } } @@ -389,7 +603,7 @@ private fun SmallSpaceIcon( icon: SpaceIconView, modifier: Modifier ) { - val size = 18.dp + val size = 20.dp when (icon) { is SpaceIconView.Image -> { Image( @@ -419,6 +633,7 @@ private fun SmallSpaceIcon( .background(gradient) ) } + else -> { // Draw nothing. } @@ -435,30 +650,33 @@ typealias SaveAsOption = Int sealed class SharingData { abstract val data: String + data class Url(val url: String) : SharingData() { override val data: String get() = url } + data class Text(val raw: String) : SharingData() { override val data: String get() = raw } + data class Image(val uri: String) : SharingData() { override val data: String get() = uri } - data class Images(val uris: List): SharingData() { + data class Images(val uris: List) : SharingData() { override val data: String get() = uris.toString() } - data class Files(val uris: List): SharingData() { + data class Files(val uris: List) : SharingData() { override val data: String get() = uris.toString() } - data class File(val uri: String): SharingData() { + data class File(val uri: String) : SharingData() { override val data: String get() = uri } 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 b3d6f31861..f5cfedb022 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 @@ -17,6 +17,7 @@ import androidx.navigation.fragment.findNavController import com.anytypeio.anytype.R import com.anytypeio.anytype.core_utils.ext.arg import com.anytypeio.anytype.core_utils.ext.argStringList +import com.anytypeio.anytype.core_utils.ext.getFormattedDateTime import com.anytypeio.anytype.core_utils.ext.toast import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment import com.anytypeio.anytype.di.common.componentManager @@ -24,6 +25,7 @@ import com.anytypeio.anytype.presentation.home.OpenObjectNavigation import com.anytypeio.anytype.presentation.sharing.AddToAnytypeViewModel import com.anytypeio.anytype.ui.editor.EditorFragment import com.anytypeio.anytype.ui.settings.typography +import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.flow.map @@ -79,24 +81,29 @@ class SharingFragment : BaseBottomSheetComposeFragment() { } }.collectAsState(initial = "").value, data = sharedData, - onDoneClicked = { option -> + onAddClicked = { option -> when(option) { SAVE_AS_BOOKMARK -> vm.onCreateBookmark(url = sharedData.data) SAVE_AS_NOTE -> vm.onCreateNote(sharedData.data) - SAVE_AS_IMAGE -> vm.onShareMedia(listOf(sharedData.data)) SAVE_AS_FILE -> vm.onShareMedia(listOf(sharedData.data)) - SAVE_AS_IMAGES -> { + SAVE_AS_IMAGES, SAVE_AS_IMAGE -> { + val formattedDateTime = getFormattedDateTime(Locale.getDefault()) + val objTitle = + getString(R.string.sharing_media_wrapper_object_title, formattedDateTime) val data = sharedData if (data is SharingData.Images) { - vm.onShareMedia(uris = data.uris) + vm.onShareMedia(uris = data.uris, wrapperObjTitle = objTitle) } else { toast("Unexpected data format") } } SAVE_AS_FILES -> { + val formattedDateTime = getFormattedDateTime(Locale.getDefault()) + val objTitle = + getString(R.string.sharing_files_wrapper_object_title, formattedDateTime) val data = sharedData if (data is SharingData.Files) { - vm.onShareMedia(uris = data.uris) + vm.onShareMedia(uris = data.uris, wrapperObjTitle = objTitle) } else { toast("Unexpected data format") } @@ -109,7 +116,10 @@ class SharingFragment : BaseBottomSheetComposeFragment() { } }, spaces = vm.spaceViews.collectAsStateWithLifecycle().value, - onSelectSpaceClicked = { vm.onSelectSpaceClicked(it) } + onSelectSpaceClicked = { vm.onSelectSpaceClicked(it) }, + progressState = vm.progressState.collectAsStateWithLifecycle().value, + onOpenClicked = vm::proceedWithNavigation, + onCancelProcessClicked = { processId -> } ) LaunchedEffect(Unit) { vm.navigation.collect { nav -> diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/Command.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/Command.kt index eec87f8930..a0817c8649 100644 --- a/core-models/src/main/java/com/anytypeio/anytype/core_models/Command.kt +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/Command.kt @@ -30,6 +30,14 @@ sealed class Command { val type: Block.Content.File.Type? ) + class FileDrop( + val space: SpaceId, + val ctx: Id, + val dropTarget: Id, + val blockPosition: Position, + val localFilePaths: List + ) + class DownloadFile( val path: String, val objectId: Id diff --git a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/AndroidExtension.kt b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/AndroidExtension.kt index 1e121f0ba3..19cfddbe38 100644 --- a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/AndroidExtension.kt +++ b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/AndroidExtension.kt @@ -50,6 +50,7 @@ import com.anytypeio.anytype.presentation.util.downloader.UriFileProvider import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import java.io.File +import java.text.DateFormat import java.text.SimpleDateFormat import java.util.* import timber.log.Timber @@ -477,4 +478,31 @@ fun BaseBottomSheetComposeFragment.setupBottomSheetBehavior(paddingTop: Int) { state = BottomSheetBehavior.STATE_EXPANDED skipCollapsed = true } +} + +fun getLocalizedDateTimePattern(locale: Locale): String { + // Get DateFormat instances for both date and time + val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, locale) + val timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT, locale) + + // Convert to SimpleDateFormat to extract the pattern + val datePattern = (dateFormat as? SimpleDateFormat)?.toPattern() + val timePattern = (timeFormat as? SimpleDateFormat)?.toPattern() + + // Combine date and time patterns + return "$datePattern $timePattern" +} + +fun getFormattedDateTime(locale: Locale): String { + // Get the current date and time + val currentDate = Date() + + // Get the localized pattern + val localizedPattern = getLocalizedDateTimePattern(locale) + + // Create a formatter with the localized pattern + val simpleDateFormat = SimpleDateFormat(localizedPattern, locale) + + // Format the current date and time + return simpleDateFormat.format(currentDate) } \ No newline at end of file diff --git a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockDataRepository.kt b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockDataRepository.kt index c6d36d81dd..24bbfd30b5 100644 --- a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockDataRepository.kt +++ b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockDataRepository.kt @@ -265,6 +265,10 @@ class BlockDataRepository( command: Command.UploadFile ): ObjectWrapper.File = remote.uploadFile(command) + override suspend fun fileDrop(command: Command.FileDrop): Payload { + return remote.fileDrop(command) + } + override suspend fun downloadFile( command: Command.DownloadFile ): String = remote.downloadFile(command) diff --git a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockRemote.kt b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockRemote.kt index ff9aa19cce..83069ea7bc 100644 --- a/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockRemote.kt +++ b/data/src/main/java/com/anytypeio/anytype/data/auth/repo/block/BlockRemote.kt @@ -89,6 +89,7 @@ interface BlockRemote { suspend fun copy(command: Command.Copy): Response.Clipboard.Copy suspend fun uploadFile(command: Command.UploadFile): ObjectWrapper.File + suspend fun fileDrop(command: Command.FileDrop): Payload suspend fun downloadFile(command: Command.DownloadFile): String suspend fun setRelationKey(command: Command.SetRelationKey): Payload diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/block/repo/BlockRepository.kt b/domain/src/main/java/com/anytypeio/anytype/domain/block/repo/BlockRepository.kt index 508531f389..4f7d624f8e 100644 --- a/domain/src/main/java/com/anytypeio/anytype/domain/block/repo/BlockRepository.kt +++ b/domain/src/main/java/com/anytypeio/anytype/domain/block/repo/BlockRepository.kt @@ -41,6 +41,7 @@ import com.anytypeio.anytype.domain.page.Undo interface BlockRepository { suspend fun uploadFile(command: Command.UploadFile): ObjectWrapper.File + suspend fun fileDrop(command: Command.FileDrop): Payload suspend fun downloadFile(command: Command.DownloadFile): String suspend fun move(command: Command.Move): Payload diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/media/FileDrop.kt b/domain/src/main/java/com/anytypeio/anytype/domain/media/FileDrop.kt new file mode 100644 index 0000000000..b76750239a --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/media/FileDrop.kt @@ -0,0 +1,36 @@ +package com.anytypeio.anytype.domain.media + +import com.anytypeio.anytype.core_models.Command +import com.anytypeio.anytype.core_models.Id +import com.anytypeio.anytype.core_models.NO_VALUE +import com.anytypeio.anytype.core_models.Payload +import com.anytypeio.anytype.core_models.Position +import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.base.ResultInteractor +import com.anytypeio.anytype.domain.block.repo.BlockRepository +import javax.inject.Inject + +class FileDrop @Inject constructor( + private val repo: BlockRepository, + dispatchers: AppCoroutineDispatchers +) : ResultInteractor(dispatchers.io) { + + override suspend fun doWork(params: Params): Payload = repo.fileDrop( + command = Command.FileDrop( + ctx = params.ctx, + space = params.space, + dropTarget = params.dropTarget, + blockPosition = params.blockPosition, + localFilePaths = params.localFilePaths + ) + ) + + data class Params( + val space: SpaceId, + val ctx: Id, + val dropTarget: Id = NO_VALUE, + val blockPosition: Position = Position.NONE, + val localFilePaths: List + ) +} \ No newline at end of file diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 9b18636f12..5a849bed07 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -1212,7 +1212,13 @@ Files Data Add to Anytype + Anytype file upload error: %1$s + All files have been successfully uploaded New object is added to the space \'%1$s\' + Shared Media, %1$s, this object is auxiliary and can be deleted; all media or files will remain in Space. + Shared Files, %1$s, this object is auxiliary and can be deleted; all media or files will remain in Space. + Add + Open diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/block/BlockMiddleware.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/block/BlockMiddleware.kt index df6b19ae06..0b29820b82 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/block/BlockMiddleware.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/block/BlockMiddleware.kt @@ -230,6 +230,10 @@ class BlockMiddleware( command: Command.UploadFile ): ObjectWrapper.File = middleware.fileUpload(command) + override suspend fun fileDrop(command: Command.FileDrop): Payload { + return middleware.fileDrop(command) + } + override suspend fun downloadFile( command: Command.DownloadFile ): String = middleware.fileDownload(command).localPath diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt index 7e175a6ad7..213a34d492 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/Middleware.kt @@ -813,7 +813,7 @@ class Middleware @Inject constructor( val request = Rpc.File.Upload.Request( localPath = command.path, type = type, - spaceId = command.space?.id.orEmpty() + spaceId = command.space.id ) if (BuildConfig.DEBUG) logRequest(request) val response = service.fileUpload(request) @@ -821,6 +821,20 @@ class Middleware @Inject constructor( return ObjectWrapper.File(response.details.orEmpty()) } + @Throws(Exception::class) + fun fileDrop(command: Command.FileDrop): Payload { + val request = Rpc.File.Drop.Request( + contextId = command.ctx, + dropTargetId = command.dropTarget, + position = command.blockPosition.toMiddlewareModel(), + localFilePaths = command.localFilePaths + ) + if (BuildConfig.DEBUG) logRequest(request) + val response = service.fileDrop(request) + if (BuildConfig.DEBUG) logResponse(response) + return response.event.toPayload() + } + @Throws(Exception::class) fun fileDownload(command: Command.DownloadFile): Rpc.File.Download.Response { val request = Rpc.File.Download.Request( diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt index a84565192f..ddab08b9fa 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt @@ -200,6 +200,9 @@ interface MiddlewareService { @Throws(Exception::class) fun spaceUsage(request: Rpc.File.SpaceUsage.Request): Rpc.File.SpaceUsage.Response + @Throws(Exception::class) + fun fileDrop(request: Rpc.File.Drop.Request): Rpc.File.Drop.Response + //endregion //region UNSPLASH commands diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt index 7ffa74a45f..80bee7ec0c 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareServiceImplementation.kt @@ -633,6 +633,17 @@ class MiddlewareServiceImplementation @Inject constructor( } } + override fun fileDrop(request: Rpc.File.Drop.Request): Rpc.File.Drop.Response { + val encoded = Service.fileDrop(Rpc.File.Drop.Request.ADAPTER.encode(request)) + val response = Rpc.File.Drop.Response.ADAPTER.decode(encoded) + val error = response.error + if (error != null && error.code != Rpc.File.Drop.Response.Error.Code.NULL) { + throw Exception(error.description) + } else { + return response + } + } + override fun fileDownload(request: Rpc.File.Download.Request): Rpc.File.Download.Response { val encoded = Service.fileDownload(Rpc.File.Download.Request.ADAPTER.encode(request)) val response = Rpc.File.Download.Response.ADAPTER.decode(encoded) diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/AddToAnytypeViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/AddToAnytypeViewModel.kt index 36a2cf6d47..cf6b1a4ad8 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/AddToAnytypeViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/sharing/AddToAnytypeViewModel.kt @@ -11,7 +11,6 @@ import com.anytypeio.anytype.analytics.base.EventsDictionary.CLICK_ONBOARDING_TO import com.anytypeio.anytype.analytics.base.EventsPropertiesKey import com.anytypeio.anytype.analytics.event.EventAnalytics import com.anytypeio.anytype.analytics.props.Props -import com.anytypeio.anytype.core_models.Block import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_models.MarketplaceObjectTypeIds import com.anytypeio.anytype.core_models.NO_VALUE @@ -20,16 +19,20 @@ import com.anytypeio.anytype.core_models.ObjectWrapper import com.anytypeio.anytype.core_models.Relations import com.anytypeio.anytype.core_models.ext.EMPTY_STRING_VALUE import com.anytypeio.anytype.core_models.primitives.SpaceId +import com.anytypeio.anytype.core_models.Process.Event +import com.anytypeio.anytype.core_models.Process.State 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.device.FileSharer +import com.anytypeio.anytype.domain.media.FileDrop 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.objects.CreateBookmarkObject import com.anytypeio.anytype.domain.objects.CreatePrefilledNote import com.anytypeio.anytype.domain.spaces.GetSpaceViews +import com.anytypeio.anytype.domain.workspace.EventProcessDropFilesChannel import com.anytypeio.anytype.domain.workspace.SpaceManager import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate import com.anytypeio.anytype.presentation.common.BaseViewModel @@ -40,6 +43,8 @@ import com.anytypeio.anytype.presentation.spaces.SpaceIconView import com.anytypeio.anytype.presentation.spaces.spaceIcon import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -58,10 +63,11 @@ class AddToAnytypeViewModel( private val urlBuilder: UrlBuilder, private val awaitAccountStartManager: AwaitAccountStartManager, private val analytics: Analytics, - private val uploadFile: UploadFile, private val fileSharer: FileSharer, private val permissions: Permissions, - private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate + private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, + private val fileDrop: FileDrop, + private val eventProcessChannel: EventProcessDropFilesChannel ) : BaseViewModel(), AnalyticSpaceHelperDelegate by analyticSpaceHelperDelegate { private val selectedSpaceId = MutableStateFlow(NO_VALUE) @@ -71,9 +77,12 @@ class AddToAnytypeViewModel( val navigation = MutableSharedFlow() val spaceViews = MutableStateFlow>(emptyList()) val commands = MutableSharedFlow() + val progressState = MutableStateFlow(ProgressState.Init) val state = MutableStateFlow(ViewState.Init) + private var progressJob: Job? = null + init { viewModelScope.launch { analytics.registerEvent( @@ -136,7 +145,70 @@ class AddToAnytypeViewModel( } } - fun onShareMedia(uris: List) { + private fun subscribeToEventProcessChannel(wrapperObjId: Id) { + if (progressJob?.isActive == true) { + progressJob?.cancel() + } + progressJob = viewModelScope.launch { + eventProcessChannel.observe() + .collect { events -> + events.forEach { event -> + when (event) { + is Event.DropFiles.New -> { + val currentProgressState = progressState.value + if (currentProgressState is ProgressState.Init + && event.process.state == State.RUNNING + ) { + progressState.value = ProgressState.Progress( + processId = event.process.id, + progress = 0f, + wrapperObjId = wrapperObjId + ) + } else { + //some process is already running + } + } + + is Event.DropFiles.Update -> { + val currentProgressState = progressState.value + val newProcess = event.process + if (currentProgressState is ProgressState.Progress + && currentProgressState.processId == event.process.id + && newProcess.state == State.RUNNING + ) { + val progress = newProcess.progress + val total = progress?.total + val done = progress?.done + progressState.value = + if (total != null && total != 0L && done != null) { + currentProgressState.copy(progress = done.toFloat() / total) + } else { + currentProgressState.copy(progress = 0f) + } + } + } + + is Event.DropFiles.Done -> { + val currentProgressState = progressState.value + val newProcess = event.process + if (currentProgressState is ProgressState.Progress + && event.process.state == State.DONE + && newProcess.id == currentProgressState.processId + ) { + progressState.value = currentProgressState.copy(progress = 1f) + delay(300) + progressState.value = ProgressState.Done( + wrapperObjId = currentProgressState.wrapperObjId + ) + } + } + } + } + } + } + } + + fun onShareMedia(uris: List, wrapperObjTitle: String? = null) { viewModelScope.launch(Dispatchers.IO) { val targetSpaceView = spaceViews.value.firstOrNull { view -> view.isSelected @@ -147,89 +219,98 @@ class AddToAnytypeViewModel( val paths = uris.mapNotNull { uri -> fileSharer.getPath(uri) } - val files = mutableListOf() - paths.forEach { path -> - uploadFile.async( - UploadFile.Params( - path = path, - space = SpaceId(targetSpaceId), - // Temporary workaround to fix issue on the MW side. - type = Block.Content.File.Type.NONE - ) - ).fold( - onSuccess = { obj -> - files.add(obj.id) - }, - onFailure = { e -> - Timber.e(e, "Error while uploading file").also { - sendToast(e.msg()) - } - } + + when (paths.size) { + 0 -> sendToast("Could not get file paths") + else -> proceedWithCreatingWrapperObject( + filePaths = paths, + targetSpaceId = targetSpaceId, + wrapperObjTitle = wrapperObjTitle, ) } - when (files.size) { - 0 -> { - sendToast("Could not upload files") - } + } + } + } - 1 -> { - // No need to create a wrapper object, opening file object directly instead - if (targetSpaceId == spaceManager.get()) { - navigation.emit( - OpenObjectNavigation.OpenEditor( - target = files.first(), - space = targetSpaceId - ) - ) - } else { - with(commands) { - emit(Command.ObjectAddToSpaceToast(targetSpaceView.obj.name)) - emit(Command.Dismiss) - } - } - } + private suspend fun proceedWithCreatingWrapperObject( + filePaths: List, + targetSpaceId: String, + wrapperObjTitle: String? = null + ) { + val startTime = System.currentTimeMillis() + createPrefilledNote.async( + CreatePrefilledNote.Params( + text = wrapperObjTitle ?: EMPTY_STRING_VALUE, + space = targetSpaceId, + details = mapOf( + Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble(), + ), + ) + ).fold( + onSuccess = { wrapperObjId -> + viewModelScope.sendAnalyticsObjectCreateEvent( + analytics = analytics, + objType = MarketplaceObjectTypeIds.PAGE, + route = EventsDictionary.Routes.sharingExtension, + startTime = startTime, + spaceParams = provideParams(spaceManager.get()) + ) + proceedWithFilesDrop( + wrapperObjId = wrapperObjId, + filePaths = filePaths, + targetSpaceId = targetSpaceId + ) + }, + onFailure = { + Timber.d(it, "Error while creating page") + sendToast("Error while creating page: ${it.msg()}") + } + ) + } - else -> { - // Creating a wrapper object for file objects - val startTime = System.currentTimeMillis() - createPrefilledNote.async( - CreatePrefilledNote.Params( - text = EMPTY_STRING_VALUE, - space = targetSpaceId, - details = mapOf( - Relations.ORIGIN to ObjectOrigin.SHARING_EXTENSION.code.toDouble() - ), - attachments = files - ) - ).fold( - onSuccess = { result -> - sendAnalyticsObjectCreateEvent( - analytics = analytics, - objType = MarketplaceObjectTypeIds.NOTE, - route = EventsDictionary.Routes.sharingExtension, - startTime = startTime, - spaceParams = provideParams(spaceManager.get()) - ) - if (targetSpaceId == spaceManager.get()) { - navigation.emit( - OpenObjectNavigation.OpenEditor( - target = result, - space = targetSpaceId - ) - ) - } else { - with(commands) { - emit(Command.ObjectAddToSpaceToast(targetSpaceView.obj.name)) - emit(Command.Dismiss) - } - } - }, - onFailure = { - Timber.d(it, "Error while creating note") - sendToast("Error while creating note: ${it.msg()}") - } - ) - } + private suspend fun proceedWithFilesDrop( + wrapperObjId: Id, + filePaths: List, + targetSpaceId: String, + ) { + subscribeToEventProcessChannel(wrapperObjId = wrapperObjId) + val params = FileDrop.Params( + ctx = wrapperObjId, + space = SpaceId(targetSpaceId), + localFilePaths = filePaths + ) + fileDrop.async(params).fold( + onSuccess = { _ -> Timber.d("Files dropped successfully") }, + onFailure = { e -> + Timber.e(e, "Error while dropping files").also { + sendToast(e.msg()) + } + } + ) + } + + fun proceedWithNavigation(wrapperObjId: Id) { + val targetSpaceView = spaceViews.value.firstOrNull { view -> + view.isSelected + } + val targetSpaceId = targetSpaceView?.obj?.targetSpaceId + viewModelScope.launch { + Timber.d("proceedWithNavigation: $wrapperObjId, $targetSpaceId") + if (targetSpaceId == spaceManager.get()) { + Timber.d("proceedWithNavigation: OpenEditor") + delay(300) + navigation.emit( + OpenObjectNavigation.OpenEditor( + target = wrapperObjId, + space = targetSpaceId + ) + ) + } else { + Timber.d("proceedWithNavigation: ObjectAddToSpaceToast") + delay(300) + with(commands) { + emit(Command.ObjectAddToSpaceToast(targetSpaceView?.obj?.name)) + emit(Command.Dismiss) } } } @@ -381,10 +462,11 @@ class AddToAnytypeViewModel( private val urlBuilder: UrlBuilder, private val awaitAccountStartManager: AwaitAccountStartManager, private val analytics: Analytics, - private val uploadFile: UploadFile, private val fileSharer: FileSharer, private val permissions: Permissions, - private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate + private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate, + private val fileDrop: FileDrop, + private val eventProcessChannel: EventProcessDropFilesChannel ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { @@ -396,10 +478,11 @@ class AddToAnytypeViewModel( urlBuilder = urlBuilder, awaitAccountStartManager = awaitAccountStartManager, analytics = analytics, - uploadFile = uploadFile, fileSharer = fileSharer, permissions = permissions, - analyticSpaceHelperDelegate = analyticSpaceHelperDelegate + analyticSpaceHelperDelegate = analyticSpaceHelperDelegate, + fileDrop = fileDrop, + eventProcessChannel = eventProcessChannel ) as T } } @@ -411,17 +494,26 @@ class AddToAnytypeViewModel( ) sealed class Command { - object Dismiss : Command() + data object Dismiss : Command() data class ObjectAddToSpaceToast( val spaceName: String? ) : Command() } sealed class ViewState { - object Init : ViewState() + data object Init : ViewState() data class Default(val content: String) : ViewState() } + sealed class ProgressState { + data object Init : ProgressState() + data class Progress(val wrapperObjId: Id, val processId: Id, val progress: Float) : + ProgressState() + + data class Done(val wrapperObjId: Id) : ProgressState() + data class Error(val error: String) : ProgressState() + } + companion object { const val FILE_NAME_SEPARATOR = ", " }