diff --git a/CHANGELOG.md b/CHANGELOG.md index aa5c7bf2e6..82d0fded8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,16 @@ - [Feature] Add count subfolders for new file manager - [Feature] Add file downloading for new file manager +- [Feature] Add move-to to new file manager +- [Feature] Single tap for infrared remotes +- [Refactor] Move rename and file create to separated modules +- [Refactor] Improve and refactor new FileManager Editor - [FIX] Migrate url host from metric.flipperdevices.com to metric.flipp.dev - [FIX] Fix empty response in faphub category - [FIX] New file manager uploading progress - [FIX] Fix build when no metrics enabled -- [Feature] Single tap for infrared remotes +- [FIX] Fix small issues with new file manager + # 1.8.0 Attention: don't forget to add the flag for F-Droid before release diff --git a/components/bridge/connection/feature/lagsdetector/impl/src/commonMain/kotlin/com/flipperdevices/bridge/connection/feature/seriallagsdetector/impl/PendingResponseCounter.kt b/components/bridge/connection/feature/lagsdetector/impl/src/commonMain/kotlin/com/flipperdevices/bridge/connection/feature/seriallagsdetector/impl/PendingResponseCounter.kt index 8ee60caafe..5106e3822b 100644 --- a/components/bridge/connection/feature/lagsdetector/impl/src/commonMain/kotlin/com/flipperdevices/bridge/connection/feature/seriallagsdetector/impl/PendingResponseCounter.kt +++ b/components/bridge/connection/feature/lagsdetector/impl/src/commonMain/kotlin/com/flipperdevices/bridge/connection/feature/seriallagsdetector/impl/PendingResponseCounter.kt @@ -52,6 +52,6 @@ internal class PendingResponseCounter( } companion object { - internal const val LAGS_FLIPPER_DETECT_TIMEOUT_MS = 10 * 1000L // 10 seconds + internal const val LAGS_FLIPPER_DETECT_TIMEOUT_MS = 30 * 1000L // 30 seconds } } diff --git a/components/bridge/connection/sample/build.gradle.kts b/components/bridge/connection/sample/build.gradle.kts index 31b09ad358..f4f02c79fc 100644 --- a/components/bridge/connection/sample/build.gradle.kts +++ b/components/bridge/connection/sample/build.gradle.kts @@ -89,6 +89,12 @@ dependencies { implementation(projects.components.filemngr.editor.impl) implementation(projects.components.filemngr.download.api) implementation(projects.components.filemngr.download.impl) + implementation(projects.components.filemngr.rename.api) + implementation(projects.components.filemngr.rename.impl) + implementation(projects.components.filemngr.create.api) + implementation(projects.components.filemngr.create.impl) + implementation(projects.components.filemngr.transfer.api) + implementation(projects.components.filemngr.transfer.impl) implementation(projects.components.newfilemanager.api) implementation(projects.components.newfilemanager.impl) diff --git a/components/core/share/src/androidMain/kotlin/com/flipperdevices/core/share/AndroidShareHelper.kt b/components/core/share/src/androidMain/kotlin/com/flipperdevices/core/share/AndroidShareHelper.kt index 35506eb4c2..632a238333 100644 --- a/components/core/share/src/androidMain/kotlin/com/flipperdevices/core/share/AndroidShareHelper.kt +++ b/components/core/share/src/androidMain/kotlin/com/flipperdevices/core/share/AndroidShareHelper.kt @@ -2,6 +2,7 @@ package com.flipperdevices.core.share import android.content.Context import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.core.ktx.jre.createClearNewFileWithMkDirs import com.squareup.anvil.annotations.ContributesBinding import okio.Path.Companion.toOkioPath import javax.inject.Inject @@ -12,8 +13,9 @@ class AndroidShareHelper @Inject constructor( ) : PlatformShareHelper { override fun provideSharableFile(fileName: String): PlatformSharableFile { - val path = SharableFile(context, fileName).toOkioPath() - return PlatformSharableFile(path) + val sharableFile = SharableFile(context, fileName) + sharableFile.createClearNewFileWithMkDirs() + return PlatformSharableFile(sharableFile.toOkioPath()) } override fun shareFile(file: PlatformSharableFile, title: String) { diff --git a/components/core/ui/ktx/src/commonMain/kotlin/com/flipperdevices/core/ui/ktx/elements/ComposableFlipperButton.kt b/components/core/ui/ktx/src/commonMain/kotlin/com/flipperdevices/core/ui/ktx/elements/ComposableFlipperButton.kt index 88c80ac61a..e02bf8b864 100644 --- a/components/core/ui/ktx/src/commonMain/kotlin/com/flipperdevices/core/ui/ktx/elements/ComposableFlipperButton.kt +++ b/components/core/ui/ktx/src/commonMain/kotlin/com/flipperdevices/core/ui/ktx/elements/ComposableFlipperButton.kt @@ -1,20 +1,27 @@ package com.flipperdevices.core.ui.ktx.elements +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp import com.flipperdevices.core.ui.ktx.clickableRipple import com.flipperdevices.core.ui.ktx.placeholderByLocalProvider import com.flipperdevices.core.ui.theme.LocalPallet +import com.flipperdevices.core.ui.theme.LocalPalletV2 import com.flipperdevices.core.ui.theme.LocalTypography @Composable @@ -24,27 +31,45 @@ fun ComposableFlipperButton( textPadding: PaddingValues = PaddingValues(vertical = 16.dp, horizontal = 38.dp), onClick: () -> Unit = {}, textStyle: TextStyle = TextStyle(), - enabled: Boolean = true + enabled: Boolean = true, + isLoading: Boolean = false, ) { - val background = if (enabled) { - LocalPallet.current.accentSecond - } else { - LocalPallet.current.flipperDisableButton - } + val background by animateColorAsState( + targetValue = if (enabled && !isLoading) { + LocalPallet.current.accentSecond + } else { + LocalPallet.current.flipperDisableButton + } + ) Box( modifier = modifier .clip(RoundedCornerShape(size = 30.dp)) .placeholderByLocalProvider() .background(background) - .clickableRipple(onClick = onClick), + .clickableRipple(onClick = onClick, enabled = enabled && !isLoading), contentAlignment = Alignment.Center ) { - Text( - modifier = Modifier.padding(textPadding), - text = text, - color = LocalPallet.current.onFlipperButton, - style = LocalTypography.current.buttonB16.merge(textStyle) + AnimatedContent( + targetState = isLoading, + contentAlignment = Alignment.Center, + content = { animatedIsLoading -> + if (animatedIsLoading) { + CircularProgressIndicator( + color = LocalPalletV2.current.action.blue.icon.onColor, + modifier = Modifier.padding(textPadding).size(22.dp), + strokeCap = StrokeCap.Round, + strokeWidth = 2.dp + ) + } else { + Text( + modifier = Modifier.padding(textPadding), + text = text, + color = LocalPallet.current.onFlipperButton, + style = LocalTypography.current.buttonB16.merge(textStyle) + ) + } + } ) } } diff --git a/components/filemngr/create/api/build.gradle.kts b/components/filemngr/create/api/build.gradle.kts new file mode 100644 index 0000000000..4f3654c92c --- /dev/null +++ b/components/filemngr/create/api/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("flipper.multiplatform") + id("flipper.multiplatform-dependencies") +} + +android.namespace = "com.flipperdevices.filemanager.create.api" + +commonDependencies { + implementation(projects.components.bridge.connection.feature.storage.api) + + implementation(projects.components.core.ui.decompose) + + implementation(libs.compose.ui) + implementation(libs.decompose) + + implementation(libs.okio) +} diff --git a/components/filemngr/create/api/src/commonMain/kotlin/com/flipperdevices/filemanager/create/api/CreateFileDecomposeComponent.kt b/components/filemngr/create/api/src/commonMain/kotlin/com/flipperdevices/filemanager/create/api/CreateFileDecomposeComponent.kt new file mode 100644 index 0000000000..5781990cc7 --- /dev/null +++ b/components/filemngr/create/api/src/commonMain/kotlin/com/flipperdevices/filemanager/create/api/CreateFileDecomposeComponent.kt @@ -0,0 +1,31 @@ +package com.flipperdevices.filemanager.create.api + +import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent +import kotlinx.coroutines.flow.StateFlow +import okio.Path + +abstract class CreateFileDecomposeComponent( + componentContext: ComponentContext +) : ScreenDecomposeComponent(componentContext) { + abstract val canCreateFiles: StateFlow + + abstract fun startCreateFile(parent: Path) + + abstract fun startCreateFolder(parent: Path) + + abstract fun startCreate(parent: Path, type: FileType) + + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + createCallback: CreatedCallback, + ): CreateFileDecomposeComponent + } + + fun interface CreatedCallback { + fun invoke(item: ListingItem) + } +} diff --git a/components/filemngr/create/impl/build.gradle.kts b/components/filemngr/create/impl/build.gradle.kts new file mode 100644 index 0000000000..480e390edb --- /dev/null +++ b/components/filemngr/create/impl/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("flipper.multiplatform-compose") + id("flipper.multiplatform-dependencies") + id("flipper.anvil-multiplatform") + id("kotlinx-serialization") +} +android.namespace = "com.flipperdevices.filemanager.create.impl" + +commonDependencies { + implementation(projects.components.core.di) + implementation(projects.components.core.ktx) + implementation(projects.components.core.log) + implementation(projects.components.core.preference) + + implementation(projects.components.core.ui.lifecycle) + implementation(projects.components.core.ui.theme) + implementation(projects.components.core.ui.decompose) + implementation(projects.components.core.ui.ktx) + implementation(projects.components.core.ui.res) + implementation(projects.components.core.ui.dialog) + + implementation(projects.components.bridge.connection.feature.common.api) + implementation(projects.components.bridge.connection.transport.common.api) + implementation(projects.components.bridge.connection.feature.provider.api) + implementation(projects.components.bridge.connection.feature.storage.api) + implementation(projects.components.bridge.connection.feature.storageinfo.api) + implementation(projects.components.bridge.connection.feature.serialspeed.api) + implementation(projects.components.bridge.connection.feature.rpcinfo.api) + implementation(projects.components.bridge.dao.api) + + implementation(projects.components.filemngr.util) + implementation(projects.components.filemngr.uiComponents) + implementation(projects.components.filemngr.create.api) + + // Compose + implementation(libs.compose.ui) + implementation(libs.compose.tooling) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + + implementation(libs.kotlin.serialization.json) + implementation(libs.ktor.client) + + implementation(libs.decompose) + implementation(libs.kotlin.coroutines) + implementation(libs.essenty.lifecycle) + implementation(libs.essenty.lifecycle.coroutines) + + implementation(libs.bundles.decompose) + implementation(libs.okio) + implementation(libs.kotlin.immutable.collections) +} diff --git a/components/filemngr/create/impl/src/commonMain/composeResources/values/strings.xml b/components/filemngr/create/impl/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000000..c50f7680bb --- /dev/null +++ b/components/filemngr/create/impl/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,7 @@ + + + Enter Name: + Create File + Create Folder + Allowed characters: %1$s + \ No newline at end of file diff --git a/components/filemngr/create/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/create/impl/api/CreateFileDecomposeComponentImpl.kt b/components/filemngr/create/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/create/impl/api/CreateFileDecomposeComponentImpl.kt new file mode 100644 index 0000000000..f71e52bf5d --- /dev/null +++ b/components/filemngr/create/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/create/impl/api/CreateFileDecomposeComponentImpl.kt @@ -0,0 +1,93 @@ +package com.flipperdevices.filemanager.create.impl.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.filemanager.create.api.CreateFileDecomposeComponent +import com.flipperdevices.filemanager.create.impl.viewmodel.CreateFileViewModel +import com.flipperdevices.filemanager.ui.components.name.NameDialog +import com.flipperdevices.filemanager.util.constant.FileManagerConstants +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import flipperapp.components.filemngr.create.impl.generated.resources.fmc_create_file_allowed_chars +import flipperapp.components.filemngr.create.impl.generated.resources.fmc_create_file_title +import flipperapp.components.filemngr.create.impl.generated.resources.fml_create_file_btn +import flipperapp.components.filemngr.create.impl.generated.resources.fml_create_folder_btn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.gulya.anvil.assisted.ContributesAssistedFactory +import okio.Path +import org.jetbrains.compose.resources.stringResource +import javax.inject.Provider +import flipperapp.components.filemngr.create.impl.generated.resources.Res as FMC + +@ContributesAssistedFactory(AppGraph::class, CreateFileDecomposeComponent.Factory::class) +class CreateFileDecomposeComponentImpl @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted val createdCallback: CreatedCallback, + renameViewModelProvider: Provider +) : CreateFileDecomposeComponent(componentContext) { + private val createFileViewModel = instanceKeeper.getOrCreate { + renameViewModelProvider.get() + } + + override val canCreateFiles = createFileViewModel.canCreateFiles + + override fun startCreateFile(parent: Path) { + createFileViewModel.startCreateFile(parent) + } + + override fun startCreateFolder(parent: Path) { + createFileViewModel.startCreateFolder(parent) + } + + override fun startCreate(parent: Path, type: FileType) { + createFileViewModel.startCreate(parent, type) + } + + @Composable + override fun Render() { + val state by createFileViewModel.state.collectAsState() + LaunchedEffect(createFileViewModel) { + createFileViewModel.event + .onEach { event -> + when (event) { + is CreateFileViewModel.Event.Created -> { + createdCallback.invoke(event.item) + } + } + }.launchIn(this) + } + when (val localState = state) { + CreateFileViewModel.State.Pending -> Unit + is CreateFileViewModel.State.Creating -> { + NameDialog( + value = localState.name, + title = stringResource(FMC.string.fmc_create_file_title), + buttonText = when (localState.type) { + FileType.FILE -> stringResource(FMC.string.fml_create_file_btn) + FileType.DIR -> stringResource(FMC.string.fml_create_folder_btn) + }, + subtitle = stringResource( + FMC.string.fmc_create_file_allowed_chars, + FileManagerConstants.FILE_NAME_AVAILABLE_CHARACTERS + ), + onFinish = createFileViewModel::onConfirm, + isError = !localState.isValid, + isEnabled = !localState.isCreating, + needShowOptions = localState.needShowOptions, + onTextChange = createFileViewModel::onNameChange, + onDismissRequest = createFileViewModel::dismiss, + onOptionSelect = createFileViewModel::onOptionSelected, + options = localState.options, + isLoading = localState.isCreating + ) + } + } + } +} diff --git a/components/filemngr/create/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/create/impl/viewmodel/CreateFileViewModel.kt b/components/filemngr/create/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/create/impl/viewmodel/CreateFileViewModel.kt new file mode 100644 index 0000000000..994a225863 --- /dev/null +++ b/components/filemngr/create/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/create/impl/viewmodel/CreateFileViewModel.kt @@ -0,0 +1,200 @@ +package com.flipperdevices.filemanager.create.impl.viewmodel + +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureProvider +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureStatus +import com.flipperdevices.bridge.connection.feature.provider.api.get +import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi +import com.flipperdevices.bridge.connection.feature.storage.api.fm.FFileUploadApi +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem +import com.flipperdevices.bridge.connection.feature.storage.api.model.StorageRequestPriority +import com.flipperdevices.core.ktx.jre.FlipperFileNameValidator +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.error +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import com.flipperdevices.filemanager.util.constant.FileManagerConstants +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okio.ByteString +import okio.Path +import okio.buffer +import javax.inject.Inject + +class CreateFileViewModel @Inject constructor( + private val featureProvider: FFeatureProvider, +) : DecomposeViewModel(), LogTagProvider { + override val TAG: String = "CreateFileViewModel" + + private val fileNameValidator = FlipperFileNameValidator() + + private val eventChannel = Channel() + val event = eventChannel.receiveAsFlow() + + private val _state = MutableStateFlow(State.Pending) + val state = _state.asStateFlow() + + private var featureJob: Job? = null + private val featureMutex = Mutex() + + val canCreateFiles = featureProvider.get() + .filterIsInstance>() + .map { true } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun startCreate(parent: Path, type: FileType) { + _state.update { + State.Creating( + parent = parent, + type = type, + name = "", + isValid = false, + isCreating = false + ) + } + } + + fun startCreateFile(parent: Path) { + startCreate(parent, FileType.FILE) + } + + fun startCreateFolder(parent: Path) { + startCreate(parent, FileType.DIR) + } + + fun dismiss() { + _state.update { State.Pending } + } + + fun onNameChange(name: String) { + _state.update { state -> + (state as? State.Creating) + ?.copy(name = name) + ?: state + } + } + + fun onOptionSelected(index: Int) { + _state.update { state -> + (state as? State.Creating)?.let { renamingState -> + val option = renamingState.options.getOrNull(index).orEmpty() + renamingState.copy(name = "${renamingState.name}.$option") + } ?: state + } + } + + private suspend fun FFileUploadApi.createNewFile(pathOnFlipper: String): Result { + return runCatching { + sink( + pathOnFlipper = pathOnFlipper, + priority = StorageRequestPriority.FOREGROUND + ).buffer().use { bufferedSink -> bufferedSink.write(ByteString.of()) } + } + } + + private suspend fun create(uploadApi: FFileUploadApi) { + val state = (state.first() as? State.Creating) + if (state == null) { + error { "#rename state was not Renaming" } + return + } + val fullPath = state.parent.resolve(state.name) + _state.emit(state.copy(isCreating = true)) + when (state.type) { + FileType.FILE -> { + uploadApi.createNewFile(fullPath.toString()) + } + + FileType.DIR -> { + uploadApi.mkdir(fullPath.toString()) + } + }.onSuccess { + val item = ListingItem( + fileType = state.type, + fileName = fullPath.name, + size = 0L + ) + val event = Event.Created(item) + eventChannel.send(event) + }.onFailure { + error(it) { "Could not create file $fullPath" } + } + _state.emit(State.Pending) + featureJob?.cancelAndJoin() + } + + fun onConfirm() { + viewModelScope.launch { + featureJob?.cancelAndJoin() + featureMutex.withLock { + featureJob = featureProvider.get() + .onEach { status -> + when (status) { + FFeatureStatus.NotFound -> Unit + FFeatureStatus.Retrieving -> Unit + FFeatureStatus.Unsupported -> Unit + is FFeatureStatus.Supported -> { + create(status.featureApi.uploadApi()) + } + } + }.launchIn(viewModelScope) + featureJob?.join() + } + } + } + + private fun collectNameValidation() { + state + .filterIsInstance() + .distinctUntilChangedBy { state -> state.name } + .onEach { state -> + _state.emit(state.copy(isValid = fileNameValidator.isValid(state.name))) + }.launchIn(viewModelScope) + } + + init { + collectNameValidation() + } + + sealed interface State { + data object Pending : State + data class Creating( + val parent: Path, + val name: String, + val isValid: Boolean, + val type: FileType, + val isCreating: Boolean + ) : State { + val options: ImmutableList + get() = when (type) { + FileType.FILE -> FileManagerConstants.FILE_EXTENSION_HINTS + + FileType.DIR -> emptyList() + }.toImmutableList() + + val needShowOptions: Boolean + get() = !name.contains(".") && options.isNotEmpty() + } + } + + sealed interface Event { + data class Created(val item: ListingItem) : Event + } +} diff --git a/components/filemngr/download/api/src/commonMain/kotlin/com/flipperdevices/filemanager/download/api/DownloadDecomposeComponent.kt b/components/filemngr/download/api/src/commonMain/kotlin/com/flipperdevices/filemanager/download/api/DownloadDecomposeComponent.kt index 495869a718..ce803a9c59 100644 --- a/components/filemngr/download/api/src/commonMain/kotlin/com/flipperdevices/filemanager/download/api/DownloadDecomposeComponent.kt +++ b/components/filemngr/download/api/src/commonMain/kotlin/com/flipperdevices/filemanager/download/api/DownloadDecomposeComponent.kt @@ -1,9 +1,9 @@ package com.flipperdevices.filemanager.download.api import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.filemanager.download.model.DownloadableFile import com.flipperdevices.ui.decompose.ScreenDecomposeComponent import kotlinx.coroutines.flow.StateFlow -import okio.Path abstract class DownloadDecomposeComponent( componentContext: ComponentContext @@ -12,10 +12,7 @@ abstract class DownloadDecomposeComponent( abstract fun onCancel() - abstract fun download( - fullPath: Path, - size: Long - ) + abstract fun download(file: DownloadableFile) fun interface Factory { operator fun invoke( diff --git a/components/filemngr/download/api/src/commonMain/kotlin/com/flipperdevices/filemanager/download/model/DownloadableFile.kt b/components/filemngr/download/api/src/commonMain/kotlin/com/flipperdevices/filemanager/download/model/DownloadableFile.kt new file mode 100644 index 0000000000..b2125b540b --- /dev/null +++ b/components/filemngr/download/api/src/commonMain/kotlin/com/flipperdevices/filemanager/download/model/DownloadableFile.kt @@ -0,0 +1,8 @@ +package com.flipperdevices.filemanager.download.model + +import okio.Path + +data class DownloadableFile( + val fullPath: Path, + val size: Long +) diff --git a/components/filemngr/download/impl/build.gradle.kts b/components/filemngr/download/impl/build.gradle.kts index 4b1450333f..489eafa83a 100644 --- a/components/filemngr/download/impl/build.gradle.kts +++ b/components/filemngr/download/impl/build.gradle.kts @@ -30,6 +30,7 @@ commonDependencies { implementation(projects.components.bridge.dao.api) implementation(projects.components.filemngr.download.api) + implementation(projects.components.filemngr.uiComponents) // Compose implementation(libs.compose.ui) diff --git a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/api/DownloadDecomposeComponentImpl.kt b/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/api/DownloadDecomposeComponentImpl.kt index 7c4ada5d24..2002ad7bc9 100644 --- a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/api/DownloadDecomposeComponentImpl.kt +++ b/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/api/DownloadDecomposeComponentImpl.kt @@ -13,26 +13,23 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.essenty.instancekeeper.getOrCreate import com.arkivanov.essenty.lifecycle.coroutines.coroutineScope import com.flipperdevices.core.di.AppGraph -import com.flipperdevices.core.share.PlatformShareHelper import com.flipperdevices.core.ui.theme.LocalPalletV2 import com.flipperdevices.filemanager.download.api.DownloadDecomposeComponent import com.flipperdevices.filemanager.download.impl.composable.DownloadingComposable -import com.flipperdevices.filemanager.download.impl.model.DownloadableFile import com.flipperdevices.filemanager.download.impl.viewmodel.DownloadViewModel +import com.flipperdevices.filemanager.download.model.DownloadableFile import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import me.gulya.anvil.assisted.ContributesAssistedFactory -import okio.Path import javax.inject.Provider @ContributesAssistedFactory(AppGraph::class, DownloadDecomposeComponent.Factory::class) class DownloadDecomposeComponentImpl @AssistedInject constructor( @Assisted componentContext: ComponentContext, private val downloadViewModelFactory: Provider, - private val platformShareHelper: PlatformShareHelper ) : DownloadDecomposeComponent(componentContext) { private val downloadViewModel = instanceKeeper.getOrCreate { downloadViewModelFactory.get() @@ -44,15 +41,9 @@ class DownloadDecomposeComponentImpl @AssistedInject constructor( override fun onCancel() = downloadViewModel.onCancel() - override fun download( - fullPath: Path, - size: Long - ) = downloadViewModel.tryDownload( - file = DownloadableFile( - fullPath = fullPath, - size = size - ) - ) + override fun download(file: DownloadableFile) { + downloadViewModel.tryDownload(file = file) + } @Composable override fun Render() { diff --git a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/composable/DownloadingComposable.kt b/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/composable/DownloadingComposable.kt index 3bfe684da1..d2afa575bd 100644 --- a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/composable/DownloadingComposable.kt +++ b/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/composable/DownloadingComposable.kt @@ -1,25 +1,14 @@ package com.flipperdevices.filemanager.download.impl.composable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.flipperdevices.core.ui.ktx.clickableRipple -import com.flipperdevices.core.ui.theme.LocalPalletV2 -import com.flipperdevices.core.ui.theme.LocalTypography +import com.flipperdevices.core.ktx.jre.toFormattedSize import com.flipperdevices.filemanager.download.impl.viewmodel.DownloadViewModel +import com.flipperdevices.filemanager.ui.components.transfer.FileTransferFullScreenComposable import flipperapp.components.filemngr.download.impl.generated.resources.fm_cancel import flipperapp.components.filemngr.download.impl.generated.resources.fm_downloading +import flipperapp.components.filemngr.download.impl.generated.resources.fm_in_progress_file_size +import flipperapp.components.filemngr.download.impl.generated.resources.fm_in_progress_speed import org.jetbrains.compose.resources.stringResource import flipperapp.components.filemngr.download.impl.generated.resources.Res as FDR @@ -29,38 +18,24 @@ fun DownloadingComposable( onCancel: () -> Unit, modifier: Modifier = Modifier ) { - Box( - modifier = modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - Text( - text = stringResource(FDR.string.fm_downloading), - style = LocalTypography.current.titleB18, - color = LocalPalletV2.current.text.title.primary, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - - Text( - text = stringResource(FDR.string.fm_cancel), - style = LocalTypography.current.bodyM14, - color = LocalPalletV2.current.action.danger.text.default, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - .clip(RoundedCornerShape(12.dp)) - .clickableRipple(onClick = onCancel), - textAlign = TextAlign.Center + FileTransferFullScreenComposable( + modifier = modifier, + title = stringResource(FDR.string.fm_downloading), + actionText = stringResource(FDR.string.fm_cancel), + onActionClick = onCancel, + progressTitle = state.fullPath.name, + progress = if (state.totalSize == 0L) 0f else state.downloadedSize / state.totalSize.toFloat(), + progressText = stringResource( + FDR.string.fm_in_progress_file_size, + state.downloadedSize.toFormattedSize(), + state.totalSize.toFormattedSize() + ), + speedText = when (state.downloadSpeed) { + 0L -> null + else -> stringResource( + FDR.string.fm_in_progress_speed, + state.downloadSpeed.toFormattedSize(), ) } - InProgressComposable( - state = state, - ) - } + ) } diff --git a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/composable/InProgressComposable.kt b/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/composable/InProgressComposable.kt deleted file mode 100644 index 3fcb6fcb24..0000000000 --- a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/composable/InProgressComposable.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.flipperdevices.filemanager.download.impl.composable - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.flipperdevices.core.ktx.jre.toFormattedSize -import com.flipperdevices.core.ui.ktx.elements.FlipperProgressIndicator -import com.flipperdevices.core.ui.theme.LocalPallet -import com.flipperdevices.core.ui.theme.LocalPalletV2 -import com.flipperdevices.core.ui.theme.LocalTypography -import com.flipperdevices.filemanager.download.impl.viewmodel.DownloadViewModel -import flipperapp.components.filemngr.download.impl.generated.resources.fm_downloading_file -import flipperapp.components.filemngr.download.impl.generated.resources.fm_in_progress_file_size -import flipperapp.components.filemngr.download.impl.generated.resources.fm_in_progress_speed -import org.jetbrains.compose.resources.stringResource -import flipperapp.components.filemngr.download.impl.generated.resources.Res as FDR - -@Composable -private fun InProgressDetailComposable( - state: DownloadViewModel.State.Downloading, - modifier: Modifier = Modifier -) { - Text( - text = stringResource( - FDR.string.fm_downloading_file, - state.fullPath.name, - state.downloadedSize.toFormattedSize(), - state.totalSize.toFormattedSize() - ), - style = LocalTypography.current.subtitleM12, - color = LocalPalletV2.current.text.body.secondary, - modifier = modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) -} - -@Composable -private fun InProgressTitleComposable( - state: DownloadViewModel.State.Downloading, - modifier: Modifier = Modifier -) { - Text( - text = state.fullPath.name, - style = LocalTypography.current.titleB18, - color = LocalPalletV2.current.text.title.primary, - modifier = modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) -} - -@Composable -internal fun InProgressComposable( - state: DownloadViewModel.State.Downloading, -) { - val animatedProgress by animateFloatAsState( - targetValue = if (state.totalSize == 0L) 0f else state.downloadedSize / state.totalSize.toFloat(), - animationSpec = tween(durationMillis = 500, easing = LinearEasing), - label = "Progress" - ) - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - InProgressTitleComposable(state) - Spacer(Modifier.height(12.dp)) - FlipperProgressIndicator( - modifier = Modifier.padding(horizontal = 32.dp), - accentColor = LocalPalletV2.current.action.blue.border.primary.default, - secondColor = LocalPallet.current.actionOnFlipperProgress, - painter = null, - percent = animatedProgress - ) - Spacer(Modifier.height(8.dp)) - InProgressDetailComposable(state) - Text( - text = stringResource( - FDR.string.fm_in_progress_file_size, - state.downloadedSize.toFormattedSize(), - state.totalSize.toFormattedSize() - ), - style = LocalTypography.current.subtitleM12, - color = LocalPalletV2.current.text.body.secondary, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource( - FDR.string.fm_in_progress_speed, - state.downloadSpeed.toFormattedSize(), - ), - style = LocalTypography.current.subtitleM12, - color = LocalPalletV2.current.text.body.secondary, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } -} diff --git a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/model/DownloadableFile.kt b/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/model/DownloadableFile.kt deleted file mode 100644 index dbd48c624b..0000000000 --- a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/model/DownloadableFile.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.flipperdevices.filemanager.download.impl.model - -import okio.Path - -class DownloadableFile( - val fullPath: Path, - val size: Long -) diff --git a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/viewmodel/DownloadViewModel.kt b/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/viewmodel/DownloadViewModel.kt index 06854b59dc..b0757a8733 100644 --- a/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/viewmodel/DownloadViewModel.kt +++ b/components/filemngr/download/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/download/impl/viewmodel/DownloadViewModel.kt @@ -9,7 +9,7 @@ import com.flipperdevices.core.log.LogTagProvider import com.flipperdevices.core.log.error import com.flipperdevices.core.share.PlatformShareHelper import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel -import com.flipperdevices.filemanager.download.impl.model.DownloadableFile +import com.flipperdevices.filemanager.download.model.DownloadableFile import flipperapp.components.filemngr.download.impl.generated.resources.Res import flipperapp.components.filemngr.download.impl.generated.resources.fm_share_title import kotlinx.coroutines.Dispatchers @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -53,7 +52,6 @@ class DownloadViewModel @Inject constructor( pathOnFlipper = flipperFileFullPath.toString(), fileOnAndroid = pathOnAndroid.path, progressListener = { current, max -> - println("DownloadViewModel progressListener") _state.update { state -> (state as? State.Downloading) ?.copy(downloadedSize = current, totalSize = max) @@ -128,7 +126,6 @@ class DownloadViewModel @Inject constructor( } }.catch { it.printStackTrace() }.launchIn(viewModelScope) _featureJob?.join() - println("DownloadViewModel out of mutex") } } } diff --git a/components/filemngr/editor/api/build.gradle.kts b/components/filemngr/editor/api/build.gradle.kts index bbf7380e3a..64ec4e8235 100644 --- a/components/filemngr/editor/api/build.gradle.kts +++ b/components/filemngr/editor/api/build.gradle.kts @@ -7,6 +7,7 @@ android.namespace = "com.flipperdevices.filemanager.editor.api" commonDependencies { implementation(projects.components.core.ui.decompose) + implementation(projects.components.bridge.connection.feature.storage.api) implementation(libs.compose.ui) implementation(libs.decompose) diff --git a/components/filemngr/editor/api/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileManagerEditorDecomposeComponent.kt b/components/filemngr/editor/api/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileManagerEditorDecomposeComponent.kt index 13f1c89318..838f4d1267 100644 --- a/components/filemngr/editor/api/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileManagerEditorDecomposeComponent.kt +++ b/components/filemngr/editor/api/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileManagerEditorDecomposeComponent.kt @@ -1,18 +1,18 @@ package com.flipperdevices.filemanager.editor.api import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem +import com.flipperdevices.ui.decompose.CompositeDecomposeComponent import com.flipperdevices.ui.decompose.DecomposeOnBackParameter -import com.flipperdevices.ui.decompose.ScreenDecomposeComponent import okio.Path -abstract class FileManagerEditorDecomposeComponent( - componentContext: ComponentContext -) : ScreenDecomposeComponent(componentContext) { +abstract class FileManagerEditorDecomposeComponent : CompositeDecomposeComponent() { fun interface Factory { operator fun invoke( componentContext: ComponentContext, onBack: DecomposeOnBackParameter, + onFileChanged: (ListingItem) -> Unit, path: Path - ): FileManagerEditorDecomposeComponent + ): FileManagerEditorDecomposeComponent<*> } } diff --git a/components/filemngr/editor/impl/build.gradle.kts b/components/filemngr/editor/impl/build.gradle.kts index e3a72ae0fc..7e2b622bec 100644 --- a/components/filemngr/editor/impl/build.gradle.kts +++ b/components/filemngr/editor/impl/build.gradle.kts @@ -34,8 +34,8 @@ commonDependencies { implementation(projects.components.filemngr.uiComponents) implementation(projects.components.filemngr.editor.api) - implementation(projects.components.filemngr.upload.api) implementation(projects.components.filemngr.main.api) + implementation(projects.components.filemngr.util) // Compose implementation(libs.compose.ui) diff --git a/components/filemngr/editor/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/editor/composable/ContentPreview.kt b/components/filemngr/editor/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/editor/composable/ContentPreview.kt deleted file mode 100644 index bef9b9482f..0000000000 --- a/components/filemngr/editor/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/editor/composable/ContentPreview.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.flipperdevices.filemanager.editor.composable - -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.flipperdevices.core.ui.theme.FlipperThemeInternal -import com.flipperdevices.filemanager.editor.composable.content.EditorLoadingContent -import com.flipperdevices.filemanager.editor.composable.content.ErrorContent -import com.flipperdevices.filemanager.editor.composable.content.TooBigContent - -@Preview -@Composable -private fun ErrorContentPreview() { - FlipperThemeInternal { - Scaffold { - ErrorContent(modifier = Modifier.padding(it)) - } - } -} - -@Preview -@Composable -private fun LoadingContentPreview() { - FlipperThemeInternal { - Scaffold { - EditorLoadingContent(modifier = Modifier.padding(it)) - } - } -} - -@Preview -@Composable -private fun TooBigContentPreview() { - FlipperThemeInternal { - Scaffold { - TooBigContent(modifier = Modifier.padding(it)) - } - } -} diff --git a/components/filemngr/editor/impl/src/commonMain/composeResources/values/strings.xml b/components/filemngr/editor/impl/src/commonMain/composeResources/values/strings.xml index 11eccc0ad9..de36895cc2 100644 --- a/components/filemngr/editor/impl/src/commonMain/composeResources/values/strings.xml +++ b/components/filemngr/editor/impl/src/commonMain/composeResources/values/strings.xml @@ -3,10 +3,16 @@ The file is larger than 1MB and therefore only part of the file is shown. Save File as... Save + Downloading... + Uploading... + Speed: %1$s/s + Cancel Allowed characters: %1$s TXT HEX Enter name: Save as New File Allowed Characters: %1$s + The file is too large! + Unfortunately, flipper Android doesn't allow to edit files larger: %1$s \ No newline at end of file diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/EditFileNameDecomposeComponent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/EditFileNameDecomposeComponent.kt new file mode 100644 index 0000000000..5bb33b9728 --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/EditFileNameDecomposeComponent.kt @@ -0,0 +1,46 @@ +package com.flipperdevices.filemanager.editor.api + +import androidx.compose.runtime.Composable +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.filemanager.editor.composable.dialog.CreateFileDialogComposable +import com.flipperdevices.filemanager.editor.viewmodel.EditFileNameViewModel +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import okio.Path + +class EditFileNameDecomposeComponent @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted("fullPathOnFlipper") private val fullPathOnFlipper: Path, + @Assisted private val onBack: DecomposeOnBackParameter, + @Assisted private val onChanged: (Path) -> Unit, + editFileNameViewModelFactory: EditFileNameViewModel.Factory +) : ScreenDecomposeComponent(componentContext) { + private val editFileNameViewModel = instanceKeeper.getOrCreate { + editFileNameViewModelFactory.invoke(fullPathOnFlipper) + } + + @Composable + override fun Render() { + CreateFileDialogComposable( + editFileNameViewModel = editFileNameViewModel, + onFinish = { name -> + fullPathOnFlipper.parent?.resolve(name)?.run(onChanged) + }, + onDismiss = onBack::invoke + ) + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + onBack: DecomposeOnBackParameter, + onChanged: (Path) -> Unit + ): EditFileNameDecomposeComponent + } +} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/EditorDecomposeComponent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/EditorDecomposeComponent.kt new file mode 100644 index 0000000000..ccbfa9969e --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/EditorDecomposeComponent.kt @@ -0,0 +1,102 @@ +package com.flipperdevices.filemanager.editor.api + +import androidx.compose.runtime.Composable +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.filemanager.editor.composable.FileManagerEditorComposable +import com.flipperdevices.filemanager.editor.viewmodel.EditorViewModel +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable +import okio.Path + +@Suppress("LongParameterList") +class EditorDecomposeComponent @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted private val onBack: DecomposeOnBackParameter, + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + @Assisted private val editFinishedCallback: EditFinishedCallback, + editorViewModelFactory: EditorViewModel.Factory, + editFileNameDecomposeComponentFactory: EditFileNameDecomposeComponent.Factory +) : ScreenDecomposeComponent(componentContext) { + + private val editorViewModel = instanceKeeper.getOrCreate { + editorViewModelFactory.invoke( + fullPathOnFlipper = fullPathOnFlipper, + fullPathOnDevice = fullPathOnDevice + ) + } + + private fun saveFile() { + editorViewModel.writeNow() + editFinishedCallback.invoke( + fullPathOnFlipper = editorViewModel.state.value.fullPathOnFlipper + ) + } + + @Serializable + sealed interface SlotConfiguration { + data object ChangeFlipperFileName : SlotConfiguration + } + + private val slotNavigation = SlotNavigation() + + private val fileOptionsSlot = childSlot( + source = slotNavigation, + handleBackButton = true, + serializer = SlotConfiguration.serializer(), + childFactory = { config, childContext -> + when (config) { + SlotConfiguration.ChangeFlipperFileName -> { + editFileNameDecomposeComponentFactory.invoke( + componentContext = childContext, + fullPathOnFlipper = editorViewModel.state.value.fullPathOnFlipper, + onBack = slotNavigation::dismiss, + onChanged = { fullPathOnFlipper -> + editorViewModel.onFlipperPathChanged(fullPathOnFlipper) + slotNavigation.dismiss() + saveFile() + } + ) + } + } + } + ) + + @Composable + override fun Render() { + FileManagerEditorComposable( + editorViewModel = editorViewModel, + onBack = onBack::invoke, + onSaveAsClick = { + slotNavigation.activate(SlotConfiguration.ChangeFlipperFileName) + }, + onSaveClick = { saveFile() } + ) + fileOptionsSlot.subscribeAsState().value.child?.instance?.Render() + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + onBack: DecomposeOnBackParameter, + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + editFinishedCallback: EditFinishedCallback + ): EditorDecomposeComponent + } + + fun interface EditFinishedCallback { + fun invoke(fullPathOnFlipper: Path) + } +} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileDownloadDecomposeComponent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileDownloadDecomposeComponent.kt new file mode 100644 index 0000000000..be9ebec916 --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileDownloadDecomposeComponent.kt @@ -0,0 +1,104 @@ +package com.flipperdevices.filemanager.editor.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.core.ktx.jre.toFormattedSize +import com.flipperdevices.filemanager.editor.composable.download.UploadingComposable +import com.flipperdevices.filemanager.editor.viewmodel.DownloadViewModel +import com.flipperdevices.filemanager.ui.components.error.ErrorContentComposable +import com.flipperdevices.filemanager.ui.components.error.UnknownErrorComposable +import com.flipperdevices.filemanager.ui.components.error.UnsupportedErrorComposable +import com.flipperdevices.filemanager.util.constant.FileManagerConstants +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import flipperapp.components.filemngr.editor.impl.generated.resources.fme_error_too_large_desc +import flipperapp.components.filemngr.editor.impl.generated.resources.fme_error_too_large_title +import flipperapp.components.filemngr.editor.impl.generated.resources.fme_status_downloading +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import okio.Path +import org.jetbrains.compose.resources.stringResource +import flipperapp.components.filemngr.editor.impl.generated.resources.Res as FME + +class FileDownloadDecomposeComponent @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + @Assisted private val onDownloaded: () -> Unit, + @Assisted private val onBack: DecomposeOnBackParameter, + downloadViewModelFactory: DownloadViewModel.Factory +) : ScreenDecomposeComponent(componentContext) { + private val downloadViewModel = instanceKeeper.getOrCreate { + downloadViewModelFactory.invoke( + fullPathOnFlipper = fullPathOnFlipper, + fullPathOnDevice = fullPathOnDevice + ) + } + + @Composable + override fun Render() { + LaunchedEffect(downloadViewModel) { + downloadViewModel.state + .filterIsInstance() + .onEach { onDownloaded.invoke() } + .launchIn(this) + } + val state by downloadViewModel.state.collectAsState() + + when (val localState = state) { + DownloadViewModel.State.CouldNotDownload -> { + UnknownErrorComposable() + } + + // Screen closed + DownloadViewModel.State.Downloaded -> Unit + + is DownloadViewModel.State.Downloading -> { + UploadingComposable( + progress = localState.progress, + fullPathOnFlipper = localState.fullPathOnFlipper, + current = localState.downloaded, + max = localState.total, + speed = downloadViewModel.speedState.collectAsState().value, + onCancel = onBack::invoke, + modifier = Modifier, + title = stringResource(FME.string.fme_status_downloading) + ) + } + + DownloadViewModel.State.TooLarge -> { + ErrorContentComposable( + text = stringResource(FME.string.fme_error_too_large_title), + desc = stringResource( + FME.string.fme_error_too_large_desc, + FileManagerConstants.LIMITED_SIZE_BYTES.toFormattedSize() + ) + ) + } + + DownloadViewModel.State.Unsupported -> { + UnsupportedErrorComposable() + } + } + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + onBack: DecomposeOnBackParameter, + onDownloaded: () -> Unit + ): FileDownloadDecomposeComponent + } +} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileManagerEditorDecomposeComponentImpl.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileManagerEditorDecomposeComponentImpl.kt index bcae8f6d74..79551db10b 100644 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileManagerEditorDecomposeComponentImpl.kt +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/FileManagerEditorDecomposeComponentImpl.kt @@ -1,66 +1,102 @@ package com.flipperdevices.filemanager.editor.api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.childContext +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.stack.replaceCurrent +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem +import com.flipperdevices.core.FlipperStorageProvider import com.flipperdevices.core.di.AppGraph -import com.flipperdevices.core.ui.lifecycle.viewModelWithFactory -import com.flipperdevices.filemanager.editor.composable.FileManagerEditorComposable -import com.flipperdevices.filemanager.editor.composable.content.RenderLoadingScreen -import com.flipperdevices.filemanager.editor.composable.dialog.CreateFileDialogComposable -import com.flipperdevices.filemanager.editor.viewmodel.EditorViewModel -import com.flipperdevices.filemanager.editor.viewmodel.FileNameViewModel -import com.flipperdevices.filemanager.upload.api.UploaderDecomposeComponent +import com.flipperdevices.filemanager.editor.model.FileManagerEditorConfiguration +import com.flipperdevices.ui.decompose.DecomposeComponent import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.popOr import dagger.assisted.Assisted import dagger.assisted.AssistedInject import me.gulya.anvil.assisted.ContributesAssistedFactory import okio.Path -import javax.inject.Provider +@Suppress("LongParameterList") @ContributesAssistedFactory(AppGraph::class, FileManagerEditorDecomposeComponent.Factory::class) class FileManagerEditorDecomposeComponentImpl @AssistedInject constructor( @Assisted componentContext: ComponentContext, @Assisted private val path: Path, @Assisted private val onBack: DecomposeOnBackParameter, - private val editorViewModelFactory: EditorViewModel.Factory, - uploaderDecomposeComponentFactory: UploaderDecomposeComponent.Factory, - private val fileNameViewModelProvider: Provider, -) : FileManagerEditorDecomposeComponent(componentContext) { - private val uploaderDecomposeComponent = uploaderDecomposeComponentFactory.invoke( - componentContext = childContext("file_editor_$path") - ) - - @Composable - override fun Render() { - val fileNameViewModel = viewModelWithFactory(null) { - fileNameViewModelProvider.get() - } - val editorViewModel = viewModelWithFactory(path.toString()) { - editorViewModelFactory.invoke(path) + @Assisted private val onFileChanged: (ListingItem) -> Unit, + fileDownloadDecomposeComponentFactory: FileDownloadDecomposeComponent.Factory, + private val editorDecomposeComponentFactory: EditorDecomposeComponent.Factory, + private val storageProvider: FlipperStorageProvider, + private val uploadFileDecomposeComponentFactory: UploadFileDecomposeComponent.Factory +) : FileManagerEditorDecomposeComponent(), + ComponentContext by componentContext { + private val editorFileKeeper = instanceKeeper.getOrCreate { + object : InstanceKeeper.Instance { + val editorFile = storageProvider.getTemporaryFile() + override fun onDestroy() { + storageProvider.fileSystem.delete(editorFile) + } } + } - CreateFileDialogComposable( - fileNameViewModel = fileNameViewModel, - onFinish = onSaveClick@{ fileName -> - val rawContent = editorViewModel.getRawContent() ?: return@onSaveClick - uploaderDecomposeComponent.uploadRaw( - folderPath = path.parent ?: return@onSaveClick, - fileName = fileName, - content = rawContent - ) - } - ) + override val stack: Value> = + childStack( + source = navigation, + serializer = FileManagerEditorConfiguration.serializer(), + initialStack = { listOf(FileManagerEditorConfiguration.Download(path)) }, + handleBackButton = true, + childFactory = { config, childContext -> + when (config) { + is FileManagerEditorConfiguration.Download -> { + fileDownloadDecomposeComponentFactory.invoke( + componentContext = childContext, + fullPathOnFlipper = path, + fullPathOnDevice = editorFileKeeper.editorFile, + onBack = { navigation.popOr(onBack::invoke) }, + onDownloaded = { + navigation.replaceCurrent( + FileManagerEditorConfiguration.Editor( + fullPathOnFlipper = config.fullPathOnFlipper, + tempPathOnDevice = editorFileKeeper.editorFile + ) + ) + } + ) + } - FileManagerEditorComposable( - path = path, - editorViewModel = editorViewModel, - uploaderDecomposeComponent = uploaderDecomposeComponent, - fileNameViewModel = fileNameViewModel, - onBack = onBack::invoke - ) + is FileManagerEditorConfiguration.Editor -> { + editorDecomposeComponentFactory.invoke( + componentContext = componentContext, + fullPathOnFlipper = config.fullPathOnFlipper, + fullPathOnDevice = config.tempPathOnDevice, + onBack = { navigation.popOr(onBack::invoke) }, + editFinishedCallback = { fullPathOnFlipper -> + navigation.pushNew( + FileManagerEditorConfiguration.Upload( + fullPathOnFlipper = fullPathOnFlipper, + tempPathOnDevice = editorFileKeeper.editorFile + ) + ) + } + ) + } - uploaderDecomposeComponent.RenderLoadingScreen() - } + is FileManagerEditorConfiguration.Upload -> { + uploadFileDecomposeComponentFactory.invoke( + componentContext = childContext, + onBack = { navigation.popOr(onBack::invoke) }, + fullPathOnFlipper = config.fullPathOnFlipper, + fullPathOnDevice = config.tempPathOnDevice, + onFinished = { + navigation.popOr(onBack::invoke) + }, + onProgress = onFileChanged + ) + } + } + }, + ) } diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/UploadFileDecomposeComponent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/UploadFileDecomposeComponent.kt new file mode 100644 index 0000000000..4dc8a00e2a --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/api/UploadFileDecomposeComponent.kt @@ -0,0 +1,106 @@ +package com.flipperdevices.filemanager.editor.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem +import com.flipperdevices.filemanager.editor.composable.download.UploadingComposable +import com.flipperdevices.filemanager.editor.viewmodel.UploadFileViewModel +import com.flipperdevices.filemanager.ui.components.error.UnknownErrorComposable +import com.flipperdevices.filemanager.ui.components.error.UnsupportedErrorComposable +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import flipperapp.components.filemngr.editor.impl.generated.resources.fme_status_uploading +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import okio.Path +import org.jetbrains.compose.resources.stringResource +import flipperapp.components.filemngr.editor.impl.generated.resources.Res as FME + +@Suppress("LongParameterList") +class UploadFileDecomposeComponent @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted private val onBack: DecomposeOnBackParameter, + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + @Assisted private val onProgress: (ListingItem) -> Unit, + @Assisted private val onFinished: () -> Unit, + uploadFileViewModelFactory: UploadFileViewModel.Factory +) : ScreenDecomposeComponent(componentContext) { + private val uploadFileViewModel = instanceKeeper.getOrCreate { + uploadFileViewModelFactory.invoke( + fullPathOnFlipper = fullPathOnFlipper, + fullPathOnDevice = fullPathOnDevice, + ) + } + + @Composable + override fun Render() { + LaunchedEffect(uploadFileViewModel) { + uploadFileViewModel.state + .filterIsInstance() + .onEach { state -> + val listingItem = ListingItem( + fileName = state.fullPathOnFlipper.name, + fileType = FileType.FILE, + size = state.uploaded + ) + onProgress.invoke(listingItem) + }.launchIn(this) + + uploadFileViewModel.state + .filterIsInstance() + .onEach { onFinished.invoke() } + .launchIn(this) + } + + val state by uploadFileViewModel.state.collectAsState() + + when (val localState = state) { + UploadFileViewModel.State.Error -> { + UnsupportedErrorComposable() + } + + // The screen is closed + is UploadFileViewModel.State.Saved -> Unit + + UploadFileViewModel.State.Unsupported -> { + UnknownErrorComposable() + } + + is UploadFileViewModel.State.Uploading -> { + UploadingComposable( + progress = localState.progress, + fullPathOnFlipper = localState.fullPathOnFlipper, + current = localState.uploaded, + max = localState.total, + speed = uploadFileViewModel.speedState.collectAsState().value, + onCancel = onBack::invoke, + modifier = Modifier, + title = stringResource(FME.string.fme_status_uploading) + ) + } + } + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + onBack: DecomposeOnBackParameter, + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + onProgress: (ListingItem) -> Unit, + onFinished: () -> Unit, + ): UploadFileDecomposeComponent + } +} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/EditorDropdown.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/EditorDropdown.kt index c3f413587f..34e38ec141 100644 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/EditorDropdown.kt +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/EditorDropdown.kt @@ -50,11 +50,17 @@ internal fun EditorDropdown( ) { TextDropdownItem( text = stringResource(FME.string.fme_save), - onClick = onSaveClick + onClick = { + onSaveClick.invoke() + isDropdownVisible = !isDropdownVisible + } ) TextDropdownItem( text = stringResource(FME.string.fme_save_as_file), - onClick = onSaveAsClick + onClick = { + onSaveAsClick.invoke() + isDropdownVisible = !isDropdownVisible + } ) } } diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/FileManagerEditorComposable.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/FileManagerEditorComposable.kt index cad93b8a24..e7400be79b 100644 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/FileManagerEditorComposable.kt +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/FileManagerEditorComposable.kt @@ -6,21 +6,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import com.flipperdevices.filemanager.editor.composable.content.EditorLoadingContent -import com.flipperdevices.filemanager.editor.composable.content.ErrorContent import com.flipperdevices.filemanager.editor.composable.content.LoadedContent import com.flipperdevices.filemanager.editor.viewmodel.EditorViewModel -import com.flipperdevices.filemanager.editor.viewmodel.FileNameViewModel -import com.flipperdevices.filemanager.upload.api.UploaderDecomposeComponent -import okio.Path @Composable fun FileManagerEditorComposable( - path: Path, editorViewModel: EditorViewModel, - uploaderDecomposeComponent: UploaderDecomposeComponent, - fileNameViewModel: FileNameViewModel, onBack: () -> Unit, + onSaveAsClick: () -> Unit, + onSaveClick: () -> Unit, modifier: Modifier = Modifier ) { val editorState by editorViewModel.state.collectAsState() @@ -29,48 +23,20 @@ fun FileManagerEditorComposable( modifier = modifier, topBar = { EditorAppBar( - path = path, - onSaveClick = onSaveClick@{ - val rawContent = editorViewModel.getRawContent() ?: return@onSaveClick - uploaderDecomposeComponent.uploadRaw( - folderPath = path.parent ?: return@onSaveClick, - fileName = path.name, - content = rawContent - ) - }, - onSaveAsClick = { - fileNameViewModel.show() - }, + path = editorState.fullPathOnFlipper, + onSaveClick = onSaveClick::invoke, + onSaveAsClick = onSaveAsClick, onBack = onBack::invoke, - editorEncodingEnum = (editorState as? EditorViewModel.State.Loaded)?.encoding, - canSave = (editorState is EditorViewModel.State.Loaded), + editorEncodingEnum = editorState.encoding, + canSave = editorState.canEdit, onEditorTabChange = editorViewModel::onEditorTypeChange ) } ) { contentPadding -> - when (val localEditorState = editorState) { - EditorViewModel.State.Error -> { - ErrorContent( - modifier = Modifier.padding(contentPadding) - ) - } - - EditorViewModel.State.Preparing, - is EditorViewModel.State.Loading -> { - EditorLoadingContent( - modifier = Modifier.padding(contentPadding) - ) - } - - is EditorViewModel.State.Loaded -> { - LoadedContent( - state = localEditorState, - onTextChange = editorViewModel::onTextChanged, - modifier = Modifier.padding(contentPadding) - ) - } - - is EditorViewModel.State.Saving -> Unit - } + LoadedContent( + state = editorState, + onTextChange = editorViewModel::onTextChanged, + modifier = Modifier.padding(contentPadding) + ) } } diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/EditorLoadingContent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/EditorLoadingContent.kt deleted file mode 100644 index 8f56c051c6..0000000000 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/EditorLoadingContent.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.flipperdevices.filemanager.editor.composable.content - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.flipperdevices.core.ui.ktx.placeholderConnecting -import kotlin.random.Random - -@Composable -fun EditorLoadingContent(modifier: Modifier = Modifier) { - val widths = remember(Unit) { - List( - size = 32, - init = { Random.nextDouble(from = 0.4, until = 0.9).toFloat() } - ) - } - Box(modifier.fillMaxSize()) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.Start - ) { - widths.forEach { width -> - Box( - modifier = Modifier.height(12.dp) - .fillMaxWidth(width) - .placeholderConnecting() - ) - } - } - } -} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/ErrorContent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/ErrorContent.kt deleted file mode 100644 index 1e5f6fbbed..0000000000 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/ErrorContent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.flipperdevices.filemanager.editor.composable.content - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun ErrorContent(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize()) { - // todo - Text("Unsopported") - } -} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/LoadedContent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/LoadedContent.kt index 1c90c50ac9..dbae9f9c69 100644 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/LoadedContent.kt +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/LoadedContent.kt @@ -11,19 +11,16 @@ import com.flipperdevices.filemanager.editor.viewmodel.EditorViewModel @Composable fun LoadedContent( - state: EditorViewModel.State.Loaded, + state: EditorViewModel.State, onTextChange: (String) -> Unit, modifier: Modifier = Modifier ) { Column(modifier = modifier) { - if (state.isTooLarge) { - TooBigContent() - } TextField( modifier = Modifier.fillMaxSize(), value = state.hexString.content, - enabled = !state.isTooLarge, - readOnly = state.isTooLarge, + enabled = state.canEdit, + readOnly = !state.canEdit, onValueChange = onTextChange, colors = TextFieldDefaults.textFieldColors( cursorColor = LocalPallet.current.text100 diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/TooBigContent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/TooBigContent.kt deleted file mode 100644 index 72b7e38e1d..0000000000 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/TooBigContent.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.flipperdevices.filemanager.editor.composable.content - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.flipperdevices.core.ui.theme.LocalPallet -import flipperapp.components.filemngr.editor.impl.generated.resources.fme_too_large_file -import org.jetbrains.compose.resources.stringResource -import flipperapp.components.filemngr.editor.impl.generated.resources.Res as FME - -@Composable -fun TooBigContent(modifier: Modifier = Modifier) { - Text( - modifier = modifier - .fillMaxWidth() - .background(LocalPallet.current.warningColor), - text = stringResource(FME.string.fme_too_large_file), - color = LocalPallet.current.textOnWarningBackground - ) -} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/UploadingContent.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/UploadingContent.kt deleted file mode 100644 index 67b7067d1c..0000000000 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/content/UploadingContent.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.flipperdevices.filemanager.editor.composable.content - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import com.flipperdevices.core.ui.theme.LocalPalletV2 -import com.flipperdevices.filemanager.upload.api.UploaderDecomposeComponent - -@Composable -fun UploaderDecomposeComponent.RenderLoadingScreen(modifier: Modifier = Modifier) { - val uploaderState by state.collectAsState() - AnimatedVisibility( - visible = uploaderState is UploaderDecomposeComponent.State.Uploading, - enter = fadeIn(), - exit = fadeOut(), - modifier = modifier.fillMaxSize() - ) { - val speedState by speedState.collectAsState(null) - Render( - state = uploaderState, - speedState = speedState, - onCancelClick = ::onCancel, - modifier = Modifier - .fillMaxSize() - .background(LocalPalletV2.current.surface.backgroundMain.body) - .navigationBarsPadding() - .systemBarsPadding(), - ) - } -} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/dialog/CreateFileDialogComposable.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/dialog/CreateFileDialogComposable.kt index 76fe44735e..2cc1773a71 100644 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/dialog/CreateFileDialogComposable.kt +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/dialog/CreateFileDialogComposable.kt @@ -3,46 +3,40 @@ package com.flipperdevices.filemanager.editor.composable.dialog import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import com.flipperdevices.filemanager.editor.viewmodel.FileNameViewModel +import com.flipperdevices.filemanager.editor.viewmodel.EditFileNameViewModel import com.flipperdevices.filemanager.ui.components.name.NameDialog +import com.flipperdevices.filemanager.util.constant.FileManagerConstants import flipperapp.components.filemngr.editor.impl.generated.resources.fme_save_as_dialog_button import flipperapp.components.filemngr.editor.impl.generated.resources.fme_save_as_dialog_chars import flipperapp.components.filemngr.editor.impl.generated.resources.fme_save_as_dialog_title import org.jetbrains.compose.resources.stringResource import flipperapp.components.filemngr.editor.impl.generated.resources.Res as FME -private const val AVAILABLE_CHARACTERS = "“0-9”, “A-Z”, “a-z”, “!#\\\$%&'()-@^_`{}~”" - @Composable fun CreateFileDialogComposable( - fileNameViewModel: FileNameViewModel, - onFinish: (String) -> Unit + editFileNameViewModel: EditFileNameViewModel, + onFinish: (String) -> Unit, + onDismiss: () -> Unit ) { - val state by fileNameViewModel.state.collectAsState() - when (val localState = state) { - is FileNameViewModel.State.Editing -> { - NameDialog( - value = localState.name, - title = stringResource(FME.string.fme_save_as_dialog_title), - buttonText = stringResource(FME.string.fme_save_as_dialog_button), - subtitle = stringResource( - resource = FME.string.fme_save_as_dialog_chars, - AVAILABLE_CHARACTERS - ), - onFinish = { - onFinish(localState.name) - fileNameViewModel.dismiss() - }, - isError = !localState.isValid, - isEnabled = true, - needShowOptions = localState.needShowOptions, - onTextChange = fileNameViewModel::onChange, - onDismissRequest = fileNameViewModel::dismiss, - onOptionSelect = fileNameViewModel::onOptionSelected, - options = localState.options - ) - } - - FileNameViewModel.State.Pending -> Unit - } + val state by editFileNameViewModel.state.collectAsState() + NameDialog( + value = state.name, + title = stringResource(FME.string.fme_save_as_dialog_title), + buttonText = stringResource(FME.string.fme_save_as_dialog_button), + subtitle = stringResource( + resource = FME.string.fme_save_as_dialog_chars, + FileManagerConstants.FILE_NAME_AVAILABLE_CHARACTERS + ), + onFinish = { + onFinish(state.name) + }, + isError = !state.isValid, + isEnabled = true, + needShowOptions = state.needShowOptions, + onTextChange = editFileNameViewModel::onChange, + onDismissRequest = onDismiss, + onOptionSelect = editFileNameViewModel::onOptionSelected, + options = state.options, + isLoading = false + ) } diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/download/UploadingComposable.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/download/UploadingComposable.kt new file mode 100644 index 0000000000..bbc2c4baa2 --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/composable/download/UploadingComposable.kt @@ -0,0 +1,41 @@ +package com.flipperdevices.filemanager.editor.composable.download + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipperdevices.core.ktx.jre.toFormattedSize +import com.flipperdevices.filemanager.ui.components.transfer.FileTransferFullScreenComposable +import flipperapp.components.filemngr.editor.impl.generated.resources.fme_cancel +import flipperapp.components.filemngr.editor.impl.generated.resources.fme_status_speed +import okio.Path +import org.jetbrains.compose.resources.stringResource +import flipperapp.components.filemngr.editor.impl.generated.resources.Res as FME + +@Composable +fun UploadingComposable( + title: String, + progress: Float, + fullPathOnFlipper: Path, + current: Long, + max: Long, + speed: Long, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + FileTransferFullScreenComposable( + modifier = modifier, + title = title, + actionText = stringResource(FME.string.fme_cancel), + onActionClick = onCancel, + progressText = fullPathOnFlipper.name, + progress = progress, + progressDetailText = if (max == 0L) null else "${current.toFormattedSize()}/${max.toFormattedSize()}", + progressTitle = fullPathOnFlipper.name, + speedText = when (speed) { + 0L -> null + else -> stringResource( + resource = FME.string.fme_status_speed, + speed.toFormattedSize() + ) + } + ) +} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/model/FileManagerEditorConfiguration.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/model/FileManagerEditorConfiguration.kt new file mode 100644 index 0000000000..5b5db4bb75 --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/model/FileManagerEditorConfiguration.kt @@ -0,0 +1,30 @@ +package com.flipperdevices.filemanager.editor.model + +import com.flipperdevices.filemanager.util.serialization.PathSerializer +import kotlinx.serialization.Serializable +import okio.Path + +@Serializable +sealed interface FileManagerEditorConfiguration { + @Serializable + data class Download( + @Serializable(PathSerializer::class) + val fullPathOnFlipper: Path + ) : FileManagerEditorConfiguration + + @Serializable + data class Editor( + @Serializable(PathSerializer::class) + val fullPathOnFlipper: Path, + @Serializable(PathSerializer::class) + val tempPathOnDevice: Path + ) : FileManagerEditorConfiguration + + @Serializable + data class Upload( + @Serializable(PathSerializer::class) + val fullPathOnFlipper: Path, + @Serializable(PathSerializer::class) + val tempPathOnDevice: Path + ) : FileManagerEditorConfiguration +} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/DownloadViewModel.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/DownloadViewModel.kt new file mode 100644 index 0000000000..95ed082aad --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/DownloadViewModel.kt @@ -0,0 +1,134 @@ +package com.flipperdevices.filemanager.editor.viewmodel + +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureProvider +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureStatus +import com.flipperdevices.bridge.connection.feature.provider.api.get +import com.flipperdevices.bridge.connection.feature.serialspeed.api.FSpeedFeatureApi +import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi +import com.flipperdevices.core.FlipperStorageProvider +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.error +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import com.flipperdevices.filemanager.util.constant.FileManagerConstants +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import okio.Path + +class DownloadViewModel @AssistedInject constructor( + @Assisted("fullPathOnFlipper") private val fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") private val fullPathOnDevice: Path, + private val featureProvider: FFeatureProvider, + private val storageProvider: FlipperStorageProvider +) : DecomposeViewModel(), LogTagProvider { + override val TAG = "DownloadViewModel" + private val _state = MutableStateFlow( + State.Downloading( + downloaded = 0, + total = 0, + fullPathOnFlipper = fullPathOnFlipper + ) + ) + val state = _state.asStateFlow() + + val speedState = featureProvider.get() + .stateIn(viewModelScope, SharingStarted.Eagerly, FFeatureStatus.Retrieving) + .flatMapLatest { + (it as? FFeatureStatus.Supported) + ?.featureApi + ?.getSpeed() + ?.map { speedState -> speedState.receiveBytesInSec } + ?: flowOf(0L) + }.stateIn(viewModelScope, SharingStarted.Eagerly, 0L) + + private fun startFeature() { + featureProvider.get() + .onEach { status -> + when (status) { + FFeatureStatus.NotFound -> _state.emit(State.Unsupported) + FFeatureStatus.Retrieving -> _state.emit( + State.Downloading( + downloaded = 0, + total = 0, + fullPathOnFlipper = fullPathOnFlipper + ) + ) + + is FFeatureStatus.Supported -> { + status.featureApi.downloadApi().download( + pathOnFlipper = fullPathOnFlipper.toString(), + fileOnAndroid = fullPathOnDevice, + progressListener = { current, max -> + _state.emit( + State.Downloading( + downloaded = current, + total = max, + fullPathOnFlipper = fullPathOnFlipper + ) + ) + } + ).onFailure { throwable -> + error(throwable) { "Failed to download file $fullPathOnFlipper" } + _state.emit(State.CouldNotDownload) + }.onSuccess { + val metaSize = + storageProvider.fileSystem.metadataOrNull(fullPathOnDevice)?.size + val isTooLarge = if (metaSize != null) { + metaSize > FileManagerConstants.LIMITED_SIZE_BYTES + } else { + false + } + if (isTooLarge) { + _state.emit(State.TooLarge) + } else { + _state.emit(State.Downloaded) + } + } + } + + FFeatureStatus.Unsupported -> _state.emit(State.Unsupported) + } + } + .launchIn(viewModelScope) + } + + init { + startFeature() + } + + sealed interface State { + data object Unsupported : State + data object CouldNotDownload : State + data class Downloading( + val downloaded: Long, + val total: Long, + val fullPathOnFlipper: Path + ) : State { + val progress: Float = when (total) { + 0L -> 0f + else -> (downloaded / total.toFloat()).coerceIn(0f, 1f) + } + } + + data object TooLarge : State + + data object Downloaded : State + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + ): DownloadViewModel + } +} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/EditFileNameViewModel.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/EditFileNameViewModel.kt new file mode 100644 index 0000000000..fa153f903b --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/EditFileNameViewModel.kt @@ -0,0 +1,65 @@ +package com.flipperdevices.filemanager.editor.viewmodel + +import com.flipperdevices.core.ktx.jre.FlipperFileNameValidator +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import com.flipperdevices.filemanager.util.constant.FileManagerConstants +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import okio.Path + +class EditFileNameViewModel @AssistedInject constructor( + @Assisted fullPathOnFlipper: Path +) : DecomposeViewModel() { + private val fileNameValidator = FlipperFileNameValidator() + + private val _state = MutableStateFlow(State(fullPathOnFlipper.name)) + val state = _state.asStateFlow() + + fun onChange(text: String) { + _state.update { state -> + state.copy(name = text) + } + } + + fun onOptionSelected(index: Int) { + _state.update { state -> + val option = state.options + .getOrNull(index) + ?: return@update state + state.copy(name = "${state.name}.$option") + } + } + + init { + state + .distinctUntilChangedBy { state -> state.name } + .onEach { state -> + _state.emit(state.copy(isValid = fileNameValidator.isValid(state.name))) + }.launchIn(viewModelScope) + } + + data class State( + val name: String = "", + val isValid: Boolean = false, + ) { + val options = FileManagerConstants.FILE_EXTENSION_HINTS.toImmutableList() + + val needShowOptions + get() = !name.contains(".") && options.isNotEmpty() + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + fullPathOnFlipper: Path, + ): EditFileNameViewModel + } +} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/EditorViewModel.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/EditorViewModel.kt index d286f7c274..04627241eb 100644 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/EditorViewModel.kt +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/EditorViewModel.kt @@ -1,46 +1,45 @@ package com.flipperdevices.filemanager.editor.viewmodel -import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureProvider -import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureStatus -import com.flipperdevices.bridge.connection.feature.provider.api.get -import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi +import com.flipperdevices.core.FlipperStorageProvider import com.flipperdevices.core.ktx.jre.limit -import com.flipperdevices.core.ktx.jre.withLock -import com.flipperdevices.core.log.LogTagProvider import com.flipperdevices.core.log.error import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel import com.flipperdevices.filemanager.editor.model.EditorEncodingEnum import com.flipperdevices.filemanager.editor.model.HexString import com.flipperdevices.filemanager.editor.util.HexConverter +import com.flipperdevices.filemanager.util.constant.FileManagerConstants import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.launch +import okio.Buffer import okio.Path import okio.buffer import okio.use class EditorViewModel @AssistedInject constructor( - @Assisted private val path: Path, - private val featureProvider: FFeatureProvider, - private val storageProvider: com.flipperdevices.core.FlipperStorageProvider -) : DecomposeViewModel(), LogTagProvider { - override val TAG = "EditorViewModel" - - private val _state = MutableStateFlow(State.Preparing) + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") private val fullPathOnDevice: Path, + private val storageProvider: FlipperStorageProvider +) : DecomposeViewModel() { + + private val _state = MutableStateFlow( + State( + fullPathOnFlipper = fullPathOnFlipper, + hexString = HexString.Text("") + ) + ) val state = _state.asStateFlow() - private val mutex = Mutex() - - private val editorFile by lazy { storageProvider.getTemporaryFile() } + fun onFlipperPathChanged(fullPathOnFlipper: Path) { + _state.update { state -> state.copy(fullPathOnFlipper = fullPathOnFlipper) } + } fun getRawContent(): ByteArray? { - val hexString = (state.value as? EditorViewModel.State.Loaded)?.hexString ?: return null + val hexString = state.value.hexString return kotlin.runCatching { HexConverter.fromHexString(hexString).content.toByteArray() } .onFailure { error(it) { "#onEditorTypeChange could not transform hex" } } .getOrNull() @@ -48,138 +47,78 @@ class EditorViewModel @AssistedInject constructor( fun onTextChanged(text: String) { _state.update { state -> - (state as? State.Loaded)?.let { loadedState -> - when (loadedState.hexString) { - is HexString.Hex -> loadedState.copy(hexString = HexString.Hex(text)) - is HexString.Text -> loadedState.copy(hexString = HexString.Text(text)) - } - } ?: state - } - } - - private suspend fun onFileDownloaded() { - val content = storageProvider - .fileSystem - .source(editorFile) - .limit(LIMITED_SIZE_BYTES) - .buffer() - .use { bufferedSource -> bufferedSource.readUtf8() } - val metaSize = storageProvider.fileSystem.metadataOrNull(editorFile)?.size - val isTooLarge = if (metaSize != null) { - metaSize > LIMITED_SIZE_BYTES - } else { - false - } - _state.emit( - State.Loaded( - path = path, - hexString = HexString.Text(content), - isTooLarge = isTooLarge - ) - ) - } - - private suspend fun loadFileSafe(storageFeatureApi: FStorageFeatureApi) { - _state.emit(State.Preparing) - storageFeatureApi.downloadApi().download( - pathOnFlipper = path.toString(), - fileOnAndroid = editorFile, - progressListener = { current, max -> - _state.emit( - State.Loading( - downloaded = current, - total = max - ) - ) + when (state.hexString) { + is HexString.Hex -> state.copy(hexString = HexString.Hex(text)) + is HexString.Text -> state.copy(hexString = HexString.Text(text)) } - ).onFailure { exception -> - error(exception) { "Failed download $path" } - _state.emit(State.Error) - }.onSuccess { - onFileDownloaded() - } - } - - private suspend fun onFeatureStateChanged( - featureStatus: FFeatureStatus - ) = withLock(mutex, "feature") { - when (featureStatus) { - FFeatureStatus.NotFound, - FFeatureStatus.Unsupported -> _state.emit(State.Error) - - FFeatureStatus.Retrieving -> _state.emit(State.Preparing) - - is FFeatureStatus.Supported -> loadFileSafe(featureStatus.featureApi) } } - init { - featureProvider.get() - .onEach { onFeatureStateChanged(it) } - .launchIn(viewModelScope) - } - - override fun onDestroy() { - super.onDestroy() - storageProvider.fileSystem.delete(editorFile) - } - fun onEditorTypeChange(type: EditorEncodingEnum) { + _state.update { state -> state.copy(canEdit = false) } _state.update { state -> - val loaded = (state as? State.Loaded) ?: return@update state - if (loaded.encoding == type) return@update state + if (state.encoding == type) return@update state // We can't always transform hex into string // As example, user can write letters beyond 0..F val hexStringResult = kotlin.runCatching { when (type) { EditorEncodingEnum.TEXT -> { - HexConverter.fromHexString(loaded.hexString) + HexConverter.fromHexString(state.hexString) } EditorEncodingEnum.HEX -> { - HexConverter.toHexString(loaded.hexString) + HexConverter.toHexString(state.hexString) } } }.onFailure { error(it) { "#onEditorTypeChange could not transform hex" } } - loaded.copy( - encoding = type - .takeIf { hexStringResult.isSuccess } - ?: loaded.encoding, - hexString = hexStringResult.getOrElse { loaded.hexString } + state.copy( + hexString = hexStringResult.getOrElse { state.hexString }, + canEdit = true ) } } - @AssistedFactory - fun interface Factory { - operator fun invoke(path: Path): EditorViewModel + fun writeNow() { + val byteArray = getRawContent() ?: return + val buffer = Buffer() + buffer.write(byteArray) + storageProvider + .fileSystem + .sink(fullPathOnDevice) + .write(buffer, byteArray.size.toLong()) } - sealed interface State { - data object Preparing : State - - data class Loading( - val downloaded: Long, - val total: Long - ) : State - - data class Saving( - val uploaded: Long, - val total: Long - ) : State + private fun loadText() { + val content = storageProvider + .fileSystem + .source(fullPathOnDevice) + .limit(FileManagerConstants.LIMITED_SIZE_BYTES) + .buffer() + .use { bufferedSource -> bufferedSource.readUtf8() } + _state.update { state -> state.copy(hexString = HexString.Text(content)) } + } - data class Loaded( - val path: Path, - val hexString: HexString, - val isTooLarge: Boolean, - val encoding: EditorEncodingEnum = EditorEncodingEnum.TEXT - ) : State + init { + viewModelScope.launch { loadText() } + } - data object Error : State + data class State( + val fullPathOnFlipper: Path, + val hexString: HexString, + val canEdit: Boolean = true + ) { + val encoding: EditorEncodingEnum = when (hexString) { + is HexString.Hex -> EditorEncodingEnum.HEX + is HexString.Text -> EditorEncodingEnum.TEXT + } } - companion object { - private const val LIMITED_SIZE_BYTES = 1024L * 1024L // 1MB + @AssistedFactory + fun interface Factory { + operator fun invoke( + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + ): EditorViewModel } } diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/FileNameViewModel.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/FileNameViewModel.kt deleted file mode 100644 index 9e0ae9fb98..0000000000 --- a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/FileNameViewModel.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.flipperdevices.filemanager.editor.viewmodel - -import com.flipperdevices.bridge.dao.api.model.FlipperKeyType -import com.flipperdevices.core.ktx.jre.FlipperFileNameValidator -import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import javax.inject.Inject - -class FileNameViewModel @Inject constructor() : DecomposeViewModel() { - - private val fileNameValidator = FlipperFileNameValidator() - - private val _state = MutableStateFlow(State.Pending) - val state = _state.asStateFlow() - - fun onChange(text: String) { - _state.update { state -> - (state as? State.Editing) - ?.copy(name = text) - ?: state - } - } - - fun dismiss() { - _state.update { State.Pending } - } - - fun onOptionSelected(index: Int) { - _state.update { state -> - (state as? State.Editing)?.let { editingState -> - val option = editingState.options - .getOrNull(index) - ?: return@let editingState - editingState.copy(name = option) - } ?: state - } - } - - fun show() { - _state.update { State.Editing() } - } - - init { - state - .filterIsInstance() - .distinctUntilChangedBy { state -> state.name } - .onEach { state -> - _state.emit(state.copy(isValid = fileNameValidator.isValid(state.name))) - }.launchIn(viewModelScope) - } - - sealed interface State { - data object Pending : State - data class Editing( - val name: String = "", - val isValid: Boolean = false - ) : State { - val options = listOf("txt") - .plus(FlipperKeyType.entries.map { it.extension }) - .map { extension -> "$name.$extension" } - .toImmutableList() - - val needShowOptions - get() = !name.contains(".") && options.isNotEmpty() - } - } -} diff --git a/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/UploadFileViewModel.kt b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/UploadFileViewModel.kt new file mode 100644 index 0000000000..4ec229ce84 --- /dev/null +++ b/components/filemngr/editor/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/editor/viewmodel/UploadFileViewModel.kt @@ -0,0 +1,132 @@ +package com.flipperdevices.filemanager.editor.viewmodel + +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureProvider +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureStatus +import com.flipperdevices.bridge.connection.feature.provider.api.get +import com.flipperdevices.bridge.connection.feature.serialspeed.api.FSpeedFeatureApi +import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.error +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import okio.Path + +class UploadFileViewModel @AssistedInject constructor( + @Assisted("fullPathOnFlipper") private val fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") private val fullPathOnDevice: Path, + private val featureProvider: FFeatureProvider, +) : DecomposeViewModel(), LogTagProvider { + override val TAG: String = "UploadViewModel" + + private val _state = MutableStateFlow( + State.Uploading( + fullPathOnFlipper = fullPathOnFlipper, + uploaded = 0L, + total = 0L + ) + ) + val state = _state.asStateFlow() + + val speedState = featureProvider.get() + .stateIn(viewModelScope, SharingStarted.Eagerly, FFeatureStatus.Retrieving) + .flatMapLatest { + (it as? FFeatureStatus.Supported) + ?.featureApi + ?.getSpeed() + ?.map { speedState -> speedState.receiveBytesInSec } + ?: flowOf(0L) + }.stateIn(viewModelScope, SharingStarted.Eagerly, 0L) + + private fun startFeature() { + featureProvider.get() + .onEach { status -> + when (status) { + FFeatureStatus.NotFound -> _state.emit(State.Unsupported) + FFeatureStatus.Retrieving -> { + _state.emit( + State.Uploading( + fullPathOnFlipper = fullPathOnFlipper, + uploaded = 0L, + total = 0L + ) + ) + } + + is FFeatureStatus.Supported -> { + status.featureApi.uploadApi() + .upload( + pathOnFlipper = fullPathOnFlipper.toString(), + fileOnAndroid = fullPathOnDevice, + progressListener = { current, max -> + _state.update { + State.Uploading( + fullPathOnFlipper = fullPathOnFlipper, + uploaded = current, + total = max + ) + } + } + ).onFailure { + error(it) { "#startFeature could not save file $fullPathOnFlipper" } + _state.emit(State.Error) + }.onSuccess { + _state.update { state -> + (state as? State.Uploading)?.let { uploadingState -> + State.Saved( + fullPathOnFlipper = uploadingState.fullPathOnFlipper, + size = uploadingState.total + ) + } ?: state + } + } + } + + FFeatureStatus.Unsupported -> _state.emit(State.Unsupported) + } + }.launchIn(viewModelScope) + } + + init { + startFeature() + } + + sealed interface State { + data object Unsupported : State + data object Error : State + data class Uploading( + val fullPathOnFlipper: Path, + val uploaded: Long, + val total: Long + ) : State { + val progress: Float = when (total) { + 0L -> 0f + else -> (uploaded / total.toFloat()).coerceIn(0f, 1f) + } + } + + data class Saved( + val fullPathOnFlipper: Path, + val size: Long + ) : State + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + @Assisted("fullPathOnFlipper") fullPathOnFlipper: Path, + @Assisted("fullPathOnDevice") fullPathOnDevice: Path, + ): UploadFileViewModel + } +} diff --git a/components/filemngr/listing/api/build.gradle.kts b/components/filemngr/listing/api/build.gradle.kts index 650fa2e89a..0c6d3a8eef 100644 --- a/components/filemngr/listing/api/build.gradle.kts +++ b/components/filemngr/listing/api/build.gradle.kts @@ -7,6 +7,7 @@ android.namespace = "com.flipperdevices.filemanager.listing.api" commonDependencies { implementation(projects.components.core.ui.decompose) + implementation(projects.components.bridge.connection.feature.storage.api) implementation(libs.compose.ui) implementation(libs.decompose) diff --git a/components/filemngr/listing/api/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/api/FilesDecomposeComponent.kt b/components/filemngr/listing/api/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/api/FilesDecomposeComponent.kt index d4aa460065..671d8d6736 100644 --- a/components/filemngr/listing/api/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/api/FilesDecomposeComponent.kt +++ b/components/filemngr/listing/api/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/api/FilesDecomposeComponent.kt @@ -1,6 +1,7 @@ package com.flipperdevices.filemanager.listing.api import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem import com.flipperdevices.ui.decompose.DecomposeOnBackParameter import com.flipperdevices.ui.decompose.ScreenDecomposeComponent import okio.Path @@ -8,6 +9,8 @@ import okio.Path abstract class FilesDecomposeComponent( componentContext: ComponentContext ) : ScreenDecomposeComponent(componentContext) { + abstract fun onFileChanged(listingItem: ListingItem) + fun interface Factory { @Suppress("LongParameterList") operator fun invoke( @@ -16,6 +19,7 @@ abstract class FilesDecomposeComponent( path: Path, pathChangedCallback: PathChangedCallback, fileSelectedCallback: FileSelectedCallback, + moveToCallback: MoveToCallback, searchCallback: SearchCallback, ): FilesDecomposeComponent } @@ -35,4 +39,7 @@ abstract class FilesDecomposeComponent( fun interface UploadCallback { fun invoke() } + fun interface MoveToCallback { + fun invoke(fullPaths: List) + } } diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/model/ExtendedListingItem.kt b/components/filemngr/listing/api/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/api/model/ExtendedListingItem.kt similarity index 95% rename from components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/model/ExtendedListingItem.kt rename to components/filemngr/listing/api/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/api/model/ExtendedListingItem.kt index 1f3808e03e..b81e44c764 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/model/ExtendedListingItem.kt +++ b/components/filemngr/listing/api/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/api/model/ExtendedListingItem.kt @@ -1,4 +1,4 @@ -package com.flipperdevices.filemanager.listing.impl.model +package com.flipperdevices.filemanager.listing.api.model import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem diff --git a/components/filemngr/listing/impl/build.gradle.kts b/components/filemngr/listing/impl/build.gradle.kts index 57d367d198..1cf261510d 100644 --- a/components/filemngr/listing/impl/build.gradle.kts +++ b/components/filemngr/listing/impl/build.gradle.kts @@ -33,6 +33,9 @@ commonDependencies { implementation(projects.components.filemngr.main.api) implementation(projects.components.filemngr.upload.api) implementation(projects.components.filemngr.download.api) + implementation(projects.components.filemngr.rename.api) + implementation(projects.components.filemngr.create.api) + implementation(projects.components.filemngr.util) // Compose implementation(libs.compose.ui) diff --git a/components/filemngr/listing/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/BottomBarOptionsPreview.kt b/components/filemngr/listing/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/BottomBarOptionsPreview.kt index 3985e31d3f..16beb0a1bd 100644 --- a/components/filemngr/listing/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/BottomBarOptionsPreview.kt +++ b/components/filemngr/listing/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/BottomBarOptionsPreview.kt @@ -14,7 +14,10 @@ private fun BottomBarOptionsPreview() { onRename = {}, onCopyTo = {}, onMove = {}, - canRename = true + canRename = true, + canMove = true, + canDelete = true, + canExport = true ) } } diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/api/FilesDecomposeComponentImpl.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/api/FilesDecomposeComponentImpl.kt index 273a08e4a9..bac349b64f 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/api/FilesDecomposeComponentImpl.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/api/FilesDecomposeComponentImpl.kt @@ -1,6 +1,7 @@ package com.flipperdevices.filemanager.listing.impl.api import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.childContext import com.arkivanov.decompose.router.slot.ChildSlot @@ -10,20 +11,23 @@ import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.backhandler.BackCallback import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem import com.flipperdevices.core.di.AppGraph import com.flipperdevices.core.ui.lifecycle.viewModelWithFactory +import com.flipperdevices.filemanager.create.api.CreateFileDecomposeComponent import com.flipperdevices.filemanager.download.api.DownloadDecomposeComponent +import com.flipperdevices.filemanager.download.model.DownloadableFile import com.flipperdevices.filemanager.listing.api.FilesDecomposeComponent import com.flipperdevices.filemanager.listing.impl.composable.ComposableFileListScreen import com.flipperdevices.filemanager.listing.impl.composable.LaunchedEventsComposable import com.flipperdevices.filemanager.listing.impl.composable.modal.FileOptionsBottomSheet import com.flipperdevices.filemanager.listing.impl.model.PathWithType import com.flipperdevices.filemanager.listing.impl.viewmodel.DeleteFilesViewModel -import com.flipperdevices.filemanager.listing.impl.viewmodel.EditFileViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.FilesViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.OptionsViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.SelectionViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.StorageInfoViewModel +import com.flipperdevices.filemanager.rename.api.RenameDecomposeComponent import com.flipperdevices.filemanager.upload.api.UploadDecomposeComponent import com.flipperdevices.ui.decompose.DecomposeOnBackParameter import dagger.assisted.Assisted @@ -41,17 +45,20 @@ class FilesDecomposeComponentImpl @AssistedInject constructor( @Assisted private val pathChangedCallback: PathChangedCallback, @Assisted private val fileSelectedCallback: FileSelectedCallback, @Assisted private val searchCallback: SearchCallback, + @Assisted private val moveToCallback: MoveToCallback, private val storageInfoViewModelFactory: Provider, private val optionsInfoViewModelFactory: Provider, - private val editFileViewModelFactory: Provider, private val deleteFilesViewModelFactory: Provider, private val filesViewModelFactory: FilesViewModel.Factory, private val downloadDecomposeComponentFactory: DownloadDecomposeComponent.Factory, private val createSelectionViewModel: Provider, private val uploadDecomposeComponentFactory: UploadDecomposeComponent.Factory, + private val renameDecomposeComponentFactory: RenameDecomposeComponent.Factory, + private val createFileDecomposeComponentFactory: CreateFileDecomposeComponent.Factory, ) : FilesDecomposeComponent(componentContext) { private val slotNavigation = SlotNavigation() + val fileOptionsSlot: Value> = childSlot( source = slotNavigation, handleBackButton = true, @@ -79,6 +86,24 @@ class FilesDecomposeComponentImpl @AssistedInject constructor( ) } + private val renameDecomposeComponent by lazy { + renameDecomposeComponentFactory.invoke( + componentContext = childContext("FilesDecomposeComponent_renameDecomposeComponent"), + renamedCallback = { oldFullPath, newFullPath -> + filesViewModel.fileRenamed(oldFullPath, newFullPath) + } + ) + } + + private val createDecomposeComponent by lazy { + createFileDecomposeComponentFactory.invoke( + componentContext = childContext("FilesDecomposeComponent_createDecomposeComponent"), + createCallback = { item -> + filesViewModel.onFilesChanged(listOf(item)) + } + ) + } + private val backCallback = BackCallback { val parent = path.parent when { @@ -104,6 +129,11 @@ class FilesDecomposeComponentImpl @AssistedInject constructor( backHandler.register(backCallback) } + override fun onFileChanged(listingItem: ListingItem) { + filesViewModel.onFilesChanged(listOf(listingItem)) + } + + @Suppress("LongMethod") @Composable override fun Render() { val multipleFilesPicker = uploadDecomposeComponent.rememberMultipleFilesPicker(path) @@ -113,21 +143,18 @@ class FilesDecomposeComponentImpl @AssistedInject constructor( val optionsViewModel = viewModelWithFactory(path.root.toString()) { optionsInfoViewModelFactory.get() } - val createFileViewModel = viewModelWithFactory(path.root.toString()) { - editFileViewModelFactory.get() - } val deleteFileViewModel = viewModelWithFactory(path.toString()) { deleteFilesViewModelFactory.get() } LaunchedEventsComposable( - editFileViewModel = createFileViewModel, deleteFilesViewModel = deleteFileViewModel, - onFileRemove = filesViewModel::fileDeleted, - onFileListChange = filesViewModel::tryListFiles + onFileDelete = { path -> + selectionViewModel.deselect(path) + filesViewModel.fileDeleted(path) + }, ) ComposableFileListScreen( path = path, - editFileViewModel = createFileViewModel, deleteFileViewModel = deleteFileViewModel, filesViewModel = filesViewModel, optionsViewModel = optionsViewModel, @@ -138,17 +165,51 @@ class FilesDecomposeComponentImpl @AssistedInject constructor( onPathChange = pathChangedCallback::invoke, onFileMoreClick = slotNavigation::activate, onSearchClick = searchCallback::invoke, - onEditFileClick = fileSelectedCallback::invoke + onEditFileClick = fileSelectedCallback::invoke, + onRename = { pathWithType -> + renameDecomposeComponent.startRename(pathWithType.fullPath, pathWithType.fileType) + }, + canCreateFiles = createDecomposeComponent.canCreateFiles + .collectAsState() + .value, + onCreate = { type -> + createDecomposeComponent.startCreate(path, type) + }, + onMove = { pathsWithType -> + moveToCallback.invoke(pathsWithType.map(PathWithType::fullPath)) + }, + onExport = { pathsWithTypes -> + pathsWithTypes.firstOrNull()?.let { pathWithType -> + DownloadableFile( + fullPath = pathWithType.fullPath, + size = pathWithType.size + ) + }?.run(downloadDecomposeComponent::download) + } ) FileOptionsBottomSheet( fileOptionsSlot = fileOptionsSlot, slotNavigation = slotNavigation, selectionViewModel = selectionViewModel, - createFileViewModel = createFileViewModel, deleteFileViewModel = deleteFileViewModel, - onDownloadFile = downloadDecomposeComponent::download + onDownloadFile = { pathWithType -> + downloadDecomposeComponent.download( + file = DownloadableFile( + fullPath = pathWithType.fullPath, + size = pathWithType.size + ) + ) + }, + onRename = { pathWithType -> + renameDecomposeComponent.startRename(pathWithType.fullPath, pathWithType.fileType) + }, + onMoveTo = { pathWithType -> + moveToCallback.invoke(listOf(pathWithType.fullPath)) + } ) uploadDecomposeComponent.Render() downloadDecomposeComponent.Render() + renameDecomposeComponent.Render() + createDecomposeComponent.Render() } } diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/ComposableFileListScreen.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/ComposableFileListScreen.kt index 7a51faf2ae..aac4bccfdd 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/ComposableFileListScreen.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/ComposableFileListScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable @@ -12,25 +13,26 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType import com.flipperdevices.core.preference.pb.FileManagerOrientation import com.flipperdevices.filemanager.listing.impl.composable.appbar.FileListAppBar -import com.flipperdevices.filemanager.listing.impl.composable.dialog.CreateFileDialogComposable import com.flipperdevices.filemanager.listing.impl.composable.dialog.DeleteFileDialog import com.flipperdevices.filemanager.listing.impl.composable.options.FullScreenBottomBarOptions import com.flipperdevices.filemanager.listing.impl.model.PathWithType import com.flipperdevices.filemanager.listing.impl.viewmodel.DeleteFilesViewModel -import com.flipperdevices.filemanager.listing.impl.viewmodel.EditFileViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.FilesViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.OptionsViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.SelectionViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.StorageInfoViewModel +import com.flipperdevices.filemanager.ui.components.error.UnknownErrorComposable +import com.flipperdevices.filemanager.ui.components.error.UnsupportedErrorComposable import okio.Path @Suppress("LongMethod") @Composable fun ComposableFileListScreen( path: Path, - editFileViewModel: EditFileViewModel, + canCreateFiles: Boolean, deleteFileViewModel: DeleteFilesViewModel, filesViewModel: FilesViewModel, optionsViewModel: OptionsViewModel, @@ -42,9 +44,12 @@ fun ComposableFileListScreen( onPathChange: (Path) -> Unit, onEditFileClick: (Path) -> Unit, onFileMoreClick: (PathWithType) -> Unit, + onCreate: (FileType) -> Unit, + onRename: (PathWithType) -> Unit, + onMove: (List) -> Unit, + onExport: (List) -> Unit, modifier: Modifier = Modifier ) { - val canCreateFiles by editFileViewModel.canCreateFiles.collectAsState() val canDeleteFiles by deleteFileViewModel.canDeleteFiles.collectAsState() val filesListState by filesViewModel.state.collectAsState() val optionsState by optionsViewModel.state.collectAsState() @@ -60,17 +65,14 @@ fun ComposableFileListScreen( filesListState = filesListState, optionsState = optionsState, optionsViewModel = optionsViewModel, - canCreateFiles = canCreateFiles, onUploadClick = onUploadClick, - editFileViewModel = editFileViewModel, onBack = onBack, - onSearchClick = onSearchClick + onSearchClick = onSearchClick, + onCreate = onCreate, + canCreateFiles = canCreateFiles ) } ) { contentPadding -> - CreateFileDialogComposable( - editFileViewModel = editFileViewModel, - ) DeleteFileDialog( deleteFileState = deleteFileState, deleteFileViewModel = deleteFileViewModel @@ -114,10 +116,16 @@ fun ComposableFileListScreen( } FilesViewModel.State.Unsupported -> { - item { NoListingFeatureComposable() } + item(span = { GridItemSpan(maxLineSpan) }) { + UnsupportedErrorComposable() + } } - FilesViewModel.State.CouldNotListPath -> Unit + FilesViewModel.State.CouldNotListPath -> { + item(span = { GridItemSpan(maxLineSpan) }) { + UnknownErrorComposable() + } + } } } FilesFailComposable( @@ -131,8 +139,10 @@ fun ComposableFileListScreen( selectionState = selectionState, filesListState = filesListState, selectionViewModel = selectionViewModel, - editFileViewModel = editFileViewModel, deleteFileViewModel = deleteFileViewModel, + onRename = onRename, + onMove = onMove, + onExport = onExport ) } } diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LaunchedEventsComposable.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LaunchedEventsComposable.kt index 162909995f..d3ee334515 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LaunchedEventsComposable.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LaunchedEventsComposable.kt @@ -3,30 +3,20 @@ package com.flipperdevices.filemanager.listing.impl.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import com.flipperdevices.filemanager.listing.impl.viewmodel.DeleteFilesViewModel -import com.flipperdevices.filemanager.listing.impl.viewmodel.EditFileViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import okio.Path @Composable fun LaunchedEventsComposable( - editFileViewModel: EditFileViewModel, deleteFilesViewModel: DeleteFilesViewModel, - onFileListChange: () -> Unit, - onFileRemove: (Path) -> Unit + onFileDelete: (Path) -> Unit ) { - LaunchedEffect(editFileViewModel, deleteFilesViewModel) { - editFileViewModel.event.onEach { - when (it) { - EditFileViewModel.Event.FilesChanged -> { - onFileListChange.invoke() - } - } - }.launchIn(this) + LaunchedEffect(deleteFilesViewModel) { deleteFilesViewModel.event.onEach { when (it) { DeleteFilesViewModel.Event.CouldNotDeleteSomeFiles -> Unit - is DeleteFilesViewModel.Event.FileDeleted -> onFileRemove.invoke(it.path) + is DeleteFilesViewModel.Event.FileDeleted -> onFileDelete.invoke(it.path) } }.launchIn(this) } diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LoadedFilesComposable.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LoadedFilesComposable.kt index b6fed028a6..771fd953c2 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LoadedFilesComposable.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/LoadedFilesComposable.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.Modifier import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType import com.flipperdevices.core.ktx.jre.toFormattedSize import com.flipperdevices.core.preference.pb.FileManagerOrientation -import com.flipperdevices.filemanager.listing.impl.model.ExtendedListingItem +import com.flipperdevices.filemanager.listing.api.model.ExtendedListingItem import com.flipperdevices.filemanager.listing.impl.model.PathWithType import com.flipperdevices.filemanager.listing.impl.viewmodel.DeleteFilesViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.FilesViewModel diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/appbar/FileListAppBar.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/appbar/FileListAppBar.kt index dc1ad58d5f..65ad1fbbcd 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/appbar/FileListAppBar.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/appbar/FileListAppBar.kt @@ -22,7 +22,6 @@ import com.flipperdevices.core.ui.ktx.clickableRipple import com.flipperdevices.core.ui.theme.LocalPalletV2 import com.flipperdevices.filemanager.listing.impl.composable.MoreIconComposable import com.flipperdevices.filemanager.listing.impl.model.PathWithType -import com.flipperdevices.filemanager.listing.impl.viewmodel.EditFileViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.FilesViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.OptionsViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.SelectionViewModel @@ -46,7 +45,7 @@ fun FileListAppBar( canCreateFiles: Boolean, onUploadClick: () -> Unit, onSearchClick: () -> Unit, - editFileViewModel: EditFileViewModel, + onCreate: (FileType) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier ) { @@ -101,10 +100,10 @@ fun FileListAppBar( onUploadClick = onUploadClick, onSelectClick = selectionViewModel::toggleMode, onCreateFolderClick = { - editFileViewModel.onCreate(path, FileType.DIR) + onCreate.invoke(FileType.DIR) }, onCreateFileClick = { - editFileViewModel.onCreate(path, FileType.FILE) + onCreate.invoke(FileType.FILE) } ) } diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/dialog/CreateFileDialogComposable.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/dialog/CreateFileDialogComposable.kt deleted file mode 100644 index 57af38ca59..0000000000 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/dialog/CreateFileDialogComposable.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.flipperdevices.filemanager.listing.impl.composable.dialog - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType -import com.flipperdevices.filemanager.listing.impl.viewmodel.EditFileViewModel -import com.flipperdevices.filemanager.ui.components.name.NameDialog -import flipperapp.components.filemngr.listing.impl.generated.resources.fml_create_file_allowed_chars -import flipperapp.components.filemngr.listing.impl.generated.resources.fml_create_file_file_btn -import flipperapp.components.filemngr.listing.impl.generated.resources.fml_create_file_folder_btn -import flipperapp.components.filemngr.listing.impl.generated.resources.fml_create_file_title -import org.jetbrains.compose.resources.stringResource -import flipperapp.components.filemngr.listing.impl.generated.resources.Res as FML - -private const val AVAILABLE_CHARACTERS = "“0-9”, “A-Z”, “a-z”, “!#\\\$%&'()-@^_`{}~”" - -@Composable -fun CreateFileDialogComposable( - editFileViewModel: EditFileViewModel, -) { - val createFileState by editFileViewModel.state.collectAsState() - - when (val localCreateFileState = createFileState) { - EditFileViewModel.State.Pending -> Unit - is EditFileViewModel.State.Edit -> { - NameDialog( - value = localCreateFileState.name, - title = stringResource(FML.string.fml_create_file_title), - buttonText = stringResource( - resource = when (localCreateFileState.itemType) { - FileType.FILE -> FML.string.fml_create_file_file_btn - FileType.DIR -> FML.string.fml_create_file_folder_btn - } - ), - subtitle = stringResource( - FML.string.fml_create_file_allowed_chars, - AVAILABLE_CHARACTERS - ), - onFinish = { editFileViewModel.onFinish() }, - isError = !localCreateFileState.isValid, - isEnabled = !localCreateFileState.isLoading, - needShowOptions = localCreateFileState.needShowOptions, - onTextChange = editFileViewModel::onNameChange, - onDismissRequest = editFileViewModel::dismiss, - onOptionSelect = editFileViewModel::onOptionSelected, - options = localCreateFileState.options - ) - } - } -} diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/modal/BottomSheetOptionsContent.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/modal/BottomSheetOptionsContent.kt index 79373ec86b..09396432b7 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/modal/BottomSheetOptionsContent.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/modal/BottomSheetOptionsContent.kt @@ -93,7 +93,7 @@ fun BottomSheetOptionsContent( text = stringResource(FML.string.fml_move_to), painter = painterResource(FR.drawable.ic_move), onClick = onMoveTo, - isEnabled = false + isEnabled = true ) HorizontalTextIconButton( modifier = Modifier.fillMaxWidth(), @@ -107,7 +107,7 @@ fun BottomSheetOptionsContent( text = stringResource(FML.string.fml_rename), painter = painterResource(FR.drawable.ic_edit), onClick = onRename, - isEnabled = fileType == FileType.FILE + isEnabled = true ) HorizontalTextIconButton( modifier = Modifier.fillMaxWidth(), diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/modal/FileOptionsBottomSheet.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/modal/FileOptionsBottomSheet.kt index f8034acb50..95d03667c2 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/modal/FileOptionsBottomSheet.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/modal/FileOptionsBottomSheet.kt @@ -9,18 +9,17 @@ import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.decompose.value.Value import com.flipperdevices.filemanager.listing.impl.model.PathWithType import com.flipperdevices.filemanager.listing.impl.viewmodel.DeleteFilesViewModel -import com.flipperdevices.filemanager.listing.impl.viewmodel.EditFileViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.SelectionViewModel -import okio.Path @Composable fun FileOptionsBottomSheet( - createFileViewModel: EditFileViewModel, fileOptionsSlot: Value>, slotNavigation: SlotNavigation, selectionViewModel: SelectionViewModel, deleteFileViewModel: DeleteFilesViewModel, - onDownloadFile: (Path, Long) -> Unit, + onDownloadFile: (PathWithType) -> Unit, + onRename: (PathWithType) -> Unit, + onMoveTo: (PathWithType) -> Unit, modifier: Modifier = Modifier ) { SlotModalBottomSheet( @@ -37,18 +36,21 @@ fun FileOptionsBottomSheet( slotNavigation.dismiss() }, onRename = { - createFileViewModel.onRename(pathWithType) + onRename.invoke(pathWithType) slotNavigation.dismiss() }, onExport = { - onDownloadFile.invoke(pathWithType.fullPath, pathWithType.size) + onDownloadFile.invoke(pathWithType) slotNavigation.dismiss() }, onDelete = { deleteFileViewModel.tryDelete(pathWithType.fullPath) slotNavigation.dismiss() }, - onMoveTo = {} // todo + onMoveTo = { + onMoveTo.invoke(pathWithType) + slotNavigation.dismiss() + } ) } ) diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/BottomBarOptions.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/BottomBarOptions.kt index d964f2360e..7f0a3606fe 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/BottomBarOptions.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/BottomBarOptions.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.unit.dp import com.flipperdevices.core.ui.theme.LocalPalletV2 import com.flipperdevices.filemanager.listing.impl.model.PathWithType import com.flipperdevices.filemanager.listing.impl.viewmodel.DeleteFilesViewModel -import com.flipperdevices.filemanager.listing.impl.viewmodel.EditFileViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.FilesViewModel import com.flipperdevices.filemanager.listing.impl.viewmodel.SelectionViewModel import com.flipperdevices.filemanager.ui.components.dropdown.IconDropdownItem @@ -43,6 +42,7 @@ import flipperapp.components.filemngr.ui_components.generated.resources.ic_more_ import flipperapp.components.filemngr.ui_components.generated.resources.ic_move import flipperapp.components.filemngr.ui_components.generated.resources.ic_trash_white import flipperapp.components.filemngr.ui_components.generated.resources.ic_upload +import kotlinx.collections.immutable.toImmutableList import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import flipperapp.components.filemngr.listing.impl.generated.resources.Res as FML @@ -86,6 +86,9 @@ private fun MoreBottomBarOptions( @Composable fun BottomBarOptions( canRename: Boolean, + canDelete: Boolean, + canMove: Boolean, + canExport: Boolean, onRename: () -> Unit, onExport: () -> Unit, onMove: () -> Unit, @@ -105,27 +108,32 @@ fun BottomBarOptions( .background(LocalPalletV2.current.surface.border.default.secondary) .padding(1.dp) .background(LocalPalletV2.current.surface.popUp.body.default), - horizontalArrangement = Arrangement.spacedBy(32.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { VerticalTextIconButton( text = stringResource(FML.string.fml_dialog_delete_btn), painter = painterResource(FR.drawable.ic_trash_white), iconTint = LocalPalletV2.current.action.danger.icon.default, + iconDisabledTint = LocalPalletV2.current.action.danger.icon.disabled, textColor = LocalPalletV2.current.action.danger.text.default, - onClick = onDelete + textDisabledColor = LocalPalletV2.current.action.danger.text.disabled, + onClick = onDelete, + isEnabled = canDelete ) VerticalTextIconButton( text = stringResource(FML.string.fml_move), painter = painterResource(FR.drawable.ic_move), onClick = onMove, + isEnabled = canMove ) VerticalTextIconButton( text = stringResource(FML.string.fml_export), painter = painterResource(FR.drawable.ic_upload), - onClick = onExport + onClick = onExport, + isEnabled = canExport ) MoreBottomBarOptions( onCopyTo = onCopyTo, @@ -138,10 +146,12 @@ fun BottomBarOptions( @Composable fun FullScreenBottomBarOptions( deleteFileViewModel: DeleteFilesViewModel, - editFileViewModel: EditFileViewModel, selectionViewModel: SelectionViewModel, filesListState: FilesViewModel.State, selectionState: SelectionViewModel.State, + onRename: (PathWithType) -> Unit, + onMove: (List) -> Unit, + onExport: (List) -> Unit, modifier: Modifier = Modifier ) { Box( @@ -157,17 +167,25 @@ fun FullScreenBottomBarOptions( ) { BottomBarOptions( canRename = selectionState.canRename, - onMove = {}, // todo + canMove = selectionState.canMove, + canDelete = selectionState.canDelete, + canExport = selectionState.canExport, + onMove = { + onMove.invoke(selectionState.selected.toList()) + }, onRename = { - val path = selectionState.selected.firstOrNull() ?: return@BottomBarOptions + val pathWithType = + selectionState.selected.firstOrNull() ?: return@BottomBarOptions selectionViewModel.toggleMode() - editFileViewModel.onRename(path) + onRename.invoke(pathWithType) }, onDelete = { deleteFileViewModel.tryDelete(selectionState.selected.map(PathWithType::fullPath)) selectionViewModel.toggleMode() }, - onExport = {}, // todo + onExport = { + onExport.invoke(selectionState.selected.toImmutableList()) + }, onCopyTo = {} // todo ) } diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/VerticalTextIconButton.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/VerticalTextIconButton.kt index d6ccd4bd42..ce4bc887ec 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/VerticalTextIconButton.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/composable/options/VerticalTextIconButton.kt @@ -1,5 +1,6 @@ package com.flipperdevices.filemanager.listing.impl.composable.options +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -33,21 +34,21 @@ fun VerticalTextIconButton( Column( modifier = modifier .clip(RoundedCornerShape(12.dp)) - .clickableRipple(onClick = onClick) - .padding(vertical = 12.dp), + .clickableRipple(onClick = { if (isEnabled) onClick.invoke() }) + .padding(vertical = 12.dp, horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(2.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painter, - tint = if (isEnabled) iconTint else iconDisabledTint, + tint = animateColorAsState(if (isEnabled) iconTint else iconDisabledTint).value, modifier = Modifier.size(24.dp), contentDescription = null ) Text( text = text, style = LocalTypography.current.subtitleM12, - color = if (isEnabled) textColor else textDisabledColor + color = animateColorAsState(if (isEnabled) textColor else textDisabledColor).value ) } } diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/model/PathWithType.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/model/PathWithType.kt index c64c772919..59fc27a1ca 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/model/PathWithType.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/model/PathWithType.kt @@ -1,7 +1,7 @@ package com.flipperdevices.filemanager.listing.impl.model import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType -import com.flipperdevices.filemanager.main.serialization.PathSerializer +import com.flipperdevices.filemanager.util.serialization.PathSerializer import kotlinx.serialization.Serializable import okio.Path diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/EditFileViewModel.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/EditFileViewModel.kt deleted file mode 100644 index dd8207badf..0000000000 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/EditFileViewModel.kt +++ /dev/null @@ -1,231 +0,0 @@ -package com.flipperdevices.filemanager.listing.impl.viewmodel - -import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureProvider -import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureStatus -import com.flipperdevices.bridge.connection.feature.provider.api.get -import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi -import com.flipperdevices.bridge.connection.feature.storage.api.fm.FFileUploadApi -import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType -import com.flipperdevices.bridge.connection.feature.storage.api.model.StorageRequestPriority -import com.flipperdevices.bridge.dao.api.model.FlipperKeyType -import com.flipperdevices.core.ktx.jre.FlipperFileNameValidator -import com.flipperdevices.core.ktx.jre.launchWithLock -import com.flipperdevices.core.log.LogTagProvider -import com.flipperdevices.core.log.error -import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel -import com.flipperdevices.filemanager.listing.impl.model.PathWithType -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.sync.Mutex -import okio.ByteString -import okio.Path -import okio.buffer -import javax.inject.Inject - -class EditFileViewModel @Inject constructor( - featureProvider: FFeatureProvider, -) : DecomposeViewModel(), LogTagProvider { - override val TAG: String = "CreateFolderViewModel" - - private val channel = Channel() - val event = channel.receiveAsFlow() - - private val fileNameValidator = FlipperFileNameValidator() - private val mutex = Mutex() - - private val _state = MutableStateFlow(State.Pending) - val state = _state.asStateFlow() - - fun onCreate(path: Path, fileType: FileType) { - _state.update { - State.Edit.Create( - name = "", - itemType = fileType, - path = path, - isValid = false - ) - } - } - - fun onRename(pathWithType: PathWithType) { - _state.update { - State.Edit.Rename( - name = pathWithType.fullPath.name, - itemType = pathWithType.fileType, - fullPath = pathWithType.fullPath, - isValid = true - ) - } - } - - fun dismiss() { - _state.value = State.Pending - } - - fun onNameChange(name: String) { - val visibleState = state.value as? State.Edit ?: return - _state.value = visibleState.with(name = name) - } - - fun onOptionSelected(index: Int) { - val visibleState = state.value as? State.Edit ?: return - val option = visibleState.options.getOrNull(index) ?: return - _state.update { visibleState.with(name = option) } - } - - private val featureState = featureProvider.get() - .stateIn(viewModelScope, SharingStarted.Eagerly, FFeatureStatus.Retrieving) - - val canCreateFiles = featureState - .filterIsInstance>() - .map { true } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) - - private suspend fun uploadFolder(uploadApi: FFileUploadApi, pathOnFlipper: Path) { - uploadApi.mkdir(pathOnFlipper.toString()) - .onSuccess { channel.send(Event.FilesChanged) } - .onFailure { error(it) { "Could not create folder" } } - } - - private suspend fun uploadFile(uploadApi: FFileUploadApi, pathOnFlipper: Path) { - runCatching { - uploadApi.sink( - pathOnFlipper = pathOnFlipper.toString(), - priority = StorageRequestPriority.FOREGROUND - ).buffer().use { it.write(ByteString.of()) } - }.onSuccess { channel.send(Event.FilesChanged) } - .onFailure { error(it) { "Could not create file" } } - } - - fun onFinish() { - val state = state.value as? State.Edit ?: return - if (!state.isValid) return - if (!canCreateFiles.value) return - launchWithLock(mutex, viewModelScope, "create folder") { - _state.emit(state.with(isLoading = true)) - val storageApi = featureState - .filterIsInstance>() - .first() - .featureApi - - val uploadApi = storageApi.uploadApi() - when (state) { - is State.Edit.Create -> { - val pathOnFlipper = state.path.resolve(state.name) - when (state.itemType) { - FileType.FILE -> uploadFile( - uploadApi = uploadApi, - pathOnFlipper = pathOnFlipper - ) - - FileType.DIR -> uploadFolder( - uploadApi = uploadApi, - pathOnFlipper = pathOnFlipper - ) - } - } - - is State.Edit.Rename -> { - val pathOnFlipper = state.fullPath.parent?.resolve(state.name) ?: run { - error { "#onFinish could not move file because parent is null ${state.fullPath}" } - return@launchWithLock - } - // todo folders doesn't rename - uploadApi.move( - oldPath = state.fullPath, - newPath = pathOnFlipper - ).onSuccess { channel.send(Event.FilesChanged) } - .onFailure { error(it) { "#onFinish could not move file ${state.fullPath} -> $pathOnFlipper" } } - } - } - - _state.emit(State.Pending) - } - } - - init { - state - .filterIsInstance() - .distinctUntilChangedBy { state -> state.name } - .onEach { state -> - _state.emit(state.with(isValid = fileNameValidator.isValid(state.name))) - }.launchIn(viewModelScope) - } - - sealed interface State { - data object Pending : State - - sealed interface Edit : State { - val name: String - val isValid: Boolean - val itemType: FileType - val isLoading: Boolean - - fun with( - name: String = this.name, - isValid: Boolean = this.isValid, - isLoading: Boolean = this.isLoading - ): Edit - - data class Create( - val path: Path, - override val name: String = "", - override val isValid: Boolean = false, - override val itemType: FileType, - override val isLoading: Boolean = false, - ) : Edit { - override fun with(name: String, isValid: Boolean, isLoading: Boolean): Create { - return copy( - name = name, - isValid = isValid, - isLoading = isLoading - ) - } - } - - data class Rename( - val fullPath: Path, - override val name: String = "", - override val isValid: Boolean = false, - override val itemType: FileType, - override val isLoading: Boolean = false - ) : Edit { - override fun with(name: String, isValid: Boolean, isLoading: Boolean): Rename { - return copy( - name = name, - isValid = isValid, - isLoading = isLoading - ) - } - } - - val options - get() = when (itemType) { - FileType.FILE -> listOf("txt") - .plus(FlipperKeyType.entries.map { it.extension }) - .map { extension -> "$name.$extension" } - - FileType.DIR -> emptyList() - }.toImmutableList() - - val needShowOptions - get() = !name.contains(".") && options.isNotEmpty() - } - } - - sealed interface Event { - data object FilesChanged : Event - } -} diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/FilesViewModel.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/FilesViewModel.kt index d1e3179262..4999e4e497 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/FilesViewModel.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/FilesViewModel.kt @@ -17,7 +17,7 @@ import com.flipperdevices.core.log.error import com.flipperdevices.core.preference.pb.FileManagerSort import com.flipperdevices.core.preference.pb.Settings import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel -import com.flipperdevices.filemanager.listing.impl.model.ExtendedListingItem +import com.flipperdevices.filemanager.listing.api.model.ExtendedListingItem import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -168,6 +168,21 @@ class FilesViewModel @AssistedInject constructor( } } + fun fileRenamed(oldPath: Path, newPath: Path) { + _state.update { state -> + (state as? State.Loaded)?.let { loadedState -> + val mutableFiles = loadedState.files.toMutableList() + val i = mutableFiles.indexOfFirst { item -> item.path.name == oldPath.name } + if (i == -1) return@let loadedState + mutableFiles[i] = when (val item = mutableFiles[i]) { + is ExtendedListingItem.File -> item.copy(path = newPath.name.toPath()) + is ExtendedListingItem.Folder -> item.copy(path = newPath.name.toPath()) + } + loadedState.copy(files = mutableFiles.toImmutableList()) + } ?: state + } + } + fun tryListFiles() { launchWithLock(mutex, viewModelScope, "try_list_files") { _state.emit(State.Loading) diff --git a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/SelectionViewModel.kt b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/SelectionViewModel.kt index 45a88096c6..0ebddaac10 100644 --- a/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/SelectionViewModel.kt +++ b/components/filemngr/listing/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/listing/impl/viewmodel/SelectionViewModel.kt @@ -1,5 +1,6 @@ package com.flipperdevices.filemanager.listing.impl.viewmodel +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel import com.flipperdevices.filemanager.listing.impl.model.PathWithType import kotlinx.collections.immutable.ImmutableSet @@ -7,7 +8,10 @@ import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import okio.Path import javax.inject.Inject class SelectionViewModel @Inject constructor() : DecomposeViewModel() { @@ -28,6 +32,15 @@ class SelectionViewModel @Inject constructor() : DecomposeViewModel() { } } + fun deselect(path: Path) { + viewModelScope.launch { + state.first() + .selected + .firstOrNull { it.fullPath.name == path.name } + ?.run(::deselect) + } + } + fun select(path: PathWithType) { select(listOf(path)) } @@ -59,5 +72,8 @@ class SelectionViewModel @Inject constructor() : DecomposeViewModel() { val isEnabled: Boolean = false ) { val canRename: Boolean = selected.size == 1 + val canExport: Boolean = selected.size == 1 && selected.all { it.fileType == FileType.FILE } + val canMove: Boolean = selected.size >= 1 + val canDelete: Boolean = selected.size >= 1 } } diff --git a/components/filemngr/main/impl/build.gradle.kts b/components/filemngr/main/impl/build.gradle.kts index f6f5451bea..dfcec26fd8 100644 --- a/components/filemngr/main/impl/build.gradle.kts +++ b/components/filemngr/main/impl/build.gradle.kts @@ -17,15 +17,17 @@ commonDependencies { implementation(projects.components.core.ui.ktx) implementation(projects.components.core.ui.res) + implementation(projects.components.bridge.connection.feature.storage.api) implementation(projects.components.bridge.dao.api) implementation(projects.components.filemngr.uiComponents) implementation(projects.components.filemngr.main.api) + implementation(projects.components.filemngr.transfer.api) implementation(projects.components.filemngr.listing.api) implementation(projects.components.filemngr.upload.api) implementation(projects.components.filemngr.search.api) implementation(projects.components.filemngr.editor.api) - implementation(projects.components.newfilemanager.api) + implementation(projects.components.filemngr.util) // Compose implementation(libs.compose.ui) diff --git a/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/api/FileManagerDecomposeComponentImpl.kt b/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/api/FileManagerDecomposeComponentImpl.kt index 64345e4ffa..edb444bb17 100644 --- a/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/api/FileManagerDecomposeComponentImpl.kt +++ b/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/api/FileManagerDecomposeComponentImpl.kt @@ -12,8 +12,11 @@ import com.flipperdevices.filemanager.listing.api.FilesDecomposeComponent import com.flipperdevices.filemanager.main.api.FileManagerDecomposeComponent import com.flipperdevices.filemanager.main.impl.model.FileManagerNavigationConfig import com.flipperdevices.filemanager.search.api.SearchDecomposeComponent +import com.flipperdevices.filemanager.transfer.api.TransferDecomposeComponent +import com.flipperdevices.filemanager.transfer.api.model.TransferType import com.flipperdevices.ui.decompose.DecomposeComponent import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.findComponentByConfig import com.flipperdevices.ui.decompose.popOr import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -27,6 +30,7 @@ class FileManagerDecomposeComponentImpl @AssistedInject constructor( private val filesDecomposeComponentFactory: FilesDecomposeComponent.Factory, private val searchDecomposeComponentFactory: SearchDecomposeComponent.Factory, private val editorDecomposeComponentFactory: FileManagerEditorDecomposeComponent.Factory, + private val transferDecomposeComponentFactory: TransferDecomposeComponent.Factory ) : FileManagerDecomposeComponent(), ComponentContext by componentContext { @@ -38,6 +42,7 @@ class FileManagerDecomposeComponentImpl @AssistedInject constructor( childFactory = ::child, ) + @Suppress("LongMethod") private fun child( config: FileManagerNavigationConfig, componentContext: ComponentContext @@ -53,6 +58,15 @@ class FileManagerDecomposeComponentImpl @AssistedInject constructor( fileSelectedCallback = { navigation.pushNew(FileManagerNavigationConfig.Edit(it)) }, + moveToCallback = { fullPaths -> + navigation.pushNew( + FileManagerNavigationConfig.Transfer( + path = config.path, + transferType = TransferType.MOVE, + fullPathToMove = fullPaths + ) + ) + }, searchCallback = { navigation.pushNew(FileManagerNavigationConfig.Search(config.path)) }, ) } @@ -71,6 +85,36 @@ class FileManagerDecomposeComponentImpl @AssistedInject constructor( componentContext = componentContext, path = config.path, onBack = { navigation.popOr(onBack::invoke) }, + onFileChanged = { item -> + val component = stack.findComponentByConfig( + configClazz = FileManagerNavigationConfig.FileTree::class + ) as? FilesDecomposeComponent + component?.onFileChanged(item) + } + ) + } + + is FileManagerNavigationConfig.Transfer -> { + transferDecomposeComponentFactory.invoke( + componentContext = componentContext, + param = TransferDecomposeComponent.Param( + path = config.path, + transferType = config.transferType, + fullPathToMove = config.fullPathToMove + ), + onBack = { navigation.popOr(onBack::invoke) }, + onMoved = { path -> + navigation.replaceAll(FileManagerNavigationConfig.FileTree(path)) + }, + onPathChange = { path -> + navigation.replaceCurrent( + FileManagerNavigationConfig.Transfer( + path = path, + transferType = config.transferType, + fullPathToMove = config.fullPathToMove + ) + ) + } ) } } diff --git a/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/model/FileManagerNavigationConfig.kt b/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/model/FileManagerNavigationConfig.kt index d98b19bc90..cd3645f00a 100644 --- a/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/model/FileManagerNavigationConfig.kt +++ b/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/model/FileManagerNavigationConfig.kt @@ -1,7 +1,8 @@ package com.flipperdevices.filemanager.main.impl.model import androidx.compose.runtime.Stable -import com.flipperdevices.filemanager.main.serialization.PathSerializer +import com.flipperdevices.filemanager.transfer.api.model.TransferType +import com.flipperdevices.filemanager.util.serialization.PathSerializer import kotlinx.serialization.Serializable import okio.Path import okio.Path.Companion.toPath @@ -27,6 +28,17 @@ sealed interface FileManagerNavigationConfig { val path: Path ) : FileManagerNavigationConfig + @Serializable + data class Transfer( + @Serializable(with = PathSerializer::class) + val path: Path, + val transferType: TransferType, + val fullPathToMove: List< + @Serializable(with = PathSerializer::class) + Path + >, + ) : FileManagerNavigationConfig + companion object { val DefaultFileTree: FileTree get() = FileTree("/".toPath()) diff --git a/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/serialization/PathSerializer.kt b/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/serialization/PathSerializer.kt deleted file mode 100644 index fecf366f75..0000000000 --- a/components/filemngr/main/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/main/impl/serialization/PathSerializer.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.flipperdevices.filemanager.main.impl.serialization - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import okio.Path -import okio.Path.Companion.toPath - -object PathSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( - serialName = "okio.Path", - kind = PrimitiveKind.STRING - ) - - override fun deserialize(decoder: Decoder): Path { - val path = decoder.decodeString() - return path.toPath() - } - - override fun serialize(encoder: Encoder, value: Path) { - encoder.encodeString(value.toString()) - } -} diff --git a/components/filemngr/rename/api/build.gradle.kts b/components/filemngr/rename/api/build.gradle.kts new file mode 100644 index 0000000000..70ff8d0d93 --- /dev/null +++ b/components/filemngr/rename/api/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("flipper.multiplatform") + id("flipper.multiplatform-dependencies") +} + +android.namespace = "com.flipperdevices.filemanager.rename.api" + +commonDependencies { + implementation(projects.components.bridge.connection.feature.storage.api) + + implementation(projects.components.core.ui.decompose) + + implementation(libs.compose.ui) + implementation(libs.decompose) + + implementation(libs.okio) +} diff --git a/components/filemngr/rename/api/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/api/RenameDecomposeComponent.kt b/components/filemngr/rename/api/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/api/RenameDecomposeComponent.kt new file mode 100644 index 0000000000..ef510262ad --- /dev/null +++ b/components/filemngr/rename/api/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/api/RenameDecomposeComponent.kt @@ -0,0 +1,24 @@ +package com.flipperdevices.filemanager.rename.api + +import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent +import okio.Path + +abstract class RenameDecomposeComponent( + componentContext: ComponentContext +) : ScreenDecomposeComponent(componentContext) { + + abstract fun startRename(fullPath: Path, type: FileType) + + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + renamedCallback: RenamedCallback, + ): RenameDecomposeComponent + } + + fun interface RenamedCallback { + fun invoke(oldFullPath: Path, newFullPath: Path) + } +} diff --git a/components/filemngr/rename/impl/build.gradle.kts b/components/filemngr/rename/impl/build.gradle.kts new file mode 100644 index 0000000000..1be48b0b3d --- /dev/null +++ b/components/filemngr/rename/impl/build.gradle.kts @@ -0,0 +1,52 @@ +plugins { + id("flipper.multiplatform-compose") + id("flipper.multiplatform-dependencies") + id("flipper.anvil-multiplatform") + id("kotlinx-serialization") +} +android.namespace = "com.flipperdevices.filemanager.rename.impl" + +commonDependencies { + implementation(projects.components.core.di) + implementation(projects.components.core.ktx) + implementation(projects.components.core.log) + implementation(projects.components.core.preference) + + implementation(projects.components.core.ui.lifecycle) + implementation(projects.components.core.ui.theme) + implementation(projects.components.core.ui.decompose) + implementation(projects.components.core.ui.ktx) + implementation(projects.components.core.ui.res) + implementation(projects.components.core.ui.dialog) + + implementation(projects.components.bridge.connection.feature.common.api) + implementation(projects.components.bridge.connection.transport.common.api) + implementation(projects.components.bridge.connection.feature.provider.api) + implementation(projects.components.bridge.connection.feature.storage.api) + implementation(projects.components.bridge.connection.feature.storageinfo.api) + implementation(projects.components.bridge.connection.feature.serialspeed.api) + implementation(projects.components.bridge.connection.feature.rpcinfo.api) + implementation(projects.components.bridge.dao.api) + + implementation(projects.components.filemngr.util) + implementation(projects.components.filemngr.uiComponents) + implementation(projects.components.filemngr.rename.api) + + // Compose + implementation(libs.compose.ui) + implementation(libs.compose.tooling) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + + implementation(libs.kotlin.serialization.json) + implementation(libs.ktor.client) + + implementation(libs.decompose) + implementation(libs.kotlin.coroutines) + implementation(libs.essenty.lifecycle) + implementation(libs.essenty.lifecycle.coroutines) + + implementation(libs.bundles.decompose) + implementation(libs.okio) + implementation(libs.kotlin.immutable.collections) +} diff --git a/components/filemngr/rename/impl/src/commonMain/composeResources/values/strings.xml b/components/filemngr/rename/impl/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000000..d2c65a2181 --- /dev/null +++ b/components/filemngr/rename/impl/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,6 @@ + + + Enter Name: + Save + Allowed characters: %1$s + \ No newline at end of file diff --git a/components/filemngr/rename/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/impl/api/RenameDecomposeComponentImpl.kt b/components/filemngr/rename/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/impl/api/RenameDecomposeComponentImpl.kt new file mode 100644 index 0000000000..46f2a76896 --- /dev/null +++ b/components/filemngr/rename/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/impl/api/RenameDecomposeComponentImpl.kt @@ -0,0 +1,79 @@ +package com.flipperdevices.filemanager.rename.impl.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.filemanager.rename.api.RenameDecomposeComponent +import com.flipperdevices.filemanager.rename.impl.viewmodel.RenameViewModel +import com.flipperdevices.filemanager.ui.components.name.NameDialog +import com.flipperdevices.filemanager.util.constant.FileManagerConstants +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import flipperapp.components.filemngr.rename.impl.generated.resources.fmr_create_file_allowed_chars +import flipperapp.components.filemngr.rename.impl.generated.resources.fmr_create_file_folder_btn +import flipperapp.components.filemngr.rename.impl.generated.resources.fmr_create_file_title +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.gulya.anvil.assisted.ContributesAssistedFactory +import okio.Path +import org.jetbrains.compose.resources.stringResource +import javax.inject.Provider +import flipperapp.components.filemngr.rename.impl.generated.resources.Res as FMR + +@ContributesAssistedFactory(AppGraph::class, RenameDecomposeComponent.Factory::class) +class RenameDecomposeComponentImpl @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted val renamedCallback: RenamedCallback, + renameViewModelProvider: Provider +) : RenameDecomposeComponent(componentContext) { + private val renameViewModel = instanceKeeper.getOrCreate { + renameViewModelProvider.get() + } + + override fun startRename(fullPath: Path, type: FileType) { + renameViewModel.startRename(fullPath, type) + } + + @Composable + override fun Render() { + val state by renameViewModel.state.collectAsState() + LaunchedEffect(renameViewModel) { + renameViewModel.event + .onEach { event -> + when (event) { + is RenameViewModel.Event.Renamed -> { + renamedCallback.invoke(event.oldFullPath, event.newFullPath) + } + } + }.launchIn(this) + } + when (val localState = state) { + RenameViewModel.State.Pending -> Unit + is RenameViewModel.State.Renaming -> { + NameDialog( + value = localState.name, + title = stringResource(FMR.string.fmr_create_file_title), + buttonText = stringResource(FMR.string.fmr_create_file_folder_btn), + subtitle = stringResource( + FMR.string.fmr_create_file_allowed_chars, + FileManagerConstants.FILE_NAME_AVAILABLE_CHARACTERS + ), + onFinish = renameViewModel::onConfirm, + isError = !localState.isValid, + isEnabled = !localState.isRenaming, + needShowOptions = localState.needShowOptions, + onTextChange = renameViewModel::onNameChange, + onDismissRequest = renameViewModel::dismiss, + onOptionSelect = renameViewModel::onOptionSelected, + options = localState.options, + isLoading = localState.isRenaming + ) + } + } + } +} diff --git a/components/filemngr/rename/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/impl/viewmodel/RenameViewModel.kt b/components/filemngr/rename/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/impl/viewmodel/RenameViewModel.kt new file mode 100644 index 0000000000..3966368339 --- /dev/null +++ b/components/filemngr/rename/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/rename/impl/viewmodel/RenameViewModel.kt @@ -0,0 +1,176 @@ +package com.flipperdevices.filemanager.rename.impl.viewmodel + +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureProvider +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureStatus +import com.flipperdevices.bridge.connection.feature.provider.api.get +import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi +import com.flipperdevices.bridge.connection.feature.storage.api.fm.FFileUploadApi +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.core.ktx.jre.FlipperFileNameValidator +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.error +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import com.flipperdevices.filemanager.util.constant.FileManagerConstants +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okio.Path +import javax.inject.Inject + +class RenameViewModel @Inject constructor( + private val featureProvider: FFeatureProvider, +) : DecomposeViewModel(), LogTagProvider { + override val TAG: String = "RenameViewModel" + + private val fileNameValidator = FlipperFileNameValidator() + + private val eventChannel = Channel() + val event = eventChannel.receiveAsFlow() + + private val _state = MutableStateFlow(State.Pending) + val state = _state.asStateFlow() + + private var featureJob: Job? = null + private val featureMutex = Mutex() + + val canRenameFiles = featureProvider.get() + .filterIsInstance>() + .map { true } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + fun startRename(fullPath: Path, type: FileType) { + _state.update { + State.Renaming( + fullPath = fullPath, + type = type, + name = fullPath.name, + isValid = false, + isRenaming = false + ) + } + } + + fun dismiss() { + _state.update { State.Pending } + } + + fun onNameChange(name: String) { + _state.update { state -> + (state as? State.Renaming) + ?.copy(name = name) + ?: state + } + } + + fun onOptionSelected(index: Int) { + _state.update { state -> + (state as? State.Renaming)?.let { renamingState -> + val option = renamingState.options.getOrNull(index).orEmpty() + renamingState.copy(name = "${renamingState.name}.$option") + } ?: state + } + } + + private suspend fun rename(uploadApi: FFileUploadApi) { + val state = (state.first() as? State.Renaming) + if (state == null) { + error { "#rename state was not Renaming" } + return + } + val newPath = state.fullPath.parent?.resolve(state.name) + if (newPath == null) { + error { "#rename parent path was null for ${state.fullPath}" } + return + } + _state.emit(state.copy(isRenaming = true)) + uploadApi.move( + oldPath = state.fullPath, + newPath = newPath + ).onSuccess { + val event = Event.Renamed( + oldFullPath = state.fullPath, + newFullPath = newPath + ) + eventChannel.send(event) + }.onFailure { + error(it) { "#onFinish could not move file ${state.fullPath} -> $newPath" } + } + _state.emit(State.Pending) + featureJob?.cancelAndJoin() + } + + fun onConfirm() { + viewModelScope.launch { + featureJob?.cancelAndJoin() + featureMutex.withLock { + featureJob = featureProvider.get() + .onEach { status -> + when (status) { + FFeatureStatus.NotFound -> Unit + FFeatureStatus.Retrieving -> Unit + FFeatureStatus.Unsupported -> Unit + is FFeatureStatus.Supported -> { + rename(status.featureApi.uploadApi()) + } + } + }.launchIn(viewModelScope) + featureJob?.join() + } + } + } + + private fun collectNameValidation() { + state + .filterIsInstance() + .distinctUntilChangedBy { state -> state.name } + .onEach { state -> + _state.emit(state.copy(isValid = fileNameValidator.isValid(state.name))) + }.launchIn(viewModelScope) + } + + init { + collectNameValidation() + } + + sealed interface State { + data object Pending : State + data class Renaming( + val fullPath: Path, + val name: String, + val isValid: Boolean, + val type: FileType, + val isRenaming: Boolean + ) : State { + val options: ImmutableList + get() = when (type) { + FileType.FILE -> FileManagerConstants.FILE_EXTENSION_HINTS + + FileType.DIR -> emptyList() + }.toImmutableList() + + val needShowOptions: Boolean + get() = !name.contains(".") && options.isNotEmpty() + } + } + + sealed interface Event { + data class Renamed(val oldFullPath: Path, val newFullPath: Path) : Event + } +} diff --git a/components/filemngr/search/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/search/impl/composable/ComposableFilesSearchScreen.kt b/components/filemngr/search/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/search/impl/composable/ComposableFilesSearchScreen.kt index 05dfe967f3..c1ababdc3d 100644 --- a/components/filemngr/search/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/search/impl/composable/ComposableFilesSearchScreen.kt +++ b/components/filemngr/search/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/search/impl/composable/ComposableFilesSearchScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.flipperdevices.filemanager.search.impl.viewmodel.SearchViewModel +import com.flipperdevices.filemanager.ui.components.error.UnsupportedErrorComposable import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance import okio.Path @@ -58,7 +59,7 @@ fun ComposableFilesSearchScreen( } SearchViewModel.State.Unsupported -> { - NoListingFeatureComposable() + UnsupportedErrorComposable() } } } diff --git a/components/filemngr/search/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/search/impl/composable/NoListingFeatureComposable.kt b/components/filemngr/search/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/search/impl/composable/NoListingFeatureComposable.kt deleted file mode 100644 index 7b365ddd20..0000000000 --- a/components/filemngr/search/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/search/impl/composable/NoListingFeatureComposable.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.flipperdevices.filemanager.search.impl.composable - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -fun NoListingFeatureComposable(modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize()) { - // todo - Text("Unsopported") - } -} diff --git a/components/filemngr/transfer/api/build.gradle.kts b/components/filemngr/transfer/api/build.gradle.kts new file mode 100644 index 0000000000..1e9e8c64b4 --- /dev/null +++ b/components/filemngr/transfer/api/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("flipper.multiplatform-compose") + id("flipper.multiplatform") + id("flipper.multiplatform-dependencies") +} + +android.namespace = "com.flipperdevices.filemanager.transfer.api" + +commonDependencies { + implementation(projects.components.core.ui.decompose) + implementation(projects.components.deeplink.api) + + implementation(projects.components.filemngr.listing.api) + + implementation(libs.compose.ui) + implementation(libs.decompose) + + implementation(libs.okio) +} diff --git a/components/filemngr/transfer/api/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/api/TransferDecomposeComponent.kt b/components/filemngr/transfer/api/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/api/TransferDecomposeComponent.kt new file mode 100644 index 0000000000..a72be6b6e8 --- /dev/null +++ b/components/filemngr/transfer/api/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/api/TransferDecomposeComponent.kt @@ -0,0 +1,35 @@ +package com.flipperdevices.filemanager.transfer.api + +import com.arkivanov.decompose.ComponentContext +import com.flipperdevices.filemanager.transfer.api.model.TransferType +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import com.flipperdevices.ui.decompose.ScreenDecomposeComponent +import okio.Path + +abstract class TransferDecomposeComponent( + componentContext: ComponentContext +) : ScreenDecomposeComponent(componentContext) { + fun interface Factory { + operator fun invoke( + componentContext: ComponentContext, + param: Param, + onBack: DecomposeOnBackParameter, + onMoved: MovedCallback, + onPathChange: PathChangedCallback, + ): TransferDecomposeComponent + } + + fun interface MovedCallback { + fun invoke(path: Path) + } + + fun interface PathChangedCallback { + fun invoke(path: Path) + } + + data class Param( + val path: Path, + val transferType: TransferType, + val fullPathToMove: List, + ) +} diff --git a/components/filemngr/transfer/api/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/api/model/TransferType.kt b/components/filemngr/transfer/api/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/api/model/TransferType.kt new file mode 100644 index 0000000000..dc7aa0b2b7 --- /dev/null +++ b/components/filemngr/transfer/api/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/api/model/TransferType.kt @@ -0,0 +1,5 @@ +package com.flipperdevices.filemanager.transfer.api.model + +enum class TransferType { + MOVE +} diff --git a/components/filemngr/transfer/impl/build.gradle.kts b/components/filemngr/transfer/impl/build.gradle.kts new file mode 100644 index 0000000000..f5220ada90 --- /dev/null +++ b/components/filemngr/transfer/impl/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("flipper.multiplatform-compose") + id("flipper.multiplatform-dependencies") + id("flipper.anvil-multiplatform") + id("kotlinx-serialization") +} +android.namespace = "com.flipperdevices.filemanager.transfer.impl" + +commonDependencies { + implementation(projects.components.core.di) + implementation(projects.components.core.ktx) + implementation(projects.components.core.log) + implementation(projects.components.core.progress) + implementation(projects.components.core.preference) + + implementation(projects.components.core.ui.lifecycle) + implementation(projects.components.core.ui.theme) + implementation(projects.components.core.ui.decompose) + implementation(projects.components.core.ui.ktx) + implementation(projects.components.core.ui.res) + implementation(projects.components.core.ui.dialog) + implementation(projects.components.core.storage) + + implementation(projects.components.bridge.dao.api) + + implementation(projects.components.bridge.connection.feature.common.api) + implementation(projects.components.bridge.connection.transport.common.api) + implementation(projects.components.bridge.connection.feature.provider.api) + implementation(projects.components.bridge.connection.feature.storage.api) + implementation(projects.components.bridge.connection.feature.storageinfo.api) + implementation(projects.components.bridge.connection.feature.serialspeed.api) + implementation(projects.components.bridge.connection.feature.rpcinfo.api) + + implementation(projects.components.bridge.connection.orchestrator.api) + + implementation(projects.components.bridge.dao.api) + + implementation(projects.components.filemngr.uiComponents) + implementation(projects.components.filemngr.transfer.api) + implementation(projects.components.filemngr.listing.api) + implementation(projects.components.filemngr.create.api) + + implementation(projects.components.deeplink.api) + + // Compose + implementation(libs.compose.ui) + implementation(libs.compose.tooling) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + implementation(libs.compose.activity) + implementation(libs.compose.material.icons.core) + implementation(libs.compose.material.icons.extended) + + implementation(libs.kotlin.serialization.json) + implementation(libs.ktor.client) + + implementation(libs.decompose) + implementation(libs.kotlin.coroutines) + implementation(libs.essenty.lifecycle) + implementation(libs.essenty.lifecycle.coroutines) + + implementation(libs.bundles.decompose) + implementation(libs.okio) + implementation(libs.kotlin.immutable.collections) +} diff --git a/components/filemngr/transfer/impl/src/commonMain/composeResources/values/strings.xml b/components/filemngr/transfer/impl/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000000..2f3bf2589f --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,14 @@ + + + Create Folder + List + Grid + Sort by Default + Sort by Size + Show Hidden Files + + %1$s items + + Moving + Move Here + \ No newline at end of file diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/api/TransferDecomposeComponentImpl.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/api/TransferDecomposeComponentImpl.kt new file mode 100644 index 0000000000..5bee7d3215 --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/api/TransferDecomposeComponentImpl.kt @@ -0,0 +1,106 @@ +package com.flipperdevices.filemanager.transfer.impl.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.childContext +import com.arkivanov.essenty.backhandler.BackCallback +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.flipperdevices.core.di.AppGraph +import com.flipperdevices.filemanager.create.api.CreateFileDecomposeComponent +import com.flipperdevices.filemanager.transfer.api.TransferDecomposeComponent +import com.flipperdevices.filemanager.transfer.impl.composable.ComposableTransferScreen +import com.flipperdevices.filemanager.transfer.impl.viewmodel.FilesViewModel +import com.flipperdevices.filemanager.transfer.impl.viewmodel.OptionsViewModel +import com.flipperdevices.filemanager.transfer.impl.viewmodel.TransferViewModel +import com.flipperdevices.ui.decompose.DecomposeOnBackParameter +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import me.gulya.anvil.assisted.ContributesAssistedFactory +import javax.inject.Provider + +@Suppress("LongParameterList") +@ContributesAssistedFactory(AppGraph::class, TransferDecomposeComponent.Factory::class) +class TransferDecomposeComponentImpl @AssistedInject constructor( + @Assisted componentContext: ComponentContext, + @Assisted private val param: Param, + @Assisted private val onBack: DecomposeOnBackParameter, + @Assisted private val onMoved: MovedCallback, + @Assisted private val onPathChange: PathChangedCallback, + filesViewModelFactory: FilesViewModel.Factory, + transferViewModelProvider: Provider, + optionsViewModelProvider: Provider, + createFileDecomposeComponentFactory: CreateFileDecomposeComponent.Factory, +) : TransferDecomposeComponent(componentContext) { + private val filesViewModel = instanceKeeper.getOrCreate("files_${param.path}") { + filesViewModelFactory.invoke(path = param.path) + } + private val transferViewModel = instanceKeeper.getOrCreate("transfer_${param.path}") { + transferViewModelProvider.get() + } + private val optionsViewModel = instanceKeeper.getOrCreate("options_${param.path}") { + optionsViewModelProvider.get() + } + private val createFileDecomposeComponent = createFileDecomposeComponentFactory.invoke( + componentContext = childContext("createfolder_${param.path}"), + createCallback = filesViewModel::onFolderCreated + ) + + // Folder should not be a subfolder of movable folder + // Folder should be the same as movable folder's parent + private val canMoveHere = param.fullPathToMove + .all { path -> !param.path.toString().startsWith(path.toString()) } + .and(param.fullPathToMove.all { path -> param.path != path.parent }) + + private val backCallback = BackCallback { + val parent = param.path.parent + if (transferViewModel.state.value is TransferViewModel.State.Moving) return@BackCallback + if (parent == null) { + onBack.invoke() + } else { + onPathChange.invoke(parent) + } + } + + init { + backHandler.register(backCallback) + } + + @Composable + override fun Render() { + LaunchedEffect(transferViewModel) { + transferViewModel.state + .filterIsInstance() + .onEach { state -> onMoved.invoke(state.targetDir) } + .launchIn(this) + } + + val optionsState by optionsViewModel.state.collectAsState() + val state by filesViewModel.state.collectAsState() + val transferState by transferViewModel.state.collectAsState() + val isMoving = transferState is TransferViewModel.State.Moving + ComposableTransferScreen( + optionsState = optionsState, + state = state, + isMoving = isMoving, + canMoveHere = canMoveHere, + param = param, + onBack = onBack::invoke, + onOptionsAction = optionsViewModel::onAction, + onCreateFolder = { createFileDecomposeComponent.startCreateFolder(param.path) }, + onPathChange = onPathChange::invoke, + onMoveStart = { + transferViewModel.move( + oldPaths = param.fullPathToMove, + targetDir = param.path + ) + }, + ) + createFileDecomposeComponent.Render() + } +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/ComposableTransferScreen.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/ComposableTransferScreen.kt new file mode 100644 index 0000000000..9f2f54bb87 --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/ComposableTransferScreen.kt @@ -0,0 +1,121 @@ +package com.flipperdevices.filemanager.transfer.impl.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.flipperdevices.core.preference.pb.FileManagerOrientation +import com.flipperdevices.filemanager.transfer.api.TransferDecomposeComponent.Param +import com.flipperdevices.filemanager.transfer.impl.viewmodel.FilesViewModel +import com.flipperdevices.filemanager.transfer.impl.viewmodel.OptionsViewModel +import com.flipperdevices.filemanager.ui.components.error.UnknownErrorComposable +import com.flipperdevices.filemanager.ui.components.error.UnsupportedErrorComposable +import com.flipperdevices.filemanager.ui.components.itemcard.FolderCardPlaceholderComposable +import okio.Path + +@Suppress("LongMethod") +@Composable +fun ComposableTransferScreen( + optionsState: OptionsViewModel.State, + state: FilesViewModel.State, + isMoving: Boolean, + canMoveHere: Boolean, + param: Param, + onBack: () -> Unit, + onOptionsAction: (OptionsViewModel.Action) -> Unit, + onCreateFolder: () -> Unit, + onPathChange: (Path) -> Unit, + onMoveStart: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TransferAppBar( + transferType = param.transferType, + onBack = onBack, + optionsState = optionsState, + onOptionsAction = onOptionsAction, + onCreateFolder = onCreateFolder + ) + } + ) { contentPaddings -> + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(14.dp), + columns = when (optionsState.orientation) { + FileManagerOrientation.GRID -> GridCells.Fixed(2) + is FileManagerOrientation.Unrecognized, + FileManagerOrientation.LIST -> GridCells.Fixed(1) + } + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + TransferPathComposable( + isMoving = isMoving, + path = param.path, + onPathChange = onPathChange + ) + } + + when (val localState = state) { + FilesViewModel.State.CouldNotListPath -> { + item( + span = { GridItemSpan(maxLineSpan) }, + content = { UnknownErrorComposable() } + ) + } + + is FilesViewModel.State.Loaded -> { + items(localState.files) { item -> + TransferFolderCardListComposable( + modifier = Modifier.animateItem(), + fullDirPath = param.path, + item = item, + onPathChange = onPathChange, + isMoving = isMoving + ) + } + item( + span = { GridItemSpan(maxLineSpan) }, + content = { Box(Modifier.height(32.dp)) } + ) + } + + FilesViewModel.State.Loading -> { + items(count = 6) { + FolderCardPlaceholderComposable( + modifier = Modifier + .fillMaxWidth() + .animateItem(), + orientation = optionsState.orientation, + ) + } + } + + FilesViewModel.State.Unsupported -> { + item( + span = { GridItemSpan(maxLineSpan) }, + content = { UnsupportedErrorComposable() } + ) + } + } + } + FullScreenMoveButtonComposable( + isLoading = isMoving, + isEnabled = !isMoving && canMoveHere, + onClick = onMoveStart + ) + } +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/FullScreenMoveButtonComposable.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/FullScreenMoveButtonComposable.kt new file mode 100644 index 0000000000..32d4d1b14c --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/FullScreenMoveButtonComposable.kt @@ -0,0 +1,34 @@ +package com.flipperdevices.filemanager.transfer.impl.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.flipperdevices.core.ui.ktx.elements.ComposableFlipperButton +import flipperapp.components.filemngr.transfer.impl.generated.resources.Res +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_move_button +import org.jetbrains.compose.resources.stringResource + +@Composable +fun FullScreenMoveButtonComposable( + isLoading: Boolean, + isEnabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + content = { + ComposableFlipperButton( + text = stringResource(Res.string.fmt_move_button), + modifier = Modifier.fillMaxWidth(), + isLoading = isLoading, + enabled = isEnabled, + onClick = onClick + ) + } + ) +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/ListOptionsComposable.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/ListOptionsComposable.kt new file mode 100644 index 0000000000..22779750f6 --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/ListOptionsComposable.kt @@ -0,0 +1,73 @@ +package com.flipperdevices.filemanager.transfer.impl.composable + +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipperdevices.filemanager.transfer.impl.viewmodel.OptionsViewModel.Action +import com.flipperdevices.filemanager.ui.components.dropdown.IconDropdownItem +import com.flipperdevices.filemanager.ui.components.dropdown.RadioDropdownItem +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_otp_create_folder +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_otp_display_grid +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_otp_display_list +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_otp_show_hidden +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_otp_sort_default +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_otp_sort_size +import flipperapp.components.filemngr.ui_components.generated.resources.ic_create_fodler +import flipperapp.components.filemngr.ui_components.generated.resources.ic_grid +import flipperapp.components.filemngr.ui_components.generated.resources.ic_list +import flipperapp.components.filemngr.ui_components.generated.resources.ic_sort_default +import flipperapp.components.filemngr.ui_components.generated.resources.ic_sort_size +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import flipperapp.components.filemngr.transfer.impl.generated.resources.Res as FMT +import flipperapp.components.filemngr.ui_components.generated.resources.Res as FR + +@Composable +fun ListOptionsDropDown( + isVisible: Boolean, + isHiddenFilesVisible: Boolean, + onAction: (Action) -> Unit, + onCreateFolderClick: () -> Unit, + modifier: Modifier = Modifier +) { + DropdownMenu( + modifier = modifier, + expanded = isVisible, + onDismissRequest = { onAction.invoke(Action.ToggleMenu) }, + ) { + IconDropdownItem( + text = stringResource(FMT.string.fmt_otp_create_folder), + painter = painterResource(FR.drawable.ic_create_fodler), + onClick = onCreateFolderClick + ) + Divider() + IconDropdownItem( + text = stringResource(FMT.string.fmt_otp_display_list), + painter = painterResource(FR.drawable.ic_list), + onClick = { onAction.invoke(Action.DisplayList) } + ) + IconDropdownItem( + text = stringResource(FMT.string.fmt_otp_display_grid), + painter = painterResource(FR.drawable.ic_grid), + onClick = { onAction.invoke(Action.DisplayGrid) } + ) + Divider() + IconDropdownItem( + text = stringResource(FMT.string.fmt_otp_sort_default), + painter = painterResource(FR.drawable.ic_sort_default), + onClick = { onAction.invoke(Action.SortByDefault) } + ) + IconDropdownItem( + text = stringResource(FMT.string.fmt_otp_sort_size), + painter = painterResource(FR.drawable.ic_sort_size), + onClick = { onAction.invoke(Action.SortBySize) } + ) + Divider() + RadioDropdownItem( + text = stringResource(FMT.string.fmt_otp_show_hidden), + onClick = { onAction.invoke(Action.ToggleHidden) }, + selected = isHiddenFilesVisible + ) + } +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/MoreIconComposable.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/MoreIconComposable.kt new file mode 100644 index 0000000000..155c9649da --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/MoreIconComposable.kt @@ -0,0 +1,46 @@ +package com.flipperdevices.filemanager.transfer.impl.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.flipperdevices.core.ui.ktx.clickableRipple +import com.flipperdevices.core.ui.theme.LocalPalletV2 +import com.flipperdevices.filemanager.transfer.impl.viewmodel.OptionsViewModel +import com.flipperdevices.filemanager.transfer.impl.viewmodel.OptionsViewModel.Action +import flipperapp.components.core.ui.res.generated.resources.ic_more_points +import org.jetbrains.compose.resources.painterResource +import flipperapp.components.core.ui.res.generated.resources.Res as CoreUiRes + +@Composable +fun MoreIconComposable( + optionsState: OptionsViewModel.State, + onAction: (Action) -> Unit, + onCreateFolderClick: () -> Unit, + modifier: Modifier = Modifier + +) { + Box(modifier = modifier) { + Icon( + modifier = Modifier + .padding(end = 14.dp) + .size(24.dp) + .clickableRipple(onClick = { onAction.invoke(Action.ToggleMenu) }), + painter = painterResource(CoreUiRes.drawable.ic_more_points), + contentDescription = null, + tint = LocalPalletV2.current.icon.blackAndWhite.blackOnColor + ) + ListOptionsDropDown( + isVisible = optionsState.isVisible, + onAction = onAction, + isHiddenFilesVisible = optionsState.isHiddenFilesVisible, + onCreateFolderClick = { + onAction.invoke(Action.ToggleMenu) + onCreateFolderClick.invoke() + }, + ) + } +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferAppBar.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferAppBar.kt new file mode 100644 index 0000000000..fbc3fe16e8 --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferAppBar.kt @@ -0,0 +1,32 @@ +package com.flipperdevices.filemanager.transfer.impl.composable + +import androidx.compose.runtime.Composable +import com.flipperdevices.core.ui.ktx.OrangeAppBar +import com.flipperdevices.filemanager.transfer.api.model.TransferType +import com.flipperdevices.filemanager.transfer.impl.viewmodel.OptionsViewModel +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_appbar_title_moving +import org.jetbrains.compose.resources.stringResource +import flipperapp.components.filemngr.transfer.impl.generated.resources.Res as FMT + +@Composable +fun TransferAppBar( + transferType: TransferType, + onBack: () -> Unit, + optionsState: OptionsViewModel.State, + onOptionsAction: (OptionsViewModel.Action) -> Unit, + onCreateFolder: () -> Unit +) { + OrangeAppBar( + title = when (transferType) { + TransferType.MOVE -> stringResource(FMT.string.fmt_appbar_title_moving) + }, + onBack = onBack::invoke, + endBlock = { + MoreIconComposable( + optionsState = optionsState, + onAction = onOptionsAction, + onCreateFolderClick = onCreateFolder, + ) + } + ) +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferFolderCardListComposable.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferFolderCardListComposable.kt new file mode 100644 index 0000000000..97ed6ab767 --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferFolderCardListComposable.kt @@ -0,0 +1,50 @@ +package com.flipperdevices.filemanager.transfer.impl.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.core.ktx.jre.toFormattedSize +import com.flipperdevices.filemanager.listing.api.model.ExtendedListingItem +import com.flipperdevices.filemanager.ui.components.itemcard.FolderCardListComposable +import com.flipperdevices.filemanager.ui.components.itemcard.components.asPainter +import com.flipperdevices.filemanager.ui.components.itemcard.components.asTint +import com.flipperdevices.filemanager.ui.components.itemcard.model.ItemUiSelectionState +import flipperapp.components.filemngr.transfer.impl.generated.resources.Res +import flipperapp.components.filemngr.transfer.impl.generated.resources.fmt_items_in_folder +import okio.Path +import org.jetbrains.compose.resources.stringResource + +@Composable +fun TransferFolderCardListComposable( + fullDirPath: Path, + item: ExtendedListingItem, + isMoving: Boolean, + onPathChange: (Path) -> Unit, + modifier: Modifier = Modifier +) { + FolderCardListComposable( + modifier = modifier, + painter = item.asListingItem().asPainter(), + iconTint = item.asListingItem().asTint(), + title = item.itemName, + subtitle = when (item) { + is ExtendedListingItem.File -> item.size.toFormattedSize() + is ExtendedListingItem.Folder -> stringResource( + resource = Res.string.fmt_items_in_folder, + item.itemsCount.toString() + ) + }, + isSubtitleLoading = when (item) { + is ExtendedListingItem.File -> false + is ExtendedListingItem.Folder -> item.itemsCount == null + }, + selectionState = ItemUiSelectionState.NONE, + onClick = onClick@{ + if (item.itemType != FileType.DIR) return@onClick + if (isMoving) return@onClick + onPathChange.invoke(fullDirPath.resolve(item.itemName)) + }, + onCheckChange = null, + onMoreClick = null + ) +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferPathComposable.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferPathComposable.kt new file mode 100644 index 0000000000..3c475059ac --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/composable/TransferPathComposable.kt @@ -0,0 +1,32 @@ +package com.flipperdevices.filemanager.transfer.impl.composable + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.flipperdevices.filemanager.ui.components.path.PathComposable +import okio.Path + +@Composable +fun TransferPathComposable( + isMoving: Boolean, + path: Path, + onPathChange: (Path) -> Unit, + modifier: Modifier = Modifier +) { + PathComposable( + path = path, + onRootPathClick = onRootPathClick@{ + if (isMoving) return@onRootPathClick + path.root?.run(onPathChange) + }, + onPathClick = onPathClick@{ clickedPath -> + if (isMoving) return@onPathClick + onPathChange.invoke(clickedPath) + }, + modifier = modifier + .fillMaxWidth() + .padding(14.dp) + ) +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/FilesViewModel.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/FilesViewModel.kt new file mode 100644 index 0000000000..b4af9c97dc --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/FilesViewModel.kt @@ -0,0 +1,226 @@ +package com.flipperdevices.filemanager.transfer.impl.viewmodel + +import androidx.datastore.core.DataStore +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureProvider +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureStatus +import com.flipperdevices.bridge.connection.feature.provider.api.get +import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi +import com.flipperdevices.bridge.connection.feature.storage.api.fm.FListingStorageApi +import com.flipperdevices.bridge.connection.feature.storage.api.model.FileType +import com.flipperdevices.bridge.connection.feature.storage.api.model.ListingItem +import com.flipperdevices.core.ktx.jre.toThrowableFlow +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.error +import com.flipperdevices.core.preference.pb.FileManagerSort +import com.flipperdevices.core.preference.pb.Settings +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import com.flipperdevices.filemanager.listing.api.model.ExtendedListingItem +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import okio.Path +import okio.Path.Companion.toPath + +class FilesViewModel @AssistedInject constructor( + private val featureProvider: FFeatureProvider, + @Assisted private val path: Path, + private val settingsDataStore: DataStore +) : DecomposeViewModel(), LogTagProvider { + override val TAG = "FilesViewModel" + + private val _state = MutableStateFlow(State.Loading) + val state = combine( + flow = settingsDataStore.data, + flow2 = _state, + transform = { settings, state -> + when (state) { + State.CouldNotListPath -> state + State.Loading -> state + State.Unsupported -> state + is State.Loaded -> { + state.copy( + files = state.files + .filter { + if (settings.show_hidden_files_on_flipper) { + true + } else { + !it.path.name.startsWith(".") + } + } + .sortedByDescending { + when (settings.file_manager_sort) { + is FileManagerSort.Unrecognized, + FileManagerSort.DEFAULT -> null + + FileManagerSort.SIZE -> { + when (it) { + is ExtendedListingItem.File -> it.size + // The default size for folder is 0 + // Here's placed 0 so sort works as on flipper + is ExtendedListingItem.Folder -> 0 + } + } + } + } + .toImmutableList() + ) + } + } + } + ).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) + + private fun ListingItem.toExtended(): ExtendedListingItem { + return when (fileType) { + FileType.DIR -> { + ExtendedListingItem.Folder( + path = fileName.toPath(), + itemsCount = null + ) + } + + null, FileType.FILE -> { + ExtendedListingItem.File( + path = fileName.toPath(), + size = size + ) + } + } + } + + fun onFolderCreated(listingItem: ListingItem) { + _state.update { state -> + (state as? State.Loaded)?.let { loadedState -> + val newItems = loadedState.files + .filter { it.path.name != listingItem.fileName } + .plus(listingItem.toExtended()) + .toImmutableList() + loadedState.copy(files = newItems) + } ?: state + } + } + + private suspend fun updateSubfoldersCount( + items: List, + listingApi: FListingStorageApi + ) { + items + .filterIsInstance() + .filter { directory -> directory.itemsCount == null } + .onEach { directory -> + _state.update { state -> + val loadedState = (state as? State.Loaded) + if (loadedState == null) { + error { "#updateFiles state changed during update" } + return@update state + } + val newList = loadedState.files.toMutableList() + val i = newList.indexOfFirst { item -> item == directory } + if (i == -1) { + error { "#updateFiles could not find item in list" } + return@update loadedState + } + val itemsCount = listingApi.ls(path.resolve(directory.path).toString()) + .getOrNull() + .orEmpty() + .size + val updatedDirectory = directory.copy(itemsCount = itemsCount) + newList[i] = updatedDirectory + loadedState.copy(files = newList.toImmutableList()) + } + } + } + + private suspend fun listFiles(listingApi: FListingStorageApi) { + listingApi.lsFlow(path.toString()) + .toThrowableFlow() + .catch { _state.emit(State.CouldNotListPath) } + .map { items -> items.map { item -> item.toExtended() } } + .onEach { files -> + _state.updateAndGet { state -> + when (state) { + is State.Loaded -> { + state.copy(state.files.plus(files).toImmutableList()) + } + + else -> { + State.Loaded(files.toImmutableList()) + } + } + } + }.launchIn(viewModelScope) + } + + private fun startListFiles() { + featureProvider + .get() + .onEach { featureStatus -> + when (featureStatus) { + FFeatureStatus.Unsupported, + FFeatureStatus.NotFound -> { + _state.emit(State.Unsupported) + } + + is FFeatureStatus.Supported -> { + listFiles(featureStatus.featureApi.listingApi()) + } + + FFeatureStatus.Retrieving -> { + _state.emit(State.Loading) + } + } + } + .launchIn(viewModelScope) + } + + private fun startSubfolderCountUpdater() { + combine( + flow = featureProvider + .get() + .filterIsInstance>(), + flow2 = state + .filterIsInstance() + .distinctUntilChangedBy { it.files.size }, + transform = { feature, state -> + updateSubfoldersCount( + items = state.files, + listingApi = feature.featureApi.listingApi() + ) + } + ).launchIn(viewModelScope) + } + + init { + startListFiles() + startSubfolderCountUpdater() + } + + sealed interface State { + data object Loading : State + data object Unsupported : State + data object CouldNotListPath : State + data class Loaded( + val files: ImmutableList, + ) : State + } + + @AssistedFactory + fun interface Factory { + operator fun invoke( + path: Path + ): FilesViewModel + } +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/OptionsViewModel.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/OptionsViewModel.kt new file mode 100644 index 0000000000..190e7d602a --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/OptionsViewModel.kt @@ -0,0 +1,113 @@ +package com.flipperdevices.filemanager.transfer.impl.viewmodel + +import androidx.datastore.core.DataStore +import com.flipperdevices.core.preference.pb.FileManagerOrientation +import com.flipperdevices.core.preference.pb.FileManagerSort +import com.flipperdevices.core.preference.pb.Settings +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +class OptionsViewModel @Inject constructor( + private val settingsDataStore: DataStore +) : DecomposeViewModel() { + private val _state = MutableStateFlow( + State( + // need runBlocking to get rid of animation change from list to grid + orientation = runBlocking { settingsDataStore.data.first().file_manager_orientation } + ) + ) + val state = _state.asStateFlow() + + fun toggleMenu() { + _state.update { it.copy(isVisible = !it.isVisible) } + } + + private fun setHiddenFiles(isHiddenFilesVisible: Boolean) { + toggleMenu() + viewModelScope.launch { + settingsDataStore.updateData { it.copy(show_hidden_files_on_flipper = isHiddenFilesVisible) } + } + } + + fun toggleHiddenFiles() { + setHiddenFiles(!state.value.isHiddenFilesVisible) + } + + private fun setOrientation(orientation: FileManagerOrientation) { + toggleMenu() + viewModelScope.launch { + settingsDataStore.updateData { it.copy(file_manager_orientation = orientation) } + } + } + + fun setListOrientation() { + setOrientation(FileManagerOrientation.LIST) + } + + fun setGridOrientation() { + setOrientation(FileManagerOrientation.GRID) + } + + private fun setSort(sort: FileManagerSort) { + toggleMenu() + viewModelScope.launch { + settingsDataStore.updateData { it.copy(file_manager_sort = sort) } + } + } + + fun setDefaultSort() { + setSort(FileManagerSort.DEFAULT) + } + + fun setSizeSort() { + setSort(FileManagerSort.SIZE) + } + + fun onAction(action: Action) { + when (action) { + Action.DisplayGrid -> setGridOrientation() + Action.DisplayList -> setListOrientation() + Action.SortByDefault -> setDefaultSort() + Action.SortBySize -> setSizeSort() + Action.ToggleHidden -> toggleHiddenFiles() + Action.ToggleMenu -> toggleMenu() + } + } + + init { + settingsDataStore.data + .onEach { settings -> + _state.update { state -> + state.copy( + orientation = settings.file_manager_orientation, + sortType = settings.file_manager_sort, + isHiddenFilesVisible = settings.show_hidden_files_on_flipper + ) + } + }.launchIn(viewModelScope) + } + + data class State( + val isVisible: Boolean = false, + val isHiddenFilesVisible: Boolean = false, + val orientation: FileManagerOrientation = FileManagerOrientation.LIST, + val sortType: FileManagerSort = FileManagerSort.DEFAULT + ) + + sealed interface Action { + data object ToggleMenu : Action + data object DisplayGrid : Action + data object DisplayList : Action + data object SortBySize : Action + data object SortByDefault : Action + data object ToggleHidden : Action + } +} diff --git a/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/TransferViewModel.kt b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/TransferViewModel.kt new file mode 100644 index 0000000000..a8426e49fc --- /dev/null +++ b/components/filemngr/transfer/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/transfer/impl/viewmodel/TransferViewModel.kt @@ -0,0 +1,92 @@ +package com.flipperdevices.filemanager.transfer.impl.viewmodel + +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureProvider +import com.flipperdevices.bridge.connection.feature.provider.api.FFeatureStatus +import com.flipperdevices.bridge.connection.feature.provider.api.get +import com.flipperdevices.bridge.connection.feature.storage.api.FStorageFeatureApi +import com.flipperdevices.bridge.connection.feature.storage.api.fm.FFileUploadApi +import com.flipperdevices.core.log.LogTagProvider +import com.flipperdevices.core.log.error +import com.flipperdevices.core.ui.lifecycle.DecomposeViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okio.Path +import javax.inject.Inject + +class TransferViewModel @Inject constructor( + private val featureProvider: FFeatureProvider, +) : DecomposeViewModel(), LogTagProvider { + override val TAG = "FilesViewModel" + + private val _state = MutableStateFlow(State.Pending) + val state = _state.asStateFlow() + + private val featureMutex = Mutex() + private var featureJob: Job? = null + private var moveMutex = Mutex() + private var moveJob: Job? = null + + private suspend fun move( + uploadApi: FFileUploadApi, + oldPaths: List, + targetDir: Path + ) { + oldPaths.map { oldPath -> + val targetPath = targetDir.resolve(oldPath.name) + uploadApi.move( + oldPath = oldPath, + newPath = targetPath + ).onFailure { + error(it) { "#onFinish could not move file $oldPaths -> $targetPath" } + } + }.firstOrNull { result -> result.isSuccess }?.onSuccess { + _state.emit(State.Moved(targetDir)) + } + } + + fun move(oldPaths: List, targetDir: Path) { + viewModelScope.launch { + featureJob?.cancelAndJoin() + featureMutex.withLock { + featureJob = featureProvider.get() + .onEach { status -> + moveJob?.cancelAndJoin() + moveMutex.withLock { + when (status) { + FFeatureStatus.NotFound -> _state.emit(State.Unsupported) + FFeatureStatus.Retrieving -> _state.emit(State.Moving) + is FFeatureStatus.Supported -> { + moveJob = viewModelScope.launch { + _state.emit(State.Moving) + move( + uploadApi = status.featureApi.uploadApi(), + oldPaths = oldPaths, + targetDir = targetDir + ) + } + } + + FFeatureStatus.Unsupported -> _state.emit(State.Unsupported) + } + moveJob?.join() + } + }.launchIn(viewModelScope) + featureJob?.join() + } + } + } + + sealed interface State { + data object Unsupported : State + data object Pending : State + data object Moving : State + data class Moved(val targetDir: Path) : State + } +} diff --git a/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/error/ErrorContentComposablePreview.kt b/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/error/ErrorContentComposablePreview.kt new file mode 100644 index 0000000000..d67a963f1d --- /dev/null +++ b/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/error/ErrorContentComposablePreview.kt @@ -0,0 +1,18 @@ +package com.flipperdevices.filemanager.ui.components.error + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.flipperdevices.core.ui.theme.FlipperThemeInternal + +@Preview +@Composable +@Suppress("MaximumLineLength", "MaxLineLength") +private fun ErrorContentComposablePreview() { + FlipperThemeInternal { + ErrorContentComposable( + text = "Some unknown error happened :(", + desc = "Sorry, but we don't know why that happened so it's impossible to resolve. Try to turn off and turn on the device! Good luck!", + onRetry = {} + ) + } +} diff --git a/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/name/NameDialogPreview.kt b/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/name/NameDialogPreview.kt index 10a138cf2b..eb18b53b08 100644 --- a/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/name/NameDialogPreview.kt +++ b/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/name/NameDialogPreview.kt @@ -46,7 +46,8 @@ private fun NameDialogPreview() { needShowOptions = File(value).extension.isBlank(), isError = File(value).extension.isBlank(), onFinish = { }, - isEnabled = true + isEnabled = true, + isLoading = false ) } } diff --git a/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/FileTransferFullScreenComposablePreview.kt b/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/FileTransferFullScreenComposablePreview.kt new file mode 100644 index 0000000000..9e25aa3e64 --- /dev/null +++ b/components/filemngr/ui-components/src/androidMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/FileTransferFullScreenComposablePreview.kt @@ -0,0 +1,22 @@ +package com.flipperdevices.filemanager.ui.components.transfer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.flipperdevices.core.ui.theme.FlipperThemeInternal + +@Preview +@Composable +private fun FileTransferFullScreenComposablePreview() { + FlipperThemeInternal { + FileTransferFullScreenComposable( + title = "Downloading", + actionText = "Cancel", + onActionClick = {}, + progressTitle = "flipperfile.txt", + progress = 0.3f, + progressText = "Downloading 3KiB/5KiB", + speedText = "Speed: 10KiB/s", + progressDetailText = "1/3 items finished" + ) + } +} diff --git a/components/filemngr/ui-components/src/commonMain/composeResources/values/strings.xml b/components/filemngr/ui-components/src/commonMain/composeResources/values/strings.xml index e9e0a1d0bf..79340ecbc6 100644 --- a/components/filemngr/ui-components/src/commonMain/composeResources/values/strings.xml +++ b/components/filemngr/ui-components/src/commonMain/composeResources/values/strings.xml @@ -5,4 +5,11 @@ Insert a microSD card in Flipper Zero to access Files Used Total + Retry + + Unsupported Feature + Unfortunately, this feature is no supported on current device + + Unexpected error + Unexpected error has occur \ No newline at end of file diff --git a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/ErrorContentComposable.kt b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/ErrorContentComposable.kt new file mode 100644 index 0000000000..2578b33d34 --- /dev/null +++ b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/ErrorContentComposable.kt @@ -0,0 +1,76 @@ +package com.flipperdevices.filemanager.ui.components.error + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flipperdevices.core.ui.ktx.clickableRipple +import com.flipperdevices.core.ui.theme.LocalPallet +import com.flipperdevices.core.ui.theme.LocalTypography +import flipperapp.components.filemngr.ui_components.generated.resources.Res +import flipperapp.components.filemngr.ui_components.generated.resources.filemngr_error_retry +import flipperapp.components.filemngr.ui_components.generated.resources.ic__no_files_black +import flipperapp.components.filemngr.ui_components.generated.resources.ic__no_files_white +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ErrorContentComposable( + text: String, + desc: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, + painter: Painter = painterResource( + when { + MaterialTheme.colors.isLight -> Res.drawable.ic__no_files_white + else -> Res.drawable.ic__no_files_black + } + ), +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) + ) { + Image( + modifier = Modifier.size(124.dp), + painter = painter, + contentDescription = null + ) + Text( + text = text, + style = LocalTypography.current.bodyM14, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier.padding(horizontal = 24.dp), + text = desc, + textAlign = TextAlign.Center, + style = LocalTypography.current.bodyR14.copy( + color = LocalPallet.current.text30 + ) + ) + if (onRetry != null) { + Text( + modifier = Modifier + .padding(top = 6.dp) + .clickableRipple(onClick = onRetry), + text = stringResource(Res.string.filemngr_error_retry), + textAlign = TextAlign.Center, + style = LocalTypography.current.buttonM16.copy( + color = LocalPallet.current.accentSecond + ) + ) + } + } +} diff --git a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/UnknownErrorComposable.kt b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/UnknownErrorComposable.kt new file mode 100644 index 0000000000..444b1a4c09 --- /dev/null +++ b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/UnknownErrorComposable.kt @@ -0,0 +1,21 @@ +package com.flipperdevices.filemanager.ui.components.error + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import flipperapp.components.filemngr.ui_components.generated.resources.Res +import flipperapp.components.filemngr.ui_components.generated.resources.filemngr_error_unknown_desc +import flipperapp.components.filemngr.ui_components.generated.resources.filemngr_error_unknown_title +import org.jetbrains.compose.resources.stringResource + +@Composable +fun UnknownErrorComposable( + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + ErrorContentComposable( + modifier = modifier, + onRetry = onRetry, + text = stringResource(Res.string.filemngr_error_unknown_title), + desc = stringResource(Res.string.filemngr_error_unknown_desc) + ) +} diff --git a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/UnsupportedErrorComposable.kt b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/UnsupportedErrorComposable.kt new file mode 100644 index 0000000000..69405ea67b --- /dev/null +++ b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/error/UnsupportedErrorComposable.kt @@ -0,0 +1,21 @@ +package com.flipperdevices.filemanager.ui.components.error + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import flipperapp.components.filemngr.ui_components.generated.resources.filemngr_error_unsupported_desc +import flipperapp.components.filemngr.ui_components.generated.resources.filemngr_error_unsupported_title +import org.jetbrains.compose.resources.stringResource +import flipperapp.components.filemngr.ui_components.generated.resources.Res as FR + +@Composable +fun UnsupportedErrorComposable( + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + ErrorContentComposable( + modifier = modifier, + onRetry = onRetry, + text = stringResource(FR.string.filemngr_error_unsupported_title), + desc = stringResource(FR.string.filemngr_error_unsupported_desc) + ) +} diff --git a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/itemcard/FolderCardListComposable.kt b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/itemcard/FolderCardListComposable.kt index fda29be805..2290f5b918 100644 --- a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/itemcard/FolderCardListComposable.kt +++ b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/itemcard/FolderCardListComposable.kt @@ -109,9 +109,9 @@ fun FolderCardListComposable( isSubtitleLoading: Boolean, selectionState: ItemUiSelectionState, onClick: () -> Unit, - onCheckChange: (Boolean) -> Unit, - onMoreClick: () -> Unit, modifier: Modifier = Modifier, + onCheckChange: ((Boolean) -> Unit)? = null, + onMoreClick: (() -> Unit)? = null, iconTint: Color = Color.Unspecified, showEndBox: Boolean = true ) { @@ -148,7 +148,7 @@ fun FolderCardListComposable( } } - if (showEndBox) { + if (showEndBox && onCheckChange != null && onMoreClick != null) { ItemCardEndBox( selectionState = selectionState, onCheckChange = onCheckChange, diff --git a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/name/AutoCompleteTextField.kt b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/name/AutoCompleteTextField.kt index 748370cad0..b850854d2d 100644 --- a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/name/AutoCompleteTextField.kt +++ b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/name/AutoCompleteTextField.kt @@ -35,6 +35,7 @@ internal fun AutoCompleteTextField( onOptionSelect: (index: Int) -> Unit, modifier: Modifier = Modifier, needShowOptions: Boolean = true, + isEnabled: Boolean = true, ) { var isExpanded by rememberSaveable { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } @@ -50,14 +51,15 @@ internal fun AutoCompleteTextField( onTextChange = onTextChange, interactionSource = interactionSource, modifier = Modifier.onFocusEvent { isExpanded = it.isFocused }, - isError = isError + isError = isError, + enabled = isEnabled ) DropdownMenu( modifier = Modifier .heightIn(max = 152.dp) .wrapContentWidth(), - expanded = isExpanded && needShowOptions && options.isNotEmpty(), + expanded = isExpanded && needShowOptions && options.isNotEmpty() && isEnabled, onDismissRequest = { }, properties = PopupProperties(focusable = false) ) { diff --git a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/name/NameDialog.kt b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/name/NameDialog.kt index 35449fd38d..6aa7bb2dc9 100644 --- a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/name/NameDialog.kt +++ b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/name/NameDialog.kt @@ -37,6 +37,7 @@ fun NameDialog( needShowOptions: Boolean, isError: Boolean, isEnabled: Boolean, + isLoading: Boolean, onTextChange: (String) -> Unit, options: ImmutableList, onOptionSelect: (index: Int) -> Unit, @@ -74,7 +75,8 @@ fun NameDialog( needShowOptions = needShowOptions, title = title, subtitle = subtitle, - isError = isError + isError = isError, + isEnabled = isEnabled ) Spacer(Modifier.height(24.dp)) @@ -83,7 +85,8 @@ fun NameDialog( modifier = Modifier.fillMaxWidth(), textPadding = PaddingValues(vertical = 12.dp), onClick = onFinish, - enabled = isEnabled + enabled = isEnabled, + isLoading = isLoading ) } } diff --git a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/FileTransferFullScreenComposable.kt b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/FileTransferFullScreenComposable.kt new file mode 100644 index 0000000000..ebcc008ff3 --- /dev/null +++ b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/FileTransferFullScreenComposable.kt @@ -0,0 +1,70 @@ +package com.flipperdevices.filemanager.ui.components.transfer + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flipperdevices.core.ui.ktx.clickableRipple +import com.flipperdevices.core.ui.theme.LocalPalletV2 +import com.flipperdevices.core.ui.theme.LocalTypography + +@Composable +fun FileTransferFullScreenComposable( + title: String, + actionText: String, + onActionClick: () -> Unit, + progressTitle: String, + progress: Float, + modifier: Modifier = Modifier, + progressText: String? = null, + progressDetailText: String? = null, + speedText: String? = null +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() + ) { + Text( + text = title, + style = LocalTypography.current.titleB18, + color = LocalPalletV2.current.text.title.primary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + Text( + text = actionText, + style = LocalTypography.current.bodyM14, + color = LocalPalletV2.current.action.danger.text.default, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickableRipple(onClick = onActionClick) + .padding(12.dp), + textAlign = TextAlign.Center + ) + } + FileTransferProgressComposable( + progressTitle = progressTitle, + progressDetailText = progressDetailText, + progress = progress, + progressText = progressText, + speedText = speedText + ) + } +} diff --git a/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/InProgressComposable.kt b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/InProgressComposable.kt new file mode 100644 index 0000000000..ab4cb765e6 --- /dev/null +++ b/components/filemngr/ui-components/src/commonMain/kotlin/com/flipperdevices/filemanager/ui/components/transfer/InProgressComposable.kt @@ -0,0 +1,107 @@ +package com.flipperdevices.filemanager.ui.components.transfer + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flipperdevices.core.ui.ktx.elements.FlipperProgressIndicator +import com.flipperdevices.core.ui.theme.LocalPallet +import com.flipperdevices.core.ui.theme.LocalPalletV2 +import com.flipperdevices.core.ui.theme.LocalTypography + +@Suppress("LongMethod") +@Composable +internal fun FileTransferProgressComposable( + progressTitle: String, + progress: Float, + modifier: Modifier = Modifier, + progressText: String? = null, + progressDetailText: String? = null, + speedText: String? = null +) { + val animatedProgress by animateFloatAsState( + targetValue = progress.coerceIn(0f, 1f), + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + label = "Progress" + ) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + Text( + text = progressTitle, + style = LocalTypography.current.titleB18, + color = LocalPalletV2.current.text.title.primary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + FlipperProgressIndicator( + modifier = Modifier.padding(horizontal = 32.dp), + accentColor = LocalPalletV2.current.action.blue.border.primary.default, + secondColor = LocalPallet.current.actionOnFlipperProgress, + painter = null, + percent = animatedProgress + ) + Spacer(Modifier.height(8.dp)) + AnimatedVisibility(progressDetailText != null) { + if (progressDetailText != null) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = progressDetailText, + style = LocalTypography.current.subtitleM12, + color = LocalPalletV2.current.text.body.secondary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + } + } + } + AnimatedVisibility(progressText != null) { + if (progressText != null) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = progressText, + style = LocalTypography.current.subtitleM12, + color = LocalPalletV2.current.text.body.secondary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + } + } + } + AnimatedVisibility(speedText != null) { + if (speedText != null) { + Text( + text = speedText, + style = LocalTypography.current.subtitleM12, + color = LocalPalletV2.current.text.body.secondary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + } +} diff --git a/components/filemngr/upload/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/InProgressComposablePreview.kt b/components/filemngr/upload/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/InProgressComposablePreview.kt deleted file mode 100644 index 3b4f35f766..0000000000 --- a/components/filemngr/upload/impl/src/androidMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/InProgressComposablePreview.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.flipperdevices.filemanager.upload.impl.composable - -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import com.flipperdevices.core.ui.theme.FlipperThemeInternal -import com.flipperdevices.filemanager.upload.api.UploaderDecomposeComponent - -@Preview -@Composable -private fun InProgressComposablePreview() { - FlipperThemeInternal { - InProgressComposable( - state = UploaderDecomposeComponent.State.Uploading( - currentItemIndex = 0, - totalItemsAmount = 3, - uploadedSize = 123456, - totalSize = 223456, - currentItem = UploaderDecomposeComponent.UploadingItem( - fileName = "file_name.txt", - uploadedSize = 1234L, - totalSize = 1234567L - ) - ), - speed = 1222L - ) - } -} diff --git a/components/filemngr/upload/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/InProgressComposable.kt b/components/filemngr/upload/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/InProgressComposable.kt deleted file mode 100644 index f1009f6cb3..0000000000 --- a/components/filemngr/upload/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/InProgressComposable.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.flipperdevices.filemanager.upload.impl.composable - -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.flipperdevices.core.ktx.jre.toFormattedSize -import com.flipperdevices.core.ui.ktx.elements.FlipperProgressIndicator -import com.flipperdevices.core.ui.theme.LocalPallet -import com.flipperdevices.core.ui.theme.LocalPalletV2 -import com.flipperdevices.core.ui.theme.LocalTypography -import com.flipperdevices.filemanager.upload.api.UploaderDecomposeComponent -import flipperapp.components.filemngr.upload.impl.generated.resources.fm_in_progress_file_size -import flipperapp.components.filemngr.upload.impl.generated.resources.fm_in_progress_items -import flipperapp.components.filemngr.upload.impl.generated.resources.fm_in_progress_speed -import flipperapp.components.filemngr.upload.impl.generated.resources.fm_uploading_file -import org.jetbrains.compose.resources.stringResource -import flipperapp.components.filemngr.upload.impl.generated.resources.Res as FUR - -@Composable -private fun InProgressDetailComposable( - state: UploaderDecomposeComponent.State.Uploading, - modifier: Modifier = Modifier -) { - if (state.totalItemsAmount > 1) { - Text( - text = stringResource( - FUR.string.fm_uploading_file, - state.currentItem.fileName, - state.currentItem.uploadedSize.toFormattedSize(), - state.currentItem.totalSize.toFormattedSize() - ), - style = LocalTypography.current.subtitleM12, - color = LocalPalletV2.current.text.body.secondary, - modifier = modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } -} - -@Composable -private fun InProgressTitleComposable( - state: UploaderDecomposeComponent.State.Uploading, - modifier: Modifier = Modifier -) { - Text( - text = when { - state.totalItemsAmount == 1 -> state.currentItem.fileName - else -> stringResource( - FUR.string.fm_in_progress_items, - state.currentItemIndex.plus(1), - state.totalItemsAmount - ) - }, - style = LocalTypography.current.titleB18, - color = LocalPalletV2.current.text.title.primary, - modifier = modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) -} - -@Composable -internal fun InProgressComposable( - state: UploaderDecomposeComponent.State.Uploading, - speed: Long?, -) { - val animatedProgress by animateFloatAsState( - targetValue = if (state.totalSize == 0L) 0f else state.uploadedSize / state.totalSize.toFloat(), - animationSpec = tween(durationMillis = 500, easing = LinearEasing), - label = "Progress" - ) - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - InProgressTitleComposable(state) - Spacer(Modifier.height(12.dp)) - FlipperProgressIndicator( - modifier = Modifier.padding(horizontal = 32.dp), - accentColor = LocalPalletV2.current.action.blue.border.primary.default, - secondColor = LocalPallet.current.actionOnFlipperProgress, - painter = null, - percent = animatedProgress - ) - Spacer(Modifier.height(8.dp)) - InProgressDetailComposable(state) - Text( - text = stringResource( - FUR.string.fm_in_progress_file_size, - state.uploadedSize.toFormattedSize(), - state.totalSize.toFormattedSize() - ), - style = LocalTypography.current.subtitleM12, - color = LocalPalletV2.current.text.body.secondary, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - Spacer(Modifier.height(8.dp)) - speed?.let { - Text( - text = stringResource( - FUR.string.fm_in_progress_speed, - speed.toFormattedSize(), - ), - style = LocalTypography.current.subtitleM12, - color = LocalPalletV2.current.text.body.secondary, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - } -} diff --git a/components/filemngr/upload/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/UploadingComposable.kt b/components/filemngr/upload/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/UploadingComposable.kt index c0c0606952..713d729a48 100644 --- a/components/filemngr/upload/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/UploadingComposable.kt +++ b/components/filemngr/upload/impl/src/commonMain/kotlin/com/flipperdevices/filemanager/upload/impl/composable/UploadingComposable.kt @@ -1,25 +1,16 @@ package com.flipperdevices.filemanager.upload.impl.composable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.flipperdevices.core.ui.ktx.clickableRipple -import com.flipperdevices.core.ui.theme.LocalPalletV2 -import com.flipperdevices.core.ui.theme.LocalTypography +import com.flipperdevices.core.ktx.jre.toFormattedSize +import com.flipperdevices.filemanager.ui.components.transfer.FileTransferFullScreenComposable import com.flipperdevices.filemanager.upload.api.UploaderDecomposeComponent import flipperapp.components.filemngr.upload.impl.generated.resources.fm_cancel +import flipperapp.components.filemngr.upload.impl.generated.resources.fm_in_progress_file_size +import flipperapp.components.filemngr.upload.impl.generated.resources.fm_in_progress_items +import flipperapp.components.filemngr.upload.impl.generated.resources.fm_in_progress_speed import flipperapp.components.filemngr.upload.impl.generated.resources.fm_uploading +import flipperapp.components.filemngr.upload.impl.generated.resources.fm_uploading_file import org.jetbrains.compose.resources.stringResource import flipperapp.components.filemngr.upload.impl.generated.resources.Res as FUR @@ -30,39 +21,39 @@ fun UploadingComposable( onCancel: () -> Unit, modifier: Modifier = Modifier ) { - Box( - modifier = modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxSize() - ) { - Text( - text = stringResource(FUR.string.fm_uploading), - style = LocalTypography.current.titleB18, - color = LocalPalletV2.current.text.title.primary, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center + FileTransferFullScreenComposable( + modifier = modifier, + title = stringResource(FUR.string.fm_uploading), + actionText = stringResource(FUR.string.fm_cancel), + onActionClick = onCancel, + progress = if (state.totalSize == 0L) 0f else state.uploadedSize / state.totalSize.toFloat(), + progressDetailText = when (state.totalItemsAmount) { + 1 -> null + else -> stringResource( + FUR.string.fm_uploading_file, + state.currentItem.fileName, + state.currentItem.uploadedSize.toFormattedSize(), + state.currentItem.totalSize.toFormattedSize() ) - - Text( - text = stringResource(FUR.string.fm_cancel), - style = LocalTypography.current.bodyM14, - color = LocalPalletV2.current.action.danger.text.default, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - .clip(RoundedCornerShape(12.dp)) - .clickableRipple(onClick = onCancel), - textAlign = TextAlign.Center + }, + progressTitle = when { + state.totalItemsAmount == 1 -> state.currentItem.fileName + else -> stringResource( + FUR.string.fm_in_progress_items, + state.currentItemIndex.plus(1), + state.totalItemsAmount + ) + }, + progressText = stringResource( + FUR.string.fm_in_progress_file_size, + state.uploadedSize.toFormattedSize(), + state.totalSize.toFormattedSize() + ), + speedText = speed?.let { _ -> + stringResource( + FUR.string.fm_in_progress_speed, + speed.toFormattedSize(), ) - } - InProgressComposable( - state = state, - speed = speed - ) - } + }, + ) } diff --git a/components/filemngr/util/build.gradle.kts b/components/filemngr/util/build.gradle.kts new file mode 100644 index 0000000000..22e9e84584 --- /dev/null +++ b/components/filemngr/util/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("flipper.multiplatform") + id("flipper.multiplatform-dependencies") + id("kotlinx-serialization") +} + +android.namespace = "com.flipperdevices.filemanager.util" + +commonDependencies { + implementation(projects.components.bridge.dao.api) + + implementation(libs.kotlin.serialization.json) + + implementation(libs.okio) +} diff --git a/components/filemngr/util/src/commonMain/kotlin/com/flipperdevices/filemanager/util/constant/FileManagerConstants.kt b/components/filemngr/util/src/commonMain/kotlin/com/flipperdevices/filemanager/util/constant/FileManagerConstants.kt new file mode 100644 index 0000000000..f5377e4ff8 --- /dev/null +++ b/components/filemngr/util/src/commonMain/kotlin/com/flipperdevices/filemanager/util/constant/FileManagerConstants.kt @@ -0,0 +1,14 @@ +package com.flipperdevices.filemanager.util.constant + +import com.flipperdevices.bridge.dao.api.model.FlipperKeyType + +object FileManagerConstants { + const val FILE_NAME_AVAILABLE_CHARACTERS = "“0-9”, “A-Z”, “a-z”, “!#\\\$%&'()-@^_`{}~”" + + val FILE_EXTENSION_HINTS = listOf("txt").plus(FlipperKeyType.entries.map { it.extension }) + + /** + * Max file of size available to edit + */ + const val LIMITED_SIZE_BYTES = 1024L * 1024L // 1MB +} diff --git a/components/filemngr/main/api/src/commonMain/kotlin/com/flipperdevices/filemanager/main/serialization/PathSerializer.kt b/components/filemngr/util/src/commonMain/kotlin/com/flipperdevices/filemanager/util/serialization/PathSerializer.kt similarity index 93% rename from components/filemngr/main/api/src/commonMain/kotlin/com/flipperdevices/filemanager/main/serialization/PathSerializer.kt rename to components/filemngr/util/src/commonMain/kotlin/com/flipperdevices/filemanager/util/serialization/PathSerializer.kt index d9eabc4258..017237d0de 100644 --- a/components/filemngr/main/api/src/commonMain/kotlin/com/flipperdevices/filemanager/main/serialization/PathSerializer.kt +++ b/components/filemngr/util/src/commonMain/kotlin/com/flipperdevices/filemanager/util/serialization/PathSerializer.kt @@ -1,4 +1,4 @@ -package com.flipperdevices.filemanager.main.serialization +package com.flipperdevices.filemanager.util.serialization import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind diff --git a/settings.gradle.kts b/settings.gradle.kts index bfae6156c8..e9f993d972 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -86,6 +86,7 @@ include( ":components:bridge:connection:feature:serialspeed:api", ":components:bridge:connection:feature:serialspeed:impl", + ":components:filemngr:util", ":components:filemanager:api", ":components:filemanager:impl", ":components:newfilemanager:api", @@ -103,6 +104,12 @@ include( ":components:filemngr:editor:impl", ":components:filemngr:download:api", ":components:filemngr:download:impl", + ":components:filemngr:rename:api", + ":components:filemngr:rename:impl", + ":components:filemngr:create:api", + ":components:filemngr:create:impl", + ":components:filemngr:transfer:api", + ":components:filemngr:transfer:impl", ":components:core:di", ":components:core:ktx",