diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7acb022b..4af08a0d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,50 +44,59 @@ android { } } -composeCompiler { - enableStrongSkippingMode = true -} - dependencies { - implementation(libs.androidx.profileinstaller) "baselineProfile"(project(":baselineprofile")) + implementation(libs.androidx.profileinstaller) coreLibraryDesugaring(libs.desugar.jdk.libs) - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) + // Local/File-based dependencies + implementation(files("libs/APKEditor.jar")) + + // AndroidX - Core & Lifecycle + implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.compose.android) - implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.material) + // Jetpack Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.foundation) - implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.icons.extended) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.ui.tooling.preview.android) + // Other Jetpack & Android Libraries + implementation(libs.androidx.datastore) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.palette.ktx) + + // Sora Code Editor implementation(libs.sora.editor) implementation(libs.sora.editor.language.java) implementation(libs.sora.editor.language.textmate) + // Image Loading - Coil implementation(libs.coil.compose) implementation(libs.coil.gif) implementation(libs.coil.svg) implementation(libs.coil.video) - implementation(libs.gson) - implementation(libs.androidx.datastore) - + // Third-Party UI/Compose Utilities + implementation(libs.accompanist.systemuicontroller) implementation(libs.cascade.compose) implementation(libs.compose.swipebox) - implementation(libs.reorderable) - implementation(libs.storage) implementation(libs.grid) implementation(libs.lazycolumnscrollbar) + implementation(libs.reorderable) implementation(libs.zoomable) - implementation(libs.androidx.media3.exoplayer) - implementation(libs.androidx.media3.ui) - // APKEditor - implementation(files("libs/APKEditor.jar")) + // Third-Party General Utilities implementation(libs.apksig) + implementation(libs.commons.net) + implementation(libs.gson) + implementation(libs.storage) + implementation(libs.zip4j) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf4c2ec7..270e91f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,12 +3,17 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + @@ -58,6 +64,26 @@ + + + + + + + + + + + + + - + + + + + + + + + + + + + + + - + + (Ljava/lang/Object;Landro HSPLandroidx/compose/animation/core/Animatable;->(Ljava/lang/Object;Landroidx/compose/animation/core/TwoWayConverter;Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V HSPLandroidx/compose/animation/core/Animatable;->access$clampToBounds(Landroidx/compose/animation/core/Animatable;Ljava/lang/Object;)Ljava/lang/Object; HSPLandroidx/compose/animation/core/Animatable;->access$endAnimation(Landroidx/compose/animation/core/Animatable;)V -HSPLandroidx/compose/animation/core/Animatable;->access$setRunning(Landroidx/compose/animation/core/Animatable;Z)V +HSPLandroidx/compose/animation/core/Animatable;->access$setShowDialog(Landroidx/compose/animation/core/Animatable;Z)V HSPLandroidx/compose/animation/core/Animatable;->access$setTargetValue(Landroidx/compose/animation/core/Animatable;Ljava/lang/Object;)V HSPLandroidx/compose/animation/core/Animatable;->asState()Landroidx/compose/runtime/State; HSPLandroidx/compose/animation/core/Animatable;->clampToBounds(Ljava/lang/Object;)Ljava/lang/Object; @@ -1172,7 +1172,7 @@ HSPLandroidx/compose/animation/core/Animatable;->getTypeConverter()Landroidx/com HSPLandroidx/compose/animation/core/Animatable;->getValue()Ljava/lang/Object; HSPLandroidx/compose/animation/core/Animatable;->getVelocityVector()Landroidx/compose/animation/core/AnimationVector; HSPLandroidx/compose/animation/core/Animatable;->runAnimation(Landroidx/compose/animation/core/Animation;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -HSPLandroidx/compose/animation/core/Animatable;->setRunning(Z)V +HSPLandroidx/compose/animation/core/Animatable;->setShowDialog(Z)V HSPLandroidx/compose/animation/core/Animatable;->setTargetValue(Ljava/lang/Object;)V Landroidx/compose/animation/core/Animatable$runAnimation$2; HSPLandroidx/compose/animation/core/Animatable$runAnimation$2;->(Landroidx/compose/animation/core/Animatable;Ljava/lang/Object;Landroidx/compose/animation/core/Animation;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)V @@ -1227,10 +1227,10 @@ HSPLandroidx/compose/animation/core/AnimationScope;->getLastFrameTimeNanos()J HSPLandroidx/compose/animation/core/AnimationScope;->getStartTimeNanos()J HSPLandroidx/compose/animation/core/AnimationScope;->getValue()Ljava/lang/Object; HSPLandroidx/compose/animation/core/AnimationScope;->getVelocityVector()Landroidx/compose/animation/core/AnimationVector; -HSPLandroidx/compose/animation/core/AnimationScope;->isRunning()Z +HSPLandroidx/compose/animation/core/AnimationScope;->getShowDialog()Z HSPLandroidx/compose/animation/core/AnimationScope;->setFinishedTimeNanos$animation_core_release(J)V HSPLandroidx/compose/animation/core/AnimationScope;->setLastFrameTimeNanos$animation_core_release(J)V -HSPLandroidx/compose/animation/core/AnimationScope;->setRunning$animation_core_release(Z)V +HSPLandroidx/compose/animation/core/AnimationScope;->setShowDialog$animation_core_release(Z)V HSPLandroidx/compose/animation/core/AnimationScope;->setValue$animation_core_release(Ljava/lang/Object;)V HSPLandroidx/compose/animation/core/AnimationScope;->setVelocityVector$animation_core_release(Landroidx/compose/animation/core/AnimationVector;)V Landroidx/compose/animation/core/AnimationSpec; @@ -1257,10 +1257,10 @@ HSPLandroidx/compose/animation/core/AnimationState;->getLastFrameTimeNanos()J HSPLandroidx/compose/animation/core/AnimationState;->getTypeConverter()Landroidx/compose/animation/core/TwoWayConverter; HSPLandroidx/compose/animation/core/AnimationState;->getValue()Ljava/lang/Object; HSPLandroidx/compose/animation/core/AnimationState;->getVelocityVector()Landroidx/compose/animation/core/AnimationVector; -HSPLandroidx/compose/animation/core/AnimationState;->isRunning()Z +HSPLandroidx/compose/animation/core/AnimationState;->getShowDialog()Z HSPLandroidx/compose/animation/core/AnimationState;->setFinishedTimeNanos$animation_core_release(J)V HSPLandroidx/compose/animation/core/AnimationState;->setLastFrameTimeNanos$animation_core_release(J)V -HSPLandroidx/compose/animation/core/AnimationState;->setRunning$animation_core_release(Z)V +HSPLandroidx/compose/animation/core/AnimationState;->setShowDialog$animation_core_release(Z)V HSPLandroidx/compose/animation/core/AnimationState;->setValue$animation_core_release(Ljava/lang/Object;)V HSPLandroidx/compose/animation/core/AnimationState;->setVelocityVector$animation_core_release(Landroidx/compose/animation/core/AnimationVector;)V Landroidx/compose/animation/core/AnimationStateKt; @@ -1411,8 +1411,8 @@ HSPLandroidx/compose/animation/core/InfiniteTransition;->access$setStartTimeNano PLandroidx/compose/animation/core/InfiniteTransition;->access$setStartTimeNanos$p(Landroidx/compose/animation/core/InfiniteTransition;J)V HSPLandroidx/compose/animation/core/InfiniteTransition;->addAnimation$animation_core_release(Landroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;)V PLandroidx/compose/animation/core/InfiniteTransition;->addAnimation$animation_core_release(Landroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;)V -HSPLandroidx/compose/animation/core/InfiniteTransition;->isRunning()Z -PLandroidx/compose/animation/core/InfiniteTransition;->isRunning()Z +HSPLandroidx/compose/animation/core/InfiniteTransition;->getShowDialog()Z +PLandroidx/compose/animation/core/InfiniteTransition;->getShowDialog()Z HSPLandroidx/compose/animation/core/InfiniteTransition;->onFrame(J)V PLandroidx/compose/animation/core/InfiniteTransition;->onFrame(J)V PLandroidx/compose/animation/core/InfiniteTransition;->removeAnimation$animation_core_release(Landroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;)V @@ -1420,8 +1420,8 @@ HSPLandroidx/compose/animation/core/InfiniteTransition;->run$animation_core_rele PLandroidx/compose/animation/core/InfiniteTransition;->run$animation_core_release(Landroidx/compose/runtime/Composer;I)V HSPLandroidx/compose/animation/core/InfiniteTransition;->setRefreshChildNeeded(Z)V PLandroidx/compose/animation/core/InfiniteTransition;->setRefreshChildNeeded(Z)V -HSPLandroidx/compose/animation/core/InfiniteTransition;->setRunning(Z)V -PLandroidx/compose/animation/core/InfiniteTransition;->setRunning(Z)V +HSPLandroidx/compose/animation/core/InfiniteTransition;->setShowDialog(Z)V +PLandroidx/compose/animation/core/InfiniteTransition;->setShowDialog(Z)V Landroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState; HSPLandroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;->(Landroidx/compose/animation/core/InfiniteTransition;Ljava/lang/Object;Ljava/lang/Object;Landroidx/compose/animation/core/TwoWayConverter;Landroidx/compose/animation/core/AnimationSpec;Ljava/lang/String;)V PLandroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;->(Landroidx/compose/animation/core/InfiniteTransition;Ljava/lang/Object;Ljava/lang/Object;Landroidx/compose/animation/core/TwoWayConverter;Landroidx/compose/animation/core/AnimationSpec;Ljava/lang/String;)V @@ -1691,7 +1691,7 @@ HSPLandroidx/compose/animation/core/Transition;->getStartTimeNanos$animation_cor HSPLandroidx/compose/animation/core/Transition;->getTargetState()Ljava/lang/Object; HSPLandroidx/compose/animation/core/Transition;->getUpdateChildrenNeeded()Z HSPLandroidx/compose/animation/core/Transition;->get_playTimeNanos()J -HSPLandroidx/compose/animation/core/Transition;->isRunning()Z +HSPLandroidx/compose/animation/core/Transition;->getShowDialog()Z HSPLandroidx/compose/animation/core/Transition;->isSeeking()Z HSPLandroidx/compose/animation/core/Transition;->onChildAnimationUpdated()V PLandroidx/compose/animation/core/Transition;->onDisposed$animation_core_release()V @@ -1812,8 +1812,8 @@ Landroidx/compose/animation/core/TransitionState; HSPLandroidx/compose/animation/core/TransitionState;->()V HSPLandroidx/compose/animation/core/TransitionState;->()V HSPLandroidx/compose/animation/core/TransitionState;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -HSPLandroidx/compose/animation/core/TransitionState;->isRunning$animation_core_release()Z -HSPLandroidx/compose/animation/core/TransitionState;->setRunning$animation_core_release(Z)V +HSPLandroidx/compose/animation/core/TransitionState;->getShowDialog$animation_core_release()Z +HSPLandroidx/compose/animation/core/TransitionState;->setShowDialog$animation_core_release(Z)V Landroidx/compose/animation/core/TweenSpec; HSPLandroidx/compose/animation/core/TweenSpec;->()V HSPLandroidx/compose/animation/core/TweenSpec;->(IILandroidx/compose/animation/core/Easing;)V diff --git a/app/src/main/java/com/raival/compose/file/explorer/App.kt b/app/src/main/java/com/raival/compose/file/explorer/App.kt index 40155001..13184b10 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/App.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/App.kt @@ -3,7 +3,6 @@ package com.raival.compose.file.explorer import android.app.Application import android.content.Context import android.os.Process -import android.util.Log import android.widget.Toast import androidx.annotation.StringRes import coil3.ImageLoader @@ -19,13 +18,13 @@ import coil3.svg.SvgDecoder import coil3.video.VideoFrameDecoder import com.raival.compose.file.explorer.coil.apk.ApkFileDecoder import com.raival.compose.file.explorer.coil.pdf.PdfFileDecoder -import com.raival.compose.file.explorer.common.extension.emptyString -import com.raival.compose.file.explorer.common.extension.printFullStackTrace -import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.common.logger.FileExplorerLogger import com.raival.compose.file.explorer.screen.main.MainActivityManager +import com.raival.compose.file.explorer.screen.main.tab.files.FilesTabManager import com.raival.compose.file.explorer.screen.main.tab.files.coil.DocumentFileMapper -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.raival.compose.file.explorer.screen.main.tab.files.manager.FilesTabManager +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.task.TaskManager +import com.raival.compose.file.explorer.screen.main.tab.files.zip.ZipManager import com.raival.compose.file.explorer.screen.preferences.PreferencesManager import com.raival.compose.file.explorer.screen.textEditor.TextEditorManager import com.raival.compose.file.explorer.screen.viewer.ViewersManager @@ -36,6 +35,7 @@ import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel import io.github.rosemoe.sora.langs.textmate.registry.provider.AssetsFileResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.eclipse.tm4e.core.registry.IThemeSource import java.io.File @@ -47,24 +47,23 @@ class App : Application(), coil3.SingletonImageLoader.Factory { val globalClass get() = appContext as App + + val logger + get() = globalClass.logger } - val appFiles: DocumentHolder - get() = DocumentHolder.fromFile( - File(globalClass.cacheDir, "files").apply { - if (!exists()) mkdirs() - } - ) + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val errorLogFile: DocumentHolder - get() = "logs.txt".let { - DocumentHolder.fromFile(File(globalClass.cacheDir, it).apply { - if (!exists()) createNewFile() - }) - } + lateinit var logger: FileExplorerLogger + private set - val recycleBinDir: DocumentHolder - get() = DocumentHolder.fromFile(File(getExternalFilesDir(null), "bin").apply { mkdirs() }) + val appFiles: LocalFileHolder + get() = LocalFileHolder( + File(globalClass.cacheDir, "files").apply { if (!exists()) mkdirs() } + ) + + val recycleBinDir: LocalFileHolder + get() = LocalFileHolder(File(getExternalFilesDir(null), "bin").apply { mkdirs() }) private var uid = 0 @@ -72,22 +71,32 @@ class App : Application(), coil3.SingletonImageLoader.Factory { val mainActivityManager: MainActivityManager by lazy { MainActivityManager().also { it.setupTabs() } } val filesTabManager: FilesTabManager by lazy { FilesTabManager() } val preferencesManager: PreferencesManager by lazy { PreferencesManager() } - val viewersManager: ViewersManager by lazy { ViewersManager() } + val viewersManager: ViewersManager by lazy { + setupTextMate() + ViewersManager() + } + val taskManager: TaskManager by lazy { TaskManager() } + val zipManager: ZipManager by lazy { ZipManager() } override fun onCreate() { - Thread.setDefaultUncaughtExceptionHandler { _: Thread?, throwable: Throwable? -> - throwable?.let { - Log.e("AppCrash", emptyString, it).also { log(throwable) } - } - Process.killProcess(Process.myPid()) - exitProcess(2) - } - super.onCreate() + logger = FileExplorerLogger(this, applicationScope) + setupGlobalExceptionHandler() + appContext = this } + private fun setupGlobalExceptionHandler() { + val defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, exception -> + logger.logError(exception) + defaultHandler?.uncaughtException(thread, exception) + Process.killProcess(Process.myPid()) + exitProcess(2) + } + } + private fun setupTextMate() { CoroutineScope(Dispatchers.IO).launch { FileProviderRegistry.getInstance().addFileProvider( @@ -139,33 +148,6 @@ class App : Application(), coil3.SingletonImageLoader.Factory { fun generateUid() = uid++ - fun log(throwable: Throwable) { - log(throwable.printFullStackTrace(), "Error") - } - - fun log(msg: String, header: String = emptyString) { - if (!errorLogFile.exists()) return - if (errorLogFile.isFolder) return - - if (errorLogFile.fileSize > 1024 * 100) { - errorLogFile.writeText(emptyString) - } - - errorLogFile.appendText( - buildString { - if (header.isNotEmpty()) { - append(System.lineSeparator().repeat(2)) - append("-".repeat(4)) - append(" $header: ${System.currentTimeMillis().toFormattedDate()} ") - append("-".repeat(4)) - append(System.lineSeparator()) - } - append(msg) - append(System.lineSeparator()) - } - ) - } - override fun newImageLoader(context: PlatformContext): ImageLoader { return ImageLoader(context) .newBuilder() diff --git a/app/src/main/java/com/raival/compose/file/explorer/base/BaseActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/base/BaseActivity.kt index e881d3b7..9050828b 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/base/BaseActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/base/BaseActivity.kt @@ -7,12 +7,44 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.provider.Settings +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.theme.FileExplorerTheme abstract class BaseActivity : AppCompatActivity() { @@ -31,8 +63,18 @@ abstract class BaseActivity : AppCompatActivity() { open fun onPermissionGranted() {} protected fun checkPermissions() { - if (grantStoragePermissions()) { + if (canAccessStorage()) { onPermissionGranted() + return + } else { + setContent { + FileExplorerTheme { + StoragePermissionScreen( + onGrantPermission = { grantStoragePermissions() }, + onSkip = { finish() } + ) + } + } } } @@ -81,4 +123,156 @@ abstract class BaseActivity : AppCompatActivity() { onPermissionGranted() } } + + @Composable + fun StoragePermissionScreen( + onGrantPermission: () -> Unit = {}, + onSkip: () -> Unit = {} + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + Icon( + imageVector = Icons.Outlined.FolderOpen, + contentDescription = null, + modifier = Modifier + .size(80.dp) + .padding(bottom = 24.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = stringResource(R.string.storage_access_required), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + + Text( + text = stringResource(R.string.storage_access_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = 24.sp, + modifier = Modifier.padding(bottom = 32.dp) + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + PermissionFeatureItem( + icon = Icons.Outlined.Save, + title = stringResource(R.string.save_files), + description = stringResource(R.string.save_and_organize_your_documents) + ) + + PermissionFeatureItem( + icon = Icons.Outlined.Folder, + title = stringResource(R.string.access_folders), + description = stringResource(R.string.browse_and_manage_your_file_structure), + showDivider = false + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = onGrantPermission, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text( + text = stringResource(R.string.grant_access), + style = MaterialTheme.typography.labelLarge + ) + } + + TextButton( + onClick = onSkip, + modifier = Modifier.padding(top = 8.dp) + ) { + Text( + text = stringResource(R.string.skip_for_now), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + + @Composable + private fun PermissionFeatureItem( + icon: ImageVector, + title: String, + description: String, + showDivider: Boolean = true + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + + if (showDivider) { + HorizontalDivider( + modifier = Modifier.padding(start = 40.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/coil/pdf/PdfFileDecoder.kt b/app/src/main/java/com/raival/compose/file/explorer/coil/pdf/PdfFileDecoder.kt index 8410f2aa..7f6d1828 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/coil/pdf/PdfFileDecoder.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/coil/pdf/PdfFileDecoder.kt @@ -1,10 +1,10 @@ package com.raival.compose.file.explorer.coil.pdf -import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.pdf.PdfRenderer import android.os.ParcelFileDescriptor +import androidx.core.graphics.createBitmap import coil3.ImageLoader import coil3.asImage import coil3.decode.DecodeResult @@ -16,15 +16,15 @@ import java.io.File class PdfFileDecoder(val source: File) : Decoder { override suspend fun decode(): DecodeResult? { + //TODO: large pages causes lags? ParcelFileDescriptor.open(source, ParcelFileDescriptor.MODE_READ_ONLY) ?.use { fileDescriptor -> PdfRenderer(fileDescriptor).use { pdfRenderer -> pdfRenderer.openPage(0).use { page -> val resolutionMultiplier = 0.75f - val bitmap = Bitmap.createBitmap( + val bitmap = createBitmap( (page.width * resolutionMultiplier).toInt(), - (page.height * resolutionMultiplier).toInt(), - Bitmap.Config.ARGB_8888 + (page.height * resolutionMultiplier).toInt() ).also { Canvas(it).drawColor(Color.WHITE) } page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) return DecodeResult(bitmap.asImage(), false) diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/extension/CodeEditorExt.kt b/app/src/main/java/com/raival/compose/file/explorer/common/extension/CodeEditorExt.kt index 51e12978..9a8be505 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/extension/CodeEditorExt.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/extension/CodeEditorExt.kt @@ -23,133 +23,52 @@ fun CodeEditor.setContent(content: Content, fileInstance: TextEditorManager.File } } -fun CodeEditor.moveSelectionBy(step: Int, controlledCursor: Int) { - val maxLines = lineCount - - val currentRightLine = cursor.rightLine - val currentRightColumn = cursor.rightColumn - val maxColumnInCurrentRightLine = text.getColumnCount(currentRightLine) - val canMoveRightCursorToRight = currentRightColumn < maxColumnInCurrentRightLine - val canMoveRightCursorToLeft = currentRightColumn > 0 - val canMoveRightCursorToNextLine = currentRightLine < maxLines - 1 - val canMoveRightCursorToPreviousLine = currentRightLine > 0 - - val currentLeftLine = cursor.leftLine - val currentLeftColumn = cursor.leftColumn - val maxColumnInCurrentLeftLine = text.getColumnCount(currentLeftLine) - val canMoveLeftCursorToLeft = currentLeftColumn > 0 - val canMoveLeftCursorToPreviousLine = currentLeftLine > 0 - val canMoveLeftCursorToNextLine = currentLeftLine < maxLines - 1 - val canMoveLeftCursorToRight = currentLeftColumn < maxColumnInCurrentLeftLine +private data class Position(val line: Int, val column: Int) - val rightCursorColumn = if (controlledCursor > 0) { - if (step > 0) { - if (canMoveRightCursorToRight) { - currentRightColumn + step - } else { - if (canMoveRightCursorToNextLine) { - 0 - } else { - currentRightColumn - } - } +private fun CodeEditor.calculateNewCursorPosition( + line: Int, + column: Int, + step: Int +): Position { + if (step > 0) { // Moving forward + val maxColumn = text.getColumnCount(line) + return if (column < maxColumn) { + Position(line, column + step) + } else if (line < lineCount - 1) { + Position(line + 1, 0) } else { - if (canMoveRightCursorToLeft) { - currentRightColumn + step - } else { - if (canMoveRightCursorToPreviousLine) { - text.getColumnCount(currentRightLine + step) - } else { - 0 - } - } + Position(line, column) } - } else { - currentRightColumn - } - - val leftCursorColumn = if (controlledCursor < 0) { - if (step > 0) { - if (canMoveLeftCursorToRight) { - currentLeftColumn + step - } else { - if (canMoveLeftCursorToNextLine) { - 0 - } else { - currentLeftColumn - } - } + } else { // Moving backward + return if (column > 0) { + Position(line, column + step) + } else if (line > 0) { + val prevLine = line - 1 + Position(prevLine, text.getColumnCount(prevLine)) } else { - if (canMoveLeftCursorToLeft) { - currentLeftColumn + step - } else { - if (canMoveLeftCursorToPreviousLine) { - text.getColumnCount(currentLeftLine + step) - } else { - 0 - } - } + Position(line, column) } - } else { - currentLeftColumn } +} - val rightCursorLine = if (controlledCursor > 0) { - if (step > 0) { - if (canMoveRightCursorToRight) { - currentRightLine - } else { - if (canMoveRightCursorToNextLine) { - currentRightLine + 1 - } else { - currentRightLine - } - } - } else { - if (canMoveRightCursorToLeft) { - currentRightLine - } else { - if (canMoveRightCursorToPreviousLine) { - currentRightLine - 1 - } else { - currentRightLine - } - } +fun CodeEditor.moveSelectionBy(step: Int, controlledCursor: Int) { + var rightPos = Position(cursor.rightLine, cursor.rightColumn) + var leftPos = Position(cursor.leftLine, cursor.leftColumn) + + when { + controlledCursor > 0 -> { + rightPos = calculateNewCursorPosition(rightPos.line, rightPos.column, step) } - } else { - currentRightLine - } - val leftCursorLine = if (controlledCursor < 0) { - if (step > 0) { - if (canMoveLeftCursorToRight) { - currentLeftLine - } else { - if (canMoveLeftCursorToNextLine) { - currentLeftLine + 1 - } else { - currentLeftLine - } - } - } else { - if (canMoveLeftCursorToLeft) { - currentLeftLine - } else { - if (canMoveLeftCursorToPreviousLine) { - currentLeftLine - 1 - } else { - currentLeftLine - } - } + controlledCursor < 0 -> { + leftPos = calculateNewCursorPosition(leftPos.line, leftPos.column, step) } - } else { - currentLeftLine } setSelectionRegion( - rightCursorLine, - rightCursorColumn, - leftCursorLine, - leftCursorColumn + rightPos.line, + rightPos.column, + leftPos.line, + leftPos.column ) } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/extension/Extensions.kt b/app/src/main/java/com/raival/compose/file/explorer/common/extension/Extensions.kt index c17d5165..d02e1326 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/extension/Extensions.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/extension/Extensions.kt @@ -10,12 +10,19 @@ import android.graphics.Canvas import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri +import android.provider.MediaStore import android.provider.MediaStore.MediaColumns.DISPLAY_NAME +import android.provider.OpenableColumns +import android.util.Size +import android.webkit.MimeTypeMap import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType import java.io.File import java.io.FileInputStream +import java.io.FileNotFoundException import java.io.IOException import java.io.InputStream import java.io.PrintWriter @@ -31,6 +38,9 @@ fun T.conditions(cond: T.() -> Boolean) = this.cond() @Composable fun Int.dp() = (this / globalClass.resources.displayMetrics.density).dp +@Composable +fun Float.dp() = (this / globalClass.resources.displayMetrics.density).dp + val Int.px: Int get() = (this * globalClass.resources.displayMetrics.density).toInt() @@ -43,6 +53,58 @@ fun Context.findActivity(): Activity? { return null } +fun File.listFilesAndEmptyDirs(): List { + val result = mutableListOf() + + // A queue of directories to visit. Start with the root. + val directoryQueue = ArrayDeque() + if (isDirectory) { + directoryQueue.add(this) + } else if (isFile) { + // If the starting path is just a file, add it and return. + result.add(this) + return result + } else { + // Not a valid file or directory, return an empty list. + return emptyList() + } + + while (directoryQueue.isNotEmpty()) { + val dir = directoryQueue.removeFirst() + // listFiles() can be null if the directory is not accessible + val children = dir.listFiles() ?: continue + + if (children.isEmpty()) { + // This directory is empty, add it to the list. + result.add(dir) + } else { + // Process the contents of the non-empty directory + for (child in children) { + if (child.isFile) { + result.add(child) + } else if (child.isDirectory) { + directoryQueue.add(child) + } + } + } + } + return result +} + +fun Uri.getMimeType(context: Context): String { + // 1. Try to get the MIME type from the ContentResolver + // This is the most reliable way for content:// URIs + val mimeType = context.contentResolver.getType(this) + if (mimeType != null) { + return mimeType + } + + // 2. If the ContentResolver fails, fall back to the file extension + val fileExtension = MimeTypeMap.getFileExtensionFromUrl(this.toString()) + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension?.lowercase()) + ?: anyFileType +} + val Uri.name: String? get() { return when (scheme) { @@ -57,11 +119,179 @@ val Uri.name: String? } } } - "file" -> { File(path ?: "").name } + + "file" -> { + File(path ?: emptyString).name + } + else -> null } } +data class UriInfo( + val uri: Uri, + val name: String?, + val size: Long?, + val mimeType: String?, + val lastModified: Long?, + val extension: String?, + val path: String? +) + +fun Uri.lastModified(context: Context): Long { + return when (scheme) { + "content" -> { + val cursor = context.contentResolver.query(this, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val lastModifiedIndex = it.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED) + if (lastModifiedIndex != -1) { + it.getLong(lastModifiedIndex) * 1000L + } else { + 0L + } + } else { + 0L + } + } ?: 0L + } + + "file" -> { + path?.let { File(it).lastModified() } ?: 0L + } + + else -> 0L + } +} + +fun Uri.getUriInfo(context: Context): UriInfo { + val contentResolver = context.contentResolver + + var name: String? = null + var size: Long? = null + var lastModified: Long? = null + var path: String? = null + + if (scheme.equals("content", ignoreCase = true)) { + // This is a content URI. Use ContentResolver to query metadata. + val cursor = contentResolver.query(this, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + // Get Display Name + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + name = it.getString(nameIndex) + } + + // Get Size + val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1) { + size = it.getLong(sizeIndex) + } + + // Get Last Modified (might not be available for all providers) + val lastModifiedIndex = it.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED) + if (lastModifiedIndex != -1) { + // The value is in seconds, convert to milliseconds + lastModified = it.getLong(lastModifiedIndex) * 1000L + } + + // Try to get a real path. WARNING: This is not reliable and often returns null. + // It's most likely to work for MediaStore URIs. + try { + val pathIndex = it.getColumnIndex(MediaStore.Images.Media.DATA) + if (pathIndex != -1) { + path = it.getString(pathIndex) + } + } catch (e: Exception) { + // The DATA column might not exist for this URI, which is normal. + path = null + } + } + } + } else if (scheme.equals("file", ignoreCase = true)) { + // This is a file URI. + this.path?.let { filePath -> + val file = File(filePath) + if (file.exists()) { + name = file.name + size = file.length() + lastModified = file.lastModified() + path = file.absolutePath + } + } + } + + // Get MIME type from ContentResolver, which is more reliable. + val mimeType = contentResolver.getType(this) + + // Get extension from the file name. + val extension = name?.substringAfterLast('.', "") + + return UriInfo( + uri = this, + name = name, + size = size, + mimeType = mimeType, + lastModified = lastModified, + extension = extension, + path = path + ) +} + +fun Uri.exists(context: Context): Boolean { + // 1. Handle `file://` URIs + if (scheme == "file") { + return path?.let { File(it).exists() } ?: false + } + + // 2. Handle `content://` URIs + if (scheme == "content") { + try { + // Use openFileDescriptor for a lightweight check. + // "r" specifies read-only access. + context.contentResolver.openFileDescriptor(this, "r")?.use { + // If we get here, the file descriptor was opened successfully, + // which means the URI is valid and accessible. + return true + } + } catch (_: FileNotFoundException) { + // The content provider reported that the file doesn't exist. + return false + } catch (_: SecurityException) { + // We don't have permission to read the URI. + return false + } catch (_: Exception) { + // Handle other potential exceptions, such as IllegalArgumentException for a malformed URI. + return false + } + } + + // 3. For other schemes or if all checks fail + return false +} + +fun String.asCodeEditorCursorCoordinates(): Pair { + val trimmedInput = trim() + return when { + trimmedInput.matches(Regex("\\d+")) -> Pair(trimmedInput.toInt(), 0) + trimmedInput.matches(Regex("\\d+:\\d+")) -> { + val parts = trimmedInput.split(":").map { it.trim().toInt() } + Pair(parts[0], parts[1]) + } + + else -> Pair(-1, -1) + } +} + +fun Size.isZero() = width == 0 || height == 0 + +fun Bitmap.scale(scaleFactor: Float, filter: Boolean = false): Bitmap { + val newWidth = (width * scaleFactor).toInt() + val newHeight = (height * scaleFactor).toInt() + return Bitmap.createScaledBitmap(this, newWidth, newHeight, filter) +} + fun Throwable.printFullStackTrace(): String { val result: Writer = StringWriter() val printWriter = PrintWriter(result) @@ -73,6 +303,14 @@ fun Throwable.printFullStackTrace(): String { infix fun Any?.isNot(value: Any?) = this != value +fun Int.isMultipleOf100(): Boolean { + return this % 100 == 0 +} + +fun showMsg(msg: String) { + globalClass.showMsg(msg) +} + @SuppressLint("SimpleDateFormat") fun Long.toFormattedDate(): String = SimpleDateFormat("MMM dd, hh:mm a").format(this) @@ -85,6 +323,19 @@ fun Long.toFormattedSize(): String { ) + " " + units[digitGroups] } +fun Long.toFormattedTime(): String { + val totalSeconds = this / 1000 + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + return if (hours > 0) { + "%02d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%02d:%02d".format(minutes, seconds) + } +} + fun Context.isDarkTheme() = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES @@ -96,7 +347,6 @@ fun Uri.read(): ByteArray { "file" -> FileInputStream(this.path?.let { File(it) }) else -> null } - inputStream?.use { stream -> return stream.readBytes() } ?: return ByteArray(0) } @@ -104,7 +354,7 @@ fun Drawable.drawableToBitmap(): Bitmap? { return if (this is BitmapDrawable) { bitmap } else { - val bitmap = Bitmap.createBitmap(intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(intrinsicWidth, intrinsicHeight) val canvas = Canvas(bitmap) setBounds(0, 0, canvas.width, canvas.height) draw(canvas) diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/extension/ListExt.kt b/app/src/main/java/com/raival/compose/file/explorer/common/extension/ListExt.kt index e3bdbb40..4841d324 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/extension/ListExt.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/extension/ListExt.kt @@ -1,25 +1,37 @@ package com.raival.compose.file.explorer.common.extension fun ArrayList.addIf(item: T, condition: T.() -> Boolean) { - if (condition(item)) { add(item) } + if (condition(item)) { + add(item) + } } fun List.getIf(condition: T.() -> Boolean): T? { forEach { - if (condition(it)) { return it } + if (condition(it)) { + return it + } } return null } fun List.getIndexIf(condition: T.() -> Boolean): Int { forEachIndexed { index, item -> - if (condition(item)) { return index } + if (condition(item)) { + return index + } } return -1 } -fun ArrayList.addIfAbsent(toAdd: String) { if (!contains(toAdd)) add(toAdd) } +fun ArrayList.addIfAbsent(toAdd: String) { + if (!contains(toAdd)) add(toAdd) +} fun HashMap.removeIf(condition: (A, B) -> Boolean) { - keys.forEach { if (condition(it, this[it]!!)) { remove(it) } } + keys.forEach { + if (condition(it, this[it]!!)) { + remove(it) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/extension/StringExt.kt b/app/src/main/java/com/raival/compose/file/explorer/common/extension/StringExt.kt index 37311db4..d5bcebce 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/extension/StringExt.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/extension/StringExt.kt @@ -3,7 +3,9 @@ package com.raival.compose.file.explorer.common.extension import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import com.anggrayudi.storage.extension.trimFileSeparator import com.raival.compose.file.explorer.App +import java.io.File import kotlin.random.Random const val emptyString = "" @@ -17,11 +19,20 @@ fun String.Companion.randomString(length: Int): String { .joinToString(emptyString) } +fun String.hasParent(parentPath: String): Boolean { + val parentTree = parentPath.getFolderTree() + val subFolderTree = getFolderTree() + return parentTree.size <= subFolderTree.size && subFolderTree.take(parentTree.size) == parentTree +} + +private fun String.getFolderTree() = + split('/').map { it.trimFileSeparator() }.filter { it.isNotEmpty() } + fun String.orIf(value: String, condition: (String) -> Boolean) = if (condition(this)) value else this fun String.isValidAsFileName() = !conditions { - contains(":") || contains("/") || contains("*") || contains("?") + contains(":") || contains(File.separator) || contains("*") || contains("?") || contains("\"") || contains("<") || contains(">") || contains("|") } && isNotBlank() @@ -43,15 +54,51 @@ fun String.copyToClipboard() { } fun String.trimToLastTwoSegments(): String { - val segments = this.split("/") + val segments = this.split(File.separator) return if (segments.size >= 2) { - segments.takeLast(2).joinToString("/") + segments.takeLast(2).joinToString(File.separator) } else { this } } +/** + * Calculates the relative path of this string path with respect to a given [base] path string. + * This is a string-based reimplementation of [File.toRelativeString]. + * + * @param base The base path string to which the path should be made relative. + * @return The relative path string, or an empty string if the paths are the same. + * @throws IllegalArgumentException if this path does not start with the [base] path. + */ +fun String.toRelativeString(base: String): String { + // 1. Normalize both paths for consistent comparison + // - Replace backslashes with forward slashes + // - Remove any trailing slashes + val thisPath = this.replace('\\', '/').trimEnd('/') + val basePath = base.replace('\\', '/').trimEnd('/') + + // 2. Handle the case where the paths are identical + if (thisPath == basePath) { + return emptyString + } + + // 3. The base path must be a true prefix. + // We append a slash to the base path to ensure we're matching a full directory segment. + // This prevents "/a/b_c" from being considered relative to "/a/b". + // If the base path is empty, we don't add a leading slash. + val basePrefix = if (basePath.isEmpty()) emptyString else "$basePath/" + + if (!thisPath.startsWith(basePrefix)) { + return thisPath + } + + // 4. The relative path is the part of this path that comes after the base prefix. + return thisPath.substring(basePrefix.length) +} + fun String.limitLength(maxLength: Int): String { - if (this.length <= maxLength) { return this } + if (this.length <= maxLength) { + return this + } return this.substring(0, maxLength - 3) + "..." } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/logger/FileExplorerLogger.kt b/app/src/main/java/com/raival/compose/file/explorer/common/logger/FileExplorerLogger.kt new file mode 100644 index 00000000..126ea470 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/common/logger/FileExplorerLogger.kt @@ -0,0 +1,306 @@ +package com.raival.compose.file.explorer.common.logger + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.google.gson.Gson +import com.raival.compose.file.explorer.common.extension.printFullStackTrace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID + +private val Context.logsDataStore: DataStore by preferencesDataStore(name = "logs") + +data class LogHolder( + val message: String, + val timestamp: String, + val id: String = UUID.randomUUID().toString() +) + +class FileExplorerLogger(private val context: Context, private val scope: CoroutineScope) { + + companion object { + private val ERRORS_KEY = stringPreferencesKey("error_logs") + private val WARNINGS_KEY = stringPreferencesKey("warning_logs") + private val INFOS_KEY = stringPreferencesKey("info_logs") + private const val MAX_ERRORS = 5 + private const val MAX_WARNINGS = 10 + private const val MAX_INFOS = 30 + } + + private val gson = Gson() + private val logListType = object : com.google.gson.reflect.TypeToken>() {}.type + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + // ERROR LOGGING FUNCTIONS + /** + * Log a string error message + */ + fun logError(message: String) { + val errorLog = LogHolder( + message = message, + timestamp = dateFormat.format(Date()) + ) + scope.launch { + saveError(errorLog) + } + } + + /** + * Log an exception + */ + fun logError(exception: Throwable) { + val errorLog = LogHolder( + message = exception.stackTraceToString(), + timestamp = dateFormat.format(Date()) + ) + scope.launch { + saveError(errorLog) + } + } + + /** + * Save error to DataStore, maintaining only the 5 most recent + */ + private suspend fun saveError(newError: LogHolder) { + context.logsDataStore.edit { preferences -> + val currentErrorsJson = preferences[ERRORS_KEY] ?: "[]" + val currentErrors = try { + gson.fromJson>(currentErrorsJson, logListType) ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + // Add new error at the beginning and keep only the most recent 5 + val updatedErrors = (listOf(newError) + currentErrors).reversed().takeLast(MAX_ERRORS) + + preferences[ERRORS_KEY] = gson.toJson(updatedErrors) + } + } + + /** + * Get all stored errors as a Flow + */ + fun getErrors(): Flow> { + return context.logsDataStore.data.map { preferences -> + val errorsJson = preferences[ERRORS_KEY] ?: "[]" + try { + gson.fromJson>(errorsJson, logListType) ?: emptyList() + } catch (_: Exception) { + emptyList() + } + } + } + + /** + * Clear all stored errors + */ + suspend fun clearErrors() { + context.logsDataStore.edit { preferences -> + preferences.remove(ERRORS_KEY) + } + } + + /** + * Get the most recent error + */ + fun getLatestError(): Flow { + return getErrors().map { errors -> + errors.firstOrNull() + } + } + + // WARNING LOGGING FUNCTIONS + /** + * Log a string warning message + */ + fun logWarning(message: String) { + val warningLog = LogHolder( + message = message, + timestamp = dateFormat.format(Date()) + ) + scope.launch { + saveWarning(warningLog) + } + } + + /** + * Log an exception as warning + */ + fun logWarning(exception: Throwable) { + val warningLog = LogHolder( + message = exception.printFullStackTrace(), + timestamp = dateFormat.format(Date()) + ) + scope.launch { + saveWarning(warningLog) + } + } + + /** + * Save warning to DataStore, maintaining only the 10 most recent + */ + private suspend fun saveWarning(newWarning: LogHolder) { + context.logsDataStore.edit { preferences -> + val currentWarningsJson = preferences[WARNINGS_KEY] ?: "[]" + val currentWarnings = try { + gson.fromJson>(currentWarningsJson, logListType) ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + // Add new warning at the beginning and keep only the most recent 10 + val updatedWarnings = + (listOf(newWarning) + currentWarnings).reversed().takeLast(MAX_WARNINGS) + + preferences[WARNINGS_KEY] = gson.toJson(updatedWarnings) + } + } + + /** + * Get all stored warnings as a Flow + */ + fun getWarnings(): Flow> { + return context.logsDataStore.data.map { preferences -> + val warningsJson = preferences[WARNINGS_KEY] ?: "[]" + try { + gson.fromJson>(warningsJson, logListType) ?: emptyList() + } catch (_: Exception) { + emptyList() + } + } + } + + /** + * Clear all stored warnings + */ + suspend fun clearWarnings() { + context.logsDataStore.edit { preferences -> + preferences.remove(WARNINGS_KEY) + } + } + + /** + * Get the most recent warning + */ + fun getLatestWarning(): Flow { + return getWarnings().map { warnings -> + warnings.firstOrNull() + } + } + + // INFO LOGGING FUNCTIONS + /** + * Log a string info message + */ + fun logInfo(message: String) { + val infoLog = LogHolder( + message = message, + timestamp = dateFormat.format(Date()) + ) + scope.launch { + saveInfo(infoLog) + } + } + + /** + * Save info to DataStore, maintaining only the 30 most recent + */ + private suspend fun saveInfo(newInfo: LogHolder) { + context.logsDataStore.edit { preferences -> + val currentInfosJson = preferences[INFOS_KEY] ?: "[]" + val currentInfos = try { + gson.fromJson>(currentInfosJson, logListType) ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + // Add new info at the beginning and keep only the most recent 30 + val updatedInfos = (listOf(newInfo) + currentInfos).reversed().takeLast(MAX_INFOS) + + preferences[INFOS_KEY] = gson.toJson(updatedInfos) + } + } + + /** + * Get all stored infos as a Flow + */ + fun getInfos(): Flow> { + return context.logsDataStore.data.map { preferences -> + val infosJson = preferences[INFOS_KEY] ?: "[]" + try { + gson.fromJson>(infosJson, logListType) ?: emptyList() + } catch (_: Exception) { + emptyList() + } + } + } + + /** + * Clear all stored infos + */ + suspend fun clearInfos() { + context.logsDataStore.edit { preferences -> + preferences.remove(INFOS_KEY) + } + } + + /** + * Get the most recent info + */ + fun getLatestInfo(): Flow { + return getInfos().map { infos -> + infos.firstOrNull() + } + } + + // UTILITY FUNCTIONS + /** + * Clear all logs (errors, warnings, and infos) + */ + suspend fun clearAllLogs() { + context.logsDataStore.edit { preferences -> + preferences.remove(ERRORS_KEY) + preferences.remove(WARNINGS_KEY) + preferences.remove(INFOS_KEY) + } + } + + /** + * Get total count of all logs + */ + fun getTotalLogCount(): Flow { + return context.logsDataStore.data.map { preferences -> + val errorCount = try { + val errorsJson = preferences[ERRORS_KEY] ?: "[]" + gson.fromJson>(errorsJson, logListType)?.size ?: 0 + } catch (_: Exception) { + 0 + } + + val warningCount = try { + val warningsJson = preferences[WARNINGS_KEY] ?: "[]" + gson.fromJson>(warningsJson, logListType)?.size ?: 0 + } catch (_: Exception) { + 0 + } + + val infoCount = try { + val infosJson = preferences[INFOS_KEY] ?: "[]" + gson.fromJson>(infosJson, logListType)?.size ?: 0 + } catch (_: Exception) { + 0 + } + + errorCount + warningCount + infoCount + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/CheckableText.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/CheckableText.kt index 66ca640b..9a6e2d25 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/CheckableText.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/CheckableText.kt @@ -40,7 +40,7 @@ fun CheckableText( checkedBoxBackgroundColor: Color = MaterialTheme.colorScheme.primary, uncheckedBoxBackgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.5.dp), boxSize: Dp = 21.dp, - strokeWidth: Dp = 1.dp, + strokeWidth: Dp = 0.5.dp, checkIcon: ImageVector = Icons.Rounded.Done, checkIconTint: Color = MaterialTheme.colorScheme.surface, text: @Composable () -> Unit diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Compose.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Compose.kt index e23fc619..4758dd7e 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Compose.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Compose.kt @@ -45,12 +45,11 @@ fun Modifier.detectVerticalSwipe( } fun Modifier.block( - shape: Shape = RoundedCornerShape(12.dp), + shape: Shape = RoundedCornerShape(16.dp), color: Color = Color.Unspecified, - applyResultPadding: Boolean = false, - resultPadding: Dp = 4.dp, + padding: Dp = 0.dp, borderColor: Color = Color.Unspecified, - borderSize: Dp = 1.dp + borderSize: Dp = 0.dp ) = composed { val color1 = if (color.isUnspecified) { MaterialTheme.colorScheme.surfaceContainerHigh @@ -67,7 +66,7 @@ fun Modifier.block( shape = shape ) ) - .then(if (applyResultPadding) Modifier.padding(resultPadding) else Modifier) + .padding(padding) } fun ColorScheme.outlineVariant( diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DynamicSelectTextField.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/DynamicSelectTextField.kt similarity index 97% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DynamicSelectTextField.kt rename to app/src/main/java/com/raival/compose/file/explorer/common/ui/DynamicSelectTextField.kt index fc180ce8..90d73b5e 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DynamicSelectTextField.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/DynamicSelectTextField.kt @@ -1,4 +1,4 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui +package com.raival.compose.file.explorer.common.ui import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.DropdownMenuItem diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/InputDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/InputDialog.kt deleted file mode 100644 index fbf1f4ec..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/InputDialog.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.raival.compose.file.explorer.common.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import com.raival.compose.file.explorer.common.extension.emptyString - -data class InputDialogInput( - val label: String, - var content: String = emptyString, - val onValidate: ((content: String) -> String?)? = null -) - -data class InputDialogButton( - val label: String, - val onClick: (inputs: ArrayList) -> Unit -) - -@Composable -fun InputDialog( - title: String, - inputs: ArrayList, - buttons: ArrayList, - onDismiss: () -> Unit -) { - Dialog( - onDismissRequest = { onDismiss() } - ) { - Column( - Modifier - .fillMaxWidth() - .block() - .padding(24.dp)) { - Text( - modifier = Modifier.fillMaxWidth(), - text = title, - fontSize = 18.sp - ) - - inputs.forEach { input -> - Space(size = 16.dp) - - var text by remember { mutableStateOf(input.content) } - var error by remember { mutableStateOf(null) } - - TextField( - modifier = Modifier.fillMaxWidth(), - value = text, - onValueChange = { - text = it - input.content = it - error = input.onValidate?.invoke(it) - }, - label = { Text(text = input.label) }, - isError = error != null && error?.isNotEmpty() ?: false, - supportingText = { error?.let { Text(text = it) } } - ) - } - - buttons.forEach { button -> - Space(size = 8.dp) - - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { button.onClick(inputs) } - ) { - Text(text = button.label) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Isolate.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Isolate.kt index c5e39f18..7c75586e 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Isolate.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Isolate.kt @@ -3,4 +3,6 @@ package com.raival.compose.file.explorer.common.ui import androidx.compose.runtime.Composable @Composable -fun Isolate(content: @Composable () -> Unit) { content() } \ No newline at end of file +fun Isolate(content: @Composable () -> Unit) { + content() +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/SafeSurface.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/SafeSurface.kt index e22fe945..14401ea1 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/SafeSurface.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/SafeSurface.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier @Composable fun SafeSurface( + enableStatusBarsPadding: Boolean = true, content: @Composable () -> Unit ) { Surface( @@ -28,13 +29,15 @@ fun SafeSurface( color = colorScheme.surfaceContainerLowest ) { Column(Modifier.fillMaxSize()) { - Row( - Modifier - .fillMaxWidth() - .wrapContentHeight() - .background(color = colorScheme.surfaceContainer) - .windowInsetsPadding(WindowInsets.statusBars) - ) {} + if (enableStatusBarsPadding) { + Row( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .background(color = colorScheme.surfaceContainer) + .windowInsetsPadding(WindowInsets.statusBars) + ) {} + } content() } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Space.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Space.kt index 3a6dfcc6..6a469cb7 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Space.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Space.kt @@ -7,4 +7,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp @Composable -fun Space(size: Dp) { Spacer(modifier = Modifier.size(size)) } \ No newline at end of file +fun Space(size: Dp) { + Spacer(modifier = Modifier.size(size)) +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/logs/LogsActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/logs/LogsActivity.kt new file mode 100644 index 00000000..f27dac31 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/logs/LogsActivity.kt @@ -0,0 +1,24 @@ +package com.raival.compose.file.explorer.screen.logs + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.raival.compose.file.explorer.base.BaseActivity +import com.raival.compose.file.explorer.screen.logs.ui.LogsScreen +import com.raival.compose.file.explorer.theme.FileExplorerTheme + +class LogsActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + checkPermissions() + } + + override fun onPermissionGranted() { + setContent { + FileExplorerTheme { + LogsScreen { onBackPressedDispatcher.onBackPressed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/logs/ui/LogsScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/logs/ui/LogsScreen.kt new file mode 100644 index 00000000..1e37cc63 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/logs/ui/LogsScreen.kt @@ -0,0 +1,430 @@ +package com.raival.compose.file.explorer.screen.logs.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.copyToClipboard +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.logger.LogHolder +import com.raival.compose.file.explorer.common.ui.Space +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +enum class LogType( + val displayName: String, + val icon: ImageVector, + val color: Color +) { + ALL("All", Icons.Filled.FilterList, Color.Gray), + ERROR("Errors", Icons.Filled.Error, Color(0xFFD32F2F)), + WARNING("Warnings", Icons.Filled.Warning, Color(0xFFF57C00)), + INFO("Info", Icons.Filled.Info, Color(0xFF1976D2)) +} + +data class CombinedLog( + val logHolder: LogHolder, + val type: LogType +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsScreen( + onBackClick: () -> Unit +) { + val scope = rememberCoroutineScope() + + // Collect logs from logger + val errors by logger.getErrors().collectAsStateWithLifecycle(initialValue = emptyList()) + val warnings by logger.getWarnings().collectAsStateWithLifecycle(initialValue = emptyList()) + val infos by logger.getInfos().collectAsStateWithLifecycle(initialValue = emptyList()) + + // UI State + var selectedLogType by remember { mutableStateOf(LogType.ALL) } + var showClearDialog by remember { mutableStateOf(false) } + var isClearing by remember { mutableStateOf(false) } + + // Combine all logs with their types + val combinedLogs = remember(errors, warnings, infos) { + val allLogs = mutableListOf() + errors.forEach { allLogs.add(CombinedLog(it, LogType.ERROR)) } + warnings.forEach { allLogs.add(CombinedLog(it, LogType.WARNING)) } + infos.forEach { allLogs.add(CombinedLog(it, LogType.INFO)) } + + // Sort by timestamp (newest first) + allLogs.sortedByDescending { it.logHolder.timestamp } + } + + // Filter logs based on selected type + val filteredLogs = remember(combinedLogs, selectedLogType) { + when (selectedLogType) { + LogType.ALL -> combinedLogs + LogType.ERROR -> combinedLogs.filter { it.type == LogType.ERROR } + LogType.WARNING -> combinedLogs.filter { it.type == LogType.WARNING } + LogType.INFO -> combinedLogs.filter { it.type == LogType.INFO } + } + } + + // Function to handle clearing logs + suspend fun clearSelectedLogs() { + isClearing = true + try { + runBlocking { + when (selectedLogType) { + LogType.ALL -> logger.clearAllLogs() + LogType.ERROR -> logger.clearErrors() + LogType.WARNING -> logger.clearWarnings() + LogType.INFO -> logger.clearInfos() + } + } + } finally { + isClearing = false + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.title_activity_logs), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + actions = { + IconButton( + onClick = { showClearDialog = true }, + enabled = combinedLogs.isNotEmpty() + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.clear_logs) + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface + ), + navigationIcon = { + IconButton( + onClick = onBackClick + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = null + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Filter chips + LazyRow( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(LogType.entries.toTypedArray()) { logType -> + val count = when (logType) { + LogType.ALL -> combinedLogs.size + LogType.ERROR -> errors.size + LogType.WARNING -> warnings.size + LogType.INFO -> infos.size + } + + FilterChip( + onClick = { selectedLogType = logType }, + label = { + Text("${logType.displayName} ($count)") + }, + selected = selectedLogType == logType, + leadingIcon = { + Icon( + imageVector = logType.icon, + contentDescription = null, + tint = if (selectedLogType == logType) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + logType.color + }, + modifier = Modifier.size(18.dp) + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = logType.color.copy(alpha = 0.12f), + selectedLabelColor = MaterialTheme.colorScheme.onSurface + ) + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // Logs list + if (filteredLogs.isEmpty()) { + EmptyLogsState(selectedLogType = selectedLogType) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = filteredLogs, + key = { it.logHolder.id } + ) { combinedLog -> + Column( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { }, + onLongClick = { + combinedLog.logHolder.message.copyToClipboard() + globalClass.showMsg(R.string.copied_to_clipboard) + } + ) + ) { + LogItem( + combinedLog = combinedLog, + modifier = Modifier + .animateItem() + ) + Space(8.dp) + HorizontalDivider() + } + } + } + } + + // Loading overlay + if (isClearing) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier.padding(32.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Row( + modifier = Modifier.padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 3.dp + ) + Text( + text = stringResource(R.string.clearing_logs), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } + } + } + + // Clear dialog + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { Text(stringResource(R.string.clear_logs)) }, + text = { + Text(stringResource(R.string.clear_all_logs_warning)) + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { clearSelectedLogs() } + showClearDialog = false + } + ) { + Text(stringResource(R.string.clear)) + } + }, + dismissButton = { + TextButton(onClick = { showClearDialog = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} + +@Composable +private fun LogItem( + combinedLog: CombinedLog, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Log type indicator + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(combinedLog.type.color.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = combinedLog.type.icon, + contentDescription = combinedLog.type.displayName, + tint = combinedLog.type.color, + modifier = Modifier.size(20.dp) + ) + } + + // Log content + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = combinedLog.type.displayName, + style = MaterialTheme.typography.labelMedium, + color = combinedLog.type.color, + fontWeight = FontWeight.Medium + ) + Text( + text = combinedLog.logHolder.timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = combinedLog.logHolder.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun EmptyLogsState( + selectedLogType: LogType, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = selectedLogType.icon, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = when (selectedLogType) { + LogType.ALL -> stringResource(R.string.no_logs_available) + LogType.ERROR -> stringResource(R.string.no_errors_logged) + LogType.WARNING -> stringResource(R.string.no_warnings_logged) + LogType.INFO -> stringResource(R.string.no_info_logs_available) + }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = when (selectedLogType) { + LogType.ALL -> emptyString + LogType.ERROR -> stringResource(R.string.great_no_errors_have_been_encountered) + LogType.WARNING -> stringResource(R.string.no_warnings_have_been_logged) + LogType.INFO -> stringResource(R.string.no_information_logs_have_been_recorded) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivity.kt index 8633df48..aa7810aa 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivity.kt @@ -18,7 +18,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.base.BaseActivity import com.raival.compose.file.explorer.common.ui.SafeSurface -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab import com.raival.compose.file.explorer.screen.main.ui.AppInfoDialog import com.raival.compose.file.explorer.screen.main.ui.JumpToPathDialog @@ -57,12 +57,12 @@ class MainActivity : BaseActivity() { LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { if (mainActivityManager.tabs.isNotEmpty()) - mainActivityManager.tabs[mainActivityManager.selectedTabIndex].onTabResumed() + mainActivityManager.getActiveTab().onTabResumed() } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { if (mainActivityManager.tabs.isNotEmpty()) - mainActivityManager.tabs[mainActivityManager.selectedTabIndex].onTabStopped() + mainActivityManager.getActiveTab().onTabStopped() } LaunchedEffect(mainActivityManager.selectedTabIndex) { @@ -83,6 +83,9 @@ class MainActivity : BaseActivity() { if (page != mainActivityManager.selectedTabIndex) { mainActivityManager.selectTabAt(page) } + mainActivityManager.tabLayoutState.animateScrollToItem( + mainActivityManager.selectedTabIndex + ) } } @@ -114,8 +117,8 @@ class MainActivity : BaseActivity() { intent?.let { if (it.hasExtra(HOME_SCREEN_SHORTCUT_EXTRA_KEY)) { globalClass.mainActivityManager.jumpToFile( - DocumentHolder.fromFile(File(it.getStringExtra(HOME_SCREEN_SHORTCUT_EXTRA_KEY)!!)), - this + file = LocalFileHolder(File(it.getStringExtra(HOME_SCREEN_SHORTCUT_EXTRA_KEY)!!)), + context = this ) intent = null } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt index 5176cb0c..e19adedb 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt @@ -1,8 +1,7 @@ package com.raival.compose.file.explorer.screen.main import android.content.Context -import androidx.compose.material3.DrawerState -import androidx.compose.material3.DrawerValue +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf @@ -14,8 +13,8 @@ import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.common.extension.isNot import com.raival.compose.file.explorer.screen.main.tab.Tab import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDeviceHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDevice import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab import kotlinx.coroutines.CoroutineScope @@ -27,7 +26,7 @@ class MainActivityManager { var title by mutableStateOf(globalClass.getString(R.string.main_activity_title)) var subtitle by mutableStateOf(emptyString) - val storageDeviceHolders = arrayListOf() + val storageDevices = arrayListOf() var showNewTabDialog by mutableStateOf(false) var showAppInfoDialog by mutableStateOf(false) @@ -35,13 +34,14 @@ class MainActivityManager { var showSaveTextEditorFilesBeforeCloseDialog by mutableStateOf(false) var isSavingTextEditorFiles by mutableStateOf(false) + var selectedTabIndex by mutableIntStateOf(0) val tabs = mutableStateListOf() - val drawerState = DrawerState(initialValue = DrawerValue.Closed) + val tabLayoutState = LazyListState() fun setupTabs() { - storageDeviceHolders.addAll(StorageProvider.getStorageDevices(globalClass)) + storageDevices.addAll(StorageProvider.getStorageDevices(globalClass)) } fun closeAllTabs() { @@ -93,43 +93,47 @@ class MainActivityManager { } fun selectTabAt(index: Int) { - if (tabs.isNotEmpty() && selectedTabIndex isNot index && selectedTabIndex < tabs.size) tabs[selectedTabIndex].onTabStopped() + if (tabs.isNotEmpty() + && selectedTabIndex isNot index + && selectedTabIndex < tabs.size + ) getActiveTab().onTabStopped() selectedTabIndex = index - tabs[selectedTabIndex].apply { + getActiveTab().apply { if (!isCreated) onTabStarted() else onTabResumed() } } fun replaceCurrentTabWith(tab: Tab) { - if (tabs.isNotEmpty()) tabs[selectedTabIndex].onTabStopped() + if (tabs.isNotEmpty()) getActiveTab().onTabStopped() tabs[selectedTabIndex] = tab selectTabAt(selectedTabIndex) } - fun jumpToFile(file: DocumentHolder, context: Context) { + fun jumpToFile(file: LocalFileHolder, context: Context) { openFile(file, context) } - private fun openFile(file: DocumentHolder, context: Context) { + private fun openFile(file: LocalFileHolder, context: Context) { if (file.exists()) { addTabAndSelect(FilesTab(file, context)) } } - fun canExit(coroutineScope: CoroutineScope): Boolean { - if (drawerState.isOpen) { - coroutineScope.launch { - drawerState.close() - } - return false - } + fun resumeActiveTab() { + getActiveTab().onTabResumed() + } + + fun getActiveTab(): Tab { + return tabs[selectedTabIndex] + } - if (tabs[selectedTabIndex].onBackPressed()) { + fun canExit(coroutineScope: CoroutineScope): Boolean { + if (getActiveTab().onBackPressed()) { return false } - if (tabs[selectedTabIndex] !is HomeTab) { + if (getActiveTab() !is HomeTab && !globalClass.preferencesManager.behaviorPrefs.skipHomeWhenTabClosed) { replaceCurrentTabWith(HomeTab()) return false } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/apps/ui/AppsTabContentView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/apps/ui/AppsTabContentView.kt index d515be94..b5c6c657 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/apps/ui/AppsTabContentView.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/apps/ui/AppsTabContentView.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -59,18 +58,15 @@ import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.common.ui.block import com.raival.compose.file.explorer.screen.main.tab.apps.AppsTab import com.raival.compose.file.explorer.screen.main.tab.apps.holder.AppHolder -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTask import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRow import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRowIcon import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import java.io.File @Composable -fun ColumnScope.AppsTabContentView(tab: AppsTab) { +fun AppsTabContentView(tab: AppsTab) { LaunchedEffect(tab.id) { if (tab.appsList.isEmpty()) { tab.fetchInstalledApps() @@ -160,14 +156,11 @@ fun ColumnScope.AppsTabContentView(tab: AppsTab) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton( onClick = { - globalClass.filesTabManager.filesTabTasks.add( - CopyTask(arrayListOf(DocumentHolder.fromFile(File(selectedApp.path)))) - ) tab.previewAppDialog = null globalClass.showMsg(R.string.new_task_has_been_added) } ) { - Text(text = stringResource(R.string.copy)) + Text(text = stringResource(R.string.save)) } } } @@ -351,7 +344,7 @@ fun ColumnScope.AppsTabContentView(tab: AppsTab) { } } - androidx.compose.animation.AnimatedVisibility( + AnimatedVisibility( modifier = Modifier.align(Alignment.Center), visible = tab.isLoading || tab.isSearching ) { diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt index a6dded66..4c1607c5 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt @@ -5,13 +5,11 @@ import android.content.Context import android.content.Intent import android.content.pm.ShortcutInfo import android.content.pm.ShortcutManager -import android.content.res.AssetManager import android.net.Uri import android.os.Environment import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -19,81 +17,57 @@ import androidx.core.app.ShareCompat import androidx.core.content.FileProvider.getUriForFile import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.addIfAbsent import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.common.extension.getIndexIf -import com.raival.compose.file.explorer.common.extension.isNot +import com.raival.compose.file.explorer.common.extension.getMimeType import com.raival.compose.file.explorer.common.extension.orIf import com.raival.compose.file.explorer.common.extension.removeIf import com.raival.compose.file.explorer.screen.main.MainActivity import com.raival.compose.file.explorer.screen.main.tab.Tab -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.raival.compose.file.explorer.screen.main.tab.files.misc.Action +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_DATE -import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_NAME -import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_SIZE -import com.raival.compose.file.explorer.screen.main.tab.files.misc.UpdateAction -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortFoldersFirst -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortLargerFirst -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortName -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNameRev -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNewerFirst -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortOlderFirst -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortSmallerFirst import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getArchiveFiles -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getAudioFiles -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getDocumentFiles -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getImageFiles -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getRecentFiles -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getVideoFiles import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTask -import com.raival.compose.file.explorer.screen.main.tab.files.task.DeleteTask -import com.raival.compose.file.explorer.screen.main.tab.files.task.FilesTabTask -import com.raival.compose.file.explorer.screen.main.tab.files.task.FilesTabTaskCallback -import com.raival.compose.file.explorer.screen.main.tab.files.task.FilesTabTaskDetails import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File class FilesTab( - val source: DocumentHolder, + val source: ContentHolder, context: Context? = null ) : Tab() { override val id = globalClass.generateUid() companion object { - fun isValidPath(path: String) = DocumentHolder.fromFullPath(path) isNot null + fun isValidLocalPath(path: String) = File(path).exists() } - val search = Search - val taskDialog = TaskDialog - val apkDialog = ApkDialog - val compressDialog = CompressDialog - val renameDialog = RenameDialog - val fileOptionsDialog = FileOptionsDialog - val openWithDialog = OpenWithDialog + val search = Search() + val apkDialog = ApkDialog() + val fileOptionsDialog = FileOptionsDialog() + val openWithDialog = OpenWithDialog() + val renameDialog = RenameDialog() + val newZipFileDialog = NewZipFileDialog() - val homeDir: DocumentHolder = - if (isSpecialDirectory(source) || source.isFolder) source else source.parent - ?: StorageProvider.getPrimaryInternalStorage(globalClass).documentHolder - var activeFolder: DocumentHolder = homeDir + val homeDir: ContentHolder = + if (source is VirtualFileHolder || source.isFolder) source else source.getParent() + ?: StorageProvider.getPrimaryInternalStorage(globalClass).contentHolder + var activeFolder: ContentHolder = homeDir - val actions = arrayListOf() - - val activeFolderContent = mutableStateListOf() + val activeFolderContent = mutableStateListOf() val contentListStates = hashMapOf() var activeListState by mutableStateOf(LazyGridState()) - val currentPathSegments = mutableStateListOf() + val currentPathSegments = mutableStateListOf() val currentPathSegmentsListState = LazyListState() - val assetManager: AssetManager = globalClass.assets - - val selectedFiles = linkedMapOf() + val selectedFiles = linkedMapOf() var lastSelectedFileIndex = -1 var highlightedFiles = arrayListOf() @@ -107,7 +81,8 @@ class FilesTab( var showBookmarkDialog by mutableStateOf(false) var showMoreOptionsButton by mutableStateOf(false) var showEmptyRecycleBin by mutableStateOf(false) - var handleBackGesture by mutableStateOf(activeFolder.canAccessParent || selectedFiles.isNotEmpty()) + var canCreateNewContent by mutableStateOf(true) + var handleBackGesture by mutableStateOf(activeFolder.hasParent() || selectedFiles.isNotEmpty()) var tabViewLabel by mutableStateOf(emptyString) var isLoading by mutableStateOf(false) @@ -117,16 +92,17 @@ class FilesTab( override fun onTabStarted() { super.onTabStarted() - if (source.isFile) { - source.parent?.let { parent -> - if (parent.exists()) { - openFolder(parent) { - CoroutineScope(Dispatchers.Main).launch { - getFileListState().scrollToItem( - activeFolderContent.getIndexIf { path == source.path }, + if (source.isFile()) { + source.getParent()?.let { parent -> + openFolder(parent) { + CoroutineScope(Dispatchers.Main).launch { + getFileListState().scrollToItem( + maxOf( + activeFolderContent.getIndexIf { uniquePath == source.uniquePath }, 0 - ) - } + ), + 0 + ) } } } ?: also { @@ -135,7 +111,7 @@ class FilesTab( highlightedFiles.apply { clear() - add(source.path) + add(source.uniquePath) } } else { openFolder(homeDir) @@ -144,22 +120,7 @@ class FilesTab( override fun onTabResumed() { requestHomeToolbarUpdate() - - actions.forEach { action -> - if (action is UpdateAction) { - reloadFiles() - if (action.autoDismiss) { - action.due = false - action.isDone = true - } - } - - actions.removeIf { it.isDone } - } - } - - override fun onTabStopped() { - super.onTabStopped() + detectFileChanges() } override val title: String @@ -172,7 +133,7 @@ class FilesTab( get() = tabViewLabel init { - if (source.isFile) { + if (source.isFile()) { context?.let { openFile(context, source) } } } @@ -212,19 +173,15 @@ class FilesTab( private fun createTitle() = globalClass.getString(R.string.files_tab_title) - fun addNewAction(action: Action): Action { - return action.apply { actions.add(this) } - } - override fun onBackPressed(): Boolean { if (unselectAnySelectedFiles()) { return true } else if (handleBackGesture) { highlightedFiles.apply { clear() - add(activeFolder.path) + add(activeFolder.uniquePath) } - openFolder(activeFolder.parent!!) + openFolder(activeFolder.getParent()!!) return true } @@ -244,16 +201,16 @@ class FilesTab( return false } - fun openFile(context: Context, item: DocumentHolder) { - if (item.isApk || item.isApks) { - ApkDialog.show(item) + fun openFile(context: Context, item: ContentHolder) { + if (item is LocalFileHolder && item.isApk()) { + apkDialog.show(item) } else { - item.openFile(context, anonymous = false, skipSupportedExtensions = false) + item.open(context, anonymous = false, skipSupportedExtensions = false, null) } } fun openFolder( - item: DocumentHolder, + item: ContentHolder, rememberListState: Boolean = true, rememberSelectedFiles: Boolean = false, postEvent: () -> Unit = {} @@ -264,7 +221,7 @@ class FilesTab( selectedFiles.clear() lastSelectedFileIndex = -1 } else { - selectedFiles.removeIf { key, value -> !value.exists() } + selectedFiles.removeIf { key, value -> !value.isValid() } if (selectedFiles.isEmpty()) lastSelectedFileIndex = -1 } @@ -272,106 +229,99 @@ class FilesTab( activeFolder = item - showEmptyRecycleBin = activeFolder.hasParent(globalClass.recycleBinDir) - || activeFolder.path == globalClass.recycleBinDir.path + listFiles { newContent -> + if (activeFolder is LocalFileHolder) { + showEmptyRecycleBin = + (activeFolder as LocalFileHolder).hasParent(globalClass.recycleBinDir) + || activeFolder.uniquePath == globalClass.recycleBinDir.uniquePath + } else { + showEmptyRecycleBin = false + } - handleBackGesture = activeFolder.canAccessParent || selectedFiles.isNotEmpty() + canCreateNewContent = activeFolder.canAddNewContent - updatePathList() - listFiles { newContent -> + handleBackGesture = activeFolder.hasParent() || selectedFiles.isNotEmpty() + + updatePathList() + requestHomeToolbarUpdate() activeFolderContent.clear() activeFolderContent.addAll(newContent) if (!rememberListState) { - contentListStates[item.path] = LazyGridState(0, 0) + contentListStates[item.uniquePath] = LazyGridState(0, 0) } - activeListState = contentListStates[item.path] ?: LazyGridState() - .also { contentListStates[item.path] = it } + activeListState = contentListStates[item.uniquePath] ?: LazyGridState() + .also { contentListStates[item.uniquePath] = it } postEvent() } } + fun detectFileChanges(): Boolean { + if (isLoading) return false + if (activeFolder is LocalFileHolder) { + val newContent = + (activeFolder as LocalFileHolder).file.listFiles()?.toCollection(arrayListOf()) + ?.apply { + if (!globalClass.preferencesManager.fileListPrefs.showHiddenFiles) { + removeIf { it.name.startsWith(".") } + } + } + if (newContent != null && newContent.size != activeFolderContent.size) { + reloadFiles() + return true + } + if (activeFolderContent.any { (it as LocalFileHolder).hasSourceChanged() }) { + reloadFiles() + return true + } + } else if (activeFolder is ZipFileHolder) { + if (globalClass.zipManager.checkForSourceChanges()) { + reloadFiles() + return true + } + } + return false + } + fun quickReloadFiles() { if (isLoading) return - val temp = arrayListOf().apply { addAll(activeFolderContent) } + val temp = arrayListOf().apply { addAll(activeFolderContent) } activeFolderContent.clear() activeFolderContent.addAll(temp) - handleBackGesture = activeFolder.canAccessParent || selectedFiles.isNotEmpty() + handleBackGesture = activeFolder.hasParent() || selectedFiles.isNotEmpty() requestHomeToolbarUpdate() showMoreOptionsButton = selectedFiles.size > 0 - showEmptyRecycleBin = activeFolder.hasParent(globalClass.recycleBinDir) - || activeFolder.path == globalClass.recycleBinDir.path + if (activeFolder is LocalFileHolder) { + showEmptyRecycleBin = + (activeFolder as LocalFileHolder).hasParent(globalClass.recycleBinDir) + || activeFolder.uniquePath == globalClass.recycleBinDir.uniquePath + } } fun reloadFiles(postEvent: () -> Unit = {}) { openFolder(activeFolder) { postEvent() } } - private fun listFiles(onReady: (ArrayList) -> Unit) { + private fun listFiles(onReady: (ArrayList) -> Unit) { CoroutineScope(Dispatchers.IO).launch { isLoading = true - foldersCount = 0 - filesCount = 0 - - val result = if (isSpecialDirectory()) { - when (activeFolder) { - StorageProvider.audios -> getAudioFiles() - StorageProvider.videos -> getVideoFiles() - StorageProvider.images -> getImageFiles() - StorageProvider.archives -> getArchiveFiles() - StorageProvider.documents -> getDocumentFiles() - StorageProvider.bookmarks -> getBookmarks() - else -> getRecentFiles() - }.apply { - if (activeFolder != StorageProvider.recentFiles) { - val sortingPrefs = - globalClass.preferencesManager.filesSortingPrefs.getSortingPrefsFor( - activeFolder - ) - when (sortingPrefs.sortMethod) { - SORT_BY_NAME -> { - sortWith(if (sortingPrefs.reverseSorting) sortNameRev else sortName) - } - - SORT_BY_DATE -> { - sortWith(if (sortingPrefs.reverseSorting) sortOlderFirst else sortNewerFirst) - } - - SORT_BY_SIZE -> { - sortWith(if (sortingPrefs.reverseSorting) sortLargerFirst else sortSmallerFirst) - } - } + val result = activeFolder.listSortedContent() - if (sortingPrefs.showFoldersFirst) sortWith(sortFoldersFirst) - } - }.also { - filesCount = it.size - } - } else { - activeFolder.listContent( - sortingPrefs = globalClass.preferencesManager.filesSortingPrefs.getSortingPrefsFor( - activeFolder - ) - ) { - if (it.isFile) filesCount++ - else foldersCount++ - }.apply { - if (!globalClass.preferencesManager.displayPrefs.showHiddenFiles) { - removeIf { it.isHidden } - } - } + activeFolder.getContentCount().let { contentCount -> + foldersCount = contentCount.folders + filesCount = contentCount.files } withContext(Dispatchers.Main) { @@ -387,60 +337,47 @@ class FilesTab( if (quickReload) quickReloadFiles() } - fun onNewFileCreated(fileName: String, openFolder: Boolean = false) { - val newFile = activeFolder.findFile(fileName) - - newFile?.let { + fun onNewFileCreated(newFile: ContentHolder, openFolder: Boolean = false) { + if (openFolder) { + openFolder(newFile) + } else { highlightedFiles.apply { clear() - add(it.path) + add(newFile.uniquePath) } reloadFiles { CoroutineScope(Dispatchers.Main).launch { val newItemIndex = - activeFolderContent.getIndexIf { path == newFile.path } + activeFolderContent.getIndexIf { displayName == newFile.displayName } if (newItemIndex > -1) { getFileListState().scrollToItem(newItemIndex, 0) } - - if (openFolder) { - openFolder(newFile) - } } } } } - private fun getBookmarks() = globalClass.filesTabManager.bookmarks - .map { DocumentHolder.fromFullPath(it) } - .takeWhile { it != null } as ArrayList - - fun getFileListState() = contentListStates[activeFolder.path] ?: LazyGridState().also { - contentListStates[activeFolder.path] = it + fun getFileListState() = contentListStates[activeFolder.uniquePath] ?: LazyGridState().also { + contentListStates[activeFolder.uniquePath] = it } private fun updateTabViewLabel() { val fullName = - activeFolder.getName().orIf(globalClass.getString(R.string.internal_storage)) { - activeFolder.path == Environment.getExternalStorageDirectory().absolutePath + activeFolder.displayName.orIf(globalClass.getString(R.string.internal_storage)) { + activeFolder.uniquePath == Environment.getExternalStorageDirectory().absolutePath } tabViewLabel = if (fullName.length > 18) fullName.substring(0, 15) + "..." else fullName } private fun updatePathList() { + val path = generateSequence(activeFolder) { it.getParent() } + + val newPathSegments = path.filter { it.canRead }.toList().reversed() + currentPathSegments.apply { clear() - add(activeFolder) - if (activeFolder.canAccessParent) { - var parentDir = activeFolder.parent!! - add(parentDir) - while (parentDir.canAccessParent) { - parentDir = parentDir.parent!! - add(parentDir) - } - } - reverse() + addAll(newPathSegments) }.also { updateTabViewLabel() } } @@ -448,47 +385,28 @@ class FilesTab( globalClass.mainActivityManager.addTabAndSelect(tab) } - fun addNewTask(task: FilesTabTask) { - globalClass.filesTabManager.filesTabTasks.add(task) - globalClass.showMsg(R.string.new_task_has_been_added) - } - fun hideDocumentOptionsMenu() { - FileOptionsDialog.hide() + fileOptionsDialog.hide() } - fun deleteFiles( - targetFiles: List, - taskCallback: FilesTabTaskCallback, - moveToRecycleBin: Boolean = true - ) { - CoroutineScope(Dispatchers.IO).launch { - DeleteTask(targetFiles, moveToRecycleBin).execute(activeFolder, taskCallback) - } - } - - fun share( - context: Context, - targetDocumentHolder: DocumentHolder - ) { + /** + * Shares the selected files. + * Only local content can be shared, other types must create a local copy first. + */ + fun shareSelectedFiles(context: Context) { val uris = arrayListOf() - selectedFiles.forEach { - val file = it.component2().toFile() - if (file != null) { + + selectedFiles.forEach { selectedFile -> + val content = selectedFile.component2() + if (content is LocalFileHolder) { uris.add( - getUriForFile( - context, - globalClass.packageName + ".provider", - file - ) + getUriForFile(context, globalClass.packageName + ".provider", content.file) ) - } else { - uris.add(it.component2().uri) } } val builder = ShareCompat.IntentBuilder(globalClass) - .setType(if (uris.size == 1) targetDocumentHolder.mimeType else anyFileType) + .setType(if (uris.size == 1) uris[0].getMimeType(globalClass) else anyFileType) uris.forEach { builder.addStream(it) } @@ -505,24 +423,24 @@ class FilesTab( ) } - fun addToHomeScreen(context: Context, file: DocumentHolder) { + fun addToHomeScreen(context: Context, file: LocalFileHolder) { val shortcutManager = context.getSystemService(ShortcutManager::class.java) val pinShortcutInfo = ShortcutInfo - .Builder(context, file.path) + .Builder(context, file.uniquePath) .setIntent( Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW - putExtra("filePath", file.path) + putExtra("filePath", file.uniquePath) flags = Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT } ) .setIcon( android.graphics.drawable.Icon.createWithResource( context, - if (file.isFile) R.mipmap.default_shortcut_icon else R.mipmap.folder_shortcut_icon + if (file.isFile()) R.mipmap.default_shortcut_icon else R.mipmap.folder_shortcut_icon ) ) - .setShortLabel(file.getName()) + .setShortLabel(file.displayName) .build() val pinnedShortcutCallbackIntent = shortcutManager.createShortcutResultIntent(pinShortcutInfo) @@ -537,170 +455,88 @@ class FilesTab( ) } - fun isSpecialDirectory(source: DocumentHolder = activeFolder) = when (source) { - StorageProvider.archives, - StorageProvider.recentFiles, - StorageProvider.images, - StorageProvider.videos, - StorageProvider.audios, - StorageProvider.bookmarks, - StorageProvider.documents -> true - - else -> false - } - - fun canCreateNewFile() = !isSpecialDirectory() - - fun canRunTasks() = !isSpecialDirectory() - - object TaskDialog { - var showTaskDialog by mutableStateOf(false) - var taskDialogTitle by mutableStateOf(emptyString) - var taskDialogSubtitle by mutableStateOf(emptyString) - var taskDialogInfo by mutableStateOf(emptyString) - var showTaskDialogProgressbar by mutableStateOf(true) - var taskDialogProgress by mutableFloatStateOf(-1f) - } - - object ApkDialog { + class ApkDialog { var showApkDialog by mutableStateOf(false) private set - var apkFile: DocumentHolder? = null + var apkFile: LocalFileHolder? = null private set - var ApksArchive = false - private set - fun show(file: DocumentHolder) { + fun show(file: LocalFileHolder) { apkFile = file showApkDialog = true - ApksArchive = file.isApks } fun hide() { showApkDialog = false - apkFile = null } } - object CompressDialog { - var showCompressDialog by mutableStateOf(false) + class FileOptionsDialog { + var showFileOptionsDialog by mutableStateOf(false) private set - var task: CompressTask? = null + var targetFile: ContentHolder? = null private set - fun show(task: CompressTask) { - CompressDialog.task = task - showCompressDialog = true + fun show(file: ContentHolder) { + targetFile = file + showFileOptionsDialog = true } fun hide() { - showCompressDialog = false + showFileOptionsDialog = false } } - object RenameDialog { - var showRenameFileDialog by mutableStateOf(false) + class OpenWithDialog { + var showOpenWithDialog by mutableStateOf(false) private set - var targetFile: DocumentHolder? = null + var targetFile: LocalFileHolder? = null private set - fun show(file: DocumentHolder) { + fun show(file: LocalFileHolder) { targetFile = file - showRenameFileDialog = true + showOpenWithDialog = true } fun hide() { - showRenameFileDialog = false - targetFile = null + showOpenWithDialog = false } } - object FileOptionsDialog { - var showFileOptionsDialog by mutableStateOf(false) + class NewZipFileDialog { + var show by mutableStateOf(false) private set - var targetFile: DocumentHolder? = null + var task: CompressTask? = null private set - fun show(file: DocumentHolder) { - targetFile = file - showFileOptionsDialog = true + fun show(task: CompressTask) { + this.task = task + show = true } fun hide() { - showFileOptionsDialog = false - targetFile = null + show = false } } - object OpenWithDialog { - var showOpenWithDialog by mutableStateOf(false) + class RenameDialog { + var show by mutableStateOf(false) private set - var targetFile: DocumentHolder? = null + var targetContent: ContentHolder? = null private set - fun show(file: DocumentHolder) { - targetFile = file - showOpenWithDialog = true + fun show(content: ContentHolder) { + targetContent = content + show = true } fun hide() { - showOpenWithDialog = false - targetFile = null + show = false } } - object Search { + class Search { var searchQuery by mutableStateOf(emptyString) - var searchResults = mutableStateListOf() - } - - val taskCallback = object : FilesTabTaskCallback(CoroutineScope(Dispatchers.IO)) { - override fun onPrepare(details: FilesTabTaskDetails) { - taskDialog.apply { - showTaskDialog = true - taskDialogTitle = details.title - taskDialogSubtitle = details.subtitle - showTaskDialogProgressbar = true - taskDialogProgress = details.progress - taskDialogInfo = details.info - } - } - - override fun onReport(details: FilesTabTaskDetails) { - taskDialog.apply { - taskDialogTitle = details.title - taskDialogSubtitle = details.subtitle - taskDialogProgress = details.progress - taskDialogInfo = details.info - } - } - - override fun onComplete(details: FilesTabTaskDetails) { - highlightedFiles.clear() - - details.task.getSourceFiles().forEach { - activeFolder.findFile(it.getName())?.let { file -> - highlightedFiles.addIfAbsent(file.path) - } - } - - globalClass.showMsg(buildString { - append(details.subtitle) - }) - - TaskDialog.showTaskDialog = false - showTasksPanel = false - - reloadFiles() - - globalClass.filesTabManager.filesTabTasks.removeIf { it.id == details.task.id } - } - - override fun onFailed(details: FilesTabTaskDetails) { - globalClass.showMsg(details.subtitle) - TaskDialog.showTaskDialog = false - showTasksPanel = false - reloadFiles() - } + var searchResults = mutableStateListOf() } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/manager/FilesTabManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTabManager.kt similarity index 62% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/manager/FilesTabManager.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTabManager.kt index f1bb8728..03872db3 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/manager/FilesTabManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTabManager.kt @@ -1,15 +1,11 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.manager +package com.raival.compose.file.explorer.screen.main.tab.files import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.setValue import androidx.datastore.preferences.core.stringSetPreferencesKey -import com.raival.compose.file.explorer.screen.main.tab.files.task.FilesTabTask import com.raival.compose.file.explorer.screen.preferences.misc.prefMutableState class FilesTabManager { - val filesTabTasks = mutableStateListOf() - var bookmarks by prefMutableState( keyName = "bookmarks", defaultValue = emptySet(), diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/DocumentFileMapper.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/DocumentFileMapper.kt index 8cf9a255..1020fbe9 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/DocumentFileMapper.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/DocumentFileMapper.kt @@ -3,52 +3,54 @@ package com.raival.compose.file.explorer.screen.main.tab.files.coil import coil3.map.Mapper import coil3.request.Options import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_AI -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_APK -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_ARCHIVE -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_AUDIO -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_CODE -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_CSS -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_DOC -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_FOLDER -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_FONT -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_ISO -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_JAVA -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_JS -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_KOTLIN -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_PPT -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_PSD -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_SQL -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_TEXT -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_VCF -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_VECTOR -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_XLS +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.aiFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.archiveFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.audioFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.codeFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.cssFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.docFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.editableFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.excelFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.fontFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.imageFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.isoFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pptFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.psdFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.sqlFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vcfFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vectorFileType -class DocumentFileMapper : Mapper { - override fun map(data: DocumentHolder, options: Options): Any { +class DocumentFileMapper : Mapper { + override fun map(data: ContentHolder, options: Options): Any { return when { - data.getFileIconType() == FILE_TYPE_FOLDER -> R.drawable.baseline_folder_24 - data.getFileIconType() == FILE_TYPE_AI -> R.drawable.ai_file_extension - data.getFileIconType() == FILE_TYPE_CSS -> R.drawable.css_file_extension - data.getFileIconType() == FILE_TYPE_ISO -> R.drawable.iso_file_extension - data.getFileIconType() == FILE_TYPE_JS -> R.drawable.js_file_extension - data.getFileIconType() == FILE_TYPE_PSD -> R.drawable.psd_file_extension - data.getFileIconType() == FILE_TYPE_SQL -> R.drawable.sql_file_extension - data.getFileIconType() == FILE_TYPE_VCF -> R.drawable.vcf_file_extension - data.getFileIconType() == FILE_TYPE_JAVA -> R.drawable.css_file_extension - data.getFileIconType() == FILE_TYPE_KOTLIN -> R.drawable.css_file_extension - data.getFileIconType() == FILE_TYPE_DOC -> R.drawable.doc_file_extension - data.getFileIconType() == FILE_TYPE_XLS -> R.drawable.xls_file_extension - data.getFileIconType() == FILE_TYPE_PPT -> R.drawable.ppt_file_extension - data.getFileIconType() == FILE_TYPE_FONT -> R.drawable.font_file_extension - data.getFileIconType() == FILE_TYPE_VECTOR -> R.drawable.vector_file_extension - data.getFileIconType() == FILE_TYPE_AUDIO -> R.drawable.music_file_extension - data.getFileIconType() == FILE_TYPE_CODE -> R.drawable.css_file_extension - data.getFileIconType() == FILE_TYPE_TEXT -> R.drawable.txt_file_extension - data.getFileIconType() == FILE_TYPE_ARCHIVE -> R.drawable.zip_file_extension - data.getFileIconResource() == FILE_TYPE_APK -> R.drawable.apk_file_extension - canUseCoil(data) -> data.uri + data.isFolder -> R.drawable.baseline_folder_24 + data is LocalFileHolder && canUseCoil(data) -> data.icon + data.extension == aiFileType -> R.drawable.ai_file_extension + data.extension == cssFileType -> R.drawable.css_file_extension + data.extension == isoFileType -> R.drawable.iso_file_extension + data.extension == jsFileType -> R.drawable.js_file_extension + data.extension == psdFileType -> R.drawable.psd_file_extension + data.extension == sqlFileType -> R.drawable.sql_file_extension + data.extension == vcfFileType -> R.drawable.vcf_file_extension + data.extension == javaFileType -> R.drawable.css_file_extension + data.extension == kotlinFileType -> R.drawable.css_file_extension + imageFileType.contains(data.extension) -> R.drawable.jpg_file_extension + docFileType.contains(data.extension) -> R.drawable.doc_file_extension + excelFileType.contains(data.extension) -> R.drawable.xls_file_extension + pptFileType.contains(data.extension) -> R.drawable.ppt_file_extension + fontFileType.contains(data.extension) -> R.drawable.font_file_extension + vectorFileType.contains(data.extension) -> R.drawable.vector_file_extension + audioFileType.contains(data.extension) -> R.drawable.music_file_extension + codeFileType.contains(data.extension) -> R.drawable.css_file_extension + editableFileType.contains(data.extension) -> R.drawable.txt_file_extension + archiveFileType.contains(data.extension) -> R.drawable.zip_file_extension + apkFileType == data.extension -> R.drawable.apk_file_extension else -> R.drawable.unknown_file_extension } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/Utils.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/Utils.kt index f95c02af..dca5370b 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/Utils.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/Utils.kt @@ -1,7 +1,16 @@ package com.raival.compose.file.explorer.screen.main.tab.files.coil -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.imageFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pdfFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.videoFileType -fun canUseCoil(documentHolder: DocumentHolder): Boolean { - return (documentHolder.isFile && documentHolder.isImage || documentHolder.isVideo || documentHolder.isApk || documentHolder.isPdf) +fun canUseCoil(contentHolder: ContentHolder): Boolean { + return (contentHolder.isFile() + && imageFileType.contains(contentHolder.extension) + || videoFileType.contains(contentHolder.extension) + || contentHolder.extension == apkFileType + || contentHolder.extension == pdfFileType + ) } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ContentHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ContentHolder.kt index fe07629f..85256b18 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ContentHolder.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ContentHolder.kt @@ -1,10 +1,199 @@ package com.raival.compose.file.explorer.screen.main.tab.files.holder +import android.content.Context +import androidx.annotation.DrawableRes import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.aiFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.archiveFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.audioFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.codeFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.cssFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.docFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.editableFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.excelFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.fontFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.imageFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.isoFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pptFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.psdFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.sqlFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vcfFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vectorFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_DATE +import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_NAME +import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_SIZE +import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortFoldersFirst +import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortLargerFirst +import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortName +import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNameRev +import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNewerFirst +import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortOlderFirst +import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortSmallerFirst +/** + * Abstract class representing a content holder. + * This class provides common properties and methods for managing content. + */ abstract class ContentHolder { val uid = globalClass.generateUid() + abstract val uniquePath: String - abstract fun getName(): String - abstract fun getContent(): Any + abstract val displayName: String + abstract val details: String + abstract val icon: Any + + @get:DrawableRes + abstract val iconPlaceholder: Int + + abstract val isFolder: Boolean + abstract val lastModified: Long + abstract val size: Long + abstract val extension: String + abstract val canRead: Boolean + abstract val canWrite: Boolean + + abstract val canAddNewContent: Boolean + + /** + * Returns unsorted content list, use listSortedContent() to get sorted list. + */ + abstract suspend fun listContent(): ArrayList + abstract fun getParent(): ContentHolder? + + open fun createSubFile(name: String, onCreated: (ContentHolder?) -> Unit) { + onCreated(null) + } + + open fun createSubFolder(name: String, onCreated: (ContentHolder?) -> Unit) { + onCreated(null) + } + + abstract fun getContentCount(): ContentCount + abstract fun findFile(name: String): ContentHolder? + + /** + * Checks if the content holder is a valid content. + * For example, it might check if the underlying file or folder still exists on the file system. + * + * @return `true` if the content holder is valid, `false` otherwise. + */ + abstract fun isValid(): Boolean + + open fun open( + context: Context, + anonymous: Boolean, + skipSupportedExtensions: Boolean, + customMimeType: String? + ) { + } + + open fun isFile(): Boolean { + return !isFolder + } + + open fun hasParent(onlyReadable: Boolean = true): Boolean { + val parent = getParent() + return parent != null && parent.isValid() && (!onlyReadable || parent.canRead) + } + + open suspend fun walk(includeEmptyFolders: Boolean, onContent: (ContentHolder) -> Unit) { + if (!isValid() || !isFolder) return + + if (listContent().isEmpty() && !includeEmptyFolders) return + + listContent().forEach { + onContent(it) + it.walk(includeEmptyFolders, onContent) + } + } + + open suspend fun listSortedContent(): ArrayList { + val sortingPrefs = globalClass.preferencesManager.filesSortingPrefs.getSortingPrefsFor(this) + + return listContent().apply { + when (sortingPrefs.sortMethod) { + SORT_BY_NAME -> { + sortWith(if (sortingPrefs.reverseSorting) sortNameRev else sortName) + } + + SORT_BY_DATE -> { + sortWith(if (sortingPrefs.reverseSorting) sortOlderFirst else sortNewerFirst) + } + + SORT_BY_SIZE -> { + sortWith(if (sortingPrefs.reverseSorting) sortLargerFirst else sortSmallerFirst) + } + } + + if (sortingPrefs.showFoldersFirst) sortWith(sortFoldersFirst) + + if (!globalClass.preferencesManager.fileListPrefs.showHiddenFiles) { + removeIf { it.isHidden() } + } + } + } + + fun isApk(): Boolean = extension.endsWith(apkFileType) + + fun isHidden(): Boolean = displayName.startsWith(".") + + fun getFormattedFileCount(filesCount: Int, foldersCount: Int): String { + return buildString { + if (foldersCount == 0 && filesCount == 0) { + append(globalClass.getString(R.string.empty_folder)) + } else { + if (foldersCount > 0) { + append( + globalClass.getString( + R.string.folders_count, + foldersCount + ) + ) + if (filesCount > 0) append(", ") + } + if (filesCount > 0) { + append( + globalClass.getString( + R.string.files_count, + filesCount + ) + ) + } + } + } + } + + @DrawableRes + fun getContentIconPlaceholderResource(): Int { + return when { + isFolder -> R.drawable.baseline_folder_24 + extension == aiFileType -> R.drawable.ai_file_extension + extension == cssFileType -> R.drawable.css_file_extension + extension == isoFileType -> R.drawable.iso_file_extension + extension == jsFileType -> R.drawable.js_file_extension + extension == psdFileType -> R.drawable.psd_file_extension + extension == sqlFileType -> R.drawable.sql_file_extension + extension == vcfFileType -> R.drawable.vcf_file_extension + extension == javaFileType -> R.drawable.css_file_extension + extension == kotlinFileType -> R.drawable.css_file_extension + imageFileType.contains(extension) -> R.drawable.jpg_file_extension + docFileType.contains(extension) -> R.drawable.doc_file_extension + excelFileType.contains(extension) -> R.drawable.xls_file_extension + pptFileType.contains(extension) -> R.drawable.ppt_file_extension + fontFileType.contains(extension) -> R.drawable.font_file_extension + vectorFileType.contains(extension) -> R.drawable.vector_file_extension + audioFileType.contains(extension) -> R.drawable.music_file_extension + codeFileType.contains(extension) -> R.drawable.css_file_extension + editableFileType.contains(extension) -> R.drawable.txt_file_extension + archiveFileType.contains(extension) -> R.drawable.zip_file_extension + apkFileType == extension -> R.drawable.apk_file_extension + else -> R.drawable.unknown_file_extension + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/DocumentHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/DocumentHolder.kt deleted file mode 100644 index 5d67650c..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/DocumentHolder.kt +++ /dev/null @@ -1,575 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.holder - -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.webkit.MimeTypeMap -import androidx.compose.runtime.Immutable -import androidx.core.content.FileProvider -import androidx.core.net.toFile -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.file.DocumentFileCompat -import com.anggrayudi.storage.file.deleteRecursively -import com.anggrayudi.storage.file.extension -import com.anggrayudi.storage.file.getAbsolutePath -import com.anggrayudi.storage.file.getBasePath -import com.anggrayudi.storage.file.getStorageId -import com.anggrayudi.storage.file.hasParent -import com.anggrayudi.storage.file.isEmpty -import com.anggrayudi.storage.file.isRawFile -import com.anggrayudi.storage.file.mimeType -import com.anggrayudi.storage.file.openInputStream -import com.anggrayudi.storage.file.openOutputStream -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.conditions -import com.raival.compose.file.explorer.common.extension.drawableToBitmap -import com.raival.compose.file.explorer.common.extension.emptyString -import com.raival.compose.file.explorer.common.extension.isNot -import com.raival.compose.file.explorer.common.extension.toFormattedDate -import com.raival.compose.file.explorer.common.extension.toFormattedSize -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.aiFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkBundleFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.archiveFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.audioFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.codeFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.cssFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.docFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.editableFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.excelFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.fontFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.imageFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.isoFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pdfFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pptFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.psdFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.sqlFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.svgFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vcfFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vectorFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.videoFileType -import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileSortingPrefs -import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_DATE -import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_NAME -import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_SIZE -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortFoldersFirst -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortLargerFirst -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortName -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNameRev -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNewerFirst -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortOlderFirst -import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortSmallerFirst -import java.io.File - -@Immutable -data class DocumentHolder(val documentFile: DocumentFile) : ContentHolder() { - var formattedDetailsCache = emptyString - - override fun getName() = documentFile.name ?: UNKNOWN_NAME - override fun getContent() = documentFile - - val fileExtension by lazy { documentFile.extension.lowercase() } - - val fileSize by lazy { documentFile.length() } - - val lastModified by lazy { documentFile.lastModified() } - - val isHidden by lazy { getName().startsWith(".") } - - val uri by lazy { documentFile.uri } - - val path by lazy { documentFile.getAbsolutePath(globalClass) } - - val isFile by lazy { documentFile.isFile } - - val isFolder by lazy { documentFile.isDirectory } - - val isArchive by lazy { isFile && archiveFileType.contains(fileExtension) } - - val isApk by lazy { isFile && fileExtension == apkFileType } - - val isApks by lazy { isFile && apkBundleFileType.contains(fileExtension) } - - val isImage by lazy { isFile && imageFileType.contains(fileExtension) } - - val isVideo by lazy { isFile && videoFileType.contains(fileExtension) } - - val isPdf by lazy { isFile && fileExtension == pdfFileType } - - val basePath by lazy { documentFile.getBasePath(globalClass) } - - val storageId by lazy { documentFile.getStorageId(globalClass) } - - val mimeType by lazy { documentFile.mimeType ?: getMimeType(uri) ?: anyFileType } - - val canAccessParent by lazy { documentFile.parentFile != null || parentAlt != null } - - val parent: DocumentHolder? by lazy { - documentFile.parentFile?.let { DocumentHolder(it) } ?: parentAlt - } - - private val parentAlt: DocumentHolder? by lazy { - val file = File(path) - if (file.exists() && file.canRead()) { - file.parentFile?.takeIf { it.exists() && it.canRead() }?.let { - DocumentHolder(DocumentFile.fromFile(it)) - } - } else null - } - - fun exists() = documentFile.exists() - - fun isEmpty() = documentFile.isEmpty(globalClass) - - fun hasParent(parent: DocumentHolder): Boolean = - documentFile.hasParent(globalClass, parent.documentFile) - - private fun getMimeType(uri: Uri): String? = MimeTypeMap - .getSingleton() - .getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(uri.toString())) - - fun findFile(name: String): DocumentHolder? = - documentFile.findFile(name)?.let { DocumentHolder(it) } - - fun delete() = documentFile.delete() - - fun deleteRecursively() = documentFile.deleteRecursively(globalClass) - - fun openInputStream() = documentFile.openInputStream(globalClass) - - fun openOutputStream() = documentFile.openOutputStream(globalClass) - - fun createSubFile(name: String) = - documentFile.createFile(anyFileType, name)?.let { DocumentHolder(it) } - - fun createSubFolder(name: String) = - documentFile.createDirectory(name)?.let { DocumentHolder(it) } - - fun renameTo(newName: String) { - documentFile.renameTo(newName) - } - - private fun createUri() = if (documentFile.isRawFile) { - FileProvider.getUriForFile( - globalClass, - "com.raival.compose.file.explorer.provider", - toFile()!! - ) - } else { - uri - } - - fun walk(includeNoneEmptyFolders: Boolean = false): List { - val fileTree = mutableListOf() - listContent(false) { - if (it.isFolder) { - if (it.isEmpty()) { - fileTree.add(it) - } else { - if (includeNoneEmptyFolders) fileTree.add(it) - fileTree.addAll(it.walk(includeNoneEmptyFolders)) - } - } else { - fileTree.add(it) - } - } - return fileTree - } - - fun analyze( - onCountFile: () -> Unit, - onCountFolder: (isEmpty: Boolean) -> Unit, - onCountSize: (size: Long) -> Unit - ) { - if (documentFile.isFile) { - onCountFile() - onCountSize(fileSize) - } else if (documentFile.isDirectory) { - onCountFolder(documentFile.isEmpty(globalClass)) - listContent(false) { file -> - file.analyze(onCountFile, onCountFolder, onCountSize) - } - } - } - - fun listContent( - sort: Boolean = true, - sortingPrefs: FileSortingPrefs = FileSortingPrefs(), - onFile: (DocumentHolder) -> Unit = { } - ): ArrayList { - return arrayListOf().apply { - documentFile.listFiles().forEach { - DocumentHolder(it).let { file -> - add(file) - onFile(file) - } - } - - if (sort) { - when (sortingPrefs.sortMethod) { - SORT_BY_NAME -> { - sortWith(if (sortingPrefs.reverseSorting) sortNameRev else sortName) - } - - SORT_BY_DATE -> { - sortWith(if (sortingPrefs.reverseSorting) sortOlderFirst else sortNewerFirst) - } - - SORT_BY_SIZE -> { - sortWith(if (sortingPrefs.reverseSorting) sortLargerFirst else sortSmallerFirst) - } - } - - if (sortingPrefs.showFoldersFirst) sortWith(sortFoldersFirst) - } - } - } - - fun getFormattedDetails( - useCache: Boolean = false, - showFolderContentCount: Boolean - ): String { - if (useCache && formattedDetailsCache.isNotEmpty()) { - return formattedDetailsCache - } - - val separator = " | " - - formattedDetailsCache = buildString { - append(documentFile.lastModified().toFormattedDate()) - if (showFolderContentCount) append(separator) - if (documentFile.isFile) { - if (!showFolderContentCount) append(separator) - append(documentFile.length().toFormattedSize()) - append(separator) - append(fileExtension) - } else if (showFolderContentCount) { - append(getFormattedFileCount()) - } - } - - return formattedDetailsCache - } - - fun toFile(): File? { - if (documentFile.isRawFile) { - return uri.toFile() - } - return null - } - - fun writeText(text: String) { - if (documentFile.isRawFile) { - File(path).writeText(text) - } else { - documentFile.openOutputStream(globalClass, false)?.use { - it.write(text.toByteArray()) - } - } - } - - fun readText() = documentFile.openInputStream(globalClass)?.bufferedReader()?.use { reader -> - val text = reader.readText() - reader.close() - text - } ?: emptyString - - fun readLines( - maxLines: Int = -1, - onReadLine: (index: Int, line: String) -> Unit - ) = documentFile.openInputStream(globalClass)?.bufferedReader()?.use { reader -> - var lineCount = 0 - reader.forEachLine { line -> - if (maxLines in 1..lineCount) return@forEachLine - onReadLine(lineCount, line) - lineCount++ - } - } ?: emptyString - - fun appendText(text: String) { - if (documentFile.isRawFile) { - File(path).appendText(text) - } else { - documentFile.openOutputStream(globalClass, true)?.use { - it.write(text.toByteArray()) - } - } - } - - private fun getFormattedFileCount(): String { - var filesCount = 0 - var foldersCount = 0 - - listContent(false) { - if (it.isFile) filesCount++ else foldersCount++ - } - - return getFormattedFileCount(filesCount, foldersCount) - } - - fun getFormattedFileCount(filesCount: Int, foldersCount: Int): String { - return buildString { - if (foldersCount == 0 && filesCount == 0) { - append(globalClass.getString(R.string.empty_folder)) - } else { - if (foldersCount > 0) { - append( - globalClass.getString( - R.string.folders_count, - foldersCount - ) - ) - if (filesCount > 0) append(", ") - } - if (filesCount > 0) { - append( - globalClass.getString( - R.string.files_count, - filesCount - ) - ) - } - } - } - } - - fun openFile( - context: Context, - anonymous: Boolean, - skipSupportedExtensions: Boolean, - customMimeType: String? = null - ) { - if (!skipSupportedExtensions && handleSupportedFiles(context)) { - return - } - - Intent(Intent.ACTION_VIEW).let { newIntent -> - newIntent.setDataAndType( - createUri(), - customMimeType ?: if (anonymous) anyFileType else documentFile.mimeType - ) - - newIntent.addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_NEW_DOCUMENT - or Intent.FLAG_ACTIVITY_MULTIPLE_TASK - or Intent.FLAG_GRANT_READ_URI_PERMISSION - or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - - try { - context.startActivity(newIntent) - } catch (e: ActivityNotFoundException) { - if (!anonymous) { - openFile(context, anonymous = true, skipSupportedExtensions = true) - } else { - globalClass.showMsg(R.string.no_app_can_open_file) - } - } catch (e: Exception) { - with(globalClass) { - showMsg(getString(R.string.failed_to_open_this_file)) - log(e) - } - } - } - } - - private fun handleSupportedFiles(context: Context): Boolean { - val ext = fileExtension - - if (FileMimeType.conditions { codeFileType.contains(ext) || editableFileType.contains(ext) }) { - globalClass.textEditorManager.openTextEditor( - this, - context - ) - return true - } - - return false - } - - fun getAppsHandlingFile(mimeType: String = emptyString): List { - val packageManager: PackageManager = globalClass.packageManager - - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(createUri(), mimeType.ifEmpty { this@DocumentHolder.mimeType }) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) - } - - val appsList = ArrayList() - - packageManager.queryIntentActivities( - intent, - PackageManager.MATCH_ALL - ).onEach { - globalClass.grantUriPermission( - it.activityInfo.packageName, - createUri(), - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - appsList.add( - OpenWithActivityHolder( - label = it.activityInfo.loadLabel(packageManager).toString(), - name = it.activityInfo.name, - packageName = it.activityInfo.packageName, - icon = it.activityInfo.loadIcon(packageManager).drawableToBitmap(), - ) - ) - } - - return appsList - } - - fun openFileWithPackage(context: Context, packageName: String, className: String) { - val uri = createUri() - - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, mimeType) - addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_NEW_DOCUMENT - or Intent.FLAG_ACTIVITY_MULTIPLE_TASK - or Intent.FLAG_GRANT_READ_URI_PERMISSION - or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - setPackage(packageName) - setClassName(packageName, className) - } - - if (intent.resolveActivity(globalClass.packageManager) != null) { - context.startActivity(intent) - } else { - globalClass.showMsg("No app found to open this file.") - } - } - - fun getFileIconType(): Int { - if (isFolder) { - return FILE_TYPE_FOLDER - } else if (fileExtension == aiFileType) { - return FILE_TYPE_AI - } else if (fileExtension == apkFileType) { - return FILE_TYPE_APK - } else if (fileExtension == cssFileType) { - return FILE_TYPE_CSS - } else if (fileExtension == isoFileType) { - return FILE_TYPE_ISO - } else if (fileExtension == jsFileType) { - return FILE_TYPE_JS - } else if (fileExtension == psdFileType) { - return FILE_TYPE_PSD - } else if (fileExtension == sqlFileType) { - return FILE_TYPE_SQL - } else if (fileExtension == svgFileType) { - return FILE_TYPE_SVG - } else if (fileExtension == vcfFileType) { - return FILE_TYPE_VCF - } else if (fileExtension == pdfFileType) { - return FILE_TYPE_PDF - } else if (docFileType.contains(fileExtension)) { - return FILE_TYPE_DOC - } else if (excelFileType.contains(fileExtension)) { - return FILE_TYPE_XLS - } else if (pptFileType.contains(fileExtension)) { - return FILE_TYPE_PPT - } else if (fontFileType.contains(fileExtension)) { - return FILE_TYPE_FONT - } else if (vectorFileType.contains(fileExtension)) { - return FILE_TYPE_VECTOR - } else if (archiveFileType.contains(fileExtension) || apkBundleFileType.contains(fileExtension)) { - return FILE_TYPE_ARCHIVE - } else if (videoFileType.contains(fileExtension)) { - return FILE_TYPE_VIDEO - } else if (codeFileType.contains(fileExtension)) { - return FILE_TYPE_CODE - } else if (editableFileType.contains(fileExtension)) { - return FILE_TYPE_TEXT - } else if (imageFileType.contains(fileExtension)) { - return FILE_TYPE_IMAGE - } else if (audioFileType.contains(fileExtension)) { - return FILE_TYPE_AUDIO - } else { - return FILE_TYPE_UNKNOWN - } - } - - fun getFileIconResource() = when (getFileIconType()) { - FILE_TYPE_FOLDER -> R.drawable.baseline_folder_24 - FILE_TYPE_AI -> R.drawable.ai_file_extension - FILE_TYPE_APK -> R.drawable.apk_file_extension - FILE_TYPE_CSS -> R.drawable.css_file_extension - FILE_TYPE_ISO -> R.drawable.iso_file_extension - FILE_TYPE_JS -> R.drawable.js_file_extension - FILE_TYPE_PDF -> R.drawable.pdf_file_extension - FILE_TYPE_PSD -> R.drawable.psd_file_extension - FILE_TYPE_SQL -> R.drawable.sql_file_extension - FILE_TYPE_SVG -> R.drawable.svg_file_extension - FILE_TYPE_VCF -> R.drawable.vcf_file_extension - FILE_TYPE_JAVA -> R.drawable.javascript_file_extension - FILE_TYPE_KOTLIN -> R.drawable.css_file_extension - FILE_TYPE_DOC -> R.drawable.doc_file_extension - FILE_TYPE_XLS -> R.drawable.xls_file_extension - FILE_TYPE_PPT -> R.drawable.ppt_file_extension - FILE_TYPE_FONT -> R.drawable.font_file_extension - FILE_TYPE_VECTOR -> R.drawable.vector_file_extension - FILE_TYPE_VIDEO -> R.drawable.video_file_extension - FILE_TYPE_AUDIO -> R.drawable.music_file_extension - FILE_TYPE_IMAGE -> R.drawable.jpg_file_extension - FILE_TYPE_CODE -> R.drawable.css_file_extension - FILE_TYPE_TEXT -> R.drawable.txt_file_extension - FILE_TYPE_ARCHIVE -> R.drawable.zip_file_extension - else -> R.drawable.unknown_file_extension - } - - companion object { - const val UNKNOWN_NAME = "UNKNOWN" - - const val FILE_TYPE_FOLDER = -1 - const val FILE_TYPE_UNKNOWN = 0 - const val FILE_TYPE_AI = 1 - const val FILE_TYPE_APK = 2 - const val FILE_TYPE_CSS = 3 - const val FILE_TYPE_ISO = 4 - const val FILE_TYPE_JS = 5 - const val FILE_TYPE_PDF = 6 - const val FILE_TYPE_PSD = 7 - const val FILE_TYPE_SQL = 8 - const val FILE_TYPE_SVG = 9 - const val FILE_TYPE_VCF = 10 - const val FILE_TYPE_JAVA = 11 - const val FILE_TYPE_KOTLIN = 12 - const val FILE_TYPE_DOC = 13 - const val FILE_TYPE_XLS = 14 - const val FILE_TYPE_PPT = 15 - const val FILE_TYPE_FONT = 16 - const val FILE_TYPE_VECTOR = 17 - const val FILE_TYPE_VIDEO = 18 - const val FILE_TYPE_AUDIO = 19 - const val FILE_TYPE_IMAGE = 20 - const val FILE_TYPE_CODE = 21 - const val FILE_TYPE_TEXT = 22 - const val FILE_TYPE_ARCHIVE = 23 - - - fun fromFullPath(path: String): DocumentHolder? { - val doc = DocumentFileCompat.fromFullPath(globalClass, path) - return if (doc isNot null) DocumentHolder(doc!!) else null - } - - fun fromFile(file: File): DocumentHolder { - return DocumentHolder(DocumentFile.fromFile(file)) - } - - fun fromUri(uri: Uri): DocumentHolder? { - val doc = DocumentFileCompat.fromUri(globalClass, uri) - return if (doc isNot null) DocumentHolder(doc!!) else null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt new file mode 100644 index 00000000..9f543d51 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt @@ -0,0 +1,278 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.holder + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.content.FileProvider +import com.anggrayudi.storage.file.getBasePath +import com.anggrayudi.storage.file.mimeType +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.conditions +import com.raival.compose.file.explorer.common.extension.drawableToBitmap +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.hasParent +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.common.extension.toFormattedSize +import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType +import java.io.File + +class LocalFileHolder(val file: File) : ContentHolder() { + private var folderCount = 0 + private var fileCount = 0 + private var cachedLastModified = -1L + + override val displayName: String by lazy { file.name } + + override val details by lazy { + val separator = " | " + buildString { + append(lastModified.toFormattedDate()) + if (file.isDirectory) { + if (globalClass.preferencesManager.fileListPrefs.showFolderContentCount && file.canRead()) { + append(separator) + append(getFormattedFileCount()) + } + } else { + append(separator) + append(file.length().toFormattedSize()) + append(separator) + append(file.extension) + } + } + } + + override val isFolder: Boolean by lazy { file.isDirectory } + + override val lastModified: Long + get() = file.lastModified().also { + if (cachedLastModified == -1L) cachedLastModified = it + } + + override val size: Long by lazy { file.length() } + + override val icon: Any = file + + override val iconPlaceholder: Int by lazy { getContentIconPlaceholderResource() } + + override val uniquePath: String by lazy { file.absolutePath } + + override val extension: String by lazy { file.extension.lowercase() } + + override val canAddNewContent: Boolean = true + + override val canRead: Boolean by lazy { file.canRead() } + + override val canWrite: Boolean by lazy { file.canWrite() } + + val mimeType by lazy { file.mimeType ?: anyFileType } + + val basePath by lazy { file.getBasePath(globalClass) } + + override fun isValid(): Boolean = file.exists() + + override suspend fun listContent(): ArrayList { + folderCount = 0 + fileCount = 0 + + return arrayListOf().apply { + file.listFiles()?.forEach { add(LocalFileHolder(it)) } + } + } + + override fun getParent(): LocalFileHolder? = file.parentFile?.let { LocalFileHolder(it) } + + override fun open( + context: Context, + anonymous: Boolean, + skipSupportedExtensions: Boolean, + customMimeType: String? + ) { + if (!skipSupportedExtensions && handleSupportedFiles(context)) { + return + } + + Intent(Intent.ACTION_VIEW).let { newIntent -> + newIntent.setDataAndType( + createUri(), + customMimeType ?: if (anonymous) anyFileType else file.mimeType + ) + + newIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_NEW_DOCUMENT + or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + or Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + + try { + context.startActivity(newIntent) + } catch (_: ActivityNotFoundException) { + if (!anonymous) { + open(context, anonymous = true, skipSupportedExtensions = true, null) + } else { + globalClass.showMsg(R.string.no_app_can_open_file) + } + } catch (e: Exception) { + with(globalClass) { + logger.logError(e) + showMsg(getString(R.string.failed_to_open_this_file)) + } + } + } + } + + override fun getContentCount(): ContentCount { + if (fileCount == 0 && folderCount == 0) { + file.listFiles()?.let { list -> + list.forEach { + if (it.isFile) fileCount++ else folderCount++ + } + } + } + + return ContentCount(fileCount, folderCount) + } + + override fun createSubFile(name: String, onCreated: (ContentHolder?) -> Unit) { + File(file, name).let { newFile -> + if (newFile.createNewFile()) { + onCreated(LocalFileHolder(newFile)) + return + } + } + onCreated(null) + } + + override fun createSubFolder(name: String, onCreated: (ContentHolder?) -> Unit) { + File(file, name).let { newFolder -> + if (newFolder.exists() || newFolder.mkdir()) { + onCreated(LocalFileHolder(newFolder)) + return + } + } + onCreated(null) + } + + override fun findFile(name: String): LocalFileHolder? { + File(file, name).let { + if (it.exists()) { + return LocalFileHolder(it) + } + } + return null + } + + fun exists() = isValid() + + fun hasSourceChanged() = cachedLastModified != -1L && lastModified != cachedLastModified + + fun getAppsHandlingFile(mimeType: String = emptyString): List { + val packageManager: PackageManager = globalClass.packageManager + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(createUri(), mimeType.ifEmpty { this@LocalFileHolder.mimeType }) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT) + } + + val appsList = ArrayList() + + packageManager.queryIntentActivities( + intent, + PackageManager.MATCH_ALL + ).onEach { + globalClass.grantUriPermission( + it.activityInfo.packageName, + createUri(), + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + appsList.add( + OpenWithActivityHolder( + label = it.activityInfo.loadLabel(packageManager).toString(), + name = it.activityInfo.name, + packageName = it.activityInfo.packageName, + icon = it.activityInfo.loadIcon(packageManager).drawableToBitmap(), + ) + ) + } + + return appsList + } + + fun openFileWithPackage(context: Context, packageName: String, className: String) { + val uri = createUri() + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mimeType) + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_NEW_DOCUMENT + or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + or Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + setPackage(packageName) + setClassName(packageName, className) + } + + if (intent.resolveActivity(globalClass.packageManager) != null) { + context.startActivity(intent) + } else { + globalClass.showMsg("No app found to open this file.") + } + } + + fun writeText(text: String) { + file.writeText(text) + } + + fun appendText(text: String) { + file.appendText(text) + } + + fun readText() = file.readText() + + private fun handleSupportedFiles(context: Context): Boolean { + if (FileMimeType.conditions { + codeFileType.contains(extension) || editableFileType.contains( + extension + ) + }) { + globalClass.textEditorManager.openTextEditor( + this, + context + ) + return true + } + + if (FileMimeType.supportedArchiveFileType.contains(extension)) { + globalClass.zipManager.openArchive(this) + return true + } + + return false + } + + private fun createUri() = FileProvider.getUriForFile( + globalClass, + "com.raival.compose.file.explorer.provider", + file + ) + + fun hasParent(parent: LocalFileHolder): Boolean = + file.absolutePath.hasParent(parent.file.absolutePath) + + fun getFormattedFileCount(): String { + val contentCount = getContentCount() + + return getFormattedFileCount( + contentCount.files, + contentCount.folders + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/RootFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/RootFileHolder.kt new file mode 100644 index 00000000..5cdf1a56 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/RootFileHolder.kt @@ -0,0 +1,59 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.holder + +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount +import java.io.File + +class RootFileHolder : ContentHolder() { + companion object { + const val rootDir = "/" + } + + val virtualRootContent = listOf( + "/acct", "/apex", "/bin", "/cache", "/config", "/data", "/dev", "/etc", + "/mnt", "/odm", "/oem", "/proc", "/product", "/sbin", "/sdcard", + "/storage", "/system", "/vendor" + ) + + var contentsCount = ContentCount() + val content = arrayListOf() + + override val uniquePath = rootDir + override val displayName = globalClass.getString(R.string.root_dir) + override val details = emptyString + override val icon = R.drawable.baseline_folder_24 + override val iconPlaceholder = R.drawable.baseline_folder_24 + override val isFolder = true + override val lastModified = 0L + override val size = 0L + override val extension = emptyString + override val canAddNewContent = false + override val canRead = true + override val canWrite = false + + override suspend fun listContent(): ArrayList { + val content = ArrayList() + + virtualRootContent.forEach { item -> + File(item).let { file -> + if (file.exists()) { + content.add(LocalFileHolder(file)) + } + } + } + + contentsCount = ContentCount(folders = content.size) + + return content + } + + override fun getParent() = null + + override fun getContentCount() = contentsCount + + override fun findFile(name: String) = content.find { it.displayName == name } + + override fun isValid() = true +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDeviceHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDevice.kt similarity index 68% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDeviceHolder.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDevice.kt index 65fecc8e..ef92e420 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDeviceHolder.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDevice.kt @@ -1,9 +1,9 @@ package com.raival.compose.file.explorer.screen.main.tab.files.holder -data class StorageDeviceHolder( - val documentHolder: DocumentHolder, +data class StorageDevice( + val contentHolder: ContentHolder, val title: String, val totalSize: Long, val usedSize: Long, val type: Int -) +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/VirtualFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/VirtualFileHolder.kt new file mode 100644 index 00000000..747f7e57 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/VirtualFileHolder.kt @@ -0,0 +1,95 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.holder + +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount +import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getArchiveFiles +import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getAudioFiles +import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getBookmarks +import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getDocumentFiles +import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getImageFiles +import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getRecentFiles +import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getVideoFiles +import kotlinx.coroutines.runBlocking + +class VirtualFileHolder(val type: Int) : ContentHolder() { + private var fileCount = 0 + private val contentList = arrayListOf() + + override val displayName = when (type) { + BOOKMARKS -> globalClass.getString(R.string.bookmarks) + AUDIO -> globalClass.getString(R.string.audios) + VIDEO -> globalClass.getString(R.string.videos) + IMAGE -> globalClass.getString(R.string.images) + ARCHIVE -> globalClass.getString(R.string.archives) + DOCUMENT -> globalClass.getString(R.string.documents) + RECENT -> globalClass.getString(R.string.recent_files) + else -> globalClass.getString(R.string.unknown) + } + + override val details = emptyString + + override val icon = emptyString + + override val isFolder = true + + override val lastModified = 0L + + override val size = 0L + + override val uniquePath = displayName + + override val extension = emptyString + + override fun isValid() = true + + override fun getParent() = null + + override val iconPlaceholder = R.drawable.baseline_folder_24 + + override suspend fun listContent() = when (type) { + BOOKMARKS -> getBookmarks() + AUDIO -> getAudioFiles() + VIDEO -> getVideoFiles() + IMAGE -> getImageFiles() + ARCHIVE -> getArchiveFiles() + DOCUMENT -> getDocumentFiles() + RECENT -> getRecentFiles() + else -> arrayListOf() + }.also { + contentList.apply { + clear() + addAll(it) + } + fileCount = it.size + } + + override fun findFile(name: String): ContentHolder? { + if (contentList.isEmpty()) { + runBlocking { + listContent() + } + } + + return contentList.find { it.displayName == name } + } + + override val canAddNewContent = false + + override val canRead = true + + override val canWrite = false + + override fun getContentCount() = ContentCount(files = fileCount) + + companion object { + const val BOOKMARKS = 0 + const val AUDIO = 1 + const val VIDEO = 2 + const val IMAGE = 3 + const val ARCHIVE = 4 + const val DOCUMENT = 5 + const val RECENT = 6 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ZipFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ZipFileHolder.kt new file mode 100644 index 00000000..582cdbcb --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ZipFileHolder.kt @@ -0,0 +1,168 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.holder + +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.common.extension.toFormattedSize +import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount +import com.raival.compose.file.explorer.screen.main.tab.files.zip.ZipTree +import com.raival.compose.file.explorer.screen.main.tab.files.zip.model.ZipNode +import kotlinx.coroutines.runBlocking +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.model.ZipParameters +import java.io.ByteArrayInputStream +import java.io.File + +class ZipFileHolder( + val zipTree: ZipTree, + val node: ZipNode, +) : ContentHolder() { + override val uniquePath = node.path + override val displayName = node.name + override val details by lazy { createDetails() } + + override val icon = if (node.isDirectory) + R.drawable.baseline_folder_24 else R.drawable.unknown_file_extension + + override val iconPlaceholder = if (node.isDirectory) + R.drawable.baseline_folder_24 else R.drawable.unknown_file_extension + + override val isFolder = node.isDirectory + override val lastModified = node.lastModified + override val size = node.size + override val extension = node.extension + override val canRead = true + override val canWrite = true + override val canAddNewContent = true + + private var filesCount = 0 + private var foldersCount = 0 + + private var contentListCount = ContentCount() + + override suspend fun listContent(): ArrayList { + filesCount = 0 + foldersCount = 0 + + if (!zipTree.isReady) { + zipTree.prepare() + } + + // In case the tab reloaded with new content, the `node` linked to this holder will have the + // old data, so we need to make sure that the content is up-to-date. + // This also somewhat apply to the LocalFileHolder, but that get the information of a 'File' + // which will show latest changes (except the cached stuff). + val newNode = zipTree.findNodeByPath(uniquePath) + + if (newNode == null) return arrayListOf() + + return newNode.children + .map { + ZipFileHolder(zipTree, it).also { + if (it.node.isDirectory) foldersCount++ else filesCount++ + } + } + .toCollection(arrayListOf()).also { + contentListCount = ContentCount(filesCount, foldersCount) + } + } + + override fun getParent(): ContentHolder? { + if (!node.path.contains(File.separator) && node.path.isNotEmpty()) { + return ZipFileHolder(zipTree, zipTree.getRootNode()) + } + + var parentPath = node.parentPath + + while (parentPath.isNotEmpty()) { + val parentNode = zipTree.findNodeByPath(parentPath) + + if (parentNode != null) { + return ZipFileHolder(zipTree, parentNode) + } + + parentPath = ZipNode(emptyString, parentPath).parentPath + } + + return zipTree.source.getParent() + } + + override fun createSubFile(name: String, onCreated: (ContentHolder?) -> Unit) { + val path = "${if (uniquePath.isEmpty()) emptyString else "$uniquePath/"}$name" + val params = ZipParameters().apply { + fileNameInZip = path + isOverrideExistingFilesInZip = false + } + + ZipFile(zipTree.source.file).use { zipFile -> + zipFile.addStream(ByteArrayInputStream(ByteArray(0)), params) + } + + zipTree.prepare() + + zipTree.findNodeByPath(path)?.let { + onCreated(ZipFileHolder(zipTree, it)) + } ?: onCreated(null) + } + + override fun createSubFolder(name: String, onCreated: (ContentHolder?) -> Unit) { + val path = "${if (uniquePath.isEmpty()) emptyString else "$uniquePath/"}$name/" + val params = ZipParameters().apply { + fileNameInZip = path + isOverrideExistingFilesInZip = false + } + + ZipFile(zipTree.source.file).use { zipFile -> + zipFile.addStream(ByteArrayInputStream(ByteArray(0)), params) + } + + zipTree.prepare() + + zipTree.findNodeByPath(path.trimEnd('/'))?.let { + onCreated(ZipFileHolder(zipTree, it)) + } ?: onCreated(null) + } + + override fun getContentCount() = contentListCount + + override fun findFile(name: String) = node.children.find { it.name == name }?.let { + ZipFileHolder(zipTree, it) + } + + override fun isValid() = true + + private fun createDetails(): String { + val separator = " | " + return buildString { + append(node.lastModified.toFormattedDate()) + if (node.isDirectory) { + if (globalClass.preferencesManager.fileListPrefs.showFolderContentCount) { + append(separator) + append(getFormattedFileCount()) + } + } else { + append(separator) + append(node.size.toFormattedSize()) + append(separator) + append(node.extension) + } + } + } + + private fun getFormattedFileCount(): String { + if (filesCount == 0 && foldersCount == 0) { + runBlocking { + listContent().forEach { + if (it.node.isDirectory) foldersCount++ + else filesCount++ + } + } + } + + return getFormattedFileCount( + filesCount, + foldersCount + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Action.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Action.kt deleted file mode 100644 index 13499d42..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Action.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.misc - -open class Action( - var due: Boolean = true, - var autoDismiss: Boolean = true, - var isDone: Boolean = false, - var action: () -> Unit = {} -) - -class UpdateAction(due: Boolean) : Action(due) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Comparator.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Comparator.kt index ca5c0392..a33e911e 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Comparator.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Comparator.kt @@ -1,9 +1,9 @@ package com.raival.compose.file.explorer.screen.main.tab.files.misc -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder import java.util.Locale -val sortFoldersFirst = Comparator { file1: DocumentHolder, file2: DocumentHolder -> +val sortFoldersFirst = Comparator { file1: ContentHolder, file2: ContentHolder -> if (file1.isFolder && !file2.isFolder) { return@Comparator -1 } else if (!file1.isFolder && file2.isFolder) { @@ -13,7 +13,7 @@ val sortFoldersFirst = Comparator { file1: DocumentHolder, file2: DocumentHolder } } -val sortFilesFirst = Comparator { file2: DocumentHolder, file1: DocumentHolder -> +val sortFilesFirst = Comparator { file2: ContentHolder, file1: ContentHolder -> if (file1.isFolder && !file2.isFolder) { return@Comparator -1 } else if (!file1.isFolder && file2.isFolder) { @@ -23,26 +23,26 @@ val sortFilesFirst = Comparator { file2: DocumentHolder, file1: DocumentHolder - } } -val sortOlderFirst = Comparator.comparingLong { obj: DocumentHolder -> obj.lastModified } +val sortOlderFirst = Comparator.comparingLong { obj: ContentHolder -> obj.lastModified } -val sortNewerFirst = Comparator { file1: DocumentHolder, file2: DocumentHolder -> +val sortNewerFirst = Comparator { file1: ContentHolder, file2: ContentHolder -> file2.lastModified.compareTo(file1.lastModified) } -val sortName = Comparator.comparing { file: DocumentHolder -> - file.getName().lowercase(Locale.getDefault()) +val sortName = Comparator.comparing { file: ContentHolder -> + file.displayName.lowercase(Locale.getDefault()) } -val sortNameRev = Comparator { file1: DocumentHolder, file2: DocumentHolder -> - file2.getName().lowercase(Locale.getDefault()).compareTo( - file1.getName().lowercase( +val sortNameRev = Comparator { file1: ContentHolder, file2: ContentHolder -> + file2.displayName.lowercase(Locale.getDefault()).compareTo( + file1.displayName.lowercase( Locale.getDefault() ) ) } -val sortSmallerFirst = Comparator.comparingLong { obj: DocumentHolder -> obj.fileSize } +val sortSmallerFirst = Comparator.comparingLong { obj: ContentHolder -> obj.size } -val sortLargerFirst = Comparator { file1: DocumentHolder, file2: DocumentHolder -> - file2.fileSize.compareTo(file1.fileSize) +val sortLargerFirst = Comparator { file1: ContentHolder, file2: ContentHolder -> + file2.size.compareTo(file1.size) } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Constant.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Constant.kt index 95d178ab..f50c0372 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Constant.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Constant.kt @@ -10,4 +10,10 @@ object Language { const val LANGUAGE_JAVA = 1 const val LANGUAGE_KOTLIN = 2 const val LANGUAGE_JSON = 3 +} + +object StorageDeviceType { + const val ROOT = 0 + const val INTERNAL_STORAGE = 1 + const val EXTERNAL_STORAGE = 2 } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentCount.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentCount.kt new file mode 100644 index 00000000..fd1c988a --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentCount.kt @@ -0,0 +1,6 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.misc + +data class ContentCount( + val files: Int = 0, + val folders: Int = 0 +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentProperty.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentProperty.kt new file mode 100644 index 00000000..d407f7a3 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentProperty.kt @@ -0,0 +1,7 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.misc + +data class ContentProperty( + val label: String, + val copiable: Boolean = true, + val updateValue: () -> String +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/FileMimeType.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/FileMimeType.kt index c754817c..0c851217 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/FileMimeType.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/FileMimeType.kt @@ -29,138 +29,49 @@ object FileMimeType { val fontFileType = arrayOf("ttf", "otf") @JvmField - val vectorFileType = - arrayOf("svg", "ai", "eps", "pdf", "dxf", "wmf", "emf", "cdr", "odg", "swf") + val vectorFileType = arrayOf( + "svg", "ai", "eps", "pdf", "dxf", + "wmf", "emf", "cdr", "odg", "swf" + ) @JvmField val archiveFileType = arrayOf( - "zip", - "7z", - "tar", - "jar", - "gz", - "xz", - "obb", - "rar", - "iso", - "bz2", - "tgz", - "tbz2", - "lz", - "lzma", + "zip", "7z", "tar", "jar", "gz", "xz", "obb", "rar", + "iso", "bz2", "tgz", "tbz2", "lz", "lzma" ) + @JvmField + val supportedArchiveFileType = arrayOf("zip", "jar", "apk", "apks") + @JvmField val videoFileType = arrayOf( - "mp4", - "mov", - "avi", - "mkv", - "wmv", - "m4v", - "3gp", - "webm", - "flv", - "mpeg", - "mpg", - "ogv", - "mxf", - "vob", - "ts" + "mp4", "mov", "avi", "mkv", "wmv", "m4v", "3gp", + "webm", "flv", "mpeg", "mpg", "ogv", "mxf", "vob", "ts" ) @JvmField val codeFileType = arrayOf( - javaFileType, - "xml", - "py", - "css", - kotlinFileType, - "cs", - "xml", - jsonFileType, - "html", - "js", - "ts", - "php", - "rb", - "pl", - "sh", - "cpp", - "c", - "h", - "swift", - "go", - "rs", - "scala", - "sql", - "r", - "md", - "ini", - "yaml", - "yml" + javaFileType, "xml", "py", "css", kotlinFileType, "cs", "xml", jsonFileType, "html", + "js", "ts", "php", "rb", "pl", "sh", "cpp", "c", "h", "swift", "go", "rs", + "scala", "sql", "r", "md", "ini", "yaml", "yml" ) @JvmField val editableFileType = arrayOf( - "txt", - "text", - "log", - "dsc", - "apt", - "rtf", - "rtx", - "md", - "csv", - "tsv", - "ini", - "conf", - "cfg", - "nfo", - "json", - "xml" + "txt", "text", "log", "dsc", "apt", "rtf", "rtx", "md", + "csv", "tsv", "ini", "conf", "cfg", "nfo", "json", "xml" ) @JvmField val imageFileType = arrayOf( - "png", - "jpeg", - "jpg", - "heic", - "tiff", - "gif", - "webp", - svgFileType, - "bmp", - "raw", - "cr2", - "nef", - "orf", - "sr2", - "psd", - "ai", - "eps" + "png", "jpeg", "jpg", "heic", "tiff", "gif", "webp", svgFileType, + "bmp", "raw", "cr2", "nef", "orf", "sr2", "psd", "ai", "eps" ) @JvmField val audioFileType = arrayOf( - "mp3", - "4mp", - "aup", - "ogg", - "3ga", - "m4b", - "wav", - "acc", - "m4a", - "flac", - "aac", - "wma", - "aiff", - "amr", - "midi", - "mid", - "opus" + "mp3", "4mp", "aup", "ogg", "3ga", "m4b", "wav", "acc", + "m4a", "flac", "aac", "wma", "aiff", "amr", "midi", "mid", "opus" ) @JvmField diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/MergeHandler.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/MergeHandler.kt deleted file mode 100644 index 218952ab..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/MergeHandler.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.misc - -import android.content.Context -import com.android.apksig.ApkSigner -import com.android.apksig.ApkSigner.SignerConfig -import com.android.apksig.KeyConfig -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.reandroid.apkeditor.merge.Merger -import com.reandroid.apkeditor.merge.MergerOptions -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.security.KeyFactory -import java.security.NoSuchAlgorithmException -import java.security.SignatureException -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate -import java.security.spec.InvalidKeySpecException -import java.security.spec.PKCS8EncodedKeySpec - -class MergeHandler(private val context: Context) { - - @Throws(IOException::class) - fun executeMerge(inputFilePath: String, outputFilePath: String, onProgressUpdate: (Float, String) -> Unit) { - val options = MergerOptions().apply { - inputFile = File(inputFilePath) - outputFile = File(outputFilePath) - } - val merger = Merger(options) - val tasks = listOf( - context.getString(R.string.initializing_merge) to 0.1f, - context.getString(R.string.extracting_files) to 0.3f, - context.getString(R.string.merging_modules) to 0.6f, - context.getString(R.string.finalizing_merge) to 0.8f, - ) - for ((message, progress) in tasks) { - Thread.sleep(500) - onProgressUpdate(progress, message) - } - merger.runCommand() - } - - @Throws(IOException::class, NoSuchAlgorithmException::class, InvalidKeySpecException::class, SignatureException::class) - private fun signApk(apkFilePath: String, outFilePath: String, keyInputStream: InputStream, certInputStream: InputStream) { - val privateKey = keyInputStream.use { inputStream -> - val keyBytes = inputStream.readBytes() - val keySpec = PKCS8EncodedKeySpec(keyBytes) - val keyFactory = KeyFactory.getInstance("RSA") - keyFactory.generatePrivate(keySpec) - } - - val certificate = certInputStream.use { inputStream -> - val certificateFactory = CertificateFactory.getInstance("X.509") - certificateFactory.generateCertificate(inputStream) as X509Certificate - } - - val keyConfig = KeyConfig.Jca(privateKey) - - val signerConfig = SignerConfig.Builder( - "Android", - keyConfig, - listOf(certificate) - ).build() - - val apkSigner = ApkSigner.Builder(listOf(signerConfig)) - .setInputApk(File(apkFilePath)) - .setOutputApk(File(outFilePath)) - .setV1SigningEnabled(true) - .setV2SigningEnabled(true) - .build() - - apkSigner.sign() - } - - fun mergeApks(tab: FilesTab, apkFile: DocumentHolder, doSign: Boolean, onSuccess: () -> Unit, onError: (String) -> Unit) { - CoroutineScope(Dispatchers.IO).launch { - try { - val fileExtension = apkFile.fileExtension - val fileName = apkFile.getName().removeSuffix(".$fileExtension") - val filePath = apkFile.path - val fileDir = apkFile.parent?.path - val outputFilePath = "$fileDir/$fileName.apk" - val mergeHandler = MergeHandler(context) - withContext(Dispatchers.Main) { - tab.taskDialog.showTaskDialog = true - tab.taskDialog.taskDialogInfo = context.getString(R.string.merging_apks) - tab.taskDialog.taskDialogTitle = context.getString(R.string.merging) - tab.taskDialog.taskDialogSubtitle = context.getString(R.string.merging_apks) - tab.taskDialog.showTaskDialogProgressbar = true - tab.taskDialog.taskDialogProgress = 0f - } - mergeHandler.executeMerge(filePath, outputFilePath) { progress, message -> - launch(Dispatchers.Main) { - tab.taskDialog.taskDialogProgress = progress - tab.taskDialog.taskDialogSubtitle = message - } - } - val outputFile = File(outputFilePath) - if (!outputFile.exists()) { - withContext(Dispatchers.Main) { onError(context.getString(R.string.merge_failed)) } - return@launch - } - - if (doSign) { - // TODO: Custom KeyStore - val keyFilePath = tab.assetManager.open("keystore/testkey.pk8") - val certFilePath = tab.assetManager.open("keystore/testkey.x509.pem") - val signedApkPath = outputFilePath.replace(".apk", "-signed.apk") - - tab.taskDialog.taskDialogSubtitle = context.getString(R.string.signing_apk) - tab.taskDialog.taskDialogProgress = 0.9f - signApk(outputFilePath, signedApkPath, keyFilePath, certFilePath) - - if (!File(signedApkPath).exists()) { - withContext(Dispatchers.Main) { onError(context.getString(R.string.signing_failed)) } - return@launch - } - if (File(outputFilePath).exists()) { - File(outputFilePath).delete() - } - } - - withContext(Dispatchers.Main) { onSuccess() } - } catch (e: Exception) { - e.printStackTrace() - withContext(Dispatchers.Main) { - onError(context.getString(R.string.merge_failed_with_error, e.message)) - delay(500) - tab.taskDialog.showTaskDialog = false - } - } - } - } -} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/StorageDeviceType.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/StorageDeviceType.kt deleted file mode 100644 index f50792ac..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/StorageDeviceType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.misc - -const val ROOT = 0 -const val INTERNAL_STORAGE = 1 -const val EXTERNAL_STORAGE = 2 -const val UNKNOWN = 3 \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/ContentPropertiesProvider.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/ContentPropertiesProvider.kt new file mode 100644 index 00000000..6b2a149a --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/ContentPropertiesProvider.kt @@ -0,0 +1,380 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.provider + +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import java.io.File +import java.io.FileInputStream +import java.math.BigInteger +import java.nio.file.Files +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class CalculationProgress( + val isCalculating: Boolean = false, + val current: Long = 0, + val total: Long = 0, + val message: String = emptyString +) + +sealed interface PropertiesState { + object Loading : PropertiesState + + data class SingleContentProperties( + val name: String, + val path: String, + val type: String, + val lastModified: String, + val size: String, + val permissions: String, + val owner: String, + val contentCount: StateFlow, + val checksum: StateFlow, + val contentProgress: StateFlow, + val checksumProgress: StateFlow, + ) : PropertiesState + + data class MultipleContentProperties( + val selectedFileCount: Int, + val totalFileCount: StateFlow, + val totalSize: StateFlow, + val countProgress: StateFlow, + val sizeProgress: StateFlow, + ) : PropertiesState +} + +data class ContentPropertiesUiState( + val details: PropertiesState = PropertiesState.Loading +) + +class ContentPropertiesProvider(private val contentHolders: List) { + private val _uiState = MutableStateFlow(ContentPropertiesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val calculationScope = CoroutineScope(IO) + private val activeJobs = mutableSetOf() + + init { + when { + contentHolders.isEmpty() -> { + // Handle empty case - could show error state + } + + contentHolders.size == 1 -> { + loadSingleContentDetails(contentHolders.first()) + } + + else -> { + loadMultipleContentDetails(contentHolders) + } + } + } + + fun cleanup() { + activeJobs.forEach { it.cancel() } + calculationScope.cancel() + } + + private fun loadSingleContentDetails(file: ContentHolder) { + val contentCountFlow = MutableStateFlow(globalClass.getString(R.string.not_available)) + val checksumFlow = MutableStateFlow( + if (file.isFolder) globalClass.getString(R.string.not_available) else globalClass.getString( + R.string.calculating + ) + ) + val contentProgressFlow = MutableStateFlow(CalculationProgress()) + val checksumProgressFlow = MutableStateFlow(CalculationProgress()) + + _uiState.update { + it.copy( + details = PropertiesState.SingleContentProperties( + name = file.displayName.ifEmpty { globalClass.getString(R.string.root) }, + path = file.getParent()?.uniquePath ?: File.separator, + type = determineFileType(file), + lastModified = formatTimestamp(file.lastModified), + size = if (file.isFolder) globalClass.getString(R.string.calculating) else formatFileSize( + file.size + ), + permissions = getPermissions(file), + owner = getOwner(file), + contentCount = contentCountFlow, + checksum = checksumFlow, + contentProgress = contentProgressFlow, + checksumProgress = checksumProgressFlow + ) + ) + } + + if (file.isFolder) { + val job = calculationScope.launch { + contentProgressFlow.value = CalculationProgress(isCalculating = true) + try { + val (fileCount, folderCount, totalSize) = calculateDirectoryStats(file) { current -> + contentProgressFlow.value = CalculationProgress( + isCalculating = true, + current = current + ) + } + contentCountFlow.value = + globalClass.getString(R.string.files_folders_count, fileCount, folderCount) + + // Update size for directories + _uiState.update { state -> + val currentDetails = + state.details as? PropertiesState.SingleContentProperties + currentDetails?.let { + state.copy( + details = it.copy(size = formatFileSize(totalSize)) + ) + } ?: state + } + } catch (_: Exception) { + contentCountFlow.value = globalClass.getString(R.string.error_calculating) + } finally { + contentProgressFlow.value = CalculationProgress(isCalculating = false) + } + } + activeJobs.add(job) + } else if (file is LocalFileHolder) { + // Calculate checksum with progress + val job = calculationScope.launch { + checksumProgressFlow.value = CalculationProgress(isCalculating = true) + try { + val checksum = calculateMD5WithProgress(file) { bytesProcessed, totalBytes -> + checksumProgressFlow.value = CalculationProgress( + isCalculating = true, + current = bytesProcessed, + total = totalBytes + ) + } + checksumFlow.value = checksum + } catch (_: Exception) { + checksumFlow.value = globalClass.getString(R.string.error_calculating) + } finally { + checksumProgressFlow.value = CalculationProgress(isCalculating = false) + } + } + activeJobs.add(job) + } else { + checksumFlow.value = globalClass.getString(R.string.not_available) + } + } + + private fun loadMultipleContentDetails(files: List) { + val totalFileCountFlow = MutableStateFlow(globalClass.getString(R.string.calculating)) + val totalSizeFlow = MutableStateFlow(globalClass.getString(R.string.calculating)) + val countProgressFlow = MutableStateFlow(CalculationProgress()) + val sizeProgressFlow = MutableStateFlow(CalculationProgress()) + + _uiState.update { + it.copy( + details = PropertiesState.MultipleContentProperties( + selectedFileCount = files.size, + totalFileCount = totalFileCountFlow, + totalSize = totalSizeFlow, + countProgress = countProgressFlow, + sizeProgress = sizeProgressFlow + ) + ) + } + + val job = calculationScope.launch { + countProgressFlow.value = + CalculationProgress(isCalculating = true, total = files.size.toLong()) + sizeProgressFlow.value = + CalculationProgress(isCalculating = true, total = files.size.toLong()) + + try { + var totalFiles = 0L + var totalFolders = 0L + var totalSize = 0L + var processedItems = 0L + + files.forEach { file -> + if (!isActive) return@forEach + + processedItems++ + countProgressFlow.value = CalculationProgress( + isCalculating = true, + current = processedItems, + total = files.size.toLong() + ) + sizeProgressFlow.value = CalculationProgress( + isCalculating = true, + current = processedItems, + total = files.size.toLong() + ) + + if (file.isFolder) { + val (filesCount, foldersCount, dirSize) = calculateDirectoryStats(file) + totalFiles += filesCount + totalFolders += foldersCount + 1 // +1 for the directory itself + totalSize += dirSize + } else { + totalFiles++ + totalSize += file.size + } + + // Update intermediate results + totalFileCountFlow.value = globalClass.getString( + R.string.files_folders_count, + totalFiles, + totalFolders + ) + totalSizeFlow.value = formatFileSize(totalSize) + + yield() // Allow cancellation + } + } catch (_: Exception) { + totalFileCountFlow.value = globalClass.getString(R.string.error_calculating) + totalSizeFlow.value = globalClass.getString(R.string.error_calculating) + } finally { + countProgressFlow.value = CalculationProgress(isCalculating = false) + sizeProgressFlow.value = CalculationProgress(isCalculating = false) + } + } + activeJobs.add(job) + } + + private fun determineFileType(file: ContentHolder): String { + return when { + file.isFolder -> globalClass.getString(R.string.folder) + else -> { + file.extension.lowercase().ifEmpty { globalClass.getString(R.string.unknown) } + } + } + } + + private fun formatTimestamp(millis: Long): String { + val sdf = SimpleDateFormat("MMM dd, yyyy • hh:mm:ss a", Locale.getDefault()) + return sdf.format(Date(millis)) + } + + private fun formatFileSize(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val z = (63 - java.lang.Long.numberOfLeadingZeros(bytes)) / 10 + return String.format( + Locale.US, + "%.1f %sB", + bytes.toDouble() / (1L shl (z * 10)), + " KMGTPE"[z] + ) + } + + private fun getPermissions(file: ContentHolder): String { + if (file is LocalFileHolder) { + return buildString { + append(if (file.canRead) "r" else "-") + append(if (file.canWrite) "w" else "-") + append(if (file.file.canExecute()) "x" else "-") + } + } + + return globalClass.getString(R.string.not_available) + } + + private fun getOwner(file: ContentHolder): String { + return try { + if (file is LocalFileHolder) { + Files.getOwner(file.file.toPath()).name + } else { + globalClass.getString(R.string.unknown) + } + } catch (_: Exception) { + globalClass.getString(R.string.unknown) + } + } + + private suspend fun calculateDirectoryStats( + directory: ContentHolder, + onProgress: ((Long) -> Unit)? = null + ): Triple { + var fileCount = 0L + var folderCount = 0L + var totalSize = 0L + var processed = 0L + + suspend fun processDirectory(dir: ContentHolder) { + if (!currentCoroutineContext().isActive) return + + try { + dir.listContent().forEach { file -> + if (!currentCoroutineContext().isActive) return@forEach + + processed++ + onProgress?.invoke(processed) + + if (file.isFolder) { + folderCount++ + processDirectory(file) + } else { + fileCount++ + totalSize += file.size + } + + if (processed % 50 == 0L) { + yield() // Periodically yield for cancellation + } + } + } catch (_: SecurityException) { + // Skip directories we can't access + } + } + + processDirectory(directory) + return Triple(fileCount, folderCount, totalSize) + } + + private suspend fun calculateMD5WithProgress( + file: LocalFileHolder, + onProgress: (Long, Long) -> Unit + ): String { + if (file.isFolder) return globalClass.getString(R.string.not_available) + + return withContext(IO) { + val md = MessageDigest.getInstance("MD5") + val fileSize = file.size + var bytesProcessed = 0L + + try { + FileInputStream(file.file).use { fis -> + val buffer = ByteArray(8192) + var read: Int + while (fis.read(buffer).also { read = it } != -1) { + if (!currentCoroutineContext().isActive) break + md.update(buffer, 0, read) + bytesProcessed += read + onProgress(bytesProcessed, fileSize) + + if (bytesProcessed % (64 * 1024) == 0L) { + yield() // Yield every 64KB + } + } + } + + val digest = md.digest() + val bigInt = BigInteger(1, digest) + bigInt.toString(16).padStart(32, '0') + } catch (_: Exception) { + globalClass.getString(R.string.error_calculating) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/StorageProvider.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/StorageProvider.kt index 83a7bf56..2ee7e01f 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/StorageProvider.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/StorageProvider.kt @@ -8,35 +8,26 @@ import android.os.Environment import android.os.StatFs import android.provider.MediaStore import androidx.core.content.ContextCompat -import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.file.DocumentFileCompat import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDeviceHolder -import com.raival.compose.file.explorer.screen.main.tab.files.misc.EXTERNAL_STORAGE -import com.raival.compose.file.explorer.screen.main.tab.files.misc.INTERNAL_STORAGE -import com.raival.compose.file.explorer.screen.main.tab.files.misc.ROOT +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.RootFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDevice +import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.EXTERNAL_STORAGE +import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.INTERNAL_STORAGE +import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.ROOT import java.io.File object StorageProvider { - val recentFiles = DocumentHolder.fromFile(File("/:Recent")) - val images = DocumentHolder.fromFile(File("/:Images")) - val videos = DocumentHolder.fromFile(File("/:Videos")) - val audios = DocumentHolder.fromFile(File("/:Audios")) - val documents = DocumentHolder.fromFile(File("/:Documents")) - val archives = DocumentHolder.fromFile(File("/:Archives")) - val bookmarks = DocumentHolder.fromFile(File("/:Bookmarks")) + fun getStorageDevices(context: Context): List { + val storageDevices = mutableListOf() - fun getStorageDevices(context: Context): List { - val storageDeviceHolders = mutableListOf() - - storageDeviceHolders.add(getPrimaryInternalStorage(context)) + storageDevices.add(getPrimaryInternalStorage(context)) val externalDirs = getExternalStorageDirectories(context) for (externalDir in externalDirs) { - if (externalDir.path == Environment.getExternalStorageDirectory().path){ + if (externalDir.path == Environment.getExternalStorageDirectory().path) { continue } @@ -45,9 +36,9 @@ object StorageProvider { val availableSize = statFs.availableBytes val usedSize = totalSize - availableSize - storageDeviceHolders.add( - StorageDeviceHolder( - DocumentHolder(DocumentFile.fromFile(externalDir)), + storageDevices.add( + StorageDevice( + LocalFileHolder(externalDir), "External Storage (${externalDir.name})", totalSize, usedSize, @@ -56,12 +47,12 @@ object StorageProvider { ) } - storageDeviceHolders.add(getRoot(context)) + storageDevices.add(getRoot(context)) - return storageDeviceHolders + return storageDevices } - fun getRoot(context: Context): StorageDeviceHolder { + fun getRoot(context: Context): StorageDevice { val externalStorageDir = Environment.getRootDirectory() val externalStatFs = StatFs(externalStorageDir.absolutePath) @@ -70,8 +61,8 @@ object StorageProvider { val externalAvailableSize = externalStatFs.availableBytes val externalUsedSize = externalTotalSize - externalAvailableSize - return StorageDeviceHolder( - DocumentHolder(DocumentFileCompat.fromFile(context, externalStorageDir)!!), + return StorageDevice( + RootFileHolder(), context.getString(R.string.root_dir), externalTotalSize, externalUsedSize, @@ -79,7 +70,7 @@ object StorageProvider { ) } - fun getPrimaryInternalStorage(context: Context): StorageDeviceHolder { + fun getPrimaryInternalStorage(context: Context): StorageDevice { val externalStorageDir = Environment.getExternalStorageDirectory() val externalStatFs = StatFs(externalStorageDir.absolutePath) @@ -88,8 +79,8 @@ object StorageProvider { val externalAvailableSize = externalStatFs.availableBytes val externalUsedSize = externalTotalSize - externalAvailableSize - return StorageDeviceHolder( - DocumentHolder(DocumentFile.fromFile(externalStorageDir)), + return StorageDevice( + LocalFileHolder(externalStorageDir), context.getString(R.string.internal_storage), externalTotalSize, externalUsedSize, @@ -111,8 +102,8 @@ object StorageProvider { return directories } - fun getDocumentFiles(): ArrayList { - val documentFiles = ArrayList() + fun getDocumentFiles(): ArrayList { + val documentFiles = ArrayList() val contentResolver: ContentResolver = globalClass.contentResolver val uri: Uri = MediaStore.Files.getContentUri("external") @@ -148,15 +139,15 @@ object StorageProvider { while (it.moveToNext()) { val path = it.getString(pathColumn) - documentFiles.add(DocumentHolder.fromFile(File(path))) + documentFiles.add(LocalFileHolder(File(path))) } } return documentFiles } - fun getArchiveFiles(): ArrayList { - val archiveFiles = ArrayList() + fun getArchiveFiles(): ArrayList { + val archiveFiles = ArrayList() val contentResolver: ContentResolver = globalClass.contentResolver val uri: Uri = MediaStore.Files.getContentUri("external") @@ -190,15 +181,15 @@ object StorageProvider { while (it.moveToNext()) { val path = it.getString(pathColumn) - archiveFiles.add(DocumentHolder.fromFile(File(path))) + archiveFiles.add(LocalFileHolder(File(path))) } } return archiveFiles } - fun getImageFiles(): ArrayList { - val imageFiles = ArrayList() + fun getImageFiles(): ArrayList { + val imageFiles = ArrayList() val contentResolver: ContentResolver = globalClass.contentResolver val uri: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI @@ -220,15 +211,15 @@ object StorageProvider { while (it.moveToNext()) { val path = it.getString(pathColumn) - imageFiles.add(DocumentHolder.fromFile(File(path))) + imageFiles.add(LocalFileHolder(File(path))) } } return imageFiles } - fun getVideoFiles(): ArrayList { - val videoFiles = ArrayList() + fun getVideoFiles(): ArrayList { + val videoFiles = ArrayList() val contentResolver: ContentResolver = globalClass.contentResolver val uri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI @@ -250,15 +241,18 @@ object StorageProvider { while (it.moveToNext()) { val path = it.getString(pathColumn) - videoFiles.add(DocumentHolder.fromFile(File(path))) + videoFiles.add(LocalFileHolder(File(path))) } } return videoFiles } - fun getAudioFiles(): ArrayList { - val audioFiles = ArrayList() + fun getBookmarks() = globalClass.filesTabManager.bookmarks + .map { LocalFileHolder(File(it)) } as ArrayList + + fun getAudioFiles(): ArrayList { + val audioFiles = ArrayList() val contentResolver: ContentResolver = globalClass.contentResolver val uri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI @@ -280,7 +274,7 @@ object StorageProvider { while (it.moveToNext()) { val path = it.getString(pathColumn) - audioFiles.add(DocumentHolder.fromFile(File(path))) + audioFiles.add(LocalFileHolder(File(path))) } } @@ -290,10 +284,10 @@ object StorageProvider { fun getRecentFiles( recentHours: Int = 48, limit: Int = 25 - ): ArrayList { - val recentFiles = ArrayList() + ): ArrayList { + val recentFiles = ArrayList() val contentResolver: ContentResolver = globalClass.contentResolver - val showHiddenFiles = globalClass.preferencesManager.displayPrefs.showHiddenFiles + val showHiddenFiles = globalClass.preferencesManager.fileListPrefs.showHiddenFiles val uri: Uri = MediaStore.Files.getContentUri("external") @@ -324,7 +318,7 @@ object StorageProvider { val filePath = it.getString(columnIndexPath) val file = File(filePath) if (file.isFile && (showHiddenFiles || !file.name.startsWith("."))) { - recentFiles.add(DocumentHolder.fromFile(file)) + recentFiles.add(LocalFileHolder(file)) } } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/service/ContentOperationService.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/service/ContentOperationService.kt new file mode 100644 index 00000000..8d970ae6 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/service/ContentOperationService.kt @@ -0,0 +1,188 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.main.MainActivity +import com.raival.compose.file.explorer.screen.main.tab.files.task.TaskStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +class ContentOperationService : Service() { + private val serviceJob = SupervisorJob() + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + + // Tasks ids and whether they are running + private val runningTaskIds = ConcurrentHashMap() + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + startForeground(NOTIFICATION_ID, createNotification()) + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.task_running_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + private fun createNotification(): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val activeTasksCount = runningTaskIds.size + val userActionRequired = runningTaskIds.values.any { !it } + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(globalClass.getString(R.string.tasks)) + .setContentText( + if (userActionRequired) getString(R.string.action_required) + else globalClass.getString(R.string.task_running_notification_text) + ) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setNumber(activeTasksCount) + .build() + } + + private fun updateNotification() { + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.notify(NOTIFICATION_ID, createNotification()) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.let { intent -> + when (intent.action) { + ACTION_START_TASK -> { + val taskId = intent.getStringExtra(TASK_ID) + if (taskId != null) { + startTask(taskId) + } + } + + ACTION_REMOVE_TASK -> { + val taskId = intent.getStringExtra(TASK_ID) + if (taskId != null) { + removeTask(taskId) + } + } + } + } + return START_STICKY + } + + private fun removeTask(taskId: String) { + if (runningTaskIds.containsKey(taskId)) { + if (runningTaskIds[taskId] == true) { + globalClass.showMsg(R.string.task_is_running) + } else { + runningTaskIds.remove(taskId) + updateNotification() + + // Stop service if no more tasks + if (runningTaskIds.isEmpty()) { + stopSelf() + } + } + } + } + + private fun startTask(taskId: String) { + if (runningTaskIds.containsKey(taskId) && runningTaskIds[taskId] == true) { + return + } + + val task = globalClass.taskManager.getTask(taskId) + + if (task == null) { + return + } + + runningTaskIds[taskId] = true + updateNotification() + + serviceScope.launch { + try { + task.run() + globalClass.taskManager.handleTaskStatusChange(task, task.getCurrentStatus()) + } catch (e: Exception) { + logger.logError(e) + globalClass.taskManager.handleTaskStatusChange(task, TaskStatus.FAILED) + } finally { + globalClass.mainActivityManager.resumeActiveTab() + if (task.getCurrentStatus() == TaskStatus.SUCCESS) { + runningTaskIds.remove(taskId) + } else { + // require action from user + runningTaskIds[taskId] = false + } + + updateNotification() + + // Stop service if no more tasks + if (runningTaskIds.isEmpty()) { + stopSelf() + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + stopForeground(STOP_FOREGROUND_REMOVE) + + // Cancel all running tasks + runningTaskIds.keys.forEach { taskId -> + globalClass.taskManager.getTask(taskId)?.abortTask() + } + + serviceJob.cancel() + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + private const val TASK_ID = "taskId" + private const val NOTIFICATION_ID = 1297 + private const val CHANNEL_ID = "task_background_service" + const val ACTION_START_TASK = "ACTION_START_TASK" + const val ACTION_REMOVE_TASK = "ACTION_REMOVE_TASK" + + fun startNewBackgroundTask(context: Context, taskId: String) { + val intent = Intent(context, ContentOperationService::class.java).apply { + action = ACTION_START_TASK + putExtra(TASK_ID, taskId) + } + context.startService(intent) + } + + fun removeBackgroundTask(context: Context, taskId: String) { + val intent = Intent(context, ContentOperationService::class.java).apply { + action = ACTION_REMOVE_TASK + putExtra(TASK_ID, taskId) + } + context.startService(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/ApksMergeTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/ApksMergeTask.kt new file mode 100644 index 00000000..823cd5a3 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/ApksMergeTask.kt @@ -0,0 +1,207 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +import com.android.apksig.ApkSigner +import com.android.apksig.KeyConfig +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.reandroid.apkeditor.merge.Merger +import com.reandroid.apkeditor.merge.MergerOptions +import java.io.File +import java.security.KeyFactory +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec + +class ApksMergeTask( + val sourceContent: ContentHolder +) : Task() { + private var parameters: ApksMergeTaskParameters? = null + + override val metadata = System.currentTimeMillis().toFormattedDate().let { time -> + TaskMetadata( + id = id, + creationTime = time, + title = globalClass.resources.getString(R.string.convert_to_apk), + subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceContent.size), + displayDetails = sourceContent.displayName, + fullDetails = buildString { + append(sourceContent.displayName) + append("\n") + append(time) + }, + isCancellable = false, + canMoveToBackground = false + ) + } + + override val progressMonitor = TaskProgressMonitor( + status = TaskStatus.PENDING, + taskTitle = metadata.title, + ) + + override fun getCurrentStatus() = progressMonitor.status + + override fun validate() = sourceContent.isValid() + + private fun markAsFailed(info: String) { + progressMonitor.apply { + status = TaskStatus.FAILED + summary = info + } + } + + override suspend fun run() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return + } + run(parameters!!) + } + + override suspend fun run(params: TaskParameters) { + parameters = params as ApksMergeTaskParameters + progressMonitor.status = TaskStatus.RUNNING + protect = false + + if (!sourceContent.isValid() || sourceContent !is LocalFileHolder) { + markAsFailed(globalClass.resources.getString(R.string.invalid_bundle_file)) + return + } + + progressMonitor.processName = globalClass.resources.getString(R.string.merging) + + val mergedFile = mergeBundleFile(sourceContent) + + // mergeBundleFile function will handle failed task + if (mergedFile != null && parameters!!.autoSign) { + progressMonitor.processName = globalClass.resources.getString(R.string.signing) + signApkFile(mergedFile) + } + + if (progressMonitor.status == TaskStatus.RUNNING) { + progressMonitor.status = TaskStatus.SUCCESS + progressMonitor.summary = + globalClass.resources.getString(R.string.apk_bundle_merge_success) + } + } + + fun mergeBundleFile(holder: LocalFileHolder): LocalFileHolder? { + val inputFile = holder.file + + if (inputFile.parent == null) { + markAsFailed(globalClass.resources.getString(R.string.invalid_bundle_file)) + return null + } + + val outputFile = if (parameters!!.autoSign) { + File.createTempFile(inputFile.nameWithoutExtension, ".apk") + } else { + File(inputFile.parentFile, inputFile.nameWithoutExtension + "-unsigned.apk") + } + + val options = MergerOptions().apply { + this.inputFile = inputFile + this.outputFile = outputFile + } + + try { + Merger(options).runCommand() + } catch (e: Exception) { + logger.logError(e) + markAsFailed( + globalClass.resources.getString( + R.string.task_summary_failed, + e.message ?: emptyString + ) + ) + return null + } + + if (outputFile.exists() && outputFile.length() > 0) { + return LocalFileHolder(outputFile) + } + + markAsFailed(globalClass.resources.getString(R.string.apk_bundle_merge_failed)) + return null + } + + fun signApkFile(mergedFile: LocalFileHolder) { + val sourceFile = sourceContent as LocalFileHolder + + val inputFile = mergedFile.file + + if (sourceFile.file.parent == null) { + markAsFailed(globalClass.resources.getString(R.string.invalid_bundle_file)) + return + } + + val outputFile = File( + sourceFile.file.parentFile, + sourceFile.file.nameWithoutExtension + "-signed.apk" + ) + + try { + val testKeyInputStream = globalClass.assets.open("keystore/testkey.pk8") + val certificateInputStream = globalClass.assets.open("keystore/testkey.x509.pem") + + val testKey = testKeyInputStream.use { inputStream -> + val keyBytes = inputStream.readBytes() + val keySpec = PKCS8EncodedKeySpec(keyBytes) + val keyFactory = KeyFactory.getInstance("RSA") + keyFactory.generatePrivate(keySpec) + } + val certificate = certificateInputStream.use { inputStream -> + val certificateFactory = CertificateFactory.getInstance("X.509") + certificateFactory.generateCertificate(inputStream) as X509Certificate + } + + val keyConfig = KeyConfig.Jca(testKey) + + val signerConfig = ApkSigner.SignerConfig.Builder( + "Android", + keyConfig, + listOf(certificate) + ).build() + + ApkSigner.Builder(listOf(signerConfig)) + .setInputApk(inputFile) + .setOutputApk(outputFile) + .setV1SigningEnabled(true) + .setV2SigningEnabled(true) + .build() + .sign() + + if (!outputFile.exists() || outputFile.length() == 0L) { + markAsFailed(globalClass.resources.getString(R.string.apk_bundle_sign_failed)) + return + } + + inputFile.delete() + } catch (e: Exception) { + logger.logError(e) + markAsFailed( + globalClass.resources.getString( + R.string.task_summary_failed, + e.message ?: emptyString + ) + ) + } + } + + override suspend fun continueTask() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return + } + run(parameters!!) + } + + override fun setParameters(params: TaskParameters) { + parameters = params as ApksMergeTaskParameters + } +} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CompressTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CompressTask.kt index c8749c52..05c3ef39 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CompressTask.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CompressTask.kt @@ -1,196 +1,151 @@ package com.raival.compose.file.explorer.screen.main.tab.files.task -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Compress -import androidx.compose.ui.graphics.vector.ImageVector -import com.anggrayudi.storage.extension.launchOnUiThread import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.addIfAbsent import com.raival.compose.file.explorer.common.extension.emptyString -import com.raival.compose.file.explorer.common.extension.randomString -import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.io.ByteArrayOutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import net.lingala.zip4j.ZipFile +import java.io.File class CompressTask( - private val source: List -) : FilesTabTask() { - override val id: String = String.randomString(8) - - override fun getTitle(): String = globalClass.getString(R.string.compress) - - override fun getSubtitle(): String = if (source.size == 1) - source[0].path.trimToLastTwoSegments() - else globalClass.getString(R.string.task_subtitle, source.size) - - override suspend fun execute(destination: DocumentHolder, callback: Any) { - val taskCallback = callback as FilesTabTaskCallback - - val total: Int - var completed = 0 - var skipped = 0 - - val entriesToAdded = arrayListOf() - - val details = FilesTabTaskDetails( - this, - TASK_COMPRESS, - getTitle(), - globalClass.getString(R.string.preparing), - emptyString, - 0f + val sourceContent: List +) : Task() { + private var parameters: CompressTaskParameters? = null + private var pendingContent = arrayListOf() + + override val metadata = System.currentTimeMillis().toFormattedDate().let { time -> + TaskMetadata( + id = id, + creationTime = time, + title = globalClass.resources.getString(R.string.compress), + subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceContent.size), + displayDetails = sourceContent.joinToString(", ") { it.displayName }, + fullDetails = buildString { + sourceContent.forEachIndexed { index, source -> + append(source.displayName) + append("\n") + } + append("\n") + append(time) + }, + isCancellable = true, + canMoveToBackground = true ) + } + override val progressMonitor = TaskProgressMonitor( + status = TaskStatus.PENDING, + taskTitle = metadata.title, + ) - taskCallback.onPrepare(details) + override fun getCurrentStatus() = progressMonitor.status - val filesToCompress = arrayListOf() + override fun validate() = sourceContent.find { !it.isValid() } == null - source.forEach { - filesToCompress.add(it.path) - if (!it.isFile) { - filesToCompress.addAll(it.walk(true).map { f -> f.path }) - } + private fun markAsFailed(info: String) { + progressMonitor.apply { + status = TaskStatus.FAILED + summary = info } + } - total = filesToCompress.size - - fun updateProgress(info: String = emptyString): FilesTabTaskDetails { - return details.apply { - if (info.isNotEmpty()) this.info = info - if (progress >= 0) this.progress = (completed + skipped) / total.toFloat() - subtitle = globalClass.getString(R.string.progress, completed + skipped, total) - } + override suspend fun run() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return } + run(parameters!!) + } - fun addFileToZip( - documentHolder: DocumentHolder, - zipOut: ZipOutputStream, - buffer: ByteArray, - parentPath: String - ) { - if (!filesToCompress.contains(documentHolder.path)) return - - val entryName = if (parentPath.isEmpty()) { - documentHolder.getName() - } else { - "$parentPath/${documentHolder.getName()}" - } - - if (documentHolder.isFolder) { - val children = documentHolder.listContent(false) - if (children.isNotEmpty()) { - children.forEach { child -> - addFileToZip(child, zipOut, buffer, entryName) - } - } else { - val entry = ZipEntry("$entryName/") - zipOut.putNextEntry(entry) - zipOut.closeEntry() - completed++ - entriesToAdded.addIfAbsent(entryName) - } - } else { - taskCallback.onReport( - updateProgress( - info = globalClass.getString( - R.string.compressing, - documentHolder.getName() - ) - ) - ) - - val inputStream = - globalClass.contentResolver.openInputStream(documentHolder.uri) - - if (inputStream == null) { - skipped++ - taskCallback.onReport(updateProgress()) - } - - inputStream?.use { input -> - val entry = ZipEntry(entryName) - zipOut.putNextEntry(entry) - - var length: Int - while (input.read(buffer).also { length = it } > 0) { - zipOut.write(buffer, 0, length) - } + override suspend fun run(params: TaskParameters) { + parameters = params as CompressTaskParameters + progressMonitor.status = TaskStatus.RUNNING + protect = false - zipOut.closeEntry() + if (sourceContent.isEmpty()) { + markAsFailed(globalClass.resources.getString(R.string.task_summary_no_src)) + return + } - completed++ + progressMonitor.processName = globalClass.resources.getString(R.string.preparing) - entriesToAdded.addIfAbsent(entryName) + val basePath = sourceContent[0].getParent()?.uniquePath ?: emptyString - taskCallback.onReport(updateProgress()) - } + if (pendingContent.isEmpty()) { + sourceContent.forEachIndexed { index, content -> + pendingContent.add( + TaskContentItem( + content = content, + relativePath = content.uniquePath.removePrefix("/$basePath"), + status = TaskContentStatus.PENDING + ) + ) } } - val buffer = ByteArray(1024) - val byteArrayOutputStream = ByteArrayOutputStream() - destination.openInputStream().use { inputStream -> - ZipInputStream(BufferedInputStream(inputStream)).use { zipInputStream -> - ZipOutputStream(BufferedOutputStream(byteArrayOutputStream)).use { zipOut -> - source.forEach { documentHolder -> - addFileToZip(documentHolder, zipOut, buffer, emptyString) - } + progressMonitor.apply { + totalContent = pendingContent.size + processName = globalClass.getString(R.string.compressing) + } - var entry: ZipEntry? = zipInputStream.nextEntry - while (entry != null) { - if (entriesToAdded.contains(entry.name)) { - zipOut.closeEntry() - entry = zipInputStream.nextEntry - continue - } + ZipFile(parameters!!.destPath).use { zipFile -> + pendingContent.forEachIndexed { index, itemToCompress -> + if (aborted) { + progressMonitor.status = TaskStatus.PAUSED + return + } - zipOut.putNextEntry(entry) + if (itemToCompress.status == TaskContentStatus.PENDING) { + progressMonitor.apply { + contentName = itemToCompress.content.displayName + remainingContent = pendingContent.size - (index + 1) + progress = -1f + } - var len: Int - while (zipInputStream.read(buffer).also { len = it } > 0) { - zipOut.write(buffer, 0, len) + try { + if (itemToCompress.content.isFolder) { + zipFile.addFolder(File(itemToCompress.content.uniquePath)) + } else { + zipFile.addFile(itemToCompress.content.uniquePath) } - - zipOut.closeEntry() - entry = zipInputStream.nextEntry + itemToCompress.status = TaskContentStatus.SUCCESS + } catch (e: Exception) { + logger.logError(e) + markAsFailed( + globalClass.resources.getString( + R.string.task_summary_failed, + e.message ?: emptyString + ) + ) + return } } } } - val tempDestination = DocumentHolder.fromFile( - kotlin.io.path.createTempFile(String.randomString(16)).toFile() - ) - tempDestination.openOutputStream()?.use { outputStream -> - byteArrayOutputStream.writeTo(outputStream) - } - - destination.writeText(emptyString) - - tempDestination.openInputStream()?.use { inputStream -> - destination.openOutputStream()?.use { outputStream -> - inputStream.copyTo(outputStream) + if (progressMonitor.status == TaskStatus.RUNNING) { + progressMonitor.status = TaskStatus.SUCCESS + progressMonitor.summary = buildString { + pendingContent.forEach { content -> + append(content.content.displayName) + append(" -> ") + append(content.status.name) + } } } + } - tempDestination.delete() + override fun setParameters(params: TaskParameters) { + parameters = params as CompressTaskParameters + } - launchOnUiThread { - taskCallback.onComplete(details.apply { - subtitle = globalClass.getString(R.string.done) - }) + override suspend fun continueTask() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return } + run(parameters!!) } - override fun getIcon(): ImageVector = Icons.Rounded.Compress - - override fun getSourceFiles(): List { - return source - } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CopyTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CopyTask.kt index c68378e2..1cf2fc88 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CopyTask.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CopyTask.kt @@ -1,148 +1,731 @@ package com.raival.compose.file.explorer.screen.main.tab.files.task -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.ui.graphics.vector.ImageVector -import com.anggrayudi.storage.extension.launchOnUiThread import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.emptyString -import com.raival.compose.file.explorer.common.extension.isNot -import com.raival.compose.file.explorer.common.extension.randomString -import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.common.extension.listFilesAndEmptyDirs +import com.raival.compose.file.explorer.common.extension.orIf +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.common.extension.toRelativeString +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder +import net.lingala.zip4j.ZipFile +import net.lingala.zip4j.model.ZipParameters +import java.io.ByteArrayInputStream +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption class CopyTask( - private val source: List -) : FilesTabTask() { - override val id: String = String.randomString(8) - - override fun getTitle(): String = globalClass.getString(R.string.copy) - - override fun getSubtitle(): String = if (source.size == 1) - source[0].path.trimToLastTwoSegments() - else globalClass.getString(R.string.task_subtitle, source.size) - - override suspend fun execute(destination: DocumentHolder, callback: Any) { - val taskCallback = callback as FilesTabTaskCallback - - val total: Int - var completed = 0 - var replaced = 0 - var skipped = 0 - - val details = FilesTabTaskDetails( - this, - TASK_COPY, - getTitle(), - globalClass.getString(R.string.preparing), - emptyString, - 0f + val sourceFiles: List, + val deleteSourceFiles: Boolean +) : Task() { + private var parameters: CopyTaskParameters? = null + private val pendingFiles: ArrayList = arrayListOf() + + override val metadata = createTaskMetadata() + override val progressMonitor = TaskProgressMonitor( + status = TaskStatus.PENDING, + taskTitle = metadata.title, + ) + + override fun getCurrentStatus() = progressMonitor.status + override fun validate() = sourceFiles.all { it.isValid() && it.canRead } + + override suspend fun run() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return + } + run(parameters!!) + } + + override suspend fun run(params: TaskParameters) { + parameters = params as? CopyTaskParameters + + if (!initializeTask()) return + + try { + executeTaskBasedOnSourceType() + if (deleteSourceFiles && progressMonitor.status == TaskStatus.RUNNING) { + performSourceDeletion() + } + finalizeTask() + } catch (e: Exception) { + handleTaskError(e) + } + } + + override suspend fun continueTask() { + parameters?.let { run(it) } ?: run { + markAsFailed(globalClass.resources.getString(R.string.task_summary_missing_destination)) + } + } + + override fun setParameters(params: TaskParameters) { + parameters = params as? CopyTaskParameters + } + + // Private helper methods + + private fun createTaskMetadata(): TaskMetadata { + val time = System.currentTimeMillis().toFormattedDate() + return TaskMetadata( + id = id, + creationTime = time, + title = globalClass.resources.getString( + if (deleteSourceFiles) R.string.move else R.string.copy + ), + subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceFiles.size), + displayDetails = sourceFiles.joinToString(", ") { it.displayName }, + fullDetails = buildString { + sourceFiles.forEach { source -> + appendLine(source.displayName) + } + appendLine() + append(time) + }, + isCancellable = true, + canMoveToBackground = true ) + } - taskCallback.onPrepare(details) + private fun initializeTask(): Boolean { + progressMonitor.status = TaskStatus.RUNNING + aborted = false + protect = false - val filesToCopy = arrayListOf() + if (sourceFiles.isEmpty()) { + markAsFailed(globalClass.resources.getString(R.string.task_summary_no_src)) + return false + } - source.forEach { - filesToCopy.add(it.path) - if (!it.isFile) { - filesToCopy.addAll(it.walk(true).map { f -> f.path }) + parameters?.let { params -> + if (!validateDestination(params.destHolder)) { + return false } + } ?: run { + markAsFailed(globalClass.resources.getString(R.string.task_summary_missing_destination)) + return false } - total = filesToCopy.size + return true + } + + private fun validateDestination(destHolder: ContentHolder): Boolean { + val firstSource = sourceFiles.first() - fun updateProgress(info: String): FilesTabTaskDetails { - return details.apply { - globalClass.getString(R.string.progress, completed + skipped + replaced, total) - if (progress >= 0) this.progress = - (completed + replaced + skipped) / total.toFloat() - if (info.isNotEmpty()) this.info = info + // Prevent copying into self for local files + if (firstSource is LocalFileHolder && destHolder is LocalFileHolder) { + val sourceParent = firstSource.file.parentFile?.canonicalPath + val destPath = destHolder.file.canonicalPath + if (sourceParent == destPath) { + markAsFailed(globalClass.resources.getString(R.string.task_summary_invalid_dest)) + return false } } - fun copyFile(from: DocumentHolder, to: DocumentHolder) { - if (!filesToCopy.contains(from.path)) return + // Prevent copying zip directory into itself + if (firstSource is ZipFileHolder && destHolder is ZipFileHolder) { + val sourceFile = firstSource.zipTree.source.file.canonicalPath + val destFile = destHolder.zipTree.source.file.canonicalPath - val existingFile = from.getName().let { to.findFile(it) } + if (sourceFile == destFile) { + val destPath = destHolder.node.path + val hasInvalidNesting = sourceFiles + .filterIsInstance() + .filter { !it.isFile() } + .any { destPath.startsWith(it.node.path) } - taskCallback.onReport( - updateProgress( - info = globalClass.getString( - R.string.copying_destination, - from.getName(), - destination.getName() - ) - ) + if (hasInvalidNesting) { + markAsFailed(globalClass.resources.getString(R.string.task_summary_invalid_dest)) + return false + } + } + } + + return true + } + + private fun executeTaskBasedOnSourceType() { + val sample = sourceFiles.first() + val destHolder = parameters!!.destHolder + val sourcePath = sample.getParent()?.uniquePath ?: emptyString + + when { + sample is LocalFileHolder && destHolder is LocalFileHolder -> + copyLocalFiles(sourcePath, destHolder) + + sample is LocalFileHolder && destHolder is ZipFileHolder -> + copyLocalFilesToZip(sourcePath, destHolder) + + sample is ZipFileHolder && destHolder is LocalFileHolder -> + copyZipFilesToLocal(sourcePath, destHolder) + + sample is ZipFileHolder && destHolder is ZipFileHolder -> + copyZipFilesToZip(sourcePath, destHolder) + + else -> + throw IllegalStateException(globalClass.getString(R.string.unsupported_source_destination_combination)) + } + } + + private fun markAsFailed(info: String) { + progressMonitor.apply { + status = TaskStatus.FAILED + summary = info + } + } + + private fun handleTaskError(e: Exception) { + logger.logError(e) + markAsFailed( + globalClass.resources.getString( + R.string.task_summary_failed, + e.message ?: globalClass.getString(R.string.unknown_error) ) + ) + } - if (existingFile isNot null) { - if (from.path == existingFile?.path || !existingFile!!.delete()) { - skipped++ - return + private fun finalizeTask() { + if (progressMonitor.status == TaskStatus.RUNNING) { + progressMonitor.status = TaskStatus.SUCCESS + progressMonitor.summary = buildString { + pendingFiles.forEach { content -> + append(content.content.displayName) + append(" -> ") + appendLine(content.status.name) } } + } + } - from.getName().let { fileName -> - to.createSubFile(fileName)?.let { target -> - val inStream = from.openInputStream() - val outStream = target.openOutputStream() + private fun preparePendingFiles(sourcePath: String) { + if (pendingFiles.isEmpty()) { + sourceFiles.forEach { source -> + pendingFiles.addAll(listFilesWithRelativePath(sourcePath, source)) + } + } + } - inStream.use { input -> - outStream.use { output -> - input?.copyTo(output ?: return) - } + private fun updateProgress(index: Int, itemName: String) { + progressMonitor.apply { + contentName = itemName + remainingContent = pendingFiles.size - (index + 1) + progress = (index + 1f) / pendingFiles.size + } + } + + private fun handleConflict(item: TaskContentItem, existsAsFile: Boolean): Boolean { + if (!existsAsFile) return true // No conflict for directories or non-existent files + + return when (commonConflictResolution) { + TaskContentStatus.SKIP -> { + item.status = TaskContentStatus.SKIP + true + } + + TaskContentStatus.ASK -> { + item.status = TaskContentStatus.CONFLICT + progressMonitor.status = TaskStatus.CONFLICT + globalClass.taskManager.taskInterceptor.interceptTask(item, this) + false // Pause execution + } + + TaskContentStatus.REPLACE -> { + item.status = TaskContentStatus.REPLACE + true + } + + else -> true + } + } + + private fun performSourceDeletion() { + if (!deleteSourceFiles) return + + progressMonitor.processName = + globalClass.resources.getString(R.string.deleting_source_files) + + when (val sample = sourceFiles.first()) { + is LocalFileHolder -> deleteLocalSources() + is ZipFileHolder -> deleteZipSources(sample) + } + } + + private fun deleteLocalSources() { + val successfulItems = pendingFiles.filter { it.status == TaskContentStatus.SUCCESS } + + successfulItems.forEachIndexed { index, item -> + if (aborted) { + progressMonitor.status = TaskStatus.PAUSED + return + } + + progressMonitor.apply { + remainingContent = successfulItems.size - (index + 1) + progress = (index + 1f) / successfulItems.size + } + + (item.content as LocalFileHolder).file.deleteRecursively() + } + + // Clean up empty directories + sourceFiles.forEach { content -> + val file = (content as LocalFileHolder).file + if (file.exists() && file.walkTopDown().none { it.isFile }) { + file.deleteRecursively() + } + } + } + + private fun deleteZipSources(sample: ZipFileHolder) { + try { + ZipFile(sample.zipTree.source.file).use { zipFile -> + val successfulPaths = pendingFiles + .filter { it.status == TaskContentStatus.SUCCESS } + .map { (it.content as ZipFileHolder).node.path } + + if (successfulPaths.isNotEmpty()) { + zipFile.removeFiles(successfulPaths) + } + + // Remove empty directories + sourceFiles.forEach { src -> + val zipSrc = src as ZipFileHolder + val hasFiles = zipSrc.node.listFilesAndEmptyDirs().any { !it.isDirectory } + if (!hasFiles) { + zipFile.removeFile(zipSrc.node.path) } + } + } + } catch (e: Exception) { + logger.logError(e) + // Don't fail the entire task if deletion fails + } + } + + // Specialized copy methods - if (existingFile isNot null) replaced++ else completed++ + private fun copyLocalFiles(sourcePath: String, destinationHolder: LocalFileHolder) { + progressMonitor.processName = globalClass.resources.getString(R.string.counting_files) + preparePendingFiles(sourcePath) - taskCallback.onReport(updateProgress(emptyString)) + progressMonitor.apply { + totalContent = pendingFiles.size + processName = globalClass.resources.getString(R.string.copying) + } + + pendingFiles.forEachIndexed { index, item -> + if (aborted) { + progressMonitor.status = TaskStatus.PAUSED + return + } + if (item.status != TaskContentStatus.PENDING + && item.status != TaskContentStatus.REPLACE + && item.status != TaskContentStatus.CONFLICT + ) { + return@forEachIndexed + } + + val sourceFile = (item.content as LocalFileHolder).file + val destinationFile = File(destinationHolder.file, item.relativePath) + + updateProgress(index, sourceFile.name) + + if (item.status == TaskContentStatus.CONFLICT && !handleConflict(item, true)) { + return + } + + if (item.status == TaskContentStatus.PENDING) { + val conflictExists = destinationFile.exists() && destinationFile.isFile + if (conflictExists && !handleConflict(item, true)) { return } } - skipped++ + when (item.status) { + TaskContentStatus.PENDING, TaskContentStatus.REPLACE -> { + item.status = if (copyLocalFile( + sourceFile, + destinationFile, + item.status == TaskContentStatus.REPLACE + ) + ) { + TaskContentStatus.SUCCESS + } else { + TaskContentStatus.FAILED + } + } + + else -> { /* Already handled */ + } + } } + } - fun copyFolder(from: DocumentHolder, to: DocumentHolder) { - if (!filesToCopy.contains(from.path)) return + private fun copyLocalFile(source: File, destination: File, overwrite: Boolean): Boolean { + return try { + if (source.isFile) { + destination.parentFile?.mkdirs() + if (deleteSourceFiles) { + Files.move( + source.toPath(), + destination.toPath(), + *buildList { + add(StandardCopyOption.ATOMIC_MOVE) + if (overwrite) add(StandardCopyOption.REPLACE_EXISTING) + }.toTypedArray() + ) + } else { + source.copyTo(destination, overwrite) + } + } else { + destination.mkdirs() || destination.exists() + } + true + } catch (e: Exception) { + logger.logError(e) + false + } + } - val newFolder = from.getName().let { to.createSubFolder(it) } + private fun copyLocalFilesToZip(sourcePath: String, destinationHolder: ZipFileHolder) { + progressMonitor.processName = globalClass.resources.getString(R.string.counting_files) + preparePendingFiles(sourcePath) - if (newFolder isNot null) { - completed++ - from.listContent(false).forEach { currentFile -> - if (currentFile.isFile) { - copyFile(currentFile, newFolder!!) - } else { - copyFolder(currentFile, newFolder!!) + progressMonitor.apply { + totalContent = pendingFiles.size + processName = globalClass.resources.getString(R.string.copying) + } + + try { + ZipFile(destinationHolder.zipTree.source.file).use { targetZipFile -> + pendingFiles.forEachIndexed { index, item -> + if (aborted) { + progressMonitor.status = TaskStatus.PAUSED + return + } + + if (item.status != TaskContentStatus.PENDING + && item.status != TaskContentStatus.REPLACE + && item.status != TaskContentStatus.CONFLICT + ) { + return@forEachIndexed + } + + updateProgress(index, item.content.displayName) + + if (item.status == TaskContentStatus.CONFLICT && !handleConflict(item, true)) { + return + } + + val targetPath = + createZipEntryPath(destinationHolder.node.path, item.relativePath) + + if (item.status == TaskContentStatus.PENDING) { + val existingHeader = targetZipFile.getFileHeader(targetPath) + val conflictExists = existingHeader != null && !existingHeader.isDirectory + if (conflictExists && !handleConflict(item, true)) { + return + } + } + + when (item.status) { + TaskContentStatus.PENDING, TaskContentStatus.REPLACE -> { + item.status = if (addLocalFileToZip( + targetZipFile, + item, + targetPath, + item.status == TaskContentStatus.REPLACE + ) + ) { + TaskContentStatus.SUCCESS + } else { + TaskContentStatus.FAILED + } + } + + else -> { /* Already handled */ + } + } + } + } + } catch (e: Exception) { + throw RuntimeException(globalClass.getString(R.string.failed_to_copy_files_to_zip), e) + } + } + + private fun addLocalFileToZip( + zipFile: ZipFile, + item: TaskContentItem, + targetPath: String, + overwrite: Boolean + ): Boolean { + return try { + val sourceFile = (item.content as LocalFileHolder).file + val params = ZipParameters().apply { + isOverrideExistingFilesInZip = overwrite + fileNameInZip = targetPath + } + + if (sourceFile.isFile) { + zipFile.addFile(sourceFile, params) + } else { + zipFile.addFolder(sourceFile, params) + } + true + } catch (e: Exception) { + logger.logError(e) + false + } + } + + private fun copyZipFilesToLocal(sourcePath: String, destinationHolder: LocalFileHolder) { + progressMonitor.processName = globalClass.resources.getString(R.string.counting_files) + preparePendingFiles(sourcePath) + + progressMonitor.apply { + totalContent = pendingFiles.size + processName = globalClass.resources.getString(R.string.extracting) + } + + val sourceZipFile = (sourceFiles.first() as ZipFileHolder).zipTree.source.file + try { + ZipFile(sourceZipFile).use { sourceZip -> + pendingFiles.forEachIndexed { index, item -> + if (aborted) { + progressMonitor.status = TaskStatus.PAUSED + return + } + + if (item.status != TaskContentStatus.PENDING + && item.status != TaskContentStatus.REPLACE + && item.status != TaskContentStatus.CONFLICT + ) { + return@forEachIndexed + } + + val sourceHolder = item.content as ZipFileHolder + val destinationFile = File(destinationHolder.uniquePath, item.relativePath) + + updateProgress(index, sourceHolder.displayName) + + if (item.status == TaskContentStatus.CONFLICT && !handleConflict(item, true)) { + return + } + + if (item.status == TaskContentStatus.PENDING) { + val conflictExists = destinationFile.exists() && destinationFile.isFile + if (conflictExists && !handleConflict(item, true)) { + return + } + } + + when (item.status) { + TaskContentStatus.PENDING, TaskContentStatus.REPLACE -> { + item.status = + if (extractZipEntry(sourceZip, sourceHolder, destinationFile)) { + TaskContentStatus.SUCCESS + } else { + TaskContentStatus.FAILED + } + } + + else -> { /* Already handled */ + } } } + } + } catch (e: Exception) { + throw RuntimeException( + globalClass.getString(R.string.failed_to_extract_files_from_zip), + e + ) + } + } + + private fun extractZipEntry( + sourceZip: ZipFile, + sourceHolder: ZipFileHolder, + destinationFile: File + ): Boolean { + return try { + if (sourceHolder.isFile()) { + destinationFile.parentFile?.mkdirs() + sourceZip.getInputStream(sourceZip.getFileHeader(sourceHolder.node.path)) + .use { input -> + destinationFile.outputStream().use { output -> + input.copyTo(output) + } + } } else { - skipped++ + destinationFile.mkdirs() } + true + } catch (e: Exception) { + logger.logError(e) + false + } + } + + private fun copyZipFilesToZip(sourcePath: String, destinationHolder: ZipFileHolder) { + progressMonitor.processName = globalClass.resources.getString(R.string.counting_files) + preparePendingFiles(sourcePath) + + progressMonitor.apply { + totalContent = pendingFiles.size + processName = globalClass.resources.getString(R.string.copying) } - source.forEach { currentFile -> - if (currentFile.isFile) { - copyFile(currentFile, destination) + val sourceFile = (sourceFiles.first() as ZipFileHolder).zipTree.source.file + val destFile = destinationHolder.zipTree.source.file + + try { + ZipFile(sourceFile).use { sourceZip -> + ZipFile(destFile).use { destZip -> + pendingFiles.forEachIndexed { index, item -> + if (aborted) { + progressMonitor.status = TaskStatus.PAUSED + return + } + + if (item.status != TaskContentStatus.PENDING + && item.status != TaskContentStatus.REPLACE + && item.status != TaskContentStatus.CONFLICT + ) { + return@forEachIndexed + } + + updateProgress(index, item.content.displayName) + + if (item.status == TaskContentStatus.CONFLICT && !handleConflict( + item, + true + ) + ) { + return + } + + val targetPath = + createZipEntryPath(destinationHolder.node.path, item.relativePath) + + if (item.status == TaskContentStatus.PENDING) { + val existingHeader = destZip.getFileHeader(targetPath) + val conflictExists = + existingHeader != null && !existingHeader.isDirectory + if (conflictExists && !handleConflict(item, true)) { + return + } + } + + // Remove existing file if replacing + if (item.status == TaskContentStatus.REPLACE) { + try { + destZip.removeFile(targetPath) + } catch (e: Exception) { + logger.logError(e) // Log but continue + } + } + + when (item.status) { + TaskContentStatus.PENDING, TaskContentStatus.REPLACE -> { + item.status = + if (copyZipEntry(sourceZip, destZip, item, targetPath)) { + TaskContentStatus.SUCCESS + } else { + TaskContentStatus.FAILED + } + } + + else -> { /* Already handled */ + } + } + } + } + } + } catch (e: Exception) { + throw RuntimeException(globalClass.getString(R.string.failed_to_copy_zip_entries), e) + } + } + + private fun copyZipEntry( + sourceZip: ZipFile, + destZip: ZipFile, + item: TaskContentItem, + targetPath: String + ): Boolean { + return try { + val sourceHolder = item.content as ZipFileHolder + val params = ZipParameters().apply { + fileNameInZip = if (sourceHolder.isFile()) targetPath else "$targetPath/" + isOverrideExistingFilesInZip = true + } + + if (sourceHolder.isFile()) { + sourceZip.getInputStream(sourceZip.getFileHeader(sourceHolder.node.path)) + .use { input -> + destZip.addStream(input, params) + } } else { - copyFolder(currentFile, destination) + destZip.addStream(ByteArrayInputStream(ByteArray(0)), params) } + true + } catch (e: Exception) { + logger.logError(e) + false } + } - launchOnUiThread { - taskCallback.onComplete(details.apply { - subtitle = globalClass.getString(R.string.done) - }) + private fun createZipEntryPath(basePath: String, relativePath: String): String { + return if (basePath.isEmpty()) { + relativePath + } else { + "$basePath${File.separator}$relativePath" } } - override fun getIcon(): ImageVector = Icons.Rounded.ContentCopy + private fun listFilesWithRelativePath( + basePath: String, + startFile: ContentHolder + ): List { + if (startFile.isFile()) { + return listOf( + TaskContentItem( + content = startFile, + relativePath = startFile.displayName, + status = TaskContentStatus.PENDING + ) + ) + } + + return when (startFile) { + is LocalFileHolder -> { + startFile.file.listFilesAndEmptyDirs().map { file -> + TaskContentItem( + content = LocalFileHolder(file), + relativePath = file.toRelativeString(File(basePath)) + .orIf(startFile.displayName) { it.isEmpty() }, + status = TaskContentStatus.PENDING + ) + } + } + + is ZipFileHolder -> { + startFile.node.listFilesAndEmptyDirs().map { node -> + TaskContentItem( + content = ZipFileHolder(startFile.zipTree, node), + relativePath = node.path.toRelativeString(basePath) + .orIf(startFile.displayName) { it.isEmpty() }, + status = TaskContentStatus.PENDING + ) + } + } - override fun getSourceFiles(): List { - return source + else -> emptyList() + } } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DecompressTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DecompressTask.kt deleted file mode 100644 index 25aabebe..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DecompressTask.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.task - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Compress -import androidx.compose.ui.graphics.vector.ImageVector -import com.anggrayudi.storage.extension.launchOnUiThread -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.emptyString -import com.raival.compose.file.explorer.common.extension.isNot -import com.raival.compose.file.explorer.common.extension.randomString -import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import java.io.InputStream -import java.io.OutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream - -class DecompressTask( - private val source: List -) : FilesTabTask() { - override val id: String = String.randomString(8) - - override fun getTitle(): String = globalClass.getString(R.string.decompress) - - override fun getSubtitle(): String = if (source.size == 1) - source[0].path.trimToLastTwoSegments() - else globalClass.getString(R.string.task_subtitle, source.size) - - override suspend fun execute(destination: DocumentHolder, callback: Any) { - val taskCallback = callback as FilesTabTaskCallback - - var total = 0 - var completed = 0 - var skipped = 0 - - val details = FilesTabTaskDetails( - this, - TASK_DECOMPRESS, - getTitle(), - globalClass.getString(R.string.preparing), - emptyString, - 0f - ) - - taskCallback.onPrepare(details) - - globalClass.contentResolver.openInputStream(source[0].uri)?.use { zipInputStream -> - ZipInputStream(zipInputStream).use { zis -> - var zipEntry: ZipEntry? - while (zis.nextEntry.also { zipEntry = it } != null) { - zipEntry?.let { entry -> - if (!entry.isDirectory) total++ - } - } - } - } - - fun updateProgress(info: String = emptyString): FilesTabTaskDetails { - return details.apply { - if (info.isNotEmpty()) this.info = info - if (progress >= 0) this.progress = (completed + skipped.toFloat()) / total - subtitle = globalClass.getString(R.string.progress, completed + skipped, total) - } - } - - taskCallback.onReport(updateProgress()) - - fun createFileInTargetDir(targetDir: DocumentHolder, entry: ZipEntry): DocumentHolder? { - var currentDir = targetDir - val pathSegments = entry.name.split("/") - for (i in 0 until pathSegments.size - 1) { - currentDir = currentDir.findFile(pathSegments[i]) - ?: currentDir.createSubFolder(pathSegments[i]) ?: return null - } - - val existingFile = currentDir.findFile(pathSegments.last()) - - if (existingFile isNot null) { - if (existingFile!!.isFolder) existingFile.deleteRecursively() - else existingFile.delete() - } - - return if (entry.isDirectory) { - currentDir.createSubFolder(pathSegments.last()) - } else { - currentDir.createSubFile(pathSegments.last()) - } - } - - fun copyStream(input: InputStream, output: OutputStream) { - val buffer = ByteArray(1024) - var length: Int - while (input.read(buffer).also { length = it } > 0) { - output.write(buffer, 0, length) - } - } - - fun writeToFile(zis: ZipInputStream, outputFile: DocumentHolder?) { - outputFile?.uri?.let { uri -> - globalClass.contentResolver.openOutputStream(uri)?.use { outputStream -> - copyStream(zis, outputStream) - } - } - } - - val inputStream = globalClass.contentResolver.openInputStream(source[0].uri) - - inputStream?.use { zipInputStream -> - ZipInputStream(zipInputStream).use { zis -> - var zipEntry: ZipEntry? - while (zis.nextEntry.also { zipEntry = it } != null) { - zipEntry?.let { entry -> - taskCallback.onReport( - updateProgress( - info = globalClass.getString(R.string.decompressing, entry.name) - ) - ) - val outputFile = createFileInTargetDir(destination, entry) - if (outputFile isNot null && !entry.isDirectory) { - writeToFile(zis, outputFile) - completed++ - } else if (outputFile == null) { - skipped++ - } - taskCallback.onReport(updateProgress()) - } - } - } - } - - launchOnUiThread { - taskCallback.onComplete(details.apply { - subtitle = globalClass.getString(R.string.done) - }) - } - } - - override fun getIcon(): ImageVector = Icons.Rounded.Compress - - override fun getSourceFiles(): List { - return source - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DeleteTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DeleteTask.kt index 82b75f91..47d1bd96 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DeleteTask.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DeleteTask.kt @@ -1,142 +1,160 @@ package com.raival.compose.file.explorer.screen.main.tab.files.task -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ContentCut -import androidx.compose.ui.graphics.vector.ImageVector -import com.anggrayudi.storage.extension.launchOnUiThread import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.emptyString -import com.raival.compose.file.explorer.common.extension.randomString -import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import java.io.File +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder +import net.lingala.zip4j.ZipFile class DeleteTask( - private val source: List, - private val moveToRecycleBin: Boolean = false -) : FilesTabTask() { - override val id: String = String.randomString(8) - - override fun getTitle(): String = globalClass.getString(R.string.delete) - - override fun getSubtitle(): String = if (source.size == 1) - source[0].path.trimToLastTwoSegments() - else globalClass.getString(R.string.task_subtitle, source.size) - - override suspend fun execute(destination: DocumentHolder, callback: Any) { - if (moveToRecycleBin && !destination.hasParent(globalClass.recycleBinDir)) { - MoveTask(source).execute( - DocumentHolder.fromFile( - File( - globalClass.recycleBinDir.toFile()!!, - "${System.currentTimeMillis()}" - ).apply { - mkdirs() - } - ), - callback - ) - return + val sourceContent: List +) : Task() { + private var parameters: DeleteTaskParameters? = null + private var pendingContent = arrayListOf() + + override val metadata = System.currentTimeMillis().toFormattedDate().let { time -> + TaskMetadata( + id = id, + creationTime = time, + title = globalClass.resources.getString(R.string.delete), + subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceContent.size), + displayDetails = sourceContent.joinToString(", ") { it.displayName }, + fullDetails = buildString { + sourceContent.forEachIndexed { index, source -> + append(source.displayName) + append("\n") + } + append("\n") + append(time) + }, + isCancellable = true, + canMoveToBackground = true + ) + } + + override val progressMonitor = TaskProgressMonitor( + status = TaskStatus.PENDING, + taskTitle = metadata.title, + ) + + override fun getCurrentStatus() = progressMonitor.status + + override fun validate() = sourceContent.find { !it.isValid() } == null + + private fun markAsFailed(info: String) { + progressMonitor.apply { + status = TaskStatus.FAILED + summary = info } + } - val taskCallback = callback as FilesTabTaskCallback + override suspend fun run() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return + } + run(parameters!!) + } - var deleted = 0 - var skipped = 0 + override suspend fun run(params: TaskParameters) { + parameters = params as DeleteTaskParameters + progressMonitor.status = TaskStatus.RUNNING + protect = false - val details = FilesTabTaskDetails( - this, - TASK_DELETE, - getTitle(), - globalClass.getString(R.string.preparing), - emptyString, - -1f - ) + if (sourceContent.isEmpty()) { + markAsFailed(globalClass.resources.getString(R.string.task_summary_no_src)) + return + } - taskCallback.onPrepare(details) + progressMonitor.processName = globalClass.resources.getString(R.string.preparing) - fun updateProgress(info: String = emptyString): FilesTabTaskDetails { - return details.apply { - subtitle = globalClass.getString(R.string.progress_short, deleted) - if (info.isNotEmpty()) this.info = info + if (pendingContent.isEmpty()) { + sourceContent.forEachIndexed { index, content -> + pendingContent.add( + DeleteContentItem(source = content, status = TaskContentStatus.PENDING) + ) } } - fun deleteFolder(toDelete: DocumentHolder) { - taskCallback.onReport( - updateProgress( - globalClass.getString( - R.string.deleting, - toDelete.getName() - ) - ) - ) + progressMonitor.apply { + totalContent = pendingContent.size + processName = globalClass.getString(R.string.deleting) + } + + pendingContent.forEachIndexed { index, itemToDelete -> + if (aborted) { + progressMonitor.status = TaskStatus.PAUSED + return + } - if (toDelete.isEmpty()) { - if (toDelete.delete()) { - deleted++ - } else { - skipped++ + if (itemToDelete.status == TaskContentStatus.PENDING) { + progressMonitor.apply { + contentName = itemToDelete.source.displayName + remainingContent = pendingContent.size - (index + 1) + progress = (index + 1f) / pendingContent.size } - } else { - toDelete.listContent(false).forEach { - if (it.isFile) { - taskCallback.onReport( - updateProgress( - globalClass.getString( - R.string.deleting, - it.getName() - ) - ) - ) - if (it.delete()) { - deleted++ - } else { - skipped++ + + try { + when (itemToDelete.source) { + is LocalFileHolder -> { + itemToDelete.source.file.deleteRecursively() + } + + is ZipFileHolder -> { + ZipFile(itemToDelete.source.zipTree.source.file).use { + it.removeFile(itemToDelete.source.uniquePath) + } + } + + else -> { + itemToDelete.status = TaskContentStatus.SKIP + return@forEachIndexed } - } else { - deleteFolder(it) } - } - if (toDelete.delete()) { - deleted++ - } else { - skipped++ + itemToDelete.status = TaskContentStatus.SUCCESS + } catch (e: Exception) { + logger.logError(e) + markAsFailed( + globalClass.resources.getString( + R.string.task_summary_failed, + e.message ?: emptyString + ) + ) + return } } } - source.forEach { currentFile -> - if (currentFile.isFile) { - taskCallback.onReport( - updateProgress( - globalClass.getString( - R.string.deleting, - currentFile.getName() - ) - ) - ) - if (currentFile.delete()) { - deleted++ - } else { - skipped++ + if (progressMonitor.status == TaskStatus.RUNNING) { + progressMonitor.status = TaskStatus.SUCCESS + progressMonitor.summary = buildString { + pendingContent.forEach { content -> + append(content.source.displayName) + append(" -> ") + append(content.status.name) } - } else { - deleteFolder(currentFile) } } + } - launchOnUiThread { - taskCallback.onComplete(details.apply { - subtitle = globalClass.getString(R.string.done) - }) + override suspend fun continueTask() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return } + run(parameters!!) } - override fun getIcon(): ImageVector = Icons.Rounded.ContentCut - - override fun getSourceFiles(): List { - return source + override fun setParameters(params: TaskParameters) { + parameters = params as DeleteTaskParameters } + + internal data class DeleteContentItem( + val source: ContentHolder, + var status: TaskContentStatus + ) } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTask.kt deleted file mode 100644 index 579dfe5c..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTask.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.task - -import androidx.compose.ui.graphics.vector.ImageVector -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder - -abstract class FilesTabTask { - abstract val id: String - abstract fun getTitle(): String - abstract fun getSubtitle(): String - abstract suspend fun execute(destination: DocumentHolder, callback: Any) - abstract fun getIcon(): ImageVector - abstract fun getSourceFiles(): List - - fun isValidSourceFiles(): Boolean { - getSourceFiles().forEach { - if (!it.exists()) return false - } - return true - } - - companion object { - const val TASK_NONE = -1 - const val TASK_COPY = 0 - const val TASK_MOVE = 1 - const val TASK_DELETE = 2 - const val TASK_COMPRESS = 3 - const val TASK_DECOMPRESS = 4 - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskCallback.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskCallback.kt deleted file mode 100644 index 6c6db9a3..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskCallback.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.task - -import kotlinx.coroutines.CoroutineScope - -abstract class FilesTabTaskCallback(val processingThread: CoroutineScope) { - abstract fun onPrepare(details: FilesTabTaskDetails) - - abstract fun onReport(details: FilesTabTaskDetails) - - abstract fun onComplete(details: FilesTabTaskDetails) - - abstract fun onFailed(details: FilesTabTaskDetails) -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskDetails.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskDetails.kt deleted file mode 100644 index 57d3a065..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskDetails.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.task - -import com.raival.compose.file.explorer.common.extension.emptyString - -class FilesTabTaskDetails( - var task: FilesTabTask, - var type: Int = FilesTabTask.TASK_NONE, - var title: String = emptyString, - var subtitle: String = emptyString, - var info: String = emptyString, - var progress: Float = -1f -) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/MoveTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/MoveTask.kt deleted file mode 100644 index 818a6e5e..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/MoveTask.kt +++ /dev/null @@ -1,200 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.task - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ContentCut -import androidx.compose.ui.graphics.vector.ImageVector -import com.anggrayudi.storage.extension.launchOnUiThread -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.emptyString -import com.raival.compose.file.explorer.common.extension.isNot -import com.raival.compose.file.explorer.common.extension.randomString -import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.raival.compose.file.explorer.screen.main.tab.files.misc.Action -import java.io.File - -class MoveTask( - private val source: List -) : FilesTabTask() { - override val id: String = String.randomString(8) - - override fun getTitle(): String = globalClass.getString(R.string.move) - - override fun getSubtitle(): String = if (source.size == 1) - source[0].path.trimToLastTwoSegments() - else globalClass.getString(R.string.task_subtitle, source.size) - - val postActions = arrayListOf() - - override suspend fun execute(destination: DocumentHolder, callback: Any) { - val taskCallback = callback as FilesTabTaskCallback - - val total: Int - var completed = 0 - var replaced = 0 - var skipped = 0 - - val details = FilesTabTaskDetails( - this, - TASK_MOVE, - getTitle(), - globalClass.getString(R.string.preparing), - emptyString, - 0f - ) - - taskCallback.onPrepare(details) - - val filesToMove = arrayListOf() - val filesSkipped = arrayListOf() - - source.forEach { - if (it.storageId == destination.storageId) { - val tempFile = File(it.path) - val dest = File(destination.path) - val newFile = File(dest, tempFile.name) - val isReplace = newFile.exists() - - if (tempFile.exists() && dest.exists()) { - if (tempFile.renameTo(newFile)) { - if (isReplace) replaced++ else completed++ - } - } - } - - filesToMove.add(it.path) - - if (!it.isFile) { - filesToMove.addAll(it.walk(true).map { f -> f.path }) - } - } - - - total = filesToMove.size - - fun updateProgress(info: String): FilesTabTaskDetails { - return details.apply { - subtitle = - globalClass.getString(R.string.progress, completed + skipped + replaced, total) - if (progress >= 0) this.progress = - (completed + replaced + skipped) / total.toFloat() - if (info.isNotEmpty()) this.info = info - } - } - - fun copyFile(from: DocumentHolder, to: DocumentHolder) { - if (!filesToMove.contains(from.path)) return - - val existingFile = from.getName().let { to.findFile(it) } - - taskCallback.onReport( - updateProgress( - info = globalClass.getString( - R.string.moving_destination, - from.getName(), - destination.getName() - ) - ) - ) - - if (existingFile isNot null) { - if (from.path == existingFile?.path || !existingFile!!.delete()) { - skipped++ - filesSkipped.add(from.path) - return - } - } - - from.getName().let { fileName -> - to.createSubFile(fileName)?.let { target -> - val inStream = from.openInputStream() - val outStream = target.openOutputStream() - - inStream.use { input -> - outStream.use { output -> - input?.copyTo(output ?: return) - } - } - - if (existingFile isNot null) replaced++ else completed++ - - taskCallback.onReport(updateProgress(emptyString)) - - return - } - } - - skipped++ - filesSkipped.add(from.path) - } - - fun copyFolder(from: DocumentHolder, to: DocumentHolder) { - if (!filesToMove.contains(from.path)) return - - val newFolder = from.getName().let { to.createSubFolder(it) } - - if (newFolder isNot null) { - if (from.path == newFolder?.path) { - skipped++ - filesSkipped.add(from.path) - } else { - completed++ - } - - from.listContent(false).forEach { currentFile -> - if (currentFile.isFile) { - copyFile(currentFile, newFolder!!) - } else { - copyFolder(currentFile, newFolder!!) - } - } - } else { - skipped++ - filesSkipped.add(from.path) - } - } - - source.forEach { currentFile -> - if (currentFile.exists()) { - if (currentFile.isFile) { - copyFile(currentFile, destination) - } else { - copyFolder(currentFile, destination) - } - } - } - - details.progress = -1f - taskCallback.onReport( - updateProgress( - info = globalClass.getString(R.string.deleting_source_files) - ) - ) - - source.forEach { currentFile -> - if (!filesSkipped.contains(currentFile.path)) { - if (currentFile.exists()) { - if (currentFile.isFile) currentFile.delete() - else currentFile.deleteRecursively() - } - } - } - - launchOnUiThread { - taskCallback.onComplete(details.apply { - subtitle = globalClass.getString(R.string.done) - }) - - postActions.forEach { - it.due = true - } - } - } - - override fun getIcon(): ImageVector = Icons.Rounded.ContentCut - - override fun getSourceFiles(): List { - return source - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/RenameTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/RenameTask.kt new file mode 100644 index 00000000..3b70f010 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/RenameTask.kt @@ -0,0 +1,272 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder +import net.lingala.zip4j.ZipFile +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class RenameTask(val sourceContent: List) : Task() { + private var parameters: RenameTaskParameters? = null + private var pendingContent = arrayListOf() + + override val metadata = System.currentTimeMillis().toFormattedDate().let { time -> + TaskMetadata( + id = id, + creationTime = time, + title = globalClass.resources.getString(R.string.rename), + subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceContent.size), + displayDetails = sourceContent.joinToString(", ") { it.displayName }, + fullDetails = buildString { + sourceContent.forEachIndexed { index, source -> + append(source.displayName) + append("\n") + } + append("\n") + append(time) + }, + isCancellable = true, + canMoveToBackground = false + ) + } + + override val progressMonitor = TaskProgressMonitor( + status = TaskStatus.PENDING, + taskTitle = metadata.title, + ) + + override fun getCurrentStatus() = progressMonitor.status + + override fun validate() = sourceContent.find { !it.isValid() } == null + + private fun markAsFailed(info: String) { + progressMonitor.apply { + status = TaskStatus.FAILED + summary = info + } + } + + override suspend fun run() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return + } + run(parameters!!) + } + + override suspend fun run(params: TaskParameters) { + parameters = params as RenameTaskParameters + progressMonitor.status = TaskStatus.RUNNING + protect = false + + if (sourceContent.isEmpty()) { + markAsFailed(globalClass.resources.getString(R.string.task_summary_no_src)) + return + } + + // Validate the regular expression + try { + if (parameters!!.toFind.isNotEmpty() && parameters!!.useRegex) parameters!!.toFind.toRegex() + } catch (e: Exception) { + logger.logError(e) + markAsFailed( + globalClass.resources.getString( + R.string.task_summary_failed, + e.message ?: emptyString + ) + ) + return + } + + progressMonitor.processName = globalClass.resources.getString(R.string.preparing) + + if (pendingContent.isEmpty()) { + sourceContent.forEachIndexed { index, content -> + pendingContent.add( + RenameContentItem( + source = content, + newPath = getNewPath( + content = content, + newName = parameters!!.newName, + index = index, + ), + status = TaskContentStatus.PENDING + ) + ) + } + } + + progressMonitor.apply { + totalContent = pendingContent.size + processName = globalClass.getString(R.string.renaming) + } + + pendingContent.forEachIndexed { index, itemToRename -> + if (aborted) { + progressMonitor.status = TaskStatus.PAUSED + return + } + + if (itemToRename.status == TaskContentStatus.PENDING) { + progressMonitor.apply { + contentName = itemToRename.source.displayName + remainingContent = pendingContent.size - (index + 1) + progress = (index + 1f) / pendingContent.size + } + + try { + if (itemToRename.source is LocalFileHolder) { + itemToRename.source.file.renameTo(File(itemToRename.newPath)) + } else if (itemToRename.source is ZipFileHolder) { + ZipFile(itemToRename.source.zipTree.source.file).renameFile( + itemToRename.source.uniquePath, + itemToRename.newPath + ) + } + itemToRename.status = TaskContentStatus.SUCCESS + } catch (e: Exception) { + logger.logError(e) + markAsFailed( + globalClass.resources.getString( + R.string.task_summary_failed, + e.message ?: emptyString + ) + ) + return + } + } + } + + if (progressMonitor.status == TaskStatus.RUNNING) { + progressMonitor.status = TaskStatus.SUCCESS + progressMonitor.summary = buildString { + pendingContent.forEach { content -> + append(content.source.displayName) + append(" -> ") + append(content.status.name) + } + } + } + } + + override suspend fun continueTask() { + if (parameters == null) { + markAsFailed(globalClass.getString(R.string.unable_to_continue_task)) + return + } + run(parameters!!) + } + + override fun setParameters(params: TaskParameters) { + parameters = params as RenameTaskParameters + } + + private fun getNewPath( + content: ContentHolder, + newName: String, + index: Int, + ): String { + val file = File(content.uniquePath) + + // 1. Deconstruct the original path and file name + val parentPath = file.parent?.let { "$it/" } ?: emptyString + val originalFileName = file.name + + if (sourceContent.size == 1) return parentPath + newName + if (content.uniquePath.endsWith( + File.separator + ) + ) File.separator else emptyString + + val newFileName = originalFileName.transformFileName( + newName = newName, + index = index, + textToFind = parameters!!.toFind, + replaceText = parameters!!.toReplace, + useRegex = parameters!!.useRegex, + onLastModified = { content.lastModified } + ) + + // 2. Reconstruct the full path + // Also, preserve the trailing slash if the original path was a directory + return parentPath + newFileName + if (content.uniquePath.endsWith(File.separatorChar) && !newFileName.endsWith( + File.separator + ) + ) File.separator else emptyString + } + + companion object { + /** + * Pattern Syntax: + * - {p} -> file name without extension (e.g., "document" from "document.txt") + * - {s} -> file extension (e.g., "txt" from "document.txt") + * - {e} -> file extension with a dot (e.g., ".txt" from "document.txt") + * - {t} -> file's last modified time (formatted as "yyyyMMdd_HHmmss") + * - {n} -> number increment (e.g., {0} -> 0, 1, 2...; {1} -> 1, 2, 3...) + * - {zn} -> zero-padded number increment (e.g., {z0} -> 0, 1...; {zz0} -> 00, 01...; {zzz1} -> 001, 002...) + */ + fun String.transformFileName( + newName: String, + index: Int, + textToFind: String, + replaceText: String, + useRegex: Boolean, + onLastModified: () -> Long + ): String { + val nameWithoutExtension = substringBeforeLast('.', missingDelimiterValue = this) + val extension = substringAfterLast('.', missingDelimiterValue = emptyString) + val lastModifiedTime by lazy { + val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + sdf.format(Date(onLastModified())) + } + val placeholderRegex = Regex("""\{([^{}]+)\}""") + val zeroPaddedRegex = Regex("""^(z+)(\d+)$""") + var newFileName = newName.replace(placeholderRegex) { matchResult -> + val token = matchResult.groupValues[1] + val zMatch = zeroPaddedRegex.matchEntire(token) + when { + token == "p" -> nameWithoutExtension + token == "s" -> extension + token == "e" -> if (extension.isEmpty()) emptyString else ".${extension}" + token == "t" -> lastModifiedTime + zMatch != null -> { + val zPart = zMatch.groupValues[1] + val numberPart = zMatch.groupValues[2] + val paddingWidth = zPart.length + val startNumber = numberPart.toInt() + (startNumber + index).toString().padStart(paddingWidth, '0') + } + + token.toIntOrNull() != null -> { + val startNumber = token.toInt() + (startNumber + index).toString() + } + + else -> matchResult.value + } + } + + if (textToFind.isNotEmpty()) { + newFileName = when (useRegex) { + true -> newFileName.replace(textToFind.toRegex(), replaceText) + false -> newFileName.replace(textToFind, replaceText) + } + } + + return newFileName + } + } + + internal data class RenameContentItem( + val source: ContentHolder, + val newPath: String, + var status: TaskContentStatus + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/Task.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/Task.kt new file mode 100644 index 00000000..5186d3f6 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/Task.kt @@ -0,0 +1,34 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +import java.util.UUID + +abstract class Task( + val id: String = UUID.randomUUID().toString() +) { + var commonConflictResolution = TaskContentStatus.ASK + + @Volatile + var aborted = false + + /** + * Used to protect task from the time it is added to the running list and gets actually run + */ + var protect = false + + open fun overrideConflicts(resolution: TaskContentStatus) { + commonConflictResolution = resolution + } + + open fun abortTask() { + aborted = true + } + + abstract val metadata: TaskMetadata + abstract val progressMonitor: TaskProgressMonitor + abstract fun getCurrentStatus(): TaskStatus + abstract fun validate(): Boolean + abstract suspend fun run(params: TaskParameters) + abstract suspend fun run() + abstract suspend fun continueTask() + abstract fun setParameters(params: TaskParameters) +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskContentItem.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskContentItem.kt new file mode 100644 index 00000000..a951d8eb --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskContentItem.kt @@ -0,0 +1,9 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder + +data class TaskContentItem( + val content: ContentHolder, + val relativePath: String, + var status: TaskContentStatus +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskManager.kt new file mode 100644 index 00000000..89b6fdb4 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskManager.kt @@ -0,0 +1,304 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.showMsg +import com.raival.compose.file.explorer.screen.main.tab.files.service.ContentOperationService.Companion.startNewBackgroundTask +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap + +class TaskManager { + private val taskScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val taskMutex = Mutex() + + // Use thread-safe collections + private val allTasks = ConcurrentHashMap() + + val pendingTasks = mutableListOf() + val runningTasks = mutableListOf() + val pausedTasks = mutableListOf() + val failedTasks = mutableListOf() + val invalidTasks = mutableListOf() + val completedTasks = mutableListOf() + + private var isMonitoring = false + + @Volatile + var runningTaskDialogInfo = RunningTaskDialogInfo() + val taskInterceptor = TaskConflict() + + suspend fun addTask(task: Task, notifyUser: Boolean = true) = taskMutex.withLock { + if (task.validate()) { + allTasks[task.id] = task + pendingTasks.add(task) + if (notifyUser) + globalClass.showMsg(globalClass.resources.getString(R.string.new_task_has_been_added)) + } else { + invalidTasks.add(task) + globalClass.showMsg(globalClass.getString(R.string.task_validation_failed)) + } + } + + suspend fun addTaskAndRun(task: Task, parameters: TaskParameters) { + addTask(task, false) + if (allTasks.containsKey(task.id)) { + runTask(task.id, parameters) + } + } + + suspend fun removeTask(id: String) = taskMutex.withLock { + allTasks[id]?.abortTask() + allTasks.remove(id) + + pendingTasks.removeIf { it.id == id } + runningTasks.removeIf { it.id == id } + pausedTasks.removeIf { it.id == id } + failedTasks.removeIf { it.id == id } + invalidTasks.removeIf { it.id == id } + completedTasks.removeIf { it.id == id } + } + + suspend fun validateTasks() = taskMutex.withLock { + val iterator = pendingTasks.iterator() + while (iterator.hasNext()) { + val task = iterator.next() + if (!task.validate()) { + invalidTasks.add(task) + // Use the iterator's remove method, which is safe + iterator.remove() + } + } + } + + suspend fun runTask(id: String, taskParameters: TaskParameters) = taskMutex.withLock { + val task = allTasks[id] + if (task == null) { + showMsg(globalClass.getString(R.string.task_not_found)) + return@withLock + } + + if (task.getCurrentStatus() != TaskStatus.PENDING) { + showMsg(globalClass.getString(R.string.task_is_not_in_pending_state)) + return@withLock + } + + moveTaskToRunning(task) + task.setParameters(taskParameters) + bringToForeground(task) + startNewBackgroundTask(globalClass, task.id) + } + + suspend fun continueTask(taskId: String) = taskMutex.withLock { + val task = allTasks[taskId] + if (task == null) { + showMsg(globalClass.getString(R.string.task_not_found)) + return@withLock + } + + val currentStatus = task.getCurrentStatus() + + if (currentStatus != TaskStatus.CONFLICT + && currentStatus != TaskStatus.FAILED + && currentStatus != TaskStatus.PAUSED + ) { + showMsg( + globalClass.getString( + R.string.task_cannot_be_continued_from_current_state, + currentStatus + ) + ) + return@withLock + } + + // Move task back to running + pausedTasks.removeIf { it.id == taskId } + failedTasks.removeIf { it.id == taskId } + + if (!runningTasks.any { it.id == taskId }) { + runningTasks.add(task) + } + + bringToForeground(task) + startNewBackgroundTask(globalClass, task.id) + } + + private fun moveTaskToRunning(task: Task) { + pendingTasks.removeIf { it.id == task.id } + pausedTasks.removeIf { it.id == task.id } + failedTasks.removeIf { it.id == task.id } + + if (!runningTasks.any { it.id == task.id }) { + runningTasks.add(task) + } + } + + suspend fun handleTaskStatusChange(task: Task, newStatus: TaskStatus) = taskMutex.withLock { + when (newStatus) { + TaskStatus.SUCCESS -> { + runningTasks.removeIf { it.id == task.id } + completedTasks.add(task) + showMsg(globalClass.getString(R.string.task_completed)) + } + + TaskStatus.FAILED -> { + runningTasks.removeIf { it.id == task.id } + failedTasks.add(task) + showMsg(globalClass.getString(R.string.task_failed)) + } + + TaskStatus.PAUSED -> { + runningTasks.removeIf { it.id == task.id } + pausedTasks.add(task) + showMsg(globalClass.getString(R.string.task_paused)) + } + + TaskStatus.CONFLICT -> { + runningTasks.removeIf { it.id == task.id } + pausedTasks.add(task) + // Conflict will be handled by the UI + } + + else -> { + // No action needed for other states + } + } + } + + fun getTask(id: String): Task? = allTasks[id] + + fun overrideConflicts(taskId: String, resolution: TaskContentStatus) { + allTasks[taskId]?.overrideConflicts(resolution) + } + + fun bringToForeground(task: Task) { + if (!isMonitoring) { + taskScope.launch { + monitorRunningTask(task) + } + } + } + + private suspend fun monitorRunningTask(task: Task) { + isMonitoring = true + runningTaskDialogInfo.show(task) + try { + while (runningTaskDialogInfo.showDialog) { + if (!runningTasks.contains(task)) { + break + } + + runningTaskDialogInfo.updateInfo(task.progressMonitor) + delay(100) + } + } finally { + isMonitoring = false + runningTaskDialogInfo.hide() + } + } + + fun hideRunningTaskDialog() { + runningTaskDialogInfo.hide() + } + + class TaskConflict { + var hasConflict by mutableStateOf(false) + private set + var message by mutableStateOf(emptyString) + private set + var taskContentItem by mutableStateOf(null) + private set + var task by mutableStateOf(null) + private set + + fun interceptTask(taskContentItem: TaskContentItem, task: Task) { + this.hasConflict = true + this.message = globalClass.resources.getString( + R.string.file_already_exists, + taskContentItem.content.displayName + ) + this.taskContentItem = taskContentItem + this.task = task + } + + private fun reset() { + hasConflict = false + task = null + message = emptyString + taskContentItem = null + } + + fun hide() { + reset() + } + + fun resolve(resolution: TaskContentStatus, applyToAllConflicts: Boolean = false) { + val currentTask = task + val currentItem = taskContentItem + + if (currentTask == null || currentItem == null) return + + currentItem.status = resolution + + if (applyToAllConflicts) { + globalClass.taskManager.overrideConflicts(currentTask.id, resolution) + } + + // Reset before continuing to avoid race conditions + hide() + + // Continue the task + CoroutineScope(Dispatchers.IO).launch { + globalClass.taskManager.continueTask(currentTask.id) + } + } + } + + class RunningTaskDialogInfo { + var linkedTask by mutableStateOf(null) + var showDialog by mutableStateOf(false) + private set + var progressMonitor by mutableStateOf(null) + + fun show(task: Task) { + if (showDialog) return + + linkedTask = task + showDialog = true + } + + fun hide() { + if (!showDialog) return + + showDialog = false + reset() + } + + private fun reset() { + linkedTask = null + progressMonitor = null + } + + fun updateInfo(progressMonitor: TaskProgressMonitor) { + this.progressMonitor = TaskProgressMonitor( + status = progressMonitor.status, + taskTitle = progressMonitor.taskTitle, + processName = progressMonitor.processName, + contentName = progressMonitor.contentName, + totalContent = progressMonitor.totalContent, + remainingContent = progressMonitor.remainingContent, + progress = progressMonitor.progress, + summary = progressMonitor.summary + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskMetadata.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskMetadata.kt new file mode 100644 index 00000000..aa6a0740 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskMetadata.kt @@ -0,0 +1,12 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +data class TaskMetadata( + val id: String, + val creationTime: String, + val title: String, + val subtitle: String, + val displayDetails: String, + val fullDetails: String, + val isCancellable: Boolean, + val canMoveToBackground: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskParameters.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskParameters.kt new file mode 100644 index 00000000..2381e2d3 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskParameters.kt @@ -0,0 +1,26 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder + +interface TaskParameters + +data class CopyTaskParameters( + val destHolder: ContentHolder +) : TaskParameters + +class DeleteTaskParameters : TaskParameters + +data class CompressTaskParameters( + val destPath: String +) : TaskParameters + +data class RenameTaskParameters( + val newName: String, + val toFind: String, + val toReplace: String, + val useRegex: Boolean +) : TaskParameters + +data class ApksMergeTaskParameters( + val autoSign: Boolean +) : TaskParameters diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskProgressMonitor.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskProgressMonitor.kt new file mode 100644 index 00000000..77c3f9ac --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskProgressMonitor.kt @@ -0,0 +1,14 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +import com.raival.compose.file.explorer.common.extension.emptyString + +data class TaskProgressMonitor( + var status: TaskStatus = TaskStatus.PENDING, // Pending, Running, Success, Failed, Cancelled + var taskTitle: String = emptyString, // e.g, "Copy", "Move" + var processName: String = emptyString, // e.g, "Copying", "Moving" + var contentName: String = emptyString, // e.g, "File 1", "File 2". can be empty when not processing files + var totalContent: Int = 0, // Total number of content to be processed + var remainingContent: Int = 0, // Remaining number of content to be processed + var progress: Float = -1f, // Progress of the task from 0 to 1 + var summary: String = emptyString // Summary of the task after it stops +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskStatus.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskStatus.kt new file mode 100644 index 00000000..5a414448 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskStatus.kt @@ -0,0 +1,20 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.task + +enum class TaskStatus { + PENDING, + RUNNING, + SUCCESS, + FAILED, + PAUSED, + CONFLICT +} + +enum class TaskContentStatus { + PENDING, + SUCCESS, + FAILED, + CONFLICT, + SKIP, + REPLACE, + ASK +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BookmarksDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BookmarksDialog.kt deleted file mode 100644 index fcf56f95..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BookmarksDialog.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.kevinnzou.compose.swipebox.SwipeBox -import com.kevinnzou.compose.swipebox.SwipeDirection -import com.kevinnzou.compose.swipebox.widget.SwipeIcon -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments -import com.raival.compose.file.explorer.common.ui.BottomSheetDialog -import com.raival.compose.file.explorer.common.ui.Space -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder - -@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) -@Composable -fun BookmarksDialog(tab: FilesTab) { - if (tab.showBookmarkDialog) { - val context = LocalContext.current - BottomSheetDialog( - onDismissRequest = { tab.showBookmarkDialog = false } - ) { - Text( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - text = stringResource(R.string.bookmarks), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - Space(size = 8.dp) - LazyColumn(Modifier.animateContentSize()) { - itemsIndexed( - globalClass.filesTabManager.bookmarks - .map { DocumentHolder.fromFullPath(it) } - .takeWhile { it != null } as ArrayList, - key = { index, item -> item.path } - ) { index, item -> - SwipeBox( - modifier = Modifier.fillMaxWidth(), - swipeDirection = SwipeDirection.EndToStart, - endContentWidth = 60.dp, - endContent = { swipeableState, endSwipeProgress -> - SwipeIcon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.delete), - tint = Color.White, - background = Color(0xFFFA1E32), - weight = 1f, - iconSize = 20.dp - ) { - globalClass.filesTabManager.bookmarks -= item.path - } - } - ) { _, _, _ -> - Modifier - .fillMaxWidth() - Column( - Modifier - .animateItem() - .combinedClickable( - onClick = { - if (item.isFile) { - tab.openFile(context, item) - } else { - tab.requestNewTab(FilesTab(item)) - } - tab.showBookmarkDialog = false - }, - onLongClick = { } - ) - ) { - Space(size = 4.dp) - FileItemRow( - item = item, - fileDetails = item.path.trimToLastTwoSegments() - ) - Space(size = 4.dp) - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - thickness = 0.5.dp - ) - } - } - } - } - AnimatedVisibility(globalClass.filesTabManager.bookmarks.isEmpty()) { - Text( - modifier = Modifier - .padding(vertical = 60.dp) - .fillMaxWidth() - .alpha(0.26f), - text = stringResource(R.string.empty), - fontSize = 24.sp, - textAlign = TextAlign.Center - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBar.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBar.kt index 9aa59c2d..0b234831 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBar.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBar.kt @@ -1,7 +1,13 @@ package com.raival.compose.file.explorer.screen.main.tab.files.ui +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Sort @@ -12,14 +18,23 @@ import androidx.compose.material.icons.rounded.DeleteSweep import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.SelectAll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.common.ui.block import com.raival.compose.file.explorer.common.ui.detectVerticalSwipe import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.FileSortingMenuDialog @Composable fun BottomOptionsBar(tab: FilesTab) { @@ -40,7 +55,7 @@ fun BottomOptionsBar(tab: FilesTab) { BottomOptionsBarButton(Icons.Rounded.DeleteSweep, stringResource(R.string.empty)) { tab.unselectAllFiles(false) tab.activeFolderContent.forEach { - tab.selectedFiles[it.path] = it + tab.selectedFiles[it.uniquePath] = it } tab.quickReloadFiles() tab.showConfirmDeleteDialog = true @@ -55,20 +70,20 @@ fun BottomOptionsBar(tab: FilesTab) { tab.showSearchPenal = true } - if (!tab.showEmptyRecycleBin && tab.canCreateNewFile()) { + if (!tab.showEmptyRecycleBin && tab.canCreateNewContent) { BottomOptionsBarButton(Icons.Rounded.Add, stringResource(R.string.create)) { tab.showCreateNewFileDialog = true } } - if (tab.showMoreOptionsButton && tab.selectedFiles.isNotEmpty()) { + if (tab.showMoreOptionsButton && tab.selectedFiles.isNotEmpty()) { BottomOptionsBarButton(Icons.Rounded.SelectAll, stringResource(R.string.select_all)) { if (tab.selectedFiles.size == tab.activeFolderContent.size) { tab.unselectAllFiles() } else { tab.unselectAllFiles(false) tab.activeFolderContent.forEach { - tab.selectedFiles[it.path] = it + tab.selectedFiles[it.uniquePath] = it } tab.quickReloadFiles() } @@ -81,7 +96,7 @@ fun BottomOptionsBar(tab: FilesTab) { } else { BottomOptionsBarButton(Icons.AutoMirrored.Rounded.Sort, stringResource(R.string.sort), { if (tab.showSortingMenu) { - FileSortingMenu( + FileSortingMenuDialog( tab = tab, reloadFiles = { tab.reloadFiles() } ) { tab.showSortingMenu = false } @@ -95,4 +110,47 @@ fun BottomOptionsBar(tab: FilesTab) { } } } -} \ No newline at end of file +} + +@Composable +fun RowScope.BottomOptionsBarButton( + imageVector: ImageVector, + text: String, + view: @Composable () -> Unit = {}, + onClick: () -> Unit +) { + val preferencesManager = globalClass.preferencesManager + + Column( + modifier = Modifier + .weight(1f) + .clickable { + onClick() + } + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (!preferencesManager.appearancePrefs.showBottomBarLabels) { + Space(size = 4.dp) + } + + Icon( + modifier = Modifier.size(20.dp), + imageVector = imageVector, + contentDescription = null + ) + + Space(size = 4.dp) + + if (preferencesManager.appearancePrefs.showBottomBarLabels) { + Text( + text = text, + fontSize = 11.sp, + fontWeight = FontWeight.Medium + ) + } + + view() + } +} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBarButton.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBarButton.kt deleted file mode 100644 index 55fe69fa..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBarButton.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.common.ui.Space - -@Composable -fun RowScope.BottomOptionsBarButton( - imageVector: ImageVector, - text: String, - view: @Composable () -> Unit = {}, - onClick: () -> Unit -) { - val preferencesManager = globalClass.preferencesManager - - Column( - modifier = Modifier - .weight(1f) - .clickable { - onClick() - } - .padding(vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - if (!preferencesManager.displayPrefs.showBottomBarLabels) { - Space(size = 4.dp) - } - - Icon( - modifier = Modifier.size(20.dp), - imageVector = imageVector, - contentDescription = null - ) - - Space(size = 4.dp) - - if (preferencesManager.displayPrefs.showBottomBarLabels) { - Text( - text = text, - fontSize = 11.sp, - fontWeight = FontWeight.Medium - ) - } - - view() - } -} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/CreateNewFileDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/CreateNewFileDialog.kt deleted file mode 100644 index 2778187e..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/CreateNewFileDialog.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.isValidAsFileName -import com.raival.compose.file.explorer.common.ui.InputDialog -import com.raival.compose.file.explorer.common.ui.InputDialogButton -import com.raival.compose.file.explorer.common.ui.InputDialogInput -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab - -@Composable -fun CreateNewFileDialog(tab: FilesTab) { - if (tab.showCreateNewFileDialog) { - InputDialog( - title = stringResource(R.string.create_new), - inputs = arrayListOf( - InputDialogInput(stringResource(R.string.name)) { - if (!it.isValidAsFileName()) { - globalClass.getString(R.string.invalid_file_name) - } else { - null - } - } - ), - buttons = arrayListOf( - InputDialogButton(stringResource(R.string.file)) { inputs -> - val input = inputs[0] - if (input.content.isValidAsFileName()) { - val similarFile = tab.activeFolder.findFile(input.content) - if (similarFile == null) { - tab.activeFolder.createSubFile(input.content) - tab.showCreateNewFileDialog = false - - if (tab.activeFolder.findFile(input.content) == null) { - globalClass.showMsg(R.string.failed_to_create_file) - } else { - tab.onNewFileCreated(input.content) - } - } else { - globalClass.showMsg(R.string.similar_file_exists) - } - } else { - globalClass.showMsg(R.string.invalid_file_name) - } - }, - InputDialogButton(stringResource(R.string.folder)) { inputs -> - val input = inputs[0] - if (input.content.isValidAsFileName()) { - val similarFile = tab.activeFolder.findFile(input.content) - if (similarFile == null) { - tab.activeFolder.createSubFolder(input.content) - tab.showCreateNewFileDialog = false - - if (tab.activeFolder.findFile(input.content) == null) { - globalClass.showMsg(R.string.failed_to_create_folder) - } else { - tab.onNewFileCreated(input.content) - } - } else { - globalClass.showMsg(R.string.similar_file_exists) - } - } else { - globalClass.showMsg(R.string.invalid_folder_name) - } - } - ) - ) { - tab.showCreateNewFileDialog = false - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileCompressionDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileCompressionDialog.kt deleted file mode 100644 index 9e1b5f9e..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileCompressionDialog.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.isValidAsFileName -import com.raival.compose.file.explorer.common.ui.InputDialog -import com.raival.compose.file.explorer.common.ui.InputDialogButton -import com.raival.compose.file.explorer.common.ui.InputDialogInput -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@Composable -fun FileCompressionDialog(tab: FilesTab) { - if (tab.compressDialog.showCompressDialog) { - InputDialog( - title = stringResource(R.string.create_archive), - inputs = arrayListOf( - InputDialogInput( - stringResource(R.string.name), - "${tab.activeFolder.getName()}.zip" - ) { - if (!it.isValidAsFileName()) { - globalClass.getString(R.string.invalid_file_name) - } else { - null - } - } - ), - buttons = arrayListOf( - InputDialogButton(stringResource(R.string.create)) { inputs -> - val input = inputs[0] - if (input.content.isValidAsFileName()) { - val zip = tab.activeFolder.createSubFile(input.content) - ?: tab.activeFolder.findFile(input.content) - - if (zip != null) { - tab.showTasksPanel = false - tab.compressDialog.hide() - CoroutineScope(Dispatchers.IO).launch { - (tab.compressDialog.task)?.execute(zip, tab.taskCallback) - } - } else { - globalClass.showMsg(R.string.unable_to_create_file) - } - } else { - globalClass.showMsg(R.string.invalid_folder_name) - } - }, - InputDialogButton(stringResource(R.string.cancel)) { - tab.compressDialog.hide() - } - ) - ) { - tab.compressDialog.hide() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileIcon.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileIcon.kt deleted file mode 100644 index 5f10135e..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileIcon.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import android.annotation.SuppressLint -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Folder -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder -import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize -import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap - -@Composable -fun FileIcon( - documentHolder: DocumentHolder, - onClickListener: (() -> Unit)? = null -) { - val preferencesManager = globalClass.preferencesManager - - val iconSize = when (preferencesManager.displayPrefs.fileListSize) { - FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.IconSize.SMALL.dp - FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.IconSize.MEDIUM.dp - FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.IconSize.LARGE.dp - else -> FilesTabFileListSizeMap.IconSize.EXTRA_LARGE.dp - } - - if (documentHolder.isFile) { - ItemRowIcon( - icon = documentHolder, - alpha = if (documentHolder.isHidden) 0.4f else 1f, - onClickListener = onClickListener, - placeholder = documentHolder.getFileIconResource() - ) - } else { - Icon( - modifier = Modifier - .size(iconSize) - .clip(RoundedCornerShape(4.dp)) - .clickable { - onClickListener?.invoke() - } - .alpha(if (documentHolder.isHidden) 0.4f else 1f), - imageVector = Icons.Rounded.Folder, - contentDescription = null - ) - } -} - -@SuppressLint("CheckResult", "UseCompatLoadingForDrawables") -@Composable -fun ItemRowIcon( - icon: Any?, - alpha: Float = 1f, - onClickListener: (() -> Unit)? = null, - placeholder: Int, -) { - val preferencesManager = globalClass.preferencesManager - - val iconSize = when (preferencesManager.displayPrefs.fileListSize) { - FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.IconSize.SMALL.dp - FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.IconSize.MEDIUM.dp - FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.IconSize.LARGE.dp - else -> FilesTabFileListSizeMap.IconSize.EXTRA_LARGE.dp - } - - val modifier = Modifier - .size(iconSize) - .clip(RoundedCornerShape(4.dp)) - .then(if (onClickListener != null) Modifier.clickable { onClickListener() } else Modifier) - - AsyncImage( - modifier = modifier, - model = ImageRequest.Builder(globalClass).data(icon).build(), - filterQuality = FilterQuality.Low, - error = painterResource(id = placeholder), - contentScale = ContentScale.Fit, - alpha = alpha, - contentDescription = null - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileItemRow.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileItemRow.kt index 1a58127a..bbe97189 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileItemRow.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileItemRow.kt @@ -1,38 +1,51 @@ package com.raival.compose.file.explorer.screen.main.tab.files.ui +import android.annotation.SuppressLint import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.common.ui.Space -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize -import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap +import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListFontSize +import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListIconSize @Composable fun FileItemRow( - item: DocumentHolder, + item: ContentHolder, fileDetails: String, namePrefix: String = emptyString, onFileIconClick: (() -> Unit)? = null ) { ItemRow( - title = namePrefix + item.getName(), + title = namePrefix + item.displayName, subtitle = fileDetails, icon = { FileIcon( - documentHolder = item, + contentHolder = item, onClickListener = onFileIconClick ) } @@ -53,7 +66,7 @@ fun ItemRow( .fillMaxWidth() .then(if (onItemClick != null) Modifier.clickable { onItemClick() } else Modifier)) { Space( - size = when (preferencesManager.displayPrefs.fileListSize) { + size = when (preferencesManager.fileListPrefs.itemSize) { FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8.dp else -> 4.dp } @@ -70,12 +83,7 @@ fun ItemRow( Column( Modifier.weight(1f) ) { - val fontSize = when (preferencesManager.displayPrefs.fileListSize) { - FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.FontSize.SMALL - FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.FontSize.MEDIUM - FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.FontSize.LARGE - else -> FilesTabFileListSizeMap.FontSize.EXTRA_LARGE - } + val fontSize = getFileListFontSize() Text( text = title, @@ -96,12 +104,66 @@ fun ItemRow( } Space( - size = when (preferencesManager.displayPrefs.fileListSize) { + size = when (preferencesManager.fileListPrefs.itemSize) { FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8.dp else -> 4.dp } ) } +} + + +@Composable +fun FileIcon( + contentHolder: ContentHolder, + onClickListener: (() -> Unit)? = null +) { + val iconSize = getFileListIconSize() + + if (contentHolder.isFile()) { + ItemRowIcon( + icon = contentHolder, + alpha = if (contentHolder.isHidden()) 0.4f else 1f, + onClickListener = onClickListener, + placeholder = contentHolder.iconPlaceholder + ) + } else { + Icon( + modifier = Modifier + .size(iconSize.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable { + onClickListener?.invoke() + } + .alpha(if (contentHolder.isHidden()) 0.4f else 1f), + imageVector = Icons.Rounded.Folder, + contentDescription = null + ) + } +} +@SuppressLint("CheckResult", "UseCompatLoadingForDrawables") +@Composable +fun ItemRowIcon( + icon: Any?, + alpha: Float = 1f, + onClickListener: (() -> Unit)? = null, + placeholder: Int, +) { + val iconSize = getFileListIconSize() + val modifier = Modifier + .size(iconSize.dp) + .clip(RoundedCornerShape(4.dp)) + .then(if (onClickListener != null) Modifier.clickable { onClickListener() } else Modifier) + + AsyncImage( + modifier = modifier, + model = ImageRequest.Builder(globalClass).data(icon).build(), + filterQuality = FilterQuality.Low, + error = painterResource(id = placeholder), + contentScale = ContentScale.Fit, + alpha = alpha, + contentDescription = null + ) } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOption.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOption.kt deleted file mode 100644 index 8c340f99..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOption.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import com.raival.compose.file.explorer.common.ui.Space - -@Composable -fun FileOption( - icon: ImageVector, - text: String, - highlight: Color = Color.Unspecified, - onClick: () -> Unit -) { - Row( - Modifier - .fillMaxWidth() - .clickable { - onClick() - } - .padding(vertical = 16.dp, horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - modifier = Modifier.size(21.dp), - imageVector = icon, - tint = if (highlight == Color.Unspecified) MaterialTheme.colorScheme.onSurface else highlight, - contentDescription = null - ) - Space(size = 12.dp) - Text( - text = text, - color = if (highlight == Color.Unspecified) MaterialTheme.colorScheme.onSurface else highlight - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesDialog.kt deleted file mode 100644 index 6382e4fc..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesDialog.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.toFormattedDate -import com.raival.compose.file.explorer.common.extension.toFormattedSize -import com.raival.compose.file.explorer.common.ui.BottomSheetDialog -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@Composable -fun FilePropertiesDialog(tab: FilesTab) { - if (tab.showFileProperties) { - val targetFiles by remember { - mutableStateOf(tab.selectedFiles.map { it.value }.toList()) - } - - val totalFiles by remember { - mutableIntStateOf(targetFiles.size) - } - - val selectedFilesCount by remember { - mutableIntStateOf(targetFiles.count { it.isFile }) - } - - val selectedFoldersCount by remember { - mutableIntStateOf(targetFiles.count { it.isFolder }) - } - - var filesCount by remember { - mutableIntStateOf(0) - } - - var foldersCount by remember { - mutableIntStateOf(0) - } - - var emptyFoldersCount by remember { - mutableIntStateOf(0) - } - - var totalSize by remember { - mutableLongStateOf(0L) - } - - val countingThread = rememberCoroutineScope { Dispatchers.IO } - - val scrollState = rememberScrollState() - - LaunchedEffect(targetFiles) { - countingThread.launch { - targetFiles.forEach { - it.analyze( - onCountFile = { - filesCount++ - }, - onCountFolder = { isEmpty -> - foldersCount++ - if (isEmpty) emptyFoldersCount++ - }, - onCountSize = { size -> - totalSize += size - } - ) - } - } - } - - BottomSheetDialog( - onDismissRequest = { tab.showFileProperties = false } - ) { - Text( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - text = stringResource(R.string.properties), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - Column( - Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - ) { - if (totalFiles == 1) { - FilePropertiesRow( - title = stringResource(R.string.name).lowercase(), - value = targetFiles[0].getName(), - canCopy = true - ) - - FilePropertiesRow( - title = stringResource(R.string.path), - value = targetFiles[0].path, - canCopy = true - ) - - FilePropertiesRow( - title = stringResource(R.string.uri), - value = targetFiles[0].uri.toString(), - canCopy = true - ) - - FilePropertiesRow( - title = stringResource(R.string.modification_date), - value = targetFiles[0].lastModified.toFormattedDate(), - canCopy = true - ) - } - - FilePropertiesRow( - title = stringResource(R.string.parent), - value = tab.activeFolder.path, - canCopy = true - ) - - FilePropertiesRow( - title = stringResource(R.string.size), - value = totalSize.toFormattedSize(), - canCopy = true - ) - - FilePropertiesRow( - title = stringResource(R.string.selected), - value = stringResource( - id = R.string.selected_files_count, - totalFiles, - selectedFilesCount, - selectedFoldersCount - ) - ) - - FilePropertiesRow( - title = stringResource(R.string.content), - value = stringResource( - id = R.string.content_count, - filesCount + foldersCount, - filesCount, - foldersCount - ) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesRow.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesRow.kt deleted file mode 100644 index da20a5fb..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesRow.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.copyToClipboard -import com.raival.compose.file.explorer.common.ui.block - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun FilePropertiesRow( - title: String, - value: String, - canCopy: Boolean = false -) { - Column( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(8.dp) - ) { - Text( - modifier = Modifier.alpha(0.75f), - text = title, - fontSize = 14.sp, - ) - Text( - modifier = Modifier - .block(RoundedCornerShape(4.dp)) - .clip(RoundedCornerShape(4.dp)) - .combinedClickable( - onClick = {}, - onLongClick = { - if (canCopy) { - value.copyToClipboard() - globalClass.showMsg(R.string.copied_to_clipboard) - } - } - ) - .fillMaxWidth() - .padding(8.dp), - text = value, - fontSize = 14.sp - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesList.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesList.kt index 62dc237f..85ddd452 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesList.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesList.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -53,8 +54,9 @@ import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.ui.Isolate import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize -import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap +import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListFontSize +import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListIconSize +import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListSpace import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -63,275 +65,293 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ColumnScope.FilesList(tab: FilesTab) { - val context = LocalContext.current - val documentHolderSelectionHighlightColor = colorScheme.surfaceContainerHigh.copy(alpha = 1f) - val documentHolderHighlightColor = colorScheme.primary.copy(alpha = 0.05f) val preferencesManager = globalClass.preferencesManager val coroutineScope = rememberCoroutineScope() var isRefreshing by remember { mutableStateOf(false) } - val columnCount = preferencesManager.displayPrefs.fileListColumnCount - Box(Modifier.weight(1f)) { if (tab.activeFolderContent.isEmpty() && !tab.isLoading) { - Box( - Modifier - .fillMaxSize() - .background(colorScheme.surface.copy(alpha = 0.4f)), - contentAlignment = Alignment.Center + Column( + Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally ) { Text( modifier = Modifier .fillMaxWidth() .alpha(0.4f), - text = stringResource(R.string.empty), + text = stringResource( + when { + !tab.activeFolder.canRead -> R.string.cant_access_content + else -> R.string.empty + } + ), fontSize = 24.sp, textAlign = TextAlign.Center ) + if (tab.activeFolder.canRead && !preferencesManager.fileListPrefs.showHiddenFiles) { + Space(12.dp) + Text( + modifier = Modifier + .fillMaxWidth() + .alpha(0.4f), + text = stringResource(R.string.empty_without_hidden_files), + fontSize = 16.sp, + textAlign = TextAlign.Center + ) + } } } - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = { - isRefreshing = true - tab.openFolder(tab.activeFolder, true, true) - coroutineScope.launch { - delay(100) - isRefreshing = false - } - }, - modifier = Modifier.fillMaxSize() + if (preferencesManager.behaviorPrefs.disablePullDownToRefresh) { + FilesListGrid(tab) + } else { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + isRefreshing = true + tab.openFolder(tab.activeFolder, true, true) + coroutineScope.launch { + delay(100) + isRefreshing = false + } + }, + modifier = Modifier.fillMaxSize(), + ) { + FilesListGrid(tab) + } + } + + + androidx.compose.animation.AnimatedVisibility( + modifier = Modifier.fillMaxSize(), + visible = tab.isLoading ) { - LazyVerticalGrid( - columns = if (columnCount > 0) - GridCells.Fixed(columnCount) else GridCells.Adaptive(300.dp), - modifier = Modifier - .fillMaxSize(), - state = tab.activeListState + Box( + Modifier + .fillMaxSize() + .background(colorScheme.surface.copy(alpha = 0.4f)) + .clickable( + interactionSource = null, + indication = null, + onClick = { } + ), + contentAlignment = Alignment.Center ) { - itemsIndexed( - tab.activeFolderContent, - key = { index, item -> item.uid } - ) { index, item -> - val currentItemPath = item.path - val itemDetailsCoroutine = rememberCoroutineScope() - val isAlreadySelected = tab.selectedFiles.containsKey(currentItemPath) + CircularProgressIndicator() + } + } + } +} + +@Composable +fun FilesListGrid(tab: FilesTab) { + val context = LocalContext.current + val preferencesManager = globalClass.preferencesManager + val documentHolderSelectionHighlightColor = colorScheme.surfaceContainerHigh.copy(alpha = 1f) + val documentHolderHighlightColor = colorScheme.primary.copy(alpha = 0.05f) + val columnCount = preferencesManager.fileListPrefs.columnCount + + LazyVerticalGrid( + columns = if (columnCount > 0) + GridCells.Fixed(columnCount) else GridCells.Adaptive(300.dp), + modifier = Modifier + .fillMaxSize(), + state = tab.activeListState + ) { + itemsIndexed( + tab.activeFolderContent, + key = { index, item -> item.uid } + ) { index, item -> + val currentItemPath = item.uniquePath + val itemDetailsCoroutine = rememberCoroutineScope() + val isAlreadySelected = tab.selectedFiles.containsKey(currentItemPath) + + fun toggleSelection() { + if (tab.selectedFiles.containsKey(currentItemPath)) { + tab.selectedFiles.remove(currentItemPath) + tab.lastSelectedFileIndex = -1 + } else { + tab.selectedFiles[currentItemPath] = item + tab.lastSelectedFileIndex = index + } + } - fun toggleSelection() { - if (tab.selectedFiles.containsKey(currentItemPath)) { - tab.selectedFiles.remove(currentItemPath) - tab.lastSelectedFileIndex = -1 + Column( + Modifier + .fillMaxWidth() + .background( + color = if (isAlreadySelected) { + documentHolderSelectionHighlightColor + } else if (tab.highlightedFiles.contains(currentItemPath)) { + documentHolderHighlightColor } else { - tab.selectedFiles[currentItemPath] = item - tab.lastSelectedFileIndex = index + Color.Unspecified } - } - - Column( - Modifier - .fillMaxWidth() - .background( - color = if (isAlreadySelected) { - documentHolderSelectionHighlightColor - } else if (tab.highlightedFiles.contains(currentItemPath)) { - documentHolderHighlightColor + ) + .combinedClickable( + onClick = { + if (tab.selectedFiles.isNotEmpty()) { + toggleSelection() + tab.quickReloadFiles() + } else { + if (item.isFile()) { + tab.openFile(context, item) } else { - Color.Unspecified + tab.openFolder(item, false) } - ) - .combinedClickable( - onClick = { - if (tab.selectedFiles.isNotEmpty()) { - toggleSelection() - tab.quickReloadFiles() - } else { - if (item.isFile) { - tab.openFile(context, item) - } else { - tab.openFolder(item, false) - } - } - }, - onLongClick = { - val isFirstSelection = tab.selectedFiles.isEmpty() - val isNewSelection = !isAlreadySelected + } + }, + onLongClick = { + val isFirstSelection = tab.selectedFiles.isEmpty() + val isNewSelection = !isAlreadySelected - tab.selectedFiles[currentItemPath] = item + tab.selectedFiles[currentItemPath] = item - if ((isFirstSelection && preferencesManager.generalPrefs.showFileOptionMenuOnLongClick) || !isNewSelection) - tab.fileOptionsDialog.show(item) + if ((isFirstSelection && preferencesManager.behaviorPrefs.showFileOptionMenuOnLongClick) + || !isNewSelection + ) { + tab.fileOptionsDialog.show(item) + } - if (isNewSelection) { - if (tab.lastSelectedFileIndex >= 0) { - if (tab.lastSelectedFileIndex > index) { - for (i in tab.lastSelectedFileIndex downTo index) { - tab.selectedFiles[tab.activeFolderContent[i].path] = - tab.activeFolderContent[i] - } - } else { - for (i in tab.lastSelectedFileIndex..index) { - tab.selectedFiles[tab.activeFolderContent[i].path] = - tab.activeFolderContent[i] - } - } + if (isNewSelection) { + if (tab.lastSelectedFileIndex >= 0) { + if (tab.lastSelectedFileIndex > index) { + for (i in tab.lastSelectedFileIndex downTo index) { + tab.selectedFiles[tab.activeFolderContent[i].uniquePath] = + tab.activeFolderContent[i] + } + } else { + for (i in tab.lastSelectedFileIndex..index) { + tab.selectedFiles[tab.activeFolderContent[i].uniquePath] = + tab.activeFolderContent[i] } } - tab.lastSelectedFileIndex = index - - tab.quickReloadFiles() } - ) - ) { - Space( - size = when (preferencesManager.displayPrefs.fileListSize) { - FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8.dp - else -> 4.dp } - ) + tab.lastSelectedFileIndex = index + tab.quickReloadFiles() + } + ) + ) { + Space(size = getFileListSpace().dp) - Row( - Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Isolate { + val iconSize = getFileListIconSize() + + Box( + modifier = Modifier.size(iconSize.dp), ) { - Isolate { - val iconSize = - when (preferencesManager.displayPrefs.fileListSize) { - FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.IconSize.SMALL.dp - FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.IconSize.MEDIUM.dp - FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.IconSize.LARGE.dp - else -> FilesTabFileListSizeMap.IconSize.EXTRA_LARGE.dp - } + if (item.isFile()) { + AsyncImage( + modifier = Modifier + .size(iconSize.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable { + toggleSelection() + tab.quickReloadFiles() + }, + model = ImageRequest.Builder(globalClass).data(item) + .build(), + filterQuality = FilterQuality.Low, + error = painterResource(id = item.iconPlaceholder), + contentScale = ContentScale.Fit, + alpha = if (item.isHidden()) 0.4f else 1f, + contentDescription = null + ) + } else { + Icon( + modifier = Modifier + .size(iconSize.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable { + toggleSelection() + tab.quickReloadFiles() + } + .alpha(if (item.isHidden()) 0.4f else 1f), + imageVector = Icons.Rounded.Folder, + contentDescription = null + ) + } - if (item.isFile) { - AsyncImage( - modifier = Modifier - .size(iconSize) - .clip(RoundedCornerShape(4.dp)) - .clickable { - toggleSelection() - tab.quickReloadFiles() - }, - model = ImageRequest.Builder(globalClass).data(item) - .build(), - filterQuality = FilterQuality.Low, - error = painterResource(id = item.getFileIconResource()), - contentScale = ContentScale.Fit, - alpha = if (item.isHidden) 0.4f else 1f, - contentDescription = null - ) - } else { - Icon( - modifier = Modifier - .size(iconSize) - .clip(RoundedCornerShape(4.dp)) - .clickable { - toggleSelection() - tab.quickReloadFiles() - } - .alpha(if (item.isHidden) 0.4f else 1f), - imageVector = Icons.Rounded.Folder, - contentDescription = null - ) - } + if (!item.canRead) { + Icon( + modifier = Modifier + .size(18.dp) + .align(Alignment.Center) + .alpha(if (item.isHidden()) 0.4f else 1f), + imageVector = Icons.Rounded.Lock, + tint = Color.Red, + contentDescription = null + ) } + } + } - Space(size = 8.dp) + Space(size = 8.dp) - Column(Modifier.weight(1f)) { - val fontSize = - when (preferencesManager.displayPrefs.fileListSize) { - FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.FontSize.SMALL - FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.FontSize.MEDIUM - FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.FontSize.LARGE - else -> FilesTabFileListSizeMap.FontSize.EXTRA_LARGE - } + Column(Modifier.weight(1f)) { + val fontSize = getFileListFontSize() - Text( - text = item.getName(), - fontSize = fontSize.sp, - maxLines = 1, - lineHeight = (fontSize + 2).sp, - overflow = TextOverflow.Ellipsis, - color = if (tab.highlightedFiles.contains(currentItemPath)) { - colorScheme.primary - } else { - Color.Unspecified - } - ) - Isolate { - var details by remember( - key1 = currentItemPath, - key2 = item.lastModified - ) { mutableStateOf(item.formattedDetailsCache) } + Text( + text = item.displayName, + fontSize = fontSize.sp, + maxLines = 1, + lineHeight = (fontSize + 2).sp, + overflow = TextOverflow.Ellipsis, + color = if (tab.highlightedFiles.contains(currentItemPath)) { + colorScheme.primary + } else { + Color.Unspecified + } + ) + Isolate { + var details by remember( + key1 = currentItemPath, + key2 = item.lastModified + ) { mutableStateOf(item.details) } - LaunchedEffect( - key1 = currentItemPath, - key2 = item.lastModified - ) { - if (details.isEmpty()) { - itemDetailsCoroutine.launch(Dispatchers.IO) { - val det = item.getFormattedDetails( - true, - preferencesManager.displayPrefs.showFolderContentCount - ) - withContext(Dispatchers.Main) { details = det } - } - } + LaunchedEffect( + key1 = currentItemPath, + key2 = item.lastModified + ) { + if (details.isEmpty()) { + itemDetailsCoroutine.launch(Dispatchers.IO) { + val det = item.details + withContext(Dispatchers.Main) { details = det } } + } + } - Text( - modifier = Modifier.alpha(0.7f), - text = details, - fontSize = (fontSize - 4).sp, - maxLines = 1, - lineHeight = (fontSize + 2).sp, - overflow = TextOverflow.Ellipsis, - color = if (tab.highlightedFiles.contains( - currentItemPath - ) - ) { - colorScheme.primary - } else { - Color.Unspecified - } + Text( + modifier = Modifier.alpha(0.7f), + text = details, + fontSize = (fontSize - 4).sp, + maxLines = 1, + lineHeight = (fontSize + 2).sp, + overflow = TextOverflow.Ellipsis, + color = if (tab.highlightedFiles.contains( + currentItemPath ) + ) { + colorScheme.primary + } else { + Color.Unspecified } - } + ) } - - Space( - size = when (preferencesManager.displayPrefs.fileListSize) { - FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8.dp - else -> 4.dp - } - ) - - HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), - thickness = 0.5.dp - ) } } - } - } - androidx.compose.animation.AnimatedVisibility( - modifier = Modifier.fillMaxSize(), - visible = tab.isLoading - ) { - Box( - Modifier - .fillMaxSize() - .background(colorScheme.surface.copy(alpha = 0.4f)) - .clickable( - interactionSource = null, - indication = null, - onClick = { } - ), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() + Space(size = getFileListSpace().dp) + + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + thickness = 0.5.dp + ) } } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesTabContentView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesTabContentView.kt index 379b2352..349f3a72 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesTabContentView.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesTabContentView.kt @@ -6,6 +6,19 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.ApkPreviewDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.BookmarksDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.CreateNewFileDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.DeleteConfirmationDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.FileCompressionDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.FileOptionsMenuDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.FilePropertiesDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.OpenWithAppListDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.RenameDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.SearchDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.TaskConflictDialog +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.TaskPanel +import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.TaskRunningDialog @Composable fun ColumnScope.FilesTabContentView(tab: FilesTab) { @@ -14,14 +27,15 @@ fun ColumnScope.FilesTabContentView(tab: FilesTab) { BookmarksDialog(tab) SearchDialog(tab) TaskPanel(tab) - TaskDialog(tab) - RenameFileDialog(tab) DeleteConfirmationDialog(tab) CreateNewFileDialog(tab) + RenameDialog(tab) FileCompressionDialog(tab) FileOptionsMenuDialog(tab) FilePropertiesDialog(tab) - PathListRow(tab) + TaskRunningDialog() + TaskConflictDialog() + PathHistoryRow(tab) HorizontalDivider(modifier = Modifier, thickness = 1.dp) FilesList(tab) BottomOptionsBar(tab) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathListRow.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathHistoryRow.kt similarity index 90% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathListRow.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathHistoryRow.kt index d825f0b8..8fce181f 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathListRow.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathHistoryRow.kt @@ -33,10 +33,11 @@ import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.orIf import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab import kotlinx.coroutines.launch +import java.io.File @OptIn(ExperimentalFoundationApi::class) @Composable -fun PathListRow(tab: FilesTab) { +fun PathHistoryRow(tab: FilesTab) { val highlightedPathListItemColor = MaterialTheme.colorScheme.primary Row( @@ -60,7 +61,7 @@ fun PathListRow(tab: FilesTab) { Modifier.weight(1f), tab.currentPathSegmentsListState, ) { - itemsIndexed(tab.currentPathSegments, key = { _, it -> it.path }) { index, item -> + itemsIndexed(tab.currentPathSegments, key = { _, it -> it.uniquePath }) { index, item -> val isHighlighted = index == tab.currentPathSegments.size - 1 Row( @@ -85,9 +86,12 @@ fun PathListRow(tab: FilesTab) { ) .padding(8.dp) .alpha(0.8f), - text = item.getName() + text = item.displayName .orIf(stringResource(id = R.string.internal_storage)) { - item.path == Environment.getExternalStorageDirectory().absolutePath + item.uniquePath == Environment.getExternalStorageDirectory().absolutePath + } + .orIf(stringResource(id = R.string.root)) { + item.uniquePath == File.separator }, fontSize = 14.sp, fontWeight = if (isHighlighted) FontWeight.Medium else FontWeight.Normal, diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/RenameFileDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/RenameFileDialog.kt deleted file mode 100644 index 736c4a68..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/RenameFileDialog.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.isValidAsFileName -import com.raival.compose.file.explorer.common.ui.InputDialog -import com.raival.compose.file.explorer.common.ui.InputDialogButton -import com.raival.compose.file.explorer.common.ui.InputDialogInput -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab - -@Composable -fun RenameFileDialog(tab: FilesTab) { - if (tab.renameDialog.showRenameFileDialog && tab.selectedFiles.isNotEmpty() && tab.renameDialog.targetFile != null) { - val targetFile = tab.renameDialog.targetFile!! - - InputDialog( - title = stringResource(R.string.rename), - inputs = arrayListOf( - InputDialogInput( - label = stringResource(R.string.name), - content = targetFile.getName() - ) { - if (!it.isValidAsFileName()) { - globalClass.getString(R.string.invalid_file_name) - } else { - null - } - } - ), - buttons = arrayListOf( - InputDialogButton(globalClass.getString(R.string.rename)) { inputs -> - val input = inputs[0] - if (input.content.isValidAsFileName()) { - val similarFile = tab.activeFolder.findFile(input.content) - if (similarFile == null) { - targetFile.renameTo(input.content) - with(tab) { - highlightedFiles.apply { - clear() - add(targetFile.path) - } - unselectAllFiles(false) - reloadFiles() - renameDialog.hide() - } - } else { - globalClass.showMsg(R.string.similar_file_exists) - } - } else { - globalClass.showMsg(R.string.invalid_file_name) - } - }, - InputDialogButton(stringResource(R.string.cancel)) { - tab.renameDialog.hide() - } - ) - ) { - tab.renameDialog.hide() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskPanel.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskPanel.kt deleted file mode 100644 index 37f2aee5..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskPanel.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.kevinnzou.compose.swipebox.SwipeBox -import com.kevinnzou.compose.swipebox.SwipeDirection -import com.kevinnzou.compose.swipebox.widget.SwipeIcon -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.ui.BottomSheetDialog -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTask -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) -@Composable -fun TaskPanel(tab: FilesTab) { - if (tab.showTasksPanel) { - BottomSheetDialog( - onDismissRequest = { tab.showTasksPanel = false } - ) { - Text( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - text = stringResource(R.string.tasks), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - val coroutineScope = rememberCoroutineScope() - LazyColumn(Modifier.animateContentSize()) { - items(globalClass.filesTabManager.filesTabTasks, key = { it.id }) { task -> - SwipeBox( - modifier = Modifier.fillMaxWidth(), - swipeDirection = SwipeDirection.EndToStart, - endContentWidth = 60.dp, - endContent = { swipeableState, endSwipeProgress -> - SwipeIcon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.delete), - tint = Color.White, - background = Color(0xFFFA1E32), - weight = 1f, - iconSize = 20.dp - ) { - globalClass.filesTabManager.filesTabTasks.removeIf { it.id == task.id } - coroutineScope.launch { - swipeableState.animateTo(0) - } - } - } - ) { _, _, _ -> - Row( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (!tab.canRunTasks()) { - globalClass.showMsg(globalClass.getString(R.string.can_not_run_tasks)) - return@combinedClickable - } - - if (!task.isValidSourceFiles()) { - globalClass.showMsg(R.string.invalid_task) - return@combinedClickable - } - - var copyToExistingZipFile = false - - if (task is CompressTask) { - if (tab.selectedFiles.size == 1) { - val selectedFile = tab.selectedFiles.values.first() - if (selectedFile.isArchive) { - CoroutineScope(Dispatchers.IO).launch { - task.execute( - selectedFile, - tab.taskCallback - ) - } - copyToExistingZipFile = true - } - } - if (!copyToExistingZipFile) { - tab.compressDialog.show(task) - } - } else { - CoroutineScope(Dispatchers.IO).launch { - task.execute( - tab.activeFolder, - tab.taskCallback - ) - } - } - } - ) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - modifier = Modifier.padding(12.dp), - imageVector = task.getIcon(), - contentDescription = null - ) - - Column( - modifier = Modifier.padding(vertical = 12.dp) - ) { - Text( - text = task.getTitle(), - fontSize = 14.sp - ) - Text( - modifier = Modifier.alpha(0.7f), - text = task.getSubtitle(), - fontSize = 12.sp - ) - } - } - } - } - } - - AnimatedVisibility(visible = globalClass.filesTabManager.filesTabTasks.isEmpty()) { - Text( - modifier = Modifier - .padding(vertical = 60.dp) - .fillMaxWidth() - .alpha(0.4f), - text = stringResource(R.string.empty), - fontSize = 24.sp, - textAlign = TextAlign.Center - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskProgressDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskProgressDialog.kt deleted file mode 100644 index 40538aa3..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskProgressDialog.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.raival.compose.file.explorer.common.ui.Space -import com.raival.compose.file.explorer.common.ui.block -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab - -@Composable -fun TaskDialog(tab: FilesTab) { - if (tab.taskDialog.showTaskDialog) { - val taskDialog = tab.taskDialog - Dialog( - onDismissRequest = { tab.taskDialog.showTaskDialog = false }, - properties = DialogProperties( - dismissOnBackPress = false, - dismissOnClickOutside = false - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .block() - .padding(16.dp) - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = taskDialog.taskDialogTitle, - fontSize = 18.sp, - textAlign = TextAlign.Center - ) - - Space(size = 12.dp) - - Text( - modifier = Modifier - .alpha(0.75f) - .fillMaxWidth(), - text = taskDialog.taskDialogSubtitle, - fontSize = 16.sp - ) - - if (taskDialog.showTaskDialogProgressbar) { - Space(size = 8.dp) - if (taskDialog.taskDialogProgress < 0) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth() - ) - } else { - LinearProgressIndicator( - progress = { - taskDialog.taskDialogProgress - }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - Space(size = 4.dp) - - Text( - modifier = Modifier - .alpha(0.5f) - .fillMaxWidth(), - text = taskDialog.taskDialogInfo, - fontSize = 10.sp - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/ApkPreviewDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/ApkPreviewDialog.kt similarity index 55% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/ApkPreviewDialog.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/ApkPreviewDialog.kt index 2987971d..63e00728 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/ApkPreviewDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/ApkPreviewDialog.kt @@ -1,4 +1,4 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog import android.graphics.drawable.Drawable import android.os.Build @@ -39,17 +39,10 @@ import com.raival.compose.file.explorer.common.extension.toFormattedSize import com.raival.compose.file.explorer.common.ui.BottomSheetDialog import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.misc.MergeHandler -import com.raival.compose.file.explorer.screen.preferences.PreferencesManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @Composable fun ApkPreviewDialog(tab: FilesTab) { val apkDialog = tab.apkDialog - val isApksArchive: Boolean = apkDialog.ApksArchive if (tab.apkDialog.showApkDialog && apkDialog.apkFile != null) { val context = LocalContext.current @@ -59,41 +52,38 @@ fun ApkPreviewDialog(tab: FilesTab) { val details = remember { mutableStateListOf>() } var appName by remember { mutableStateOf(emptyString) } - val doSign = PreferencesManager.GeneralPrefs.signApk + val packageManager = globalClass.packageManager + val apkInfo = + remember { mutableStateOf(packageManager.getPackageArchiveInfo(apkFile.uniquePath, 0)) } - if (!isApksArchive) { - val packageManager = globalClass.packageManager - val apkInfo = - remember { mutableStateOf(packageManager.getPackageArchiveInfo(apkFile.path, 0)) } + LaunchedEffect(Unit) { + apkInfo.value?.let { info -> + info.applicationInfo?.sourceDir = apkFile.uniquePath + info.applicationInfo?.publicSourceDir = apkFile.uniquePath - LaunchedEffect(Unit) { - apkInfo.value?.let { info -> - info.applicationInfo?.sourceDir = apkFile.path - info.applicationInfo?.publicSourceDir = apkFile.path + icon = info.applicationInfo?.loadIcon(packageManager) + appName = info.applicationInfo?.loadLabel(packageManager).toString() - icon = info.applicationInfo?.loadIcon(packageManager) - appName = info.applicationInfo?.loadLabel(packageManager).toString() - - details.add( - Pair( - globalClass.getString(R.string.package_name), - info.packageName - ) - ) - info.versionName?.let { - details.add(Pair(globalClass.getString(R.string.version_name), it)) - } - details.add( - Pair( - globalClass.getString(R.string.version_code), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode.toString() else info.versionCode.toString() - ) + details.add( + Pair( + globalClass.getString(R.string.package_name), + info.packageName ) + ) + info.versionName?.let { + details.add(Pair(globalClass.getString(R.string.version_name), it)) } + details.add( + Pair( + globalClass.getString(R.string.version_code), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode.toString() else info.versionCode.toString() + ) + ) } } + details.add( - Pair(globalClass.getString(R.string.size), apkFile.fileSize.toFormattedSize()) + Pair(globalClass.getString(R.string.size), apkFile.size.toFormattedSize()) ) BottomSheetDialog(onDismissRequest = { apkDialog.hide() }) { @@ -148,56 +138,26 @@ fun ApkPreviewDialog(tab: FilesTab) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = { apkDialog.hide() - apkFile.openFile( + apkFile.open( context = context, anonymous = false, - skipSupportedExtensions = true, + skipSupportedExtensions = false, customMimeType = "application/zip" ) }) { Text(text = stringResource(R.string.explore)) } - if (isApksArchive) { - TextButton(onClick = { - apkDialog.hide() - val mergeHandler = MergeHandler(context) - mergeHandler.mergeApks( - tab, apkFile, doSign, - onSuccess = { - CoroutineScope(Dispatchers.Main).launch { - tab.taskDialog.taskDialogInfo = - context.getString(R.string.merge_successful) - tab.taskDialog.taskDialogSubtitle = - context.getString(R.string.merge_completed) - tab.taskDialog.taskDialogProgress = 1f - delay(500) - tab.taskDialog.showTaskDialog = false - tab.reloadFiles() - } - }, - onError = { errorMessage -> - CoroutineScope(Dispatchers.Main).launch { - tab.taskDialog.taskDialogInfo = errorMessage - tab.taskDialog.taskDialogSubtitle = - context.getString(R.string.failed) - } - } - ) - }) { - Text(text = stringResource(R.string.merge)) - } - } else { - TextButton(onClick = { - apkDialog.hide() - apkFile.openFile( - context, - anonymous = false, - skipSupportedExtensions = true - ) - }) { - Text(text = stringResource(R.string.install)) - } + TextButton(onClick = { + apkDialog.hide() + apkFile.open( + context, + anonymous = false, + skipSupportedExtensions = true, + customMimeType = null + ) + }) { + Text(text = stringResource(R.string.install)) } } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/BookmarksDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/BookmarksDialog.kt new file mode 100644 index 00000000..36623172 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/BookmarksDialog.kt @@ -0,0 +1,184 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.BookmarkBorder +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.kevinnzou.compose.swipebox.SwipeBox +import com.kevinnzou.compose.swipebox.SwipeDirection +import com.kevinnzou.compose.swipebox.widget.SwipeIcon +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments +import com.raival.compose.file.explorer.common.ui.BottomSheetDialog +import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.ui.FileItemRow +import java.io.File + +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +fun BookmarksDialog(tab: FilesTab) { + if (tab.showBookmarkDialog) { + val context = LocalContext.current + val bookmarks = remember { + mutableStateListOf() + } + + LaunchedEffect(Unit) { + val originalList = globalClass.filesTabManager.bookmarks + bookmarks.addAll( + originalList.map { LocalFileHolder(File(it)) }.filter { it.isValid() } + ) + + if (bookmarks.size != originalList.size) { + globalClass.filesTabManager.bookmarks = bookmarks.map { it.uniquePath }.toSet() + } + } + + BottomSheetDialog( + onDismissRequest = { tab.showBookmarkDialog = false } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Bookmark, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Space(8.dp) + Text( + text = stringResource(R.string.bookmarks), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + } + + Space(size = 8.dp) + + AnimatedVisibility(globalClass.filesTabManager.bookmarks.isNotEmpty()) { + LazyColumn(Modifier.animateContentSize()) { + itemsIndexed( + items = bookmarks, + key = { index, item -> item.uniquePath } + ) { index, item -> + SwipeBox( + modifier = Modifier.fillMaxWidth(), + swipeDirection = SwipeDirection.EndToStart, + endContentWidth = 60.dp, + endContent = { swipeableState, endSwipeProgress -> + SwipeIcon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.delete), + tint = Color.White, + background = Color(0xFFFA1E32), + weight = 1f, + iconSize = 20.dp + ) { + globalClass.filesTabManager.bookmarks -= item.uniquePath + } + } + ) { _, _, _ -> + Modifier + .fillMaxWidth() + Column( + Modifier + .animateItem() + .combinedClickable( + onClick = { + if (item.isFile()) { + tab.openFile(context, item) + } else { + tab.requestNewTab(FilesTab(item)) + } + tab.showBookmarkDialog = false + }, + onLongClick = { } + ) + ) { + Space(size = 4.dp) + FileItemRow( + item = item, + fileDetails = item.uniquePath.trimToLastTwoSegments() + ) + Space(size = 4.dp) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + thickness = 0.5.dp + ) + } + } + } + } + } + + AnimatedVisibility(globalClass.filesTabManager.bookmarks.isEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 64.dp, horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Rounded.BookmarkBorder, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(48.dp) + ) + Space(16.dp) + Text( + text = stringResource(R.string.empty), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Space(8.dp) + Text( + text = stringResource(R.string.no_bookmarks_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + textAlign = TextAlign.Center + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/CreateNewFileDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/CreateNewFileDialog.kt new file mode 100644 index 00000000..f47d67f5 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/CreateNewFileDialog.kt @@ -0,0 +1,201 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.isValidAsFileName +import com.raival.compose.file.explorer.common.ui.CheckableText +import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun CreateNewFileDialog(tab: FilesTab) { + if (tab.showCreateNewFileDialog) { + var isOpenFileDirectly by remember { mutableStateOf(false) } + val listContent by remember(tab.activeFolderContent) { + mutableStateOf(tab.activeFolderContent.map { it.displayName }.toTypedArray()) + } + var newNameInput by remember { mutableStateOf("") } + var error by remember { mutableStateOf("") } + + LaunchedEffect(newNameInput) { + error = if (newNameInput.isBlank()) { + emptyString + } else if (!newNameInput.isValidAsFileName()) { + globalClass.getString(R.string.invalid_file_name) + } else if (listContent.contains(newNameInput)) { + globalClass.getString(R.string.similar_file_exists) + } else { + emptyString + } + } + + Dialog( + onDismissRequest = { tab.showCreateNewFileDialog = false }, + ) { + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.create_new), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + TextField( + modifier = Modifier.fillMaxWidth(), + value = newNameInput, + onValueChange = { + newNameInput = it + }, + label = { Text(text = stringResource(R.string.name)) }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + isError = error.isNotEmpty(), + supportingText = if (error.isNotEmpty()) { + { Text(error) } + } else null + ) + + CheckableText( + checked = isOpenFileDirectly, + onCheckedChange = { isOpenFileDirectly = it }, + ) { + Text(stringResource(R.string.open_created_folder)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + modifier = Modifier.weight(1f), + onClick = { + if (newNameInput.isValidAsFileName()) { + val similarFile = tab.activeFolder.findFile(newNameInput) + if (similarFile == null) { + tab.showCreateNewFileDialog = false + tab.isLoading = true + CoroutineScope(Dispatchers.IO).launch { + tab.activeFolder.createSubFile(newNameInput) { newFile -> + tab.isLoading = false + if (newFile == null) { + globalClass.showMsg(R.string.failed_to_create_file) + } else { + tab.onNewFileCreated(newFile) + } + } + } + } else { + globalClass.showMsg(R.string.similar_file_exists) + } + } else { + globalClass.showMsg(R.string.invalid_file_name) + } + }, + enabled = error.isEmpty() && newNameInput.isNotBlank(), + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.file), + style = MaterialTheme.typography.labelLarge + ) + } + + Button( + modifier = Modifier.weight(1f), + onClick = { + if (newNameInput.isValidAsFileName()) { + val similarFile = tab.activeFolder.findFile(newNameInput) + if (similarFile == null) { + tab.showCreateNewFileDialog = false + tab.isLoading = true + CoroutineScope(Dispatchers.IO).launch { + tab.activeFolder.createSubFolder(newNameInput) { newFile -> + tab.isLoading = false + if (newFile == null) { + globalClass.showMsg(R.string.failed_to_create_folder) + } else { + tab.onNewFileCreated( + newFile, + isOpenFileDirectly + ) + } + } + } + } else { + globalClass.showMsg(R.string.similar_file_exists) + } + } else { + globalClass.showMsg(R.string.invalid_folder_name) + } + }, + enabled = error.isEmpty() && newNameInput.isNotBlank(), + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.folder), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DeleteConfirmationDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/DeleteConfirmationDialog.kt similarity index 64% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DeleteConfirmationDialog.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/DeleteConfirmationDialog.kt index 75b731e1..e58e47a7 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DeleteConfirmationDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/DeleteConfirmationDialog.kt @@ -1,4 +1,4 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -19,13 +19,21 @@ import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.ui.CheckableText import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTask +import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTaskParameters +import com.raival.compose.file.explorer.screen.main.tab.files.task.DeleteTask +import com.raival.compose.file.explorer.screen.main.tab.files.task.DeleteTaskParameters +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun DeleteConfirmationDialog(tab: FilesTab) { if (tab.showConfirmDeleteDialog) { val preferencesManager = globalClass.preferencesManager + var moveToRecycleBin by remember { - mutableStateOf(preferencesManager.generalPrefs.moveToRecycleBin) + mutableStateOf(preferencesManager.fileOperationPrefs.moveToRecycleBin) } var showRememberChoice by remember { mutableStateOf(false) @@ -34,7 +42,7 @@ fun DeleteConfirmationDialog(tab: FilesTab) { mutableStateOf(false) } - val targetFiles by remember(tab.id, tab.activeFolder.path) { + val targetFiles by remember(tab.id, tab.activeFolder.uniquePath) { mutableStateOf(tab.selectedFiles.map { it.value }.toList()) } @@ -47,12 +55,38 @@ fun DeleteConfirmationDialog(tab: FilesTab) { confirmButton = { TextButton( onClick = { + val coroutineScope = CoroutineScope(Dispatchers.IO) onDismissRequest() tab.unselectAllFiles() if (showRememberChoice && rememberChoice) { - preferencesManager.generalPrefs.moveToRecycleBin = moveToRecycleBin + preferencesManager.fileOperationPrefs.moveToRecycleBin = + moveToRecycleBin + } + coroutineScope.launch { + if (!tab.showEmptyRecycleBin && moveToRecycleBin) { + globalClass.recycleBinDir.createSubFolder( + System.currentTimeMillis().toString() + ) { newDir -> + if (newDir != null) { + coroutineScope.launch { + globalClass.taskManager.addTaskAndRun( + CopyTask(targetFiles, true), + CopyTaskParameters( + newDir + ) + ) + } + } else { + globalClass.showMsg(globalClass.getString(R.string.unable_to_move_to_recycle_bin)) + } + } + } else { + globalClass.taskManager.addTaskAndRun( + DeleteTask(targetFiles), + DeleteTaskParameters() + ) + } } - tab.deleteFiles(targetFiles, tab.taskCallback, moveToRecycleBin) } ) { Text(stringResource(R.string.confirm)) } }, diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileCompressionDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileCompressionDialog.kt new file mode 100644 index 00000000..939c6ba5 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileCompressionDialog.kt @@ -0,0 +1,171 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.isValidAsFileName +import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTaskParameters +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.launch +import java.io.File + +@Composable +fun FileCompressionDialog(tab: FilesTab) { + if (tab.newZipFileDialog.show) { + val listContent by remember(tab.activeFolderContent) { + mutableStateOf(tab.activeFolderContent.map { it.displayName }.toTypedArray()) + } + + var newNameInput by remember { mutableStateOf("${tab.activeFolder.displayName}.zip") } + var error by remember { mutableStateOf("") } + + LaunchedEffect(newNameInput) { + error = if (newNameInput.isBlank()) { + emptyString + } else if (!newNameInput.isValidAsFileName()) { + globalClass.getString(R.string.invalid_file_name) + } else if (listContent.contains(newNameInput)) { + globalClass.getString(R.string.similar_file_exists) + } else { + emptyString + } + } + + Dialog( + onDismissRequest = { tab.newZipFileDialog.hide() }, + ) { + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.create_archive), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + TextField( + modifier = Modifier.fillMaxWidth(), + value = newNameInput, + onValueChange = { + newNameInput = it + }, + label = { Text(text = stringResource(R.string.name)) }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + isError = error.isNotEmpty(), + supportingText = if (error.isNotEmpty()) { + { Text(error) } + } else null + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + tab.newZipFileDialog.hide() + }, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.labelLarge + ) + } + + Button( + modifier = Modifier.weight(1f), + onClick = { + if (newNameInput.isValidAsFileName() && !listContent.contains( + newNameInput + ) + ) { + tab.newZipFileDialog.hide() + CoroutineScope(IO).launch { + globalClass.taskManager.runTask( + tab.newZipFileDialog.task!!.id, + CompressTaskParameters( + File( + (tab.activeFolder as LocalFileHolder).file, + newNameInput + ).absolutePath + ) + ) + } + } else { + globalClass.showMsg(R.string.invalid_file_name) + } + }, + enabled = error.isEmpty() && newNameInput.isNotBlank(), + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.create), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOptionsMenuDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt similarity index 50% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOptionsMenuDialog.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt index 356664c6..dcb30518 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOptionsMenuDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt @@ -1,8 +1,11 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ExitToApp import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.BookmarkAdd import androidx.compose.material.icons.rounded.Compress @@ -13,16 +16,21 @@ import androidx.compose.material.icons.rounded.FileCopy import androidx.compose.material.icons.rounded.FormatColorText import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Merge import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -32,31 +40,37 @@ import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.ui.BottomSheetDialog import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.misc.UpdateAction -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkBundleFileType +import com.raival.compose.file.explorer.screen.main.tab.files.task.ApksMergeTask +import com.raival.compose.file.explorer.screen.main.tab.files.task.ApksMergeTaskParameters import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTask import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTask -import com.raival.compose.file.explorer.screen.main.tab.files.task.DecompressTask -import com.raival.compose.file.explorer.screen.main.tab.files.task.MoveTask +import com.raival.compose.file.explorer.screen.main.tab.files.ui.FileIcon +import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun FileOptionsMenuDialog(tab: FilesTab) { if (tab.fileOptionsDialog.showFileOptionsDialog && tab.fileOptionsDialog.targetFile != null - && tab.id == globalClass.mainActivityManager.tabs[globalClass.mainActivityManager.selectedTabIndex].id ) { - val context = LocalContext.current val targetFiles by remember { mutableStateOf(tab.selectedFiles.map { it.value }.toList()) } - val targetDocumentHolder = tab.fileOptionsDialog.targetFile!! + + val targetContentHolder = tab.fileOptionsDialog.targetFile!! val selectedFilesCount = targetFiles.size val isMultipleSelection = selectedFilesCount > 1 - val isSingleFile = !isMultipleSelection && targetDocumentHolder.isFile - val isSingleFolder = !isMultipleSelection && targetDocumentHolder.isFolder + val isSingleFile = !isMultipleSelection && targetContentHolder.isFile() + val isSingleFolder = !isMultipleSelection && targetContentHolder.isFolder var hasFolders = false tab.selectedFiles.forEach { @@ -72,18 +86,16 @@ fun FileOptionsMenuDialog(tab: FilesTab) { if (selectedFilesCount > 1) { "and %d more".format(selectedFilesCount - 1) } else { - targetDocumentHolder.getFormattedDetails( - showFolderContentCount = globalClass.preferencesManager.displayPrefs.showFolderContentCount - ) + targetContentHolder.details } ) } ItemRow( - title = targetDocumentHolder.getName(), + title = targetContentHolder.displayName, subtitle = details, icon = { - FileIcon(documentHolder = targetDocumentHolder) + FileIcon(contentHolder = targetContentHolder) } ) @@ -92,6 +104,7 @@ fun FileOptionsMenuDialog(tab: FilesTab) { Space(size = 6.dp) Row { + // Delete IconButton( modifier = Modifier.weight(1f), onClick = { @@ -106,71 +119,80 @@ fun FileOptionsMenuDialog(tab: FilesTab) { ) } + // Cut IconButton( modifier = Modifier.weight(1f), onClick = { tab.hideDocumentOptionsMenu() - tab.addNewTask(MoveTask(targetFiles).apply { - postActions.add( - tab.addNewAction(UpdateAction(due = false)) + CoroutineScope(Dispatchers.IO).launch { + globalClass.taskManager.addTask( + CopyTask( + targetFiles, + deleteSourceFiles = true + ) ) - }) + } tab.unselectAllFiles() } ) { Icon(imageVector = Icons.Rounded.ContentCut, contentDescription = null) } + // Copy IconButton( modifier = Modifier.weight(1f), onClick = { tab.hideDocumentOptionsMenu() - tab.addNewTask(CopyTask(targetFiles)) + CoroutineScope(Dispatchers.IO).launch { + globalClass.taskManager.addTask( + CopyTask( + targetFiles, + deleteSourceFiles = false + ) + ) + } tab.unselectAllFiles() } ) { Icon(imageVector = Icons.Rounded.FileCopy, contentDescription = null) } - if (isSingleFile || isSingleFolder) { - IconButton( - modifier = Modifier.weight(1f), - onClick = { - tab.hideDocumentOptionsMenu() - tab.renameDialog.show(targetDocumentHolder) - } - ) { - Icon( - imageVector = Icons.Rounded.FormatColorText, - contentDescription = null - ) + // Rename + IconButton( + modifier = Modifier.weight(1f), + onClick = { + tab.hideDocumentOptionsMenu() + tab.renameDialog.show(targetContentHolder) } + ) { + Icon( + imageVector = Icons.Rounded.FormatColorText, + contentDescription = null + ) } - if (tab.activeFolder != StorageProvider.bookmarks) { + + // Share + if (!hasFolders && targetContentHolder is LocalFileHolder) { IconButton( modifier = Modifier.weight(1f), onClick = { tab.hideDocumentOptionsMenu() - globalClass.filesTabManager.bookmarks += targetFiles.map { it.path } - .distinct() - globalClass.showMsg(R.string.added_to_bookmarks) - tab.unselectAllFiles() + tab.shareSelectedFiles(context) } ) { - Icon(imageVector = Icons.Rounded.BookmarkAdd, contentDescription = null) + Icon(imageVector = Icons.Rounded.Share, contentDescription = null) } } - if (!hasFolders) { - IconButton( - modifier = Modifier.weight(1f), - onClick = { - tab.hideDocumentOptionsMenu() - tab.share(context, targetDocumentHolder) - } - ) { - Icon(imageVector = Icons.Rounded.Share, contentDescription = null) + // Properties + IconButton( + modifier = Modifier.weight(1f), + onClick = { + tab.hideDocumentOptionsMenu() + tab.showFileProperties = true } + ) { + Icon(imageVector = Icons.Rounded.Info, contentDescription = null) } } @@ -183,58 +205,105 @@ fun FileOptionsMenuDialog(tab: FilesTab) { stringResource(R.string.open_in_new_tab) ) { tab.hideDocumentOptionsMenu() - tab.requestNewTab(FilesTab(targetDocumentHolder)) + tab.requestNewTab(FilesTab(targetContentHolder)) tab.unselectAllFiles() } } - if (isSingleFile) { + if (isSingleFile && targetContentHolder is LocalFileHolder) { FileOption( Icons.AutoMirrored.Rounded.OpenInNew, stringResource(R.string.open_with) ) { tab.hideDocumentOptionsMenu() - tab.openWithDialog.show(targetDocumentHolder) + tab.openWithDialog.show(targetContentHolder) + } + } + + if (tab.activeFolder is LocalFileHolder || + (tab.activeFolder is VirtualFileHolder && (tab.activeFolder as VirtualFileHolder).type != VirtualFileHolder.BOOKMARKS) + ) { + FileOption(Icons.Rounded.BookmarkAdd, stringResource(R.string.add_to_bookmarks)) { + tab.hideDocumentOptionsMenu() + globalClass.filesTabManager.bookmarks += targetFiles.map { it.uniquePath } + .distinct() + globalClass.showMsg(R.string.added_to_bookmarks) + tab.unselectAllFiles() } } - if (isRequestPinShortcutSupported(context) && (isSingleFile || isSingleFolder)) { + if (isRequestPinShortcutSupported(context) && tab.activeFolder is LocalFileHolder && (isSingleFile || isSingleFolder)) { FileOption(Icons.Rounded.Home, stringResource(R.string.add_to_home_screen)) { tab.hideDocumentOptionsMenu() - tab.addToHomeScreen(context, targetDocumentHolder) + tab.addToHomeScreen(context, targetContentHolder as LocalFileHolder) tab.unselectAllFiles() } } - if (isSingleFile) { + if (isSingleFile && targetContentHolder is LocalFileHolder) { FileOption(Icons.Rounded.EditNote, stringResource(R.string.edit_with_text_editor)) { tab.hideDocumentOptionsMenu() - globalClass.textEditorManager.openTextEditor(targetDocumentHolder, context) + globalClass.textEditorManager.openTextEditor(targetContentHolder, context) tab.unselectAllFiles() } - } - FileOption(Icons.Rounded.Compress, stringResource(R.string.compress)) { - tab.hideDocumentOptionsMenu() - tab.addNewTask(CompressTask(targetFiles)) - tab.unselectAllFiles() + if (apkBundleFileType.contains(targetContentHolder.file.extension)) { + FileOption(Icons.Rounded.Merge, stringResource(R.string.convert_to_apk)) { + tab.hideDocumentOptionsMenu() + CoroutineScope(Dispatchers.IO).launch { + globalClass.taskManager.addTaskAndRun( + ApksMergeTask(targetContentHolder), + ApksMergeTaskParameters( + globalClass.preferencesManager.fileOperationPrefs.signMergedApkBundleFiles + ) + ) + } + tab.unselectAllFiles() + } + } } - if (isSingleFile && !hasFolders && targetDocumentHolder.isArchive) { - FileOption( - Icons.AutoMirrored.Rounded.ExitToApp, - stringResource(R.string.decompress) - ) { + if (tab.activeFolder !is ZipFileHolder) { + FileOption(Icons.Rounded.Compress, stringResource(R.string.compress)) { + CoroutineScope(Dispatchers.IO).launch { + globalClass.taskManager.addTask( + CompressTask(targetFiles) + ) + } tab.hideDocumentOptionsMenu() - tab.addNewTask(DecompressTask(targetFiles)) tab.unselectAllFiles() } } + } + } +} - FileOption(Icons.Rounded.Info, stringResource(R.string.details)) { - tab.hideDocumentOptionsMenu() - tab.showFileProperties = true +@Composable +fun FileOption( + icon: ImageVector, + text: String, + highlight: Color = Color.Unspecified, + onClick: () -> Unit +) { + Row( + Modifier + .fillMaxWidth() + .clickable { + onClick() } - } + .padding(vertical = 16.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(21.dp), + imageVector = icon, + tint = if (highlight == Color.Unspecified) MaterialTheme.colorScheme.onSurface else highlight, + contentDescription = null + ) + Space(size = 12.dp) + Text( + text = text, + color = if (highlight == Color.Unspecified) MaterialTheme.colorScheme.onSurface else highlight + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FilePropertiesDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FilePropertiesDialog.kt new file mode 100644 index 00000000..dd9ceba7 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FilePropertiesDialog.kt @@ -0,0 +1,458 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.DataUsage +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Inventory +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.copyToClipboard +import com.raival.compose.file.explorer.common.extension.showMsg +import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.common.ui.block +import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.provider.CalculationProgress +import com.raival.compose.file.explorer.screen.main.tab.files.provider.ContentPropertiesProvider +import com.raival.compose.file.explorer.screen.main.tab.files.provider.PropertiesState +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun FilePropertiesDialog(tab: FilesTab) { + if (tab.showFileProperties) { + val selection = tab.selectedFiles.map { it.value }.toList() + val contentPropertiesProvider = remember { ContentPropertiesProvider(selection) } + val uiState by contentPropertiesProvider.uiState.collectAsState() + + DisposableEffect(contentPropertiesProvider) { + onDispose { + contentPropertiesProvider.cleanup() + } + } + + val title = when (uiState.details) { + is PropertiesState.SingleContentProperties -> stringResource(R.string.file_properties) + is PropertiesState.MultipleContentProperties -> stringResource(R.string.selection_properties) + else -> stringResource(R.string.loading_properties) + } + + Dialog( + onDismissRequest = { tab.showFileProperties = false } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .block() + .padding(24.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = when (uiState.details) { + is PropertiesState.SingleContentProperties -> if (selection.first().isFolder) Icons.Default.Folder else Icons.Default.Description + is PropertiesState.MultipleContentProperties -> Icons.Default.SelectAll + else -> Icons.Default.Info + }, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text(text = title, style = MaterialTheme.typography.headlineSmall) + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 600.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + + item { + AnimatedContent( + targetState = uiState.details, + transitionSpec = { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + }, + label = "content_transition" + ) { details -> + when (details) { + is PropertiesState.Loading -> { + LoadingContent() + } + + is PropertiesState.SingleContentProperties -> { + SingleFileContent(details) + } + + is PropertiesState.MultipleContentProperties -> { + MultipleFilesContent(details) + } + } + } + } + } + + Space(16.dp) + + Button( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(6.dp), + onClick = { tab.showFileProperties = false } + ) { + Text(stringResource(R.string.close)) + } + } + } + } +} + +@Composable +private fun LoadingContent() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + CircularProgressIndicator() + Space(16.dp) + Text( + text = stringResource(R.string.analyzing_files), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun SingleFileContent(details: PropertiesState.SingleContentProperties) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Basic properties + PropertySection(title = stringResource(R.string.general)) { + PropertyRow( + icon = Icons.Default.DriveFileRenameOutline, + label = stringResource(R.string.name), + value = details.name + ) + PropertyRow( + icon = Icons.Default.FolderOpen, + label = stringResource(R.string.location), + value = details.path + ) + PropertyRow( + icon = Icons.Default.Category, + label = stringResource(R.string.type), + value = details.type + ) + PropertyRow( + icon = Icons.Default.DataUsage, + label = stringResource(R.string.size), + value = details.size + ) + PropertyRow( + icon = Icons.Default.Schedule, + label = stringResource(R.string.modified), + value = details.lastModified + ) + } + + // System properties + PropertySection(title = stringResource(R.string.system)) { + PropertyRow( + icon = Icons.Default.Person, + label = stringResource(R.string.owner), + value = details.owner + ) + PropertyRow( + icon = Icons.Default.Security, + label = stringResource(R.string.permissions), + value = details.permissions + ) + } + + // Computed properties + PropertySection(title = stringResource(R.string.analysis)) { + AsyncPropertyRow( + icon = Icons.Default.Inventory, + label = stringResource(R.string.contents), + valueFlow = details.contentCount, + progressFlow = details.contentProgress + ) + AsyncPropertyRow( + icon = Icons.Default.Fingerprint, + label = stringResource(R.string.md5_checksum), + valueFlow = details.checksum, + progressFlow = details.checksumProgress + ) + } + } +} + +@Composable +private fun MultipleFilesContent(details: PropertiesState.MultipleContentProperties) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + PropertySection(title = stringResource(R.string.selection_summary)) { + PropertyRow( + icon = Icons.Default.SelectAll, + label = stringResource(R.string.selected), + value = stringResource(R.string.items_count, details.selectedFileCount) + ) + AsyncPropertyRow( + icon = Icons.Default.DataUsage, + label = stringResource(R.string.total_size), + valueFlow = details.totalSize, + progressFlow = details.sizeProgress + ) + AsyncPropertyRow( + icon = Icons.Default.Inventory, + label = stringResource(R.string.total_contents), + valueFlow = details.totalFileCount, + progressFlow = details.countProgress + ) + } + } +} + +@Composable +private fun PropertySection( + title: String, + content: @Composable () -> Unit +) { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp, start = 6.dp) + ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + shape = RoundedCornerShape(6.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + content() + } + } + } +} + +@Composable +fun PropertyRow( + icon: ImageVector, + label: String, + value: String +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Space(12.dp) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.width(100.dp), + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + CopiableText( + text = value, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +fun AsyncPropertyRow( + icon: ImageVector, + label: String, + valueFlow: StateFlow, + progressFlow: StateFlow +) { + val value by valueFlow.collectAsState() + val progress by progressFlow.collectAsState() + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Space(12.dp) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.width(100.dp), + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (progress.isCalculating) { + PulsingDot() + Space(8.dp) + } + CopiableText( + text = value, + color = if (progress.isCalculating) + MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} + +@Composable +private fun PulsingDot() { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + Box( + modifier = Modifier + .size(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = alpha)) + ) +} + +@Composable +fun CopiableText( + text: String, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface +) { + LocalContext.current + LocalClipboard.current + var showCopiedFeedback by remember { mutableStateOf(false) } + + LaunchedEffect(showCopiedFeedback) { + if (showCopiedFeedback) { + delay(2000) + showCopiedFeedback = false + } + } + + Box(modifier = modifier) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = color, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .pointerInput(text) { + detectTapGestures( + onLongPress = { + text.copyToClipboard() + showCopiedFeedback = true + showMsg(globalClass.getString(R.string.copied_to_clipboard)) + } + ) + } + ) + + // Subtle visual feedback + if (showCopiedFeedback) { + Box( + modifier = Modifier + .matchParentSize() + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + RoundedCornerShape(4.dp) + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileSortingMenu.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileSortingMenuDialog.kt similarity index 92% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileSortingMenu.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileSortingMenuDialog.kt index 7ff3fbba..156318eb 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileSortingMenu.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileSortingMenuDialog.kt @@ -1,4 +1,4 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -34,7 +34,7 @@ import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileSortingPr import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod @Composable -fun FileSortingMenu( +fun FileSortingMenuDialog( tab: FilesTab, reloadFiles: () -> Unit, onDismissRequest: () -> Unit, @@ -42,16 +42,16 @@ fun FileSortingMenu( val prefs = globalClass.preferencesManager.filesSortingPrefs val specificOptions = prefs.getSortingPrefsFor(tab.activeFolder) - var applyForThisFileOnly by remember(tab.activeFolder.path) { + var applyForThisFileOnly by remember(tab.activeFolder.uniquePath) { mutableStateOf(specificOptions.applyForThisFileOnly) } - var sortingMethod by remember(tab.activeFolder.path) { + var sortingMethod by remember(tab.activeFolder.uniquePath) { mutableIntStateOf(specificOptions.sortMethod) } - var showFoldersFirst by remember(tab.activeFolder.path) { + var showFoldersFirst by remember(tab.activeFolder.uniquePath) { mutableStateOf(specificOptions.showFoldersFirst) } - var reverseOrder by remember(tab.activeFolder.path) { + var reverseOrder by remember(tab.activeFolder.uniquePath) { mutableStateOf(specificOptions.reverseSorting) } @@ -151,8 +151,8 @@ fun FileSortingMenu( updateForThisFolder() } else { prefs.showFoldersFirst = showFoldersFirst - prefs.reverseFilesSortingMethod = reverseOrder - prefs.filesSortingMethod = sortingMethod + prefs.reverse = reverseOrder + prefs.defaultSortMethod = sortingMethod } reloadFiles() diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/OpenWithAppListDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/OpenWithAppListDialog.kt similarity index 91% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/OpenWithAppListDialog.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/OpenWithAppListDialog.kt index 67396897..aa511eb5 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/OpenWithAppListDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/OpenWithAppListDialog.kt @@ -1,4 +1,4 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize @@ -28,10 +28,13 @@ import androidx.compose.ui.unit.sp import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.common.ui.BottomSheetDialog +import com.raival.compose.file.explorer.common.ui.DynamicSelectTextField import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab import com.raival.compose.file.explorer.screen.main.tab.files.holder.OpenWithActivityHolder import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType +import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRow +import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRowIcon import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -39,12 +42,12 @@ import kotlinx.coroutines.launch @Composable fun OpenWithAppListDialog(tab: FilesTab) { if (tab.openWithDialog.showOpenWithDialog && tab.openWithDialog.targetFile != null) { - val documentHolder = tab.selectedFiles[tab.openWithDialog.targetFile!!.path]!! + val contentHolder = tab.openWithDialog.targetFile!! val context = LocalContext.current val appsList = remember { mutableStateListOf().apply { - addAll(documentHolder.getAppsHandlingFile()) + addAll(contentHolder.getAppsHandlingFile()) } } @@ -57,7 +60,7 @@ fun OpenWithAppListDialog(tab: FilesTab) { fun loadActivities(mimeType: String) { loading.value = true scope.launch(Dispatchers.IO) { - val list = documentHolder.getAppsHandlingFile(mimeType) + val list = contentHolder.getAppsHandlingFile(mimeType) appsList.clear() appsList.addAll(list) loading.value = false @@ -90,7 +93,7 @@ fun OpenWithAppListDialog(tab: FilesTab) { .fillMaxWidth(), initValue = 0, options = arrayListOf( - Pair(documentHolder.mimeType, documentHolder.mimeType), + Pair(contentHolder.mimeType, contentHolder.mimeType), Pair("image", "image/*"), Pair("Video", "video/*"), Pair("Audio", "audio/*"), @@ -123,7 +126,7 @@ fun OpenWithAppListDialog(tab: FilesTab) { .animateItem() .combinedClickable( onClick = { - documentHolder.openFileWithPackage( + contentHolder.openFileWithPackage( context, item.packageName, item.name diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/RenameDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/RenameDialog.kt new file mode 100644 index 00000000..8e0f981d --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/RenameDialog.kt @@ -0,0 +1,622 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile +import androidx.compose.material.icons.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.isValidAsFileName +import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.task.RenameTask +import com.raival.compose.file.explorer.screen.main.tab.files.task.RenameTask.Companion.transformFileName +import com.raival.compose.file.explorer.screen.main.tab.files.task.RenameTaskParameters +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun RenameDialog(tab: FilesTab) { + if (tab.renameDialog.show + && tab.selectedFiles.isNotEmpty() + && tab.renameDialog.targetContent != null + ) { + if (tab.selectedFiles.size == 1) { + val target by remember { + mutableStateOf(tab.renameDialog.targetContent!!) + } + val listContent by remember { + mutableStateOf(tab.activeFolderContent.map { it.displayName }.toTypedArray()) + } + + var newNameInput by remember { mutableStateOf(target.displayName) } + var error by remember { mutableStateOf("") } + + LaunchedEffect(newNameInput) { + error = if (newNameInput.isBlank() || newNameInput == target.displayName) { + emptyString + } else if (!newNameInput.isValidAsFileName()) { + globalClass.getString(R.string.invalid_file_name) + } else if (listContent.contains(newNameInput)) { + globalClass.getString(R.string.similar_file_exists) + } else { + emptyString + } + } + + Dialog( + onDismissRequest = { tab.renameDialog.hide() }, + ) { + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.rename), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + TextField( + modifier = Modifier.fillMaxWidth(), + value = newNameInput, + onValueChange = { + newNameInput = it + }, + label = { Text(text = stringResource(R.string.new_name)) }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + isError = error.isNotEmpty(), + supportingText = if (error.isNotEmpty()) { + { Text(error) } + } else null + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + tab.renameDialog.hide() + }, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.labelLarge + ) + } + + Button( + modifier = Modifier.weight(1f), + onClick = { + if (newNameInput.isValidAsFileName()) { + val similarFile = tab.activeFolder.findFile(newNameInput) + if (similarFile == null) { + tab.renameDialog.hide() + CoroutineScope(Dispatchers.IO).launch { + globalClass.taskManager.addTaskAndRun( + task = RenameTask(sourceContent = tab.selectedFiles.values.toList()), + parameters = RenameTaskParameters( + newName = newNameInput, + toFind = emptyString, + toReplace = emptyString, + useRegex = false + ) + ) + } + } else { + globalClass.showMsg(R.string.similar_file_exists) + } + } else { + globalClass.showMsg(R.string.invalid_file_name) + } + }, + enabled = error.isEmpty() && newNameInput.isNotBlank() && newNameInput != target.displayName, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.rename), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + } + } else { + AdvanceRenameDialog(tab) + } + } +} + +@Composable +fun AdvanceRenameDialog(tab: FilesTab) { + val useDarkIcons = !isSystemInDarkTheme() + val originalList = + remember { tab.selectedFiles.values.map { it.displayName to it }.toTypedArray() } + val remainingFiles = tab.activeFolderContent.map { it.displayName } + .filter { !originalList.map { it.first }.contains(it) }.toList() + val currentList = remember { mutableStateListOf>() } + val conflicts = remember { mutableStateListOf() } + val suggestions = remember { + arrayListOf>().apply { + add("{p}" to globalClass.getString(R.string.name)) + add("{s}" to globalClass.getString(R.string.extension)) + add("{e}" to globalClass.getString(R.string.extension_with_dot)) + add("{t}" to globalClass.getString(R.string.last_modified)) + add("{n}" to globalClass.getString(R.string.number_increment)) + add("{zn}" to globalClass.getString(R.string.zero_number_increment)) + } + } + + var isReady by remember { mutableStateOf(false) } + var useRegex by remember { mutableStateOf(false) } + var showInfo by remember { mutableStateOf(false) } + var newNameInput by remember { mutableStateOf(TextFieldValue("{p}{e}")) } + var findInput by remember { mutableStateOf(emptyString) } + var replaceInput by remember { mutableStateOf(emptyString) } + + LaunchedEffect(Unit) { + currentList.clear() + conflicts.clear() + currentList.addAll(originalList) + } + + fun preview() { + conflicts.clear() + currentList.clear() + val cumulativeNames = Array(originalList.size) { emptyString } + originalList.forEachIndexed { index, reference -> + val lastModified by lazy { reference.second.lastModified } + val newFileName = reference.first.transformFileName( + newName = newNameInput.text, + index = index, + textToFind = findInput, + replaceText = replaceInput, + useRegex = useRegex, + onLastModified = { lastModified } + ) + + currentList.add(newFileName to reference.second) + + if (cumulativeNames.contains(newFileName) || remainingFiles.contains(newFileName)) { + conflicts.add(newFileName) + } else { + cumulativeNames[index] = newFileName + } + } + } + + Dialog( + onDismissRequest = { tab.renameDialog.hide() }, + properties = DialogProperties( + dismissOnClickOutside = true, + decorFitsSystemWindows = false, + usePlatformDefaultWidth = false + ) + ) { + val color = MaterialTheme.colorScheme.surfaceContainerHigh + val systemUiController = rememberSystemUiController() + DisposableEffect(systemUiController, useDarkIcons) { + systemUiController.setStatusBarColor(color = color, darkIcons = useDarkIcons) + onDispose {} + } + + Card( + modifier = Modifier + .fillMaxSize() + .imePadding() + .statusBarsPadding(), + shape = RectangleShape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.batch_rename), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + TextField( + modifier = Modifier.fillMaxWidth(), + value = newNameInput, + onValueChange = { newNameInput = it.also { isReady = false } }, + label = { Text(text = stringResource(R.string.new_name)) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + trailingIcon = { + IconButton( + onClick = { showInfo = true } + ) { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null + ) + } + }, + colors = TextFieldDefaults.colors( + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) + + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items = suggestions, key = { it.first }) { suggestion -> + FilterChip( + onClick = { + val selection = newNameInput.selection + val newText: String + val newSelection: TextRange + + if (selection.collapsed) { + val cursorPosition = selection.start + val text = newNameInput.text + val textBeforeCursor = text.substring(0, cursorPosition) + val textAfterCursor = + text.substring(cursorPosition, text.length) + newText = textBeforeCursor + suggestion.first + textAfterCursor + val newCursorPosition = cursorPosition + suggestion.first.length + newSelection = TextRange(newCursorPosition) + } else { + val text = newNameInput.text + val textBeforeSelection = text.substring(0, selection.start) + val textAfterSelection = + text.substring(selection.end, text.length) + newText = + textBeforeSelection + suggestion.first + textAfterSelection + val newCursorPosition = + selection.start + suggestion.first.length + newSelection = TextRange(newCursorPosition) + } + + newNameInput = TextFieldValue( + text = newText, + selection = newSelection + ) + isReady = false + }, + label = { + Text( + text = suggestion.second, + style = MaterialTheme.typography.labelMedium + ) + }, + selected = false, + shape = RoundedCornerShape(8.dp) + ) + } + } + + // Find & Replace Section + Column( + modifier = Modifier, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.find_n_replace), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + FilterChip( + onClick = { useRegex = !useRegex.also { isReady = false } }, + label = { + Text( + text = stringResource(R.string.use_regex), + style = MaterialTheme.typography.labelMedium + ) + }, + selected = useRegex, + shape = RoundedCornerShape(8.dp) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextField( + modifier = Modifier.weight(1f), + value = findInput, + onValueChange = { findInput = it.also { isReady = false } }, + label = { Text(text = stringResource(R.string.text_to_find)) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = TextFieldDefaults.colors( + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) + TextField( + modifier = Modifier.weight(1f), + value = replaceInput, + onValueChange = { replaceInput = it.also { isReady = false } }, + label = { Text(text = stringResource(R.string.replace_with)) }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = TextFieldDefaults.colors( + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) + } + } + + // Preview Section + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.preview), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + shape = RoundedCornerShape(16.dp) + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed( + items = currentList, + key = { index, item -> item.first + item.second.displayName }) { index, item -> + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (item.second.isFolder) + Icons.Rounded.Folder + else Icons.AutoMirrored.Rounded.InsertDriveFile, + contentDescription = null, + tint = if (conflicts.contains(item.first)) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Space(12.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.second.displayName, + style = MaterialTheme.typography.bodyMedium, + color = if (conflicts.contains(item.first)) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.ArrowForward, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = if (conflicts.contains(item.first)) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary + ) + Space(8.dp) + Text( + text = item.first, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (conflicts.contains(item.first)) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface + ) + } + } + } + if (index != currentList.lastIndex) { + Space(8.dp) + HorizontalDivider() + } + } + } + } + } + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { tab.renameDialog.hide() }, + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.labelLarge + ) + } + + Button( + modifier = Modifier.weight(1f), + onClick = { + if (isReady && conflicts.isEmpty()) { + tab.renameDialog.hide() + CoroutineScope(Dispatchers.IO).launch { + globalClass.taskManager.addTaskAndRun( + task = RenameTask(tab.selectedFiles.values.toList()), + parameters = RenameTaskParameters( + newName = newNameInput.text, + toFind = findInput, + toReplace = replaceInput, + useRegex = useRegex + ) + ) + } + } else { + preview() + isReady = true + } + }, + enabled = !isReady || conflicts.isEmpty(), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = stringResource(if (isReady) R.string.rename else R.string.preview), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + } + + if (showInfo) { + AlertDialog( + onDismissRequest = { showInfo = false }, + title = { + Text( + text = stringResource(R.string.syntax), + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = stringResource(R.string.batch_rename_info), + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + TextButton( + onClick = { showInfo = false }, + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = stringResource(R.string.close), + style = MaterialTheme.typography.labelLarge + ) + } + }, + shape = RoundedCornerShape(16.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/SearchDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/SearchDialog.kt similarity index 84% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/SearchDialog.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/SearchDialog.kt index 31e36c49..dc1568e8 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/SearchDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/SearchDialog.kt @@ -1,15 +1,18 @@ -package com.raival.compose.file.explorer.screen.main.tab.files.ui +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape @@ -31,6 +34,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -48,6 +52,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.copyToClipboard @@ -56,7 +61,10 @@ import com.raival.compose.file.explorer.common.extension.getIndexIf import com.raival.compose.file.explorer.common.ui.Space import com.raival.compose.file.explorer.common.ui.block import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.ui.FileItemRow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -68,6 +76,7 @@ fun SearchDialog(tab: FilesTab) { if (tab.showSearchPenal) { val context = LocalContext.current var isSearching by remember { mutableStateOf(false) } + val useDarkIcons = !isSystemInDarkTheme() fun search(query: String) { if (query.isNotEmpty()) { @@ -79,7 +88,7 @@ fun SearchDialog(tab: FilesTab) { val searchLimit = globalClass .preferencesManager - .generalPrefs + .fileOperationPrefs .searchInFilesLimit val isExceedingTheSearchLimit = @@ -89,19 +98,19 @@ fun SearchDialog(tab: FilesTab) { tab.activeFolderContent.forEach { if (!isSearching) return - if (it.getName().contains(query, true)) { + if (it.displayName.contains(query, true)) { tab.search.searchResults += it delay(150) } } } - suspend fun searchIn(doc: DocumentHolder) { + suspend fun searchIn(contentHolder: ContentHolder) { if (!isSearching) return val searchLimit = globalClass .preferencesManager - .generalPrefs + .fileOperationPrefs .searchInFilesLimit val isExceedingTheSearchLimit = @@ -109,25 +118,25 @@ fun SearchDialog(tab: FilesTab) { if (isExceedingTheSearchLimit) return - if (doc.isFile) { - if (doc.getName().contains(query, true)) { + if (contentHolder.isFile()) { + if (contentHolder.displayName.contains(query, true)) { if (!isSearching) return - tab.search.searchResults += doc + tab.search.searchResults += contentHolder delay(150) } } - if (doc.isFolder) { + if (contentHolder.isFolder) { if (!isSearching) return - doc.documentFile.listFiles().forEach { + contentHolder.listContent().forEach { if (!isSearching) return - searchIn(DocumentHolder(it)) + searchIn(it) } } } CoroutineScope(Dispatchers.IO).launch { - if (tab.isSpecialDirectory()) { + if (tab.activeFolder is VirtualFileHolder) { searchInList() } else { searchIn(tab.activeFolder) @@ -144,13 +153,24 @@ fun SearchDialog(tab: FilesTab) { Dialog( onDismissRequest = { tab.showSearchPenal = false }, properties = DialogProperties( + dismissOnClickOutside = false, decorFitsSystemWindows = false, usePlatformDefaultWidth = false ) ) { + // There is a strange artifact that overlaps with the status bar, not sure why it occurs. + // This code is a workaround to fix it. + val color = MaterialTheme.colorScheme.surfaceContainerHigh + val systemUiController = rememberSystemUiController() + DisposableEffect(systemUiController, useDarkIcons) { + systemUiController.setStatusBarColor(color = color, darkIcons = useDarkIcons) + onDispose {} + } Column( modifier = Modifier .fillMaxSize() + .imePadding() + .statusBarsPadding() .block( shape = RectangleShape, borderSize = 0.dp @@ -261,8 +281,8 @@ fun SearchDialog(tab: FilesTab) { LazyColumn { itemsIndexed( tab.search.searchResults, - key = { index, item -> item.path }) { index, item -> - var showMoreOptionsMenu by remember(item.path) { + key = { index, item -> item.uniquePath }) { index, item -> + var showMoreOptionsMenu by remember(item.uniquePath) { mutableStateOf(false) } @@ -271,7 +291,7 @@ fun SearchDialog(tab: FilesTab) { .fillMaxWidth() .combinedClickable( onClick = { - if (item.isFile) { + if (item.isFile()) { tab.openFile(context, item) } else { tab.openFolder(item, rememberListState = false) @@ -283,7 +303,10 @@ fun SearchDialog(tab: FilesTab) { ) ) { Space(size = 4.dp) - FileItemRow(item = item, fileDetails = item.basePath) + FileItemRow( + item = item, fileDetails = if (item is LocalFileHolder) + item.basePath else item.uniquePath + ) Space(size = 4.dp) HorizontalDivider( modifier = Modifier.padding(start = 56.dp), @@ -298,15 +321,15 @@ fun SearchDialog(tab: FilesTab) { }, onClick = { tab.showSearchPenal = false - if (item.canAccessParent) { + if (item.hasParent()) { tab.highlightedFiles.apply { clear() - add(item.path) + add(item.uniquePath) } - tab.openFolder(item.parent!!) { + tab.openFolder(item.getParent()!!) { CoroutineScope(Dispatchers.Main).launch { tab.getFileListState().scrollToItem( - tab.activeFolderContent.getIndexIf { path == item.path }, + tab.activeFolderContent.getIndexIf { uniquePath == item.uniquePath }, 0 ) } @@ -321,7 +344,7 @@ fun SearchDialog(tab: FilesTab) { }, onClick = { showMoreOptionsMenu = false - item.path.copyToClipboard() + item.uniquePath.copyToClipboard() globalClass.showMsg(globalClass.getString(R.string.copied_to_clipboard)) } ) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskConflictDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskConflictDialog.kt new file mode 100644 index 00000000..d69d72e5 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskConflictDialog.kt @@ -0,0 +1,86 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.CheckableText +import com.raival.compose.file.explorer.common.ui.block +import com.raival.compose.file.explorer.screen.main.tab.files.task.TaskContentStatus + +@Composable +fun TaskConflictDialog() { + val interceptor = globalClass.taskManager.taskInterceptor + var applyToOtherConflicts by remember { mutableStateOf(interceptor.hasConflict) } + + if (interceptor.hasConflict) { + Dialog( + onDismissRequest = { }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + Column( + Modifier + .fillMaxWidth() + .block() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = globalClass.getString(R.string.conflict), + fontSize = 18.sp + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = interceptor.message, + fontSize = 14.sp + ) + CheckableText( + checked = applyToOtherConflicts, + onCheckedChange = { applyToOtherConflicts = it } + ) { + Text(text = globalClass.getString(R.string.apply_to_other_conflicts)) + } + Row( + modifier = Modifier.fillMaxWidth() + ) { + TextButton(onClick = { + interceptor.hide() + }) { + Text(text = globalClass.getString(R.string.cancel)) + } + Spacer(Modifier.weight(1f)) + TextButton(onClick = { + interceptor.resolve(TaskContentStatus.REPLACE, applyToOtherConflicts) + }) { + Text(text = globalClass.getString(R.string.replace)) + } + TextButton(onClick = { + interceptor.resolve(TaskContentStatus.SKIP, applyToOtherConflicts) + }) { + Text(text = globalClass.getString(R.string.skip)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskPanel.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskPanel.kt new file mode 100644 index 00000000..20ef8efa --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskPanel.kt @@ -0,0 +1,695 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.rounded.Task +import androidx.compose.material.icons.rounded.TaskAlt +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.kevinnzou.compose.swipebox.SwipeBox +import com.kevinnzou.compose.swipebox.SwipeDirection +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.BottomSheetDialog +import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.service.ContentOperationService.Companion.removeBackgroundTask +import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTask +import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTask +import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTaskParameters +import com.raival.compose.file.explorer.screen.main.tab.files.task.Task +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.max + +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +fun TaskPanel(tab: FilesTab) { + if (tab.showTasksPanel) { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + val runningTasks = remember { mutableStateListOf() } + val pausedTasks = remember { mutableStateListOf() } + val failedTasks = remember { mutableStateListOf() } + val pendingTasks = remember { mutableStateListOf() } + val invalidTasks = remember { mutableStateListOf() } + + LaunchedEffect(Unit) { + globalClass.taskManager.validateTasks() + + while (tab.showTasksPanel) { + if (globalClass.taskManager.runningTasks != runningTasks) { + runningTasks.clear() + runningTasks.addAll(globalClass.taskManager.runningTasks) + } + if (globalClass.taskManager.pausedTasks != pausedTasks) { + pausedTasks.clear() + pausedTasks.addAll(globalClass.taskManager.pausedTasks) + } + if (globalClass.taskManager.failedTasks != failedTasks) { + failedTasks.clear() + failedTasks.addAll(globalClass.taskManager.failedTasks) + } + if (globalClass.taskManager.pendingTasks != pendingTasks) { + pendingTasks.clear() + pendingTasks.addAll(globalClass.taskManager.pendingTasks) + } + if (globalClass.taskManager.invalidTasks != invalidTasks) { + invalidTasks.clear() + invalidTasks.addAll(globalClass.taskManager.invalidTasks) + } + delay(250) + } + } + + BottomSheetDialog( + onDismissRequest = { tab.showTasksPanel = false } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.TaskAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Space(8.dp) + Text( + text = stringResource(R.string.tasks), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + } + + AnimatedVisibility( + !runningTasks.isEmpty() || !pausedTasks.isEmpty() || + !failedTasks.isEmpty() || !pendingTasks.isEmpty() || !invalidTasks.isEmpty() + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(300)), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Running Tasks + if (runningTasks.isNotEmpty()) { + item { + TaskCategoryHeader( + title = stringResource(R.string.tasks_running), + count = runningTasks.size, + color = MaterialTheme.colorScheme.primary + ) + } + items(runningTasks, key = { "running-${it.id}" }) { task -> + RunningTaskItem( + task = task, + onClick = { + tab.showTasksPanel = false + globalClass.taskManager.bringToForeground(task) + } + ) + } + item { Space(8.dp) } + } + + // Paused Tasks + if (pausedTasks.isNotEmpty()) { + item { + TaskCategoryHeader( + title = stringResource(R.string.tasks_paused), + count = pausedTasks.size, + color = MaterialTheme.colorScheme.tertiary + ) + } + items(pausedTasks, key = { "paused-${it.id}" }) { task -> + SwipeableTaskItem( + task = task, + icon = Icons.Default.Pause, + iconTint = MaterialTheme.colorScheme.tertiary, + onSwipeToDelete = { + pausedTasks.remove(task) + coroutineScope.launch { + globalClass.taskManager.removeTask(task.id) + removeBackgroundTask(context, task.id) + } + }, + onClick = { + coroutineScope.launch { + globalClass.taskManager.continueTask(task.id) + } + tab.showTasksPanel = false + } + ) + } + item { Space(8.dp) } + } + + // Failed Tasks + if (failedTasks.isNotEmpty()) { + item { + TaskCategoryHeader( + title = stringResource(R.string.tasks_failed), + count = failedTasks.size, + color = MaterialTheme.colorScheme.error + ) + } + items(failedTasks, key = { "failed-${it.id}" }) { task -> + SwipeableTaskItem( + task = task, + icon = Icons.Default.ErrorOutline, + iconTint = MaterialTheme.colorScheme.error, + onSwipeToDelete = { + failedTasks.remove(task) + coroutineScope.launch { + globalClass.taskManager.removeTask(task.id) + removeBackgroundTask(context, task.id) + } + }, + onClick = { + coroutineScope.launch { + globalClass.taskManager.continueTask(task.id) + } + tab.showTasksPanel = false + } + ) + } + item { Space(8.dp) } + } + + // Pending Tasks + if (pendingTasks.isNotEmpty()) { + item { + TaskCategoryHeader( + title = stringResource(R.string.tasks_pending), + count = pendingTasks.size, + color = MaterialTheme.colorScheme.secondary + ) + } + items(pendingTasks, key = { "pending-${it.id}" }) { task -> + SwipeableTaskItem( + task = task, + icon = Icons.Default.HourglassEmpty, + iconTint = MaterialTheme.colorScheme.secondary, + onSwipeToDelete = { + pendingTasks.remove(task) + coroutineScope.launch { + globalClass.taskManager.removeTask(task.id) + } + }, + onClick = { + if (tab.activeFolder is VirtualFileHolder || tab !is FilesTab) { + globalClass.showMsg(globalClass.getString(R.string.can_not_run_tasks)) + return@SwipeableTaskItem + } + tab.showTasksPanel = false + when (task) { + is CopyTask -> { + coroutineScope.launch { + globalClass.taskManager.runTask( + task.id, + CopyTaskParameters(tab.activeFolder) + ) + } + } + + is CompressTask -> tab.newZipFileDialog.show(task) + } + } + ) + } + item { Space(8.dp) } + } + + // Invalid Tasks + if (invalidTasks.isNotEmpty()) { + item { + TaskCategoryHeader( + title = stringResource(R.string.tasks_invalid), + count = invalidTasks.size, + color = MaterialTheme.colorScheme.outline + ) + } + items(invalidTasks, key = { "invalid-${it.id}" }) { task -> + SwipeableTaskItem( + task = task, + icon = Icons.Default.Warning, + iconTint = MaterialTheme.colorScheme.outline, + onSwipeToDelete = { + invalidTasks.remove(task) + coroutineScope.launch { + globalClass.taskManager.removeTask(task.id) + } + }, + onClick = { } + ) + } + } + } + } + + AnimatedVisibility( + visible = runningTasks.isEmpty() && pausedTasks.isEmpty() && + failedTasks.isEmpty() && pendingTasks.isEmpty() && invalidTasks.isEmpty() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 64.dp, horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Rounded.Task, + contentDescription = null, + tint = MaterialTheme.colorScheme.outline, + modifier = Modifier.size(48.dp) + ) + Space(16.dp) + Text( + text = stringResource(R.string.empty), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Space(8.dp) + Text( + text = stringResource(R.string.no_tasks_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + textAlign = TextAlign.Center + ) + } + } + } + } +} + +@Composable +private fun TaskCategoryHeader( + title: String, + count: Int, + color: Color +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.1f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(color, shape = RoundedCornerShape(50)) + ) + Space(12.dp) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + Surface( + shape = RoundedCornerShape(12.dp), + color = color.copy(alpha = 0.2f) + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = color, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } +} + +@Composable +private fun RunningTaskItem( + task: Task, + onClick: () -> Unit +) { + val job = remember { SupervisorJob() } + val scope = remember { CoroutineScope(Dispatchers.IO + job) } + + var progress by remember(task.id) { mutableFloatStateOf(task.progressMonitor.progress) } + var processName by remember(task.id) { mutableStateOf(task.progressMonitor.processName) } + var contentName by remember(task.id) { mutableStateOf(task.progressMonitor.contentName) } + var totalContent by remember(task.id) { mutableIntStateOf(task.progressMonitor.totalContent) } + var remainingContent by remember(task.id) { mutableIntStateOf(task.progressMonitor.remainingContent) } + + DisposableEffect(task.id) { + scope.launch { + while (true) { + progress = task.progressMonitor.progress + processName = task.progressMonitor.processName + contentName = task.progressMonitor.contentName + totalContent = task.progressMonitor.totalContent + remainingContent = task.progressMonitor.remainingContent + delay(100) + } + } + + onDispose { + job.cancel() + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Space(16.dp) + Column(modifier = Modifier.weight(1f)) { + if (processName.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = processName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f, fill = false) + ) + if (contentName.isNotEmpty()) { + Text( + text = " - $contentName", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + } + if (progress > 0) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + + // Progress Info Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Content Counter + if (totalContent > 0) { + Text( + text = "${ + max( + totalContent - remainingContent, + 0 + ) + }/${totalContent}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Percentage + Text( + text = "${(progress * 100).toInt()}%", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } else { + // Indeterminate progress + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + + if (totalContent > 0) { + Text( + text = "${totalContent - remainingContent}/${totalContent}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Start) + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) +@Composable +private fun SwipeableTaskItem( + task: Task, + icon: ImageVector, + iconTint: Color, + onSwipeToDelete: () -> Unit, + onClick: () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + SwipeBox( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + swipeDirection = SwipeDirection.EndToStart, + endContentWidth = 72.dp, + endContent = { swipeableState, _ -> + Box( + modifier = Modifier + .fillMaxHeight() + .width(72.dp) + .background( + MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(topEnd = 12.dp, bottomEnd = 12.dp) + ) + .clickable { + onSwipeToDelete() + coroutineScope.launch { swipeableState.animateTo(0) } + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(24.dp) + ) + } + } + ) { _, _, _ -> + TaskItem( + task = task, + icon = icon, + iconTint = iconTint, + onClick = onClick + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TaskItem( + task: Task, + icon: ImageVector, + iconTint: Color, + onClick: () -> Unit +) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = { + expanded = !expanded + } + ) + .animateContentSize(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = iconTint.copy(alpha = 0.1f), + modifier = Modifier.size(40.dp) + ) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(20.dp) + ) + } + } + + Space(16.dp) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = task.metadata.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + if (task.metadata.subtitle.isNotBlank()) { + Space(4.dp) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier.weight(1f), + text = task.metadata.subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = task.metadata.creationTime, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + AnimatedVisibility( + visible = expanded, + enter = scaleIn() + slideInVertically { -it }, + exit = scaleOut(targetScale = 0.5f) + slideOutVertically { -it / 4 } + fadeOut() + ) { + Text( + text = task.metadata.displayDetails, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 3 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskRunningDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskRunningDialog.kt new file mode 100644 index 00000000..f0f2acc1 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskRunningDialog.kt @@ -0,0 +1,254 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.Space +import kotlin.math.max + +@Composable +fun TaskRunningDialog() { + val dialogInfo = globalClass.taskManager.runningTaskDialogInfo + val progressMonitor = dialogInfo.progressMonitor + val showDialog = dialogInfo.showDialog + val linkedTask = dialogInfo.linkedTask + if (showDialog && progressMonitor != null && linkedTask != null) { + var isAborted by remember { mutableStateOf(false) } + val isHidden by remember { mutableStateOf(false) } + Dialog( + onDismissRequest = { }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header Section + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Task Title + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = progressMonitor.taskTitle.ifEmpty { stringResource(R.string.preparing) }, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + // Process Name and Content + if (progressMonitor.processName.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = progressMonitor.processName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f, fill = false) + ) + if (progressMonitor.contentName.isNotEmpty()) { + Text( + text = " - ${progressMonitor.contentName}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + } + } + + // Progress Section + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Progress Bar + if (progressMonitor.progress > 0) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + progress = { progressMonitor.progress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + + // Progress Info Row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Content Counter + if (progressMonitor.totalContent > 0) { + Text( + text = "${ + max( + progressMonitor.totalContent - progressMonitor.remainingContent, + 0 + ) + }/${progressMonitor.totalContent}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Percentage + Text( + text = "${(progressMonitor.progress * 100).toInt()}%", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + } + } + } else { + // Indeterminate progress + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer + ) + + if (progressMonitor.totalContent > 0) { + Text( + text = "${progressMonitor.totalContent - progressMonitor.remainingContent}/${progressMonitor.totalContent}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Start) + ) + } + } + } + } + + // Actions Section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + // Move to Background Button + if (linkedTask.metadata.canMoveToBackground) { + TextButton( + enabled = !isHidden && !isAborted, + onClick = { + globalClass.taskManager.hideRunningTaskDialog() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Text( + text = stringResource(R.string.hide), + style = MaterialTheme.typography.labelLarge + ) + } + + if (linkedTask.metadata.isCancellable) { + Space(8.dp) + } + } + + // Cancel/Stop Button + if (linkedTask.metadata.isCancellable) { + FilledTonalButton( + onClick = { + linkedTask.abortTask() + isAborted = true + }, + enabled = !isAborted && !isHidden, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = if (isAborted) stringResource(R.string.stopping) else stringResource( + R.string.stop + ), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipManager.kt new file mode 100644 index 00000000..3fdb7c12 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipManager.kt @@ -0,0 +1,51 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.zip + +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder + +class ZipManager { + private val archiveList = hashMapOf() + + fun checkForSourceChanges(): Boolean { + var foundChanges = false + archiveList.values.forEach { zipTree -> + if (zipTree.source.lastModified != zipTree.timeStamp) { + zipTree.reset() + foundChanges = true + } + } + return foundChanges + } + + fun openArchive(archive: LocalFileHolder) { + val existingTreeKey = archiveList.keys.find { archive.uniquePath == it.uniquePath } + + if (existingTreeKey != null) { + archiveList[existingTreeKey]?.let { existingTree -> + if (existingTree.timeStamp == archive.lastModified) { + globalClass.mainActivityManager.let { mainManager -> + (mainManager.getActiveTab() as? FilesTab)?.openFolder( + existingTree.createRootContentHolder() + ) ?: mainManager.replaceCurrentTabWith( + FilesTab(existingTree.createRootContentHolder()) + ) + return + } + } else { + archiveList.remove(existingTreeKey) + } + } + } + + archiveList[archive] = ZipTree(archive).apply { + globalClass.mainActivityManager.let { mainManager -> + (mainManager.getActiveTab() as? FilesTab)?.openFolder( + createRootContentHolder() + ) ?: mainManager.replaceCurrentTabWith( + FilesTab(createRootContentHolder()) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipTree.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipTree.kt new file mode 100644 index 00000000..6084fbdc --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipTree.kt @@ -0,0 +1,109 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.zip + +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.zip.model.ZipNode +import java.io.File +import java.util.Enumeration +import java.util.zip.ZipEntry +import java.util.zip.ZipFile + +class ZipTree( + val source: LocalFileHolder, +) { + val timeStamp = source.lastModified + + private val zipEntries = hashMapOf() + private val nodes = hashMapOf() + + private val root = ZipNode( + name = source.displayName, + path = emptyString, + isDirectory = true, + lastModified = 0, + lastAccessed = 0, + size = 0 + ) + + var isReady = false + private set + + fun getRootNode() = root + + fun createRootContentHolder() = ZipFileHolder(this, root) + + fun findNodeByPath(path: String) = nodes[path] + + fun reset() { + isReady = false + } + + fun prepare() { + build() + } + + private fun build() { + isReady = false + zipEntries.clear() + + try { + val zipFile = ZipFile(source.file) + val enum: Enumeration<*> = zipFile.entries() + while (enum.hasMoreElements()) { + val entry = enum.nextElement() as ZipEntry + zipEntries.put(entry.name, entry) + } + } catch (e: Exception) { + logger.logError(e) + globalClass.showMsg(R.string.invalid_zip) + } + + buildTree() + + isReady = true + } + + private fun buildTree() { + nodes.clear() + nodes.put(emptyString, root.apply { children.clear() }) + + zipEntries.forEach { entry -> + val path = entry.key + + val parts = path.split(File.separator) + var currentNode = root + var currentPath = root.path + + for ((i, part) in parts.withIndex()) { + if (part.isNotEmpty()) { + val childNode = currentNode.children.find { it.name == part } + + currentPath = if (currentPath.isEmpty()) part else "$currentPath/$part" + + if (childNode == null) { + val newNode = ZipNode( + name = part, + path = currentPath, + isDirectory = if (i < parts.lastIndex) true else entry.value.isDirectory, + lastModified = entry.value.lastModifiedTime?.toMillis() ?: 0, + lastAccessed = entry.value.lastAccessTime?.toMillis() ?: 0, + size = entry.value.size + ) + currentNode.children.add(newNode) + currentNode = newNode + + nodes.put(currentPath, newNode) + } else { + currentNode = childNode + } + } + } + } + + zipEntries.clear() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/model/ZipNode.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/model/ZipNode.kt new file mode 100644 index 00000000..24815395 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/model/ZipNode.kt @@ -0,0 +1,86 @@ +package com.raival.compose.file.explorer.screen.main.tab.files.zip.model + +import com.raival.compose.file.explorer.common.extension.emptyString +import java.io.File + +data class ZipNode( + val name: String, + val path: String, + val size: Long = 0L, + val isDirectory: Boolean = true, + val lastModified: Long = 0L, + val lastAccessed: Long = 0L, + val children: MutableList = mutableListOf() +) { + val extension: String + get() { + if (this.isDirectory) { + return emptyString + } + + val dotIndex = this.name.lastIndexOf('.') + + // If a dot exists and it is not the first character in the name, + // return the substring after the dot. + return if (dotIndex > 0) { + this.name.substring(dotIndex + 1).lowercase() + } else { + // No extension found (or it's a dotfile like ".gitignore"). + emptyString + } + } + + val parentPath: String + get() { + // First, remove any trailing slash if the entry is a directory. + // This ensures that for "folder/", we are working with "folder". + val path = this.path.removeSuffix(File.separator) + + // Find the last occurrence of the path separator. + val lastSlashIndex = path.lastIndexOf(File.separatorChar) + + // If no slash is found, the entry is in the root. + // Otherwise, the parent path is everything up to and including the slash. + return if (lastSlashIndex == -1) { + emptyString + } else { + path.substring(0, lastSlashIndex) + } + } + + fun listFilesAndEmptyDirs(): List { + val result = mutableListOf() + + // A queue of directories to visit. Start with the root. + val directoryQueue = ArrayDeque() + if (isDirectory) { + directoryQueue.add(this) + } else { + // If the starting path is just a file, add it and return. + result.add(this) + return result + } + + while (directoryQueue.isNotEmpty()) { + val dir = directoryQueue.removeFirst() + // listFiles() can be null if the directory is not accessible + val children = dir.children + + if (children.isEmpty()) { + // This directory is empty, add it to the list. + result.add(dir) + } else { + // Process the contents of the non-empty directory + for (child in children) { + if (child.isDirectory) { + directoryQueue.add(child) + } else { + result.add(child) + } + } + } + } + + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt index ea9862ed..8cec0a95 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt @@ -11,16 +11,24 @@ import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.AudioFile import androidx.compose.material.icons.rounded.Image import androidx.compose.material.icons.rounded.VideoFile +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.screen.main.tab.Tab import com.raival.compose.file.explorer.screen.main.tab.apps.AppsTab import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider -import com.raival.compose.file.explorer.screen.main.tab.home.holder.HomeCategoryHolder -import com.raival.compose.file.explorer.screen.main.tab.home.holder.RecentFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.ARCHIVE +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.AUDIO +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.DOCUMENT +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.IMAGE +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.VIDEO +import com.raival.compose.file.explorer.screen.main.tab.home.holder.HomeCategory +import com.raival.compose.file.explorer.screen.main.tab.home.holder.RecentFile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -28,14 +36,12 @@ import java.io.File class HomeTab : Tab() { override val id = globalClass.generateUid() - override val title = globalClass.getString(R.string.home_tab_title) - override val subtitle = emptyString - override val header = globalClass.getString(R.string.home_tab_header) + val recentFiles = mutableStateListOf() - val recentFileHolders = mutableStateListOf() + var showCustomizeHomeTabDialog by mutableStateOf(false) override fun onTabStarted() { super.onTabStarted() @@ -48,77 +54,77 @@ class HomeTab : Tab() { } fun fetchRecentFiles() { - if (recentFileHolders.isNotEmpty()) return + if (recentFiles.isNotEmpty()) return CoroutineScope(Dispatchers.IO).launch { - recentFileHolders.addAll(getRecentFiles()) + recentFiles.addAll(getRecentFiles()) } } - fun getMainCategories() = arrayListOf().apply { + fun getMainCategories() = arrayListOf().apply { val mainActivityManager = globalClass.mainActivityManager add( - HomeCategoryHolder( + HomeCategory( name = globalClass.getString(R.string.images), icon = Icons.Rounded.Image, onClick = { mainActivityManager.replaceCurrentTabWith( - FilesTab(StorageProvider.images) + FilesTab(VirtualFileHolder(IMAGE)) ) } ) ) add( - HomeCategoryHolder( + HomeCategory( name = globalClass.getString(R.string.videos), icon = Icons.Rounded.VideoFile, onClick = { mainActivityManager.replaceCurrentTabWith( - FilesTab(StorageProvider.videos) + FilesTab(VirtualFileHolder(VIDEO)) ) } ) ) add( - HomeCategoryHolder( + HomeCategory( name = globalClass.getString(R.string.audios), icon = Icons.Rounded.AudioFile, onClick = { mainActivityManager.replaceCurrentTabWith( - FilesTab(StorageProvider.audios) + FilesTab(VirtualFileHolder(AUDIO)) ) } ) ) add( - HomeCategoryHolder( + HomeCategory( name = globalClass.getString(R.string.documents), icon = Icons.AutoMirrored.Rounded.InsertDriveFile, onClick = { mainActivityManager.replaceCurrentTabWith( - FilesTab(StorageProvider.documents) + FilesTab(VirtualFileHolder(DOCUMENT)) ) } ) ) add( - HomeCategoryHolder( + HomeCategory( name = globalClass.getString(R.string.archives), icon = Icons.Rounded.Archive, onClick = { mainActivityManager.replaceCurrentTabWith( - FilesTab(StorageProvider.archives) + FilesTab(VirtualFileHolder(ARCHIVE)) ) } ) ) add( - HomeCategoryHolder( + HomeCategory( name = globalClass.getString(R.string.apps), icon = Icons.Rounded.Android, onClick = { @@ -130,10 +136,10 @@ class HomeTab : Tab() { ) } - private fun getRecentFiles(): ArrayList { - val recentFileHolders = ArrayList() + private fun getRecentFiles(): ArrayList { + val recentFiles = ArrayList() val contentResolver: ContentResolver = globalClass.contentResolver - val showHiddenFiles = globalClass.preferencesManager.displayPrefs.showHiddenFiles + val showHiddenFiles = globalClass.preferencesManager.fileListPrefs.showHiddenFiles val uri: Uri = MediaStore.Files.getContentUri("external") @@ -164,7 +170,7 @@ class HomeTab : Tab() { it.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED) val columnName = it.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) - while (it.moveToNext() && recentFileHolders.size < 15) { + while (it.moveToNext() && recentFiles.size < 15) { val filePath = it.getString(columnIndexPath) val lastModified = it.getLong(columnLastModified) val name = it.getString(columnName) @@ -172,8 +178,8 @@ class HomeTab : Tab() { if (file.isFile && filePath != null && name != null && (showHiddenFiles || !file.name.startsWith(".")) ) { - recentFileHolders.add( - RecentFileHolder( + recentFiles.add( + RecentFile( name, filePath, lastModified @@ -183,6 +189,6 @@ class HomeTab : Tab() { } } - return recentFileHolders + return recentFiles } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt new file mode 100644 index 00000000..6e76455f --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt @@ -0,0 +1,76 @@ +package com.raival.compose.file.explorer.screen.main.tab.home.data + +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import kotlinx.serialization.Serializable + +fun getDefaultHomeLayout(minimalLayout: Boolean = false) = HomeLayout( + listOf( + HomeSectionConfig( + id = "recent_files", + type = HomeSectionType.RECENT_FILES, + title = globalClass.getString(R.string.recent_files), + isEnabled = !minimalLayout, + order = 0 + ), + HomeSectionConfig( + id = "categories", + type = HomeSectionType.CATEGORIES, + title = globalClass.getString(R.string.categories), + isEnabled = !minimalLayout, + order = 1 + ), + HomeSectionConfig( + id = "storage", + type = HomeSectionType.STORAGE, + title = globalClass.getString(R.string.storage), + isEnabled = true, + order = 2 + ), + HomeSectionConfig( + id = "bookmarks", + type = HomeSectionType.BOOKMARKS, + title = globalClass.getString(R.string.bookmarks), + isEnabled = !minimalLayout, + order = 3 + ), + HomeSectionConfig( + id = "recycle_bin", + type = HomeSectionType.RECYCLE_BIN, + title = globalClass.getString(R.string.recycle_bin), + isEnabled = !minimalLayout, + order = 4 + ), + HomeSectionConfig( + id = "jump_to_path", + type = HomeSectionType.JUMP_TO_PATH, + title = globalClass.getString(R.string.jump_to_path), + isEnabled = !minimalLayout, + order = 5 + ) + ) +) + +@Serializable +data class HomeLayout( + val sections: List +) + +@Serializable +data class HomeSectionConfig( + val id: String, + val type: HomeSectionType, + val title: String, + val isEnabled: Boolean, + var order: Int +) + +@Serializable +enum class HomeSectionType { + RECENT_FILES, + CATEGORIES, + STORAGE, + BOOKMARKS, + RECYCLE_BIN, + JUMP_TO_PATH +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategoryHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategory.kt similarity index 86% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategoryHolder.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategory.kt index f940f5b6..282fa971 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategoryHolder.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategory.kt @@ -2,7 +2,7 @@ package com.raival.compose.file.explorer.screen.main.tab.home.holder import androidx.compose.ui.graphics.vector.ImageVector -data class HomeCategoryHolder( +data class HomeCategory( val name: String, val icon: ImageVector, val onClick: () -> Unit diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFile.kt similarity index 64% rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFileHolder.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFile.kt index e3389b68..587cd4b7 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFileHolder.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFile.kt @@ -1,11 +1,11 @@ package com.raival.compose.file.explorer.screen.main.tab.home.holder -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder import java.io.File -data class RecentFileHolder( +data class RecentFile( val name: String, val path: String, val lastModified: Long, - val documentHolder: DocumentHolder = DocumentHolder.fromFile(File(path)) + val file: LocalFileHolder = LocalFileHolder(File(path)) ) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt new file mode 100644 index 00000000..b0cbeccb --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt @@ -0,0 +1,295 @@ +package com.raival.compose.file.explorer.screen.main.tab.home.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.rounded.ArrowOutward +import androidx.compose.material.icons.rounded.Bookmarks +import androidx.compose.material.icons.rounded.Category +import androidx.compose.material.icons.rounded.DeleteSweep +import androidx.compose.material.icons.rounded.History +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.google.gson.Gson +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeLayout +import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeSectionConfig +import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeSectionType +import com.raival.compose.file.explorer.screen.main.tab.home.data.getDefaultHomeLayout +import kotlinx.coroutines.launch +import sh.calvin.reorderable.ReorderableCollectionItemScope +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeLayoutSettingsScreen( + onBackClick: (List) -> Unit +) { + val useDarkIcons = !isSystemInDarkTheme() + val coroutineScope = rememberCoroutineScope() + val sections = remember { mutableStateListOf() } + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState( + lazyListState = lazyListState, + onMove = { from, to -> + sections.add( + to.index, + sections.removeAt(from.index) + ) + } + ) + Dialog( + onDismissRequest = { onBackClick(sections) }, + properties = DialogProperties( + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + usePlatformDefaultWidth = false + ) + ) { + val color = MaterialTheme.colorScheme.surfaceContainerHigh + val systemUiController = rememberSystemUiController() + DisposableEffect(systemUiController, useDarkIcons) { + systemUiController.setStatusBarColor(color = color, darkIcons = useDarkIcons) + onDispose {} + } + + LaunchedEffect(Unit) { + val config = try { + Gson().fromJson( + globalClass.preferencesManager.appearancePrefs.homeTabLayout, + HomeLayout::class.java + ) + } catch (e: Exception) { + logger.logError(e) + getDefaultHomeLayout() + }.sections.sortedBy { it.order } + + sections.addAll(config) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.customize_home_layout), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + navigationIcon = { + IconButton(onClick = { onBackClick(sections) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null + ) + } + }, + actions = { + IconButton( + onClick = { + coroutineScope.launch { + sections.clear() + sections.addAll(getDefaultHomeLayout().sections) + } + } + ) { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = null + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + titleContentColor = MaterialTheme.colorScheme.onSurface + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(vertical = 16.dp) + ) { + // Sections list + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(sections, key = { it.id }) { section -> + ReorderableItem( + state = reorderableState, + key = section.id + ) { isDragging -> + HomeSectionItem( + reorderableScope = this, + section = section, + isDragging = isDragging, + onToggle = { enabled -> + val index = sections.indexOf(section) + sections[index] = section.copy(isEnabled = enabled) + } + ) + } + } + } + } + } + } +} + +@Composable +fun HomeSectionItem( + reorderableScope: ReorderableCollectionItemScope, + section: HomeSectionConfig, + isDragging: Boolean, + onToggle: (Boolean) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(12.dp)), + colors = CardDefaults.cardColors( + containerColor = if (isDragging) + MaterialTheme.colorScheme.surfaceVariant + else + MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isDragging) 8.dp else 2.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Section icon + Box( + modifier = Modifier + .size(48.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = section.type.getIcon(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Section info + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = section.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = section.type.getDescription(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Drag handle + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = with(reorderableScope) { + Modifier + .padding(end = 8.dp) + .draggableHandle() + } + ) + + // Toggle switch + Switch( + checked = section.isEnabled, + onCheckedChange = onToggle + ) + } + } +} + +fun HomeSectionType.getIcon(): ImageVector { + return when (this) { + HomeSectionType.RECENT_FILES -> Icons.Rounded.History + HomeSectionType.CATEGORIES -> Icons.Rounded.Category + HomeSectionType.STORAGE -> Icons.Rounded.Storage + HomeSectionType.BOOKMARKS -> Icons.Rounded.Bookmarks + HomeSectionType.RECYCLE_BIN -> Icons.Rounded.DeleteSweep + HomeSectionType.JUMP_TO_PATH -> Icons.Rounded.ArrowOutward + } +} + +fun HomeSectionType.getDescription(): String { + return when (this) { + HomeSectionType.RECENT_FILES -> globalClass.getString(R.string.recently_modified_files) + HomeSectionType.CATEGORIES -> globalClass.getString(R.string.quick_access_categories) + HomeSectionType.STORAGE -> globalClass.getString(R.string.storage_devices_and_locations) + HomeSectionType.BOOKMARKS -> globalClass.getString(R.string.bookmarked_files_and_folders) + HomeSectionType.RECYCLE_BIN -> globalClass.getString(R.string.deleted_files) + HomeSectionType.JUMP_TO_PATH -> globalClass.getString(R.string.quick_path_navigation) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt index b7093a9b..71a29e68 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt @@ -31,6 +31,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,161 +47,168 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.cheonjaeung.compose.grid.SimpleGridCells import com.cheonjaeung.compose.grid.VerticalGrid +import com.google.gson.Gson import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.screen.main.MainActivityManager import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab import com.raival.compose.file.explorer.screen.main.tab.files.coil.canUseCoil +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.BOOKMARKS +import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.RECENT import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab +import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeLayout +import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeSectionConfig +import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeSectionType +import com.raival.compose.file.explorer.screen.main.tab.home.data.getDefaultHomeLayout import com.raival.compose.file.explorer.screen.main.ui.SimpleNewTabViewItem import com.raival.compose.file.explorer.screen.main.ui.StorageDeviceView +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun ColumnScope.HomeTabContentView(tab: HomeTab) { - Column(modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState())) { - val mainActivityManager = globalClass.mainActivityManager - val context = LocalContext.current + val mainActivityManager = globalClass.mainActivityManager + val scope = rememberCoroutineScope() + val enabledSections = remember { mutableStateListOf() } - LaunchedEffect(tab.id) { - tab.fetchRecentFiles() + LaunchedEffect(tab.id) { + tab.fetchRecentFiles() + val config = try { + Gson().fromJson( + globalClass.preferencesManager.appearancePrefs.homeTabLayout, + HomeLayout::class.java + ) + } catch (e: Exception) { + logger.logError(e) + getDefaultHomeLayout() + }.sections.filter { it.isEnabled }.sortedBy { it.order } + + enabledSections.addAll(config) + } + + if (tab.showCustomizeHomeTabDialog) { + HomeLayoutSettingsScreen { sections -> + tab.showCustomizeHomeTabDialog = false + var isAllDisabled = false + + // Prevent disabling all sections + if (sections.all { !it.isEnabled }) { + isAllDisabled = true + } + + sections.forEachIndexed { index, config -> + config.order = index + } + + enabledSections.apply { + clear() + if (isAllDisabled) { + addAll(getDefaultHomeLayout(true).sections.filter { it.isEnabled } + .sortedBy { it.order }) + } else { + addAll(sections.filter { it.isEnabled }.sortedBy { it.order }) + } + + } + + scope.launch { + if (isAllDisabled) { + globalClass.preferencesManager.appearancePrefs.homeTabLayout = Gson().toJson( + getDefaultHomeLayout(true) + ) + } else { + globalClass.preferencesManager.appearancePrefs.homeTabLayout = Gson().toJson( + HomeLayout(sections) + ) + } + } } + } - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 12.dp), - text = stringResource(R.string.recent_files), - style = MaterialTheme.typography.titleMedium - ) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + enabledSections.forEach { section -> + when (section.type) { + HomeSectionType.RECENT_FILES -> { + RecentFilesSection(tab = tab, mainActivityManager = mainActivityManager) + } - if (tab.recentFileHolders.isEmpty()) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 12.dp) - .clickable { - mainActivityManager.replaceCurrentTabWith( - FilesTab(StorageProvider.recentFiles) - ) - }, - text = stringResource(R.string.no_recent_files), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - } else { - LazyRow( - modifier = Modifier - .fillMaxWidth() - .height(140.dp), - verticalAlignment = Alignment.CenterVertically - ) { - item { Space(6.dp) } + HomeSectionType.CATEGORIES -> { + CategoriesSection(tab = tab) + } - items(tab.recentFileHolders, key = { it.path }) { - Column( - modifier = Modifier - .size(110.dp, 140.dp) - .padding(horizontal = 6.dp) - .background( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow - ) - .border( - width = 0.5.dp, - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = RoundedCornerShape(8.dp) - ) - .clip(RoundedCornerShape(8.dp)) - .combinedClickable( - onClick = { - it.documentHolder.openFile(context, false, false) - }, - onLongClick = { - mainActivityManager.addTabAndSelect( - FilesTab(it.documentHolder) - ) - } - ) - ) { - if (canUseCoil(it.documentHolder)) { - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .weight(2f), - model = it.documentHolder, - contentDescription = null, - contentScale = ContentScale.Crop, - filterQuality = FilterQuality.Low - ) - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(2f), - contentAlignment = Alignment.Center - ) { - Image( - modifier = Modifier.size(48.dp), - painter = painterResource(id = it.documentHolder.getFileIconResource()), - contentDescription = null - ) - } - } + HomeSectionType.STORAGE -> { + StorageSection(mainActivityManager = mainActivityManager) + } - Text( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(8.dp), - text = it.name, - style = MaterialTheme.typography.labelSmall, - maxLines = 2 - ) - } + HomeSectionType.BOOKMARKS -> { + BookmarksSection(mainActivityManager = mainActivityManager) } - item { - TextButton( - onClick = { - mainActivityManager.replaceCurrentTabWith( - FilesTab(StorageProvider.recentFiles) - ) - } - ) { - Text( - text = stringResource(R.string.more) - ) - } + HomeSectionType.RECYCLE_BIN -> { + RecycleBinSection(mainActivityManager = mainActivityManager) } - item { Space(6.dp) } + HomeSectionType.JUMP_TO_PATH -> { + JumpToPathSection(mainActivityManager = mainActivityManager) + } } } + } +} + +@Composable +private fun RecentFilesSection( + tab: HomeTab, + mainActivityManager: MainActivityManager +) { + val context = LocalContext.current - Space(12.dp) + // Recent files + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + text = stringResource(R.string.recent_files), + style = MaterialTheme.typography.titleMedium + ) + if (tab.recentFiles.isEmpty()) { Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 12.dp), - text = stringResource(R.string.categories), - style = MaterialTheme.typography.titleMedium + .height(140.dp) + .padding(horizontal = 12.dp, vertical = 12.dp) + .clickable { + mainActivityManager.replaceCurrentTabWith( + FilesTab(VirtualFileHolder(RECENT)) + ) + }, + text = stringResource(R.string.no_recent_files), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center ) - - VerticalGrid( + } else { + LazyRow( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp), - columns = SimpleGridCells.Fixed(3) + .height(140.dp), + verticalAlignment = Alignment.CenterVertically ) { - tab.getMainCategories().forEach { + item { Space(6.dp) } + + items(tab.recentFiles, key = { it.path }) { Column( - Modifier - .padding(4.dp) + modifier = Modifier + .size(110.dp, 140.dp) + .padding(horizontal = 6.dp) .background( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceContainerLow @@ -208,66 +218,184 @@ fun ColumnScope.HomeTabContentView(tab: HomeTab) { color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = RoundedCornerShape(8.dp) ) - .clickable { it.onClick() } - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .clip(RoundedCornerShape(8.dp)) + .combinedClickable( + onClick = { + it.file.open(context, false, false, null) + }, + onLongClick = { + mainActivityManager.replaceCurrentTabWith( + FilesTab(it.file) + ) + } + ) ) { - Icon( - modifier = Modifier.padding(8.dp), - imageVector = it.icon, - contentDescription = null + if (canUseCoil(it.file)) { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .weight(2f), + model = it.file, + contentDescription = null, + contentScale = ContentScale.Crop, + filterQuality = FilterQuality.Low + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(2f), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.size(48.dp), + painter = painterResource(id = it.file.iconPlaceholder), + contentDescription = null + ) + } + } + + Text( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(8.dp), + text = it.name, + style = MaterialTheme.typography.labelSmall, + maxLines = 2 ) - Text(text = it.name) } } - } - - Space(12.dp) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 12.dp), - text = stringResource(R.string.storage), - style = MaterialTheme.typography.titleMedium - ) - - StorageProvider.getStorageDevices(globalClass).forEach { - StorageDeviceView(storageDeviceHolder = it) { - mainActivityManager.replaceCurrentTabWith(FilesTab(it.documentHolder)) + item { + TextButton( + onClick = { + mainActivityManager.replaceCurrentTabWith( + FilesTab(VirtualFileHolder(RECENT)) + ) + } + ) { + Text( + text = stringResource(R.string.more) + ) + } } - HorizontalDivider() + item { Space(6.dp) } } + } - if (globalClass.filesTabManager.bookmarks.isNotEmpty()) { - SimpleNewTabViewItem( - title = stringResource(R.string.bookmarks), - imageVector = Icons.Rounded.Bookmarks + Space(12.dp) +} + +@Composable +private fun CategoriesSection( + tab: HomeTab +) { + // Quick access tiles + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + text = stringResource(R.string.categories), + style = MaterialTheme.typography.titleMedium + ) + + VerticalGrid( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + columns = SimpleGridCells.Fixed(3) + ) { + tab.getMainCategories().forEach { + Column( + Modifier + .padding(4.dp) + .background( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) + .border( + width = 0.5.dp, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(8.dp) + ) + .clickable { it.onClick() } + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - mainActivityManager.replaceCurrentTabWith( - FilesTab(StorageProvider.bookmarks) + Icon( + modifier = Modifier.padding(8.dp), + imageVector = it.icon, + contentDescription = null ) + Text(text = it.name) } - - HorizontalDivider() } + } - SimpleNewTabViewItem( - title = stringResource(R.string.recycle_bin), - imageVector = Icons.Rounded.DeleteSweep - ) { - mainActivityManager.replaceCurrentTabWith(FilesTab(globalClass.recycleBinDir)) - } + Space(12.dp) +} +@Composable +private fun StorageSection( + mainActivityManager: MainActivityManager +) { + // Storage options + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + text = stringResource(R.string.storage), + style = MaterialTheme.typography.titleMedium + ) + + StorageProvider.getStorageDevices(globalClass).forEach { + StorageDeviceView(storageDevice = it) { + mainActivityManager.replaceCurrentTabWith(FilesTab(it.contentHolder)) + } HorizontalDivider() + } +} +@Composable +private fun BookmarksSection( + mainActivityManager: MainActivityManager +) { + if (globalClass.filesTabManager.bookmarks.isNotEmpty()) { SimpleNewTabViewItem( - title = stringResource(R.string.jump_to_path), - imageVector = Icons.Rounded.ArrowOutward + title = stringResource(R.string.bookmarks), + imageVector = Icons.Rounded.Bookmarks ) { - mainActivityManager.showJumpToPathDialog = true + mainActivityManager.replaceCurrentTabWith( + FilesTab(VirtualFileHolder(BOOKMARKS)) + ) } + HorizontalDivider() + } +} + +@Composable +private fun RecycleBinSection( + mainActivityManager: MainActivityManager +) { + SimpleNewTabViewItem( + title = stringResource(R.string.recycle_bin), + imageVector = Icons.Rounded.DeleteSweep + ) { + mainActivityManager.replaceCurrentTabWith(FilesTab(globalClass.recycleBinDir)) + } + HorizontalDivider() +} + +@Composable +private fun JumpToPathSection( + mainActivityManager: MainActivityManager +) { + SimpleNewTabViewItem( + title = stringResource(R.string.jump_to_path), + imageVector = Icons.Rounded.ArrowOutward + ) { + mainActivityManager.showJumpToPathDialog = true } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AppInfoDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AppInfoDialog.kt index 04ca396a..e1bf818c 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AppInfoDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AppInfoDialog.kt @@ -1,7 +1,6 @@ package com.raival.compose.file.explorer.screen.main.ui import android.content.Intent -import android.net.Uri import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -16,10 +15,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.core.net.toUri import coil3.compose.AsyncImage import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.ui.block +import com.raival.compose.file.explorer.screen.logs.LogsActivity @Composable fun AppInfoDialog() { @@ -66,7 +67,7 @@ fun AppInfoDialog() { context.startActivity( Intent( Intent.ACTION_VIEW, - Uri.parse("https://github.com/Raival-e/File-Explorer-Compose") + "https://github.com/Raival-e/File-Explorer-Compose".toUri() ) ) } @@ -74,6 +75,17 @@ fun AppInfoDialog() { Text(text = stringResource(R.string.github)) } + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + context.startActivity( + Intent(context, LogsActivity::class.java) + ) + } + ) { + Text(text = stringResource(R.string.title_activity_logs)) + } + Button( modifier = Modifier.fillMaxWidth(), onClick = { diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/DrawerContent.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/DrawerContent.kt deleted file mode 100644 index c3315c09..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/DrawerContent.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.raival.compose.file.explorer.screen.main.ui - -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.FolderOpen -import androidx.compose.material.icons.rounded.Numbers -import androidx.compose.material.icons.rounded.OpenInBrowser -import androidx.compose.material.icons.rounded.SdStorage -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.toFormattedSize -import com.raival.compose.file.explorer.common.ui.Space -import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.misc.INTERNAL_STORAGE -import com.raival.compose.file.explorer.screen.main.tab.files.misc.ROOT -import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider -import com.raival.compose.file.explorer.screen.preferences.PreferencesActivity -import kotlinx.coroutines.launch - -@Composable -fun DrawerContent() { - val configuration = LocalConfiguration.current - val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE - val isTablet = configuration.screenWidthDp >= 600 - val drawerWidth = if(isLandscape || isTablet) 400.dp else configuration.screenWidthDp.dp / 4 * 3 - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - - Column( - Modifier - .fillMaxHeight() - .width(drawerWidth) - .background(color = MaterialTheme.colorScheme.surfaceContainerLowest) - ) { - Row( - Modifier - .fillMaxWidth() - .height(64.dp) - .background(color = MaterialTheme.colorScheme.surfaceContainer) - .padding(8.dp) - .padding(start = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text( - modifier = Modifier, - text = stringResource(id = R.string.app_name), - fontSize = 16.sp, - maxLines = 1, - fontWeight = FontWeight.Medium, - lineHeight = 20.sp, - overflow = TextOverflow.Ellipsis - ) - Text( - modifier = Modifier.alpha(0.7f), - text = "https://github.com/raival", - fontSize = 12.sp, - maxLines = 1, - lineHeight = 16.sp, - overflow = TextOverflow.Ellipsis - ) - } - - IconButton(onClick = { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("https://github.com/Raival-e/File-Explorer-Compose") - ) - ) - }) { - Icon(imageVector = Icons.Rounded.OpenInBrowser, contentDescription = null) - } - - IconButton(onClick = { - context.startActivity(Intent(context, PreferencesActivity::class.java)) - }) { - Icon(imageVector = Icons.Rounded.Settings, contentDescription = null) - } - } - - StorageProvider.getStorageDevices(globalClass).forEach { storageDevice -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - globalClass.mainActivityManager.apply { - coroutineScope.launch { - drawerState.close() - } - addTabAndSelect(FilesTab(storageDevice.documentHolder)) - } - - } - .padding(12.dp) - .padding(end = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - modifier = Modifier.padding(8.dp), - imageVector = when(storageDevice.type) { - INTERNAL_STORAGE -> Icons.Rounded.FolderOpen - ROOT -> Icons.Rounded.Numbers - else -> Icons.Rounded.SdStorage - }, - contentDescription = null - ) - Space(size = 8.dp) - Column { - Text(text = storageDevice.title) - LinearProgressIndicator( - modifier = Modifier.height(8.dp), - progress = { storageDevice.usedSize.toFloat() / storageDevice.totalSize }, - strokeCap = StrokeCap.Round - ) - Text( - modifier = Modifier - .alpha(0.6f), - text = "${storageDevice.usedSize.toFormattedSize()} used of ${storageDevice.totalSize.toFormattedSize()}", - fontSize = 12.sp, - textAlign = TextAlign.End - ) - } - } - } - } -} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/JumpToPathDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/JumpToPathDialog.kt index 41a82d3c..3fe88821 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/JumpToPathDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/JumpToPathDialog.kt @@ -1,108 +1,156 @@ package com.raival.compose.file.explorer.screen.main.ui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Cancel import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.common.ui.Space -import com.raival.compose.file.explorer.common.ui.block import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import java.io.File @Composable fun JumpToPathDialog() { val mainActivityManager = globalClass.mainActivityManager if (mainActivityManager.showJumpToPathDialog) { val context = LocalContext.current - var jumpToPathText by remember { mutableStateOf(emptyString) } - var isJumpToPathValid by remember { mutableStateOf(false) } - Dialog(onDismissRequest = { mainActivityManager.showJumpToPathDialog = false }) { - Column( - modifier = Modifier - .block() - .padding(16.dp) + Dialog( + onDismissRequest = { mainActivityManager.showJumpToPathDialog = false }, + ) { + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { - Text( + Column( modifier = Modifier - .fillMaxWidth(), - text = stringResource(R.string.jump_to_path), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - - Space(size = 12.dp) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.jump_to_path), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } - TextField( - modifier = Modifier - .fillMaxWidth(), - value = jumpToPathText, - onValueChange = { - jumpToPathText = it - isJumpToPathValid = FilesTab.isValidPath(it) - }, - label = { - Text(text = stringResource(R.string.destination_path)) - }, - trailingIcon = { - AnimatedVisibility(visible = jumpToPathText.isNotEmpty()) { - IconButton( - onClick = { jumpToPathText = emptyString } - ) { - Icon( - imageVector = Icons.Rounded.Cancel, - contentDescription = null - ) + TextField( + modifier = Modifier.fillMaxWidth(), + value = jumpToPathText, + onValueChange = { + jumpToPathText = it + isJumpToPathValid = FilesTab.isValidLocalPath(it) + }, + label = { Text(text = stringResource(R.string.destination_path)) }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + trailingIcon = { + AnimatedVisibility(visible = jumpToPathText.isNotEmpty()) { + IconButton( + onClick = { jumpToPathText = emptyString } + ) { + Icon( + imageVector = Icons.Rounded.Cancel, + contentDescription = null + ) + } } } - } - ) + ) - Space(size = 8.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + mainActivityManager.showJumpToPathDialog = false + }, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.labelLarge + ) + } - Button( - onClick = { - if (isJumpToPathValid) { - mainActivityManager.jumpToFile( - DocumentHolder.fromFullPath(jumpToPathText)!!, - context + Button( + modifier = Modifier.weight(1f), + onClick = { + if (isJumpToPathValid) { + mainActivityManager.jumpToFile( + LocalFileHolder( + File(jumpToPathText) + ), + context + ) + mainActivityManager.showJumpToPathDialog = false + } + }, + enabled = jumpToPathText.isNotEmpty() && isJumpToPathValid, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = if (isJumpToPathValid) stringResource(R.string.open) else stringResource( + R.string.invalid_path + ), + style = MaterialTheme.typography.labelLarge ) - mainActivityManager.showJumpToPathDialog = false } - }, - enabled = isJumpToPathValid - ) { - Text( - text = if (isJumpToPathValid) stringResource(R.string.open) else stringResource( - R.string.invalid_path - ) - ) + } } } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/NewTabDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/NewTabDialog.kt index 2f8c35a1..8dcee018 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/NewTabDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/NewTabDialog.kt @@ -39,8 +39,8 @@ fun NewTabDialog() { ) StorageProvider.getStorageDevices(globalClass).forEach { - StorageDeviceView(storageDeviceHolder = it) { - globalClass.mainActivityManager.addTabAndSelect(FilesTab(it.documentHolder)) + StorageDeviceView(storageDevice = it) { + globalClass.mainActivityManager.addTabAndSelect(FilesTab(it.contentHolder)) mainActivityManager.showNewTabDialog = false } HorizontalDivider() diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/StorageDeviceView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/StorageDeviceView.kt index 38433b18..3e4e2e47 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/StorageDeviceView.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/StorageDeviceView.kt @@ -23,13 +23,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.raival.compose.file.explorer.common.extension.toFormattedSize import com.raival.compose.file.explorer.common.ui.Space -import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDeviceHolder -import com.raival.compose.file.explorer.screen.main.tab.files.misc.INTERNAL_STORAGE -import com.raival.compose.file.explorer.screen.main.tab.files.misc.ROOT +import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDevice +import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.INTERNAL_STORAGE +import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.ROOT @Composable fun StorageDeviceView( - storageDeviceHolder: StorageDeviceHolder, + storageDevice: StorageDevice, onClick: () -> Unit = {} ) { Row( @@ -44,7 +44,7 @@ fun StorageDeviceView( ) { Icon( modifier = Modifier.padding(8.dp), - imageVector = when (storageDeviceHolder.type) { + imageVector = when (storageDevice.type) { INTERNAL_STORAGE -> Icons.Rounded.FolderOpen ROOT -> Icons.Rounded.Numbers else -> Icons.Rounded.SdStorage @@ -53,7 +53,7 @@ fun StorageDeviceView( ) Space(size = 8.dp) Column { - Text(text = storageDeviceHolder.title) + Text(text = storageDevice.title) Space(size = 8.dp) Row( Modifier.fillMaxWidth(), @@ -63,7 +63,7 @@ fun StorageDeviceView( modifier = Modifier .weight(1f) .height(8.dp), - progress = { storageDeviceHolder.usedSize.toFloat() / storageDeviceHolder.totalSize }, + progress = { storageDevice.usedSize.toFloat() / storageDevice.totalSize }, strokeCap = StrokeCap.Round ) Space(size = 8.dp) @@ -71,7 +71,7 @@ fun StorageDeviceView( modifier = Modifier .alpha(0.6f) .weight(1f), - text = "${storageDeviceHolder.usedSize.toFormattedSize()}/${storageDeviceHolder.totalSize.toFormattedSize()}", + text = "${storageDevice.usedSize.toFormattedSize()}/${storageDevice.totalSize.toFormattedSize()}", fontSize = 12.sp, textAlign = TextAlign.End ) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/TabLayout.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/TabLayout.kt index db676f5c..dd46df6f 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/TabLayout.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/TabLayout.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material3.Icon @@ -25,7 +24,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState @Composable fun TabLayout() { val mainActivityManager = globalClass.mainActivityManager - val tabLayoutState = rememberLazyListState() + val tabLayoutState = globalClass.mainActivityManager.tabLayoutState val reorderableLazyListState = rememberReorderableLazyListState(tabLayoutState) { from, to -> if (from.index == mainActivityManager.selectedTabIndex) mainActivityManager.selectedTabIndex = to.index @@ -57,7 +56,7 @@ fun TabLayout() { ReorderableItem(reorderableLazyListState, key = tab.id) { isDragging -> TabHeaderView( tab = tab, - isSelected = mainActivityManager.tabs[mainActivityManager.selectedTabIndex] == tab, + isSelected = mainActivityManager.getActiveTab() == tab, index = index, this ) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/ToolbarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/ToolbarView.kt index 21f3d56c..5349604b 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/ToolbarView.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/ToolbarView.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.EditAttributes import androidx.compose.material.icons.rounded.Menu import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Settings @@ -34,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab import com.raival.compose.file.explorer.screen.preferences.PreferencesActivity @Composable @@ -97,6 +99,23 @@ fun Toolbar() { onDismissRequest = { showOptionsMenu = false }, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ) { + if (mainActivityManager.getActiveTab() is HomeTab) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.customize_home_tab)) }, + onClick = { + (mainActivityManager.getActiveTab() as HomeTab).showCustomizeHomeTabDialog = + true + showOptionsMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.EditAttributes, + contentDescription = null + ) + } + ) + } + DropdownMenuItem( text = { Text(text = stringResource(R.string.preferences)) }, onClick = { diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt index 67e166c6..3959fb33 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt @@ -29,8 +29,10 @@ import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.base.BaseActivity import com.raival.compose.file.explorer.common.ui.SafeSurface import com.raival.compose.file.explorer.common.ui.Space -import com.raival.compose.file.explorer.screen.preferences.ui.DisplayContainer -import com.raival.compose.file.explorer.screen.preferences.ui.GeneralContainer +import com.raival.compose.file.explorer.screen.preferences.ui.AppearanceContainer +import com.raival.compose.file.explorer.screen.preferences.ui.BehaviorContainer +import com.raival.compose.file.explorer.screen.preferences.ui.FileListContainer +import com.raival.compose.file.explorer.screen.preferences.ui.FileOperationContainer import com.raival.compose.file.explorer.screen.preferences.ui.SingleChoiceDialog import com.raival.compose.file.explorer.screen.preferences.ui.TextEditorContainer import com.raival.compose.file.explorer.theme.FileExplorerTheme @@ -79,10 +81,17 @@ class PreferencesActivity : BaseActivity() { Column( Modifier .fillMaxSize() - .verticalScroll(rememberScrollState())) { + .verticalScroll(rememberScrollState()) + ) { + Space(size = 4.dp) + AppearanceContainer() + Space(size = 4.dp) + FileListContainer() + Space(size = 4.dp) + FileOperationContainer() + Space(size = 4.dp) + BehaviorContainer() Space(size = 4.dp) - DisplayContainer() - GeneralContainer() TextEditorContainer() Space(size = 4.dp) } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt index c9852728..dc0288a5 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt @@ -7,80 +7,60 @@ import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import com.google.gson.Gson import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.common.extension.fromJson import com.raival.compose.file.explorer.common.extension.toJson -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileSortingPrefs import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_NAME +import com.raival.compose.file.explorer.screen.main.tab.home.data.getDefaultHomeLayout import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize import com.raival.compose.file.explorer.screen.preferences.constant.ThemePreference -import com.raival.compose.file.explorer.screen.preferences.constant.dataStore +import com.raival.compose.file.explorer.screen.preferences.misc.prefDataStore import com.raival.compose.file.explorer.screen.preferences.misc.prefMutableState import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking class PreferencesManager { - object SingleChoiceDialog { - var show by mutableStateOf(false) - - var title = emptyString - private set - var description = emptyString - private set - var choices = mutableListOf() - private set - var onSelect: (choice: Int) -> Unit = {} - private set - var selectedChoice = -1 - - fun dismiss() { - show = false - title = emptyString - description = emptyString - choices.clear() - selectedChoice = -1 - onSelect = {} - } + val singleChoiceDialog = SingleChoiceDialog() - fun show( - title: String, - description: String, - choices: List, - selectedChoice: Int, - onSelect: (choice: Int) -> Unit - ) { - SingleChoiceDialog.title = title - SingleChoiceDialog.description = description - SingleChoiceDialog.choices.clear() - SingleChoiceDialog.choices.addAll(choices) - SingleChoiceDialog.onSelect = onSelect - SingleChoiceDialog.selectedChoice = selectedChoice - show = true - } - } + val appearancePrefs = AppearancePrefs() + val fileListPrefs = FileListPrefs() + val fileOperationPrefs = FileOperationPrefs() + val behaviorPrefs = BehaviorPrefs() + val textEditorPrefs = TextEditorPrefs() + val filesSortingPrefs = FilesSortingPrefs() - object DisplayPrefs { + class AppearancePrefs { var theme by prefMutableState( keyName = "theme", defaultValue = ThemePreference.SYSTEM.ordinal, getPreferencesKey = { intPreferencesKey(it) } ) - var fileListSize by prefMutableState( - keyName = "fileListSize", - defaultValue = FilesTabFileListSize.LARGE.ordinal, - getPreferencesKey = { intPreferencesKey(it) } - ) - var showBottomBarLabels by prefMutableState( keyName = "showBottomBarLabels", defaultValue = true, getPreferencesKey = { booleanPreferencesKey(it) } ) - var fileListColumnCount by prefMutableState( + var homeTabLayout by prefMutableState( + keyName = "homeTabLayout", + defaultValue = Gson().toJson(getDefaultHomeLayout()), + getPreferencesKey = { stringPreferencesKey("homeTabLayout") } + ) + } + + class FileListPrefs { + var itemSize by prefMutableState( + keyName = "fileListSize", + defaultValue = FilesTabFileListSize.MEDIUM.ordinal, + getPreferencesKey = { intPreferencesKey(it) } + ) + + var columnCount by prefMutableState( keyName = "fileListColumnCount", defaultValue = 1, getPreferencesKey = { intPreferencesKey(it) } @@ -99,7 +79,47 @@ class PreferencesManager { ) } - object TextEditorPrefs { + class BehaviorPrefs { + var showFileOptionMenuOnLongClick by prefMutableState( + keyName = "showFileOptionMenuOnLongClick", + defaultValue = true, + getPreferencesKey = { booleanPreferencesKey(it) } + ) + + var disablePullDownToRefresh by prefMutableState( + keyName = "disablePullDownToRefresh", + defaultValue = true, + getPreferencesKey = { booleanPreferencesKey(it) } + ) + + var skipHomeWhenTabClosed by prefMutableState( + keyName = "skipHomeWhenTabClosed", + defaultValue = false, + getPreferencesKey = { booleanPreferencesKey(it) } + ) + } + + class FileOperationPrefs { + var searchInFilesLimit by prefMutableState( + keyName = "searchInFilesLimit", + defaultValue = 150, + getPreferencesKey = { intPreferencesKey(it) } + ) + + var signMergedApkBundleFiles by prefMutableState( + keyName = "signMergedApkBundleFiles", + defaultValue = true, + getPreferencesKey = { booleanPreferencesKey(it) } + ) + + var moveToRecycleBin by prefMutableState( + keyName = "moveToRecycleBin", + defaultValue = true, + getPreferencesKey = { booleanPreferencesKey(it) } + ) + } + + class TextEditorPrefs { var pinLineNumber by prefMutableState( keyName = "pinLineNumber", defaultValue = true, @@ -161,34 +181,8 @@ class PreferencesManager { ) } - object GeneralPrefs { - var searchInFilesLimit by prefMutableState( - keyName = "searchInFilesLimit", - defaultValue = 150, - getPreferencesKey = { intPreferencesKey(it) } - ) - - var showFileOptionMenuOnLongClick by prefMutableState( - keyName = "showFileOptionMenuOnLongClick", - defaultValue = true, - getPreferencesKey = { booleanPreferencesKey(it) } - ) - - var moveToRecycleBin by prefMutableState( - keyName = "moveToRecycleBin", - defaultValue = true, - getPreferencesKey = { booleanPreferencesKey(it) } - ) - - var signApk by prefMutableState( - keyName = "signApk", - defaultValue = false, - getPreferencesKey = { booleanPreferencesKey(it) } - ) - } - - object FilesSortingPrefs { - var filesSortingMethod by prefMutableState( + class FilesSortingPrefs { + var defaultSortMethod by prefMutableState( keyName = "filesSortingMethod", defaultValue = SORT_BY_NAME, getPreferencesKey = { intPreferencesKey(it) } @@ -200,36 +194,70 @@ class PreferencesManager { getPreferencesKey = { booleanPreferencesKey(it) } ) - var reverseFilesSortingMethod by prefMutableState( + var reverse by prefMutableState( keyName = "reverseFilesSortingMethod", defaultValue = false, getPreferencesKey = { booleanPreferencesKey(it) } ) - fun getSortingPrefsFor(doc: DocumentHolder): FileSortingPrefs { + fun getSortingPrefsFor(content: ContentHolder): FileSortingPrefs { return runBlocking { fromJson( - globalClass.dataStore.data.first()[stringPreferencesKey("fileSortingPrefs_${doc.path}")] + globalClass.prefDataStore.data.first()[stringPreferencesKey("fileSortingPrefs_${content.uniquePath}")] ) ?: FileSortingPrefs( - sortMethod = filesSortingMethod, + sortMethod = defaultSortMethod, showFoldersFirst = showFoldersFirst, - reverseSorting = reverseFilesSortingMethod + reverseSorting = reverse ) } } - fun setSortingPrefsFor(doc: DocumentHolder, prefs: FileSortingPrefs) { + fun setSortingPrefsFor(content: ContentHolder, prefs: FileSortingPrefs) { runBlocking { - globalClass.dataStore.edit { - it[stringPreferencesKey("fileSortingPrefs_${doc.path}")] = prefs.toJson() + globalClass.prefDataStore.edit { + it[stringPreferencesKey("fileSortingPrefs_${content.uniquePath}")] = + prefs.toJson() } } } } - val singleChoiceDialog = SingleChoiceDialog - val displayPrefs = DisplayPrefs - val textEditorPrefs = TextEditorPrefs - val generalPrefs = GeneralPrefs - val filesSortingPrefs = FilesSortingPrefs + class SingleChoiceDialog { + var show by mutableStateOf(false) + + var title = emptyString + private set + var description = emptyString + private set + var choices = mutableListOf() + private set + var onSelect: (choice: Int) -> Unit = {} + private set + var selectedChoice = -1 + + fun dismiss() { + show = false + title = emptyString + description = emptyString + choices.clear() + selectedChoice = -1 + onSelect = {} + } + + fun show( + title: String, + description: String, + choices: List, + selectedChoice: Int, + onSelect: (choice: Int) -> Unit + ) { + this.title = title + this.description = description + this.choices.clear() + this.choices.addAll(choices) + this.onSelect = onSelect + this.selectedChoice = selectedChoice + show = true + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSize.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSize.kt index 1c74b9e2..7da77f61 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSize.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSize.kt @@ -1,6 +1,7 @@ package com.raival.compose.file.explorer.screen.preferences.constant enum class FilesTabFileListSize { + EXTRA_SMALL, SMALL, MEDIUM, LARGE, diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSizeMap.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSizeMap.kt index b3428d94..59d8a86f 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSizeMap.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSizeMap.kt @@ -1,7 +1,10 @@ package com.raival.compose.file.explorer.screen.preferences.constant +import com.raival.compose.file.explorer.App.Companion.globalClass + object FilesTabFileListSizeMap { object IconSize { + const val EXTRA_SMALL = 32 const val SMALL = 42 const val MEDIUM = 46 const val LARGE = 48 @@ -9,9 +12,31 @@ object FilesTabFileListSizeMap { } object FontSize { + const val EXTRA_SMALL = 12 const val SMALL = 15 const val MEDIUM = 18 const val LARGE = 20 const val EXTRA_LARGE = 22 } + + fun getFileListSpace() = when (globalClass.preferencesManager.fileListPrefs.itemSize) { + FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8 + else -> 4 + } + + fun getFileListIconSize() = when (globalClass.preferencesManager.fileListPrefs.itemSize) { + FilesTabFileListSize.EXTRA_SMALL.ordinal -> IconSize.EXTRA_SMALL + FilesTabFileListSize.SMALL.ordinal -> IconSize.SMALL + FilesTabFileListSize.MEDIUM.ordinal -> IconSize.MEDIUM + FilesTabFileListSize.LARGE.ordinal -> IconSize.LARGE + else -> IconSize.EXTRA_LARGE + } + + fun getFileListFontSize() = when (globalClass.preferencesManager.fileListPrefs.itemSize) { + FilesTabFileListSize.EXTRA_SMALL.ordinal -> FontSize.EXTRA_SMALL + FilesTabFileListSize.SMALL.ordinal -> FontSize.SMALL + FilesTabFileListSize.MEDIUM.ordinal -> FontSize.MEDIUM + FilesTabFileListSize.LARGE.ordinal -> FontSize.LARGE + else -> FontSize.EXTRA_LARGE + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/DataStore.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/DataStore.kt similarity index 54% rename from app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/DataStore.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/DataStore.kt index fb31a438..a2efe698 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/DataStore.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/DataStore.kt @@ -1,8 +1,8 @@ -package com.raival.compose.file.explorer.screen.preferences.constant +package com.raival.compose.file.explorer.screen.preferences.misc import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore -val Context.dataStore: DataStore by preferencesDataStore(name = "preferences") \ No newline at end of file +val Context.prefDataStore: DataStore by preferencesDataStore(name = "preferences") \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/PrefMutableState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/PrefMutableState.kt index 694f42eb..1e89b6c8 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/PrefMutableState.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/PrefMutableState.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.raival.compose.file.explorer.App -import com.raival.compose.file.explorer.screen.preferences.constant.dataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -17,7 +16,7 @@ inline fun prefMutableState( val key: Preferences.Key = getPreferencesKey(keyName) val snapshotMutableState: MutableState = mutableStateOf( runBlocking { - App.globalClass.dataStore.data.first()[key] ?: defaultValue + App.globalClass.prefDataStore.data.first()[key] ?: defaultValue } ) @@ -29,14 +28,14 @@ inline fun prefMutableState( snapshotMutableState.value = value runBlocking { try { - App.globalClass.dataStore.edit { + App.globalClass.prefDataStore.edit { if (value != null) { it[key] = value as A } else { it.remove(key) } } - } catch (e: Exception) { + } catch (_: Exception) { snapshotMutableState.value = rollbackValue } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AppearanceContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AppearanceContainer.kt new file mode 100644 index 00000000..ad9608b7 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AppearanceContainer.kt @@ -0,0 +1,58 @@ +package com.raival.compose.file.explorer.screen.preferences.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Label +import androidx.compose.material.icons.rounded.Nightlight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.screen.preferences.constant.ThemePreference + +@Composable +fun AppearanceContainer() { + val manager = globalClass.preferencesManager + val appearancePrefs = manager.appearancePrefs + + Container(title = stringResource(R.string.appearance)) { + PreferenceItem( + label = stringResource(R.string.theme), + supportingText = when (appearancePrefs.theme) { + ThemePreference.LIGHT.ordinal -> stringResource(R.string.light) + ThemePreference.DARK.ordinal -> stringResource(R.string.dark) + else -> stringResource(R.string.follow_system) + }, + icon = Icons.Rounded.Nightlight, + onClick = { + manager.singleChoiceDialog.show( + title = globalClass.getString(R.string.theme), + description = globalClass.getString(R.string.select_theme_preference), + choices = listOf( + globalClass.getString(R.string.light), + globalClass.getString(R.string.dark), + globalClass.getString(R.string.follow_system) + ), + selectedChoice = appearancePrefs.theme, + onSelect = { appearancePrefs.theme = it } + ) + } + ) + + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + + PreferenceItem( + label = stringResource(R.string.show_bottom_bar_labels), + supportingText = emptyString, + icon = Icons.AutoMirrored.Rounded.Label, + switchState = appearancePrefs.showBottomBarLabels, + onSwitchChange = { appearancePrefs.showBottomBarLabels = it } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/BehaviorContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/BehaviorContainer.kt new file mode 100644 index 00000000..77f1644f --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/BehaviorContainer.kt @@ -0,0 +1,55 @@ +package com.raival.compose.file.explorer.screen.preferences.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.TouchApp +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString + +@Composable +fun BehaviorContainer() { + val manager = globalClass.preferencesManager + val behaviorPrefs = manager.behaviorPrefs + + Container(title = stringResource(R.string.behavior)) { + PreferenceItem( + label = stringResource(R.string.show_files_options_menu_on_long_click), + supportingText = emptyString, + icon = Icons.Rounded.TouchApp, + switchState = behaviorPrefs.showFileOptionMenuOnLongClick, + onSwitchChange = { behaviorPrefs.showFileOptionMenuOnLongClick = it } + ) + + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + + PreferenceItem( + label = stringResource(R.string.disable_pull_down_to_refresh), + supportingText = emptyString, + icon = Icons.Rounded.Refresh, + switchState = behaviorPrefs.disablePullDownToRefresh, + onSwitchChange = { behaviorPrefs.disablePullDownToRefresh = it } + ) + + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + + PreferenceItem( + label = stringResource(R.string.skip_home_when_tab_closed), + supportingText = emptyString, + icon = Icons.Rounded.TouchApp, + switchState = behaviorPrefs.skipHomeWhenTabClosed, + onSwitchChange = { behaviorPrefs.skipHomeWhenTabClosed = it } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/Container.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/Container.kt index 7d3a2a70..e9d06929 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/Container.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/Container.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp @@ -31,15 +32,16 @@ fun Container( title: String, content: @Composable () -> Unit ) { - var expanded by remember { mutableStateOf(false) } + var expanded by remember { mutableStateOf(true) } Column( modifier = Modifier .fillMaxWidth() .padding(4.dp) .block( - color = MaterialTheme.colorScheme.surface, - borderSize = 0.dp + color = MaterialTheme.colorScheme.surfaceContainerLow, + borderSize = 0.dp, + shape = RoundedCornerShape(6.dp) ) .padding(12.dp) ) { diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/DisplayContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileListContainer.kt similarity index 55% rename from app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/DisplayContainer.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileListContainer.kt index 5e2501dc..1dd9791b 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/DisplayContainer.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileListContainer.kt @@ -1,52 +1,30 @@ package com.raival.compose.file.explorer.screen.preferences.ui import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Label import androidx.compose.material.icons.automirrored.rounded.ManageSearch import androidx.compose.material.icons.rounded.Height import androidx.compose.material.icons.rounded.HideSource -import androidx.compose.material.icons.rounded.Nightlight import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize -import com.raival.compose.file.explorer.screen.preferences.constant.ThemePreference @Composable -fun DisplayContainer() { +fun FileListContainer() { val manager = globalClass.preferencesManager - val preferences = manager.displayPrefs - - Container(title = stringResource(R.string.display)) { - PreferenceItem( - label = stringResource(R.string.theme), - supportingText = when (preferences.theme) { - ThemePreference.LIGHT.ordinal -> stringResource(R.string.light) - ThemePreference.DARK.ordinal -> stringResource(R.string.dark) - else -> stringResource(R.string.follow_system) - }, - icon = Icons.Rounded.Nightlight, - onClick = { - manager.singleChoiceDialog.show( - title = globalClass.getString(R.string.theme), - description = globalClass.getString(R.string.select_theme_preference), - choices = listOf( - globalClass.getString(R.string.light), - globalClass.getString(R.string.dark), - globalClass.getString(R.string.follow_system) - ), - selectedChoice = preferences.theme, - onSelect = { preferences.theme = it } - ) - } - ) + val fileListPrefs = manager.fileListPrefs + Container(title = stringResource(R.string.file_list)) { PreferenceItem( label = stringResource(R.string.file_list_size), - supportingText = when (preferences.fileListSize) { + supportingText = when (fileListPrefs.itemSize) { + FilesTabFileListSize.EXTRA_SMALL.ordinal -> stringResource(R.string.extra_small) FilesTabFileListSize.SMALL.ordinal -> stringResource(R.string.small) FilesTabFileListSize.MEDIUM.ordinal -> stringResource(R.string.medium) FilesTabFileListSize.LARGE.ordinal -> stringResource(R.string.large) @@ -58,66 +36,74 @@ fun DisplayContainer() { title = globalClass.getString(R.string.file_list_size), description = globalClass.getString(R.string.file_list_size_desc), choices = listOf( + globalClass.getString(R.string.extra_small), globalClass.getString(R.string.small), globalClass.getString(R.string.medium), globalClass.getString(R.string.large), globalClass.getString(R.string.extra_large) ), - selectedChoice = preferences.fileListSize, - onSelect = { preferences.fileListSize = it } + selectedChoice = fileListPrefs.itemSize, + onSelect = { fileListPrefs.itemSize = it } ) } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + val columnCount = arrayListOf( "1", "2", "3", "4", "Auto" ) PreferenceItem( label = stringResource(R.string.files_list_column_count), - supportingText = if (preferences.fileListColumnCount == -1) columnCount[4] else preferences.fileListColumnCount.toString(), + supportingText = if (fileListPrefs.columnCount == -1) columnCount[4] else fileListPrefs.columnCount.toString(), icon = Icons.AutoMirrored.Rounded.ManageSearch, onClick = { manager.singleChoiceDialog.show( title = globalClass.getString(R.string.files_list_column_count), description = globalClass.getString(R.string.choose_number_of_columns), choices = columnCount, - selectedChoice = if (preferences.fileListColumnCount == -1) 4 else columnCount.indexOf( - preferences.fileListColumnCount.toString() + selectedChoice = if (fileListPrefs.columnCount == -1) 4 else columnCount.indexOf( + fileListPrefs.columnCount.toString() ), onSelect = { val limit = when (columnCount[it]) { columnCount[4] -> -1 else -> columnCount[it].toIntOrNull() ?: -1 } - preferences.fileListColumnCount = limit + fileListPrefs.columnCount = limit } ) } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( label = stringResource(R.string.show_hidden_files), supportingText = emptyString, icon = Icons.Rounded.HideSource, - switchState = preferences.showHiddenFiles, - onSwitchChange = { preferences.showHiddenFiles = it } + switchState = fileListPrefs.showHiddenFiles, + onSwitchChange = { fileListPrefs.showHiddenFiles = it } ) - PreferenceItem( - label = stringResource(R.string.show_folder_s_content_count), - supportingText = emptyString, - icon = Icons.Rounded.Numbers, - switchState = preferences.showFolderContentCount, - onSwitchChange = { preferences.showFolderContentCount = it } + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp ) PreferenceItem( - label = stringResource(R.string.show_bottom_bar_labels), + label = stringResource(R.string.show_folder_s_content_count), supportingText = emptyString, - icon = Icons.AutoMirrored.Rounded.Label, - switchState = preferences.showBottomBarLabels, - onSwitchChange = { preferences.showBottomBarLabels = it } + icon = Icons.Rounded.Numbers, + switchState = fileListPrefs.showFolderContentCount, + onSwitchChange = { fileListPrefs.showFolderContentCount = it } ) } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/GeneralContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileOperationContaner.kt similarity index 63% rename from app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/GeneralContainer.kt rename to app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileOperationContaner.kt index ebf929fa..81805985 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/GeneralContainer.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileOperationContaner.kt @@ -2,22 +2,22 @@ package com.raival.compose.file.explorer.screen.preferences.ui import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ManageSearch -import androidx.compose.material.icons.rounded.Android +import androidx.compose.material.icons.rounded.Key +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R @Composable -fun GeneralContainer() { +fun FileOperationContainer() { val manager = globalClass.preferencesManager - val preferences = manager.generalPrefs + val preferences = manager.fileOperationPrefs + val limits = arrayListOf("15", "25", "50", "100", stringResource(R.string.unlimited)) - val limits = arrayListOf( - "15", "25", "50", "100", "Unlimited" - ) - - Container(title = stringResource(R.string.general)) { + Container(title = stringResource(R.string.file_operation)) { PreferenceItem( label = stringResource(R.string.search_in_files_limit), supportingText = if (preferences.searchInFilesLimit == -1) limits[4] else preferences.searchInFilesLimit.toString(), @@ -41,12 +41,17 @@ fun GeneralContainer() { } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( - label = stringResource(R.string.sign_apk), - supportingText = globalClass.getString(R.string.sign_apk_desc), - icon = Icons.Rounded.Android, - switchState = preferences.signApk, - onSwitchChange = { preferences.signApk = it } + label = stringResource(R.string.auto_sign_merged_apk_bundle_files), + supportingText = stringResource(R.string.auto_sign_merged_apk_bundle_files_description), + icon = Icons.Rounded.Key, + switchState = preferences.signMergedApkBundleFiles, + onSwitchChange = { preferences.signMergedApkBundleFiles = it } ) } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/TextEditorContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/TextEditorContainer.kt index fe504721..dd3a486a 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/TextEditorContainer.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/TextEditorContainer.kt @@ -9,8 +9,11 @@ import androidx.compose.material.icons.rounded.Numbers import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.SpaceBar import androidx.compose.material.icons.rounded.TextRotationNone +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R @@ -47,6 +50,11 @@ fun TextEditorContainer() { } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( label = stringResource(id = R.string.pin_numbers_line), icon = Icons.Rounded.Numbers, @@ -54,6 +62,11 @@ fun TextEditorContainer() { onSwitchChange = { preferences.pinLineNumber = it } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( label = stringResource(id = R.string.auto_symbol_pair), icon = Icons.Rounded.Code, @@ -61,6 +74,11 @@ fun TextEditorContainer() { onSwitchChange = { preferences.symbolPairAutoCompletion = it } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( label = stringResource(id = R.string.auto_indentation), icon = Icons.AutoMirrored.Rounded.KeyboardTab, @@ -68,6 +86,11 @@ fun TextEditorContainer() { onSwitchChange = { preferences.autoIndent = it } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( label = stringResource(id = R.string.magnifier), icon = Icons.Rounded.Search, @@ -75,6 +98,11 @@ fun TextEditorContainer() { onSwitchChange = { preferences.enableMagnifier = it } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( label = stringResource(id = R.string.use_icu_selection), icon = Icons.Rounded.TextRotationNone, @@ -82,6 +110,11 @@ fun TextEditorContainer() { onSwitchChange = { preferences.useICULibToSelectWords = it } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( label = stringResource(id = R.string.delete_empty_lines), icon = Icons.Rounded.DeleteSweep, @@ -89,6 +122,11 @@ fun TextEditorContainer() { onSwitchChange = { preferences.deleteEmptyLineFast = it } ) + HorizontalDivider( + color = MaterialTheme.colorScheme.surfaceContainerLow, + thickness = 3.dp + ) + PreferenceItem( label = stringResource(id = R.string.delete_tabs), icon = Icons.Rounded.SpaceBar, diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorActivity.kt index 37f52102..d489d9c1 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorActivity.kt @@ -6,9 +6,15 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Folder import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import com.raival.compose.file.explorer.App.Companion.globalClass @@ -82,16 +88,39 @@ class TextEditorActivity : BaseActivity() { ) } - WarningDialog() - JumpToPositionDialog(codeEditor) + if (textEditorManager.warningDialogProperties.showWarningDialog) { + WarningDialog(textEditorManager.warningDialogProperties) + } + + if (textEditorManager.showJumpToPositionDialog) { + JumpToPositionDialog(codeEditor) { + textEditorManager.showJumpToPositionDialog = false + } + } + RecentFilesDialog(codeEditor) ToolbarView(codeEditor, onBackPressedDispatcher) HorizontalDivider() - InfoBar() + InfoBar(textEditorManager.activitySubtitle) CodeEditorView(codeEditor) HorizontalDivider() - BottomBarView(codeEditor) - SearchPanel(codeEditor) + BottomBarView(codeEditor, textEditorManager.getSymbols(true)) { + IconButton(onClick = { + textEditorManager.hideSearchPanel(codeEditor) + textEditorManager.recentFileDialog.showRecentFileDialog = true + }) { + Icon( + modifier = Modifier.size(21.dp), + imageVector = Icons.Rounded.Folder, + contentDescription = null + ) + } + } + SearchPanel( + codeEditor, + textEditorManager.getFileInstance()!!.searcher, + textEditorManager.showSearchPanel + ) } } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorManager.kt index 84355258..9e9d44cb 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorManager.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorManager.kt @@ -3,21 +3,30 @@ package com.raival.compose.file.explorer.screen.textEditor import android.content.Context import android.content.Intent import android.graphics.Typeface +import android.os.Build import android.view.ViewGroup +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import com.google.gson.Gson import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.isDarkTheme import com.raival.compose.file.explorer.common.extension.isNot import com.raival.compose.file.explorer.common.extension.whiteSpace -import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsonFileType import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType @@ -25,82 +34,47 @@ import com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANG import com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_JSON import com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_KOTLIN import com.raival.compose.file.explorer.screen.textEditor.holder.SymbolHolder -import com.raival.compose.file.explorer.screen.textEditor.misc.setCodeEditorLanguage +import com.raival.compose.file.explorer.screen.textEditor.language.json.JsonCodeLanguage +import com.raival.compose.file.explorer.screen.textEditor.language.kotlin.KotlinCodeLanguage +import com.raival.compose.file.explorer.screen.textEditor.model.Searcher +import com.raival.compose.file.explorer.screen.textEditor.model.WarningDialogProperties +import com.raival.compose.file.explorer.screen.textEditor.scheme.DarkScheme +import com.raival.compose.file.explorer.screen.textEditor.scheme.LightScheme import io.github.rosemoe.sora.event.ContentChangeEvent import io.github.rosemoe.sora.event.PublishSearchResultEvent import io.github.rosemoe.sora.event.SelectionChangeEvent +import io.github.rosemoe.sora.lang.EmptyLanguage +import io.github.rosemoe.sora.langs.java.JavaLanguage +import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme +import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry import io.github.rosemoe.sora.text.Content import io.github.rosemoe.sora.widget.CodeEditor import io.github.rosemoe.sora.widget.component.EditorAutoCompletion import io.github.rosemoe.sora.widget.component.Magnifier +import io.github.rosemoe.sora.widget.schemes.EditorColorScheme import io.github.rosemoe.sora.widget.subscribeAlways import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import java.util.Locale class TextEditorManager { - - object RecentFileDialog { - var showRecentFileDialog by mutableStateOf(false) - - fun getRecentFiles(textEditorManager: TextEditorManager): SnapshotStateList { - val limit = globalClass.preferencesManager.textEditorPrefs.recentFilesLimit - var index = 0 - textEditorManager.fileInstanceList.removeIf { - if (limit > 0 && index > limit - 1) { - !it.file.exists() || !it.requireSave - } else { - !it.file.exists() - }.also { index++ } - } - return textEditorManager.fileInstanceList - } - } - - object Searcher { - var query by mutableStateOf(emptyString) - var replace by mutableStateOf(emptyString) - var caseSensitive by mutableStateOf(false) - var useRegex by mutableStateOf(false) - } - - data class FileInstance( - val file: DocumentHolder, - var content: Content, - var lastModified: Long, - var requireSave: Boolean = false, - val searcher: Searcher = Searcher - ) - - object WarningDialogProperties { - var showWarningDialog by mutableStateOf(false) - var title by mutableStateOf(emptyString) - var message by mutableStateOf(emptyString) - var confirmText by mutableStateOf(emptyString) - var dismissText by mutableStateOf(emptyString) - var onConfirm: () -> Unit = {} - var onDismiss: () -> Unit = {} - } - var showSearchPanel by mutableStateOf(false) - val warningDialogProperties = WarningDialogProperties - val recentFileDialog = RecentFileDialog + val warningDialogProperties = WarningDialogProperties() + val recentFileDialog = RecentFileDialog() private val untitledFileName = "untitled.txt" - private val textEditorDir = DocumentHolder.fromFile( - File(globalClass.appFiles.path, "textEditor").apply { if (!exists()) mkdirs() } + private val textEditorDir = LocalFileHolder( + File(globalClass.appFiles.uniquePath, "textEditor").apply { if (!exists()) mkdirs() } ) - private val customSymbolsFile = - DocumentHolder.fromFile(File(textEditorDir.toFile(), "symbols.txt")) + private val customSymbolsFile = LocalFileHolder( + File(textEditorDir.file, "symbols.txt") + ) - private val tempFile = textEditorDir.findFile(untitledFileName) - ?: textEditorDir.createSubFile(untitledFileName) - ?: throw RuntimeException(globalClass.getString(R.string.failed_to_create_temporary_file)) + private val tempFile = LocalFileHolder(File(textEditorDir.file, untitledFileName)) var activeFile = tempFile @@ -120,7 +94,7 @@ class TextEditorManager { SymbolHolder("="), SymbolHolder("{"), SymbolHolder("}"), - SymbolHolder("/"), + SymbolHolder(File.separator), SymbolHolder("\\"), SymbolHolder("<"), SymbolHolder(">"), @@ -133,21 +107,6 @@ class TextEditorManager { private var customSymbolHolders = arrayListOf() - val indentChar = " " - - fun parseCursorPosition(input: String): Pair { - val trimmedInput = input.trim() - return when { - trimmedInput.matches(Regex("\\d+")) -> Pair(trimmedInput.toInt(), 0) - trimmedInput.matches(Regex("\\d+:\\d+")) -> { - val parts = trimmedInput.split(":").map { it.trim().toInt() } - Pair(parts[0], parts[1]) - } - - else -> Pair(-1, -1) - } - } - fun updateSymbols() { if (!customSymbolsFile.exists()) { customSymbolsFile.writeText( @@ -160,7 +119,8 @@ class TextEditorManager { customSymbolsFile.readText(), object : TypeToken>() {}.type ) - } catch (_: Exception) { + } catch (e: Exception) { + logger.logError(e) globalClass.showMsg(R.string.failed_to_load_symbols_file) } } @@ -207,7 +167,7 @@ class TextEditorManager { confirmText = globalClass.getString(R.string.reload) dismissText = globalClass.getString(R.string.cancel) onDismiss = { showWarningDialog = false } - WarningDialogProperties.onConfirm = { + warningDialogProperties.onConfirm = { getFileInstance()?.apply { lastModified = activeFile.lastModified onConfirm() @@ -221,10 +181,10 @@ class TextEditorManager { } fun getFileInstance( - documentHolder: DocumentHolder = activeFile, + contentHolder: LocalFileHolder = activeFile, bringToTop: Boolean = false ): FileInstance? { - val index = fileInstanceList.indexOfFirst { it.file.path == documentHolder.path } + val index = fileInstanceList.indexOfFirst { it.file.uniquePath == contentHolder.uniquePath } if (index == -1) return null @@ -291,17 +251,134 @@ class TextEditorManager { } } + fun getLightScheme() = LightScheme() + + fun getDarkScheme() = DarkScheme() + + fun resetColorScheme(codeEditor: CodeEditor, isTextmate: Boolean) { + codeEditor.apply { + if (isTextmate) { + ensureTextmateTheme(codeEditor) + if (globalClass.isDarkTheme()) { + ThemeRegistry.getInstance().setTheme("dark") + } else { + ThemeRegistry.getInstance().setTheme("light") + } + adaptCodeEditorScheme(colorScheme) + } else { + colorScheme = if (globalClass.isDarkTheme()) getDarkScheme() else getLightScheme() + adaptCodeEditorScheme(colorScheme) + } + } + } + + fun ensureTextmateTheme(codeEditor: CodeEditor) { + try { + var editorColorScheme = codeEditor.colorScheme + if (editorColorScheme !is TextMateColorScheme) { + editorColorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance()) + codeEditor.colorScheme = editorColorScheme + } + } catch (e: Exception) { + logger.logError(e) + } + } + + fun adaptCodeEditorScheme(scheme: EditorColorScheme) { + val colorScheme = if (globalClass.isDarkTheme()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(globalClass) + } else { + darkColorScheme() + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicLightColorScheme(globalClass) + } else { + lightColorScheme() + } + } + scheme.apply { + setColor( + EditorColorScheme.LINE_NUMBER_CURRENT, + colorScheme.onSurface.toArgb() + ) + setColor( + EditorColorScheme.SELECTION_HANDLE, + colorScheme.primary.toArgb() + ) + setColor( + EditorColorScheme.SELECTION_INSERT, + colorScheme.primary.toArgb() + ) + setColor( + EditorColorScheme.SELECTED_TEXT_BACKGROUND, + colorScheme.primary.copy(alpha = 0.3f).toArgb() + ) + setColor( + EditorColorScheme.CURRENT_LINE, + colorScheme.surfaceContainerHigh.toArgb() + ) + setColor( + EditorColorScheme.WHOLE_BACKGROUND, + colorScheme.surfaceContainerLowest.toArgb() + ) + setColor( + EditorColorScheme.LINE_NUMBER_BACKGROUND, + colorScheme.surfaceContainer.toArgb() + ) + setColor( + EditorColorScheme.LINE_NUMBER, + colorScheme.onSurface.copy(alpha = 0.5f).toArgb() + ) + setColor( + EditorColorScheme.MATCHED_TEXT_BACKGROUND, + colorScheme.surfaceVariant.toArgb() + ) + setColor(EditorColorScheme.HIGHLIGHTED_DELIMITERS_FOREGROUND, Color.Red.toArgb()) + } + } + + fun setCodeEditorLanguage(codeEditor: CodeEditor, language: Int) { + when (language) { + LANGUAGE_JAVA -> { + codeEditor.apply { + setEditorLanguage(JavaLanguage()) + } + } + + LANGUAGE_KOTLIN -> { + codeEditor.apply { + setEditorLanguage(KotlinCodeLanguage()) + } + } + + LANGUAGE_JSON -> { + codeEditor.apply { + setEditorLanguage(JsonCodeLanguage()) + } + } + + else -> { + codeEditor.apply { + setEditorLanguage(EmptyLanguage()) + } + } + } + resetColorScheme(codeEditor, true) + } + private fun analyseFile() { - activityTitle = activeFile.getName() + activityTitle = activeFile.displayName activitySubtitle = activeFile.basePath - canFormatFile = activeFile.fileExtension.lowercase(Locale.getDefault()).let { + canFormatFile = activeFile.extension.let { it == jsonFileType || it == javaFileType || it == kotlinFileType } } private fun setLanguage(codeEditor: CodeEditor) { - when (activeFile.fileExtension.lowercase(Locale.getDefault())) { + when (activeFile.extension) { javaFileType -> setCodeEditorLanguage(codeEditor, LANGUAGE_JAVA) kotlinFileType -> setCodeEditorLanguage(codeEditor, LANGUAGE_KOTLIN) jsonFileType -> setCodeEditorLanguage(codeEditor, LANGUAGE_JSON) @@ -314,7 +391,7 @@ class TextEditorManager { codeEditor: CodeEditor, onContentReady: (content: Content, text: String, isSourceFileChanged: Boolean) -> Unit ) { - if (!fileInstance.file.exists() && !fileInstance.file.isFile) { + if (!fileInstance.file.exists() && !fileInstance.file.isFile()) { fileInstanceList.remove(fileInstance) globalClass.showMsg(R.string.file_not_found) } @@ -378,13 +455,13 @@ class TextEditorManager { * Opens a file in text editor. * return true if the file exists, false otherwise */ - fun openTextEditor(documentHolder: DocumentHolder, context: Context): Boolean { - if (!documentHolder.exists()) { + fun openTextEditor(localFileHolder: LocalFileHolder, context: Context): Boolean { + if (!localFileHolder.isValid()) { globalClass.showMsg(R.string.file_not_found) return false } - activeFile = getFileInstance(documentHolder)?.file ?: documentHolder + activeFile = getFileInstance(localFileHolder)?.file ?: localFileHolder context.startActivity(Intent(context, TextEditorActivity::class.java)) @@ -478,4 +555,29 @@ class TextEditorManager { } } } + + class RecentFileDialog { + var showRecentFileDialog by mutableStateOf(false) + + fun getRecentFiles(textEditorManager: TextEditorManager): SnapshotStateList { + val limit = globalClass.preferencesManager.textEditorPrefs.recentFilesLimit + var index = 0 + textEditorManager.fileInstanceList.removeIf { + if (limit > 0 && index > limit - 1) { + !it.file.exists() || !it.requireSave + } else { + !it.file.exists() + }.also { index++ } + } + return textEditorManager.fileInstanceList + } + } + + data class FileInstance( + val file: LocalFileHolder, + var content: Content, + var lastModified: Long, + var requireSave: Boolean = false, + val searcher: Searcher = Searcher() + ) } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/language/json/JsonFormatter.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/language/json/JsonFormatter.kt index 9ad22875..dd94680b 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/language/json/JsonFormatter.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/language/json/JsonFormatter.kt @@ -27,7 +27,7 @@ class JsonFormatter : Formatter { return try { if (isObject) JSONObject(txt).toString(2) else JSONArray(txt).toString(2) - } catch (e: Exception) { + } catch (_: Exception) { txt } } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/misc/Utils.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/misc/Utils.kt deleted file mode 100644 index fe63dd85..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/misc/Utils.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.raival.compose.file.explorer.screen.textEditor.misc - -import android.os.Build -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.common.extension.isDarkTheme -import com.raival.compose.file.explorer.screen.textEditor.language.java.JavaCodeLanguage -import com.raival.compose.file.explorer.screen.textEditor.language.json.JsonCodeLanguage -import com.raival.compose.file.explorer.screen.textEditor.language.kotlin.KotlinCodeLanguage -import com.raival.compose.file.explorer.screen.textEditor.scheme.DarkScheme -import com.raival.compose.file.explorer.screen.textEditor.scheme.LightScheme -import io.github.rosemoe.sora.lang.EmptyLanguage -import io.github.rosemoe.sora.lang.Language -import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme -import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry -import io.github.rosemoe.sora.widget.CodeEditor -import io.github.rosemoe.sora.widget.schemes.EditorColorScheme - -val javaLanguage: Language - get() = JavaCodeLanguage() - -val kotlinLanguage: Language - get() = KotlinCodeLanguage() - -val jsonLanguage: Language - get() = JsonCodeLanguage() - -fun getLightScheme() = LightScheme() - -fun getDarkScheme() = DarkScheme() - -fun resetColorScheme(codeEditor: CodeEditor, isTextmate: Boolean) { - codeEditor.apply { - if (isTextmate) { - ensureTextmateTheme(codeEditor) - if (globalClass.isDarkTheme()) { - ThemeRegistry.getInstance().setTheme("dark") - } else { - ThemeRegistry.getInstance().setTheme("light") - } - val cs = colorScheme - adaptCodeEditorScheme(cs) - - colorScheme = cs - adaptCodeEditorScheme(colorScheme) - } else { - colorScheme = if (globalClass.isDarkTheme()) getDarkScheme() else getLightScheme() - adaptCodeEditorScheme(colorScheme) - } - } -} - -fun ensureTextmateTheme(codeEditor: CodeEditor) { - try { - var editorColorScheme = codeEditor.colorScheme - if (editorColorScheme !is TextMateColorScheme) { - editorColorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance()) - codeEditor.colorScheme = editorColorScheme - } - } catch (_: Exception) { - } -} - -fun adaptCodeEditorScheme(scheme: EditorColorScheme) { - val colorScheme = if (globalClass.isDarkTheme()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicDarkColorScheme(globalClass) - } else { - darkColorScheme() - } - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - dynamicLightColorScheme(globalClass) - } else { - lightColorScheme() - } - } - scheme.apply { - setColor( - EditorColorScheme.LINE_NUMBER_CURRENT, - colorScheme.onSurface.toArgb() - ) - setColor( - EditorColorScheme.SELECTION_HANDLE, - colorScheme.primary.toArgb() - ) - setColor( - EditorColorScheme.SELECTION_INSERT, - colorScheme.primary.toArgb() - ) - setColor( - EditorColorScheme.SELECTED_TEXT_BACKGROUND, - colorScheme.primary.copy(alpha = 0.3f).toArgb() - ) - setColor( - EditorColorScheme.CURRENT_LINE, - colorScheme.surfaceContainerHigh.toArgb() - ) - setColor( - EditorColorScheme.WHOLE_BACKGROUND, - colorScheme.surfaceContainerLowest.toArgb() - ) - setColor( - EditorColorScheme.LINE_NUMBER_BACKGROUND, - colorScheme.surfaceContainer.toArgb() - ) - setColor( - EditorColorScheme.LINE_NUMBER, - colorScheme.onSurface.copy(alpha = 0.5f).toArgb() - ) - setColor( - EditorColorScheme.MATCHED_TEXT_BACKGROUND, - colorScheme.surfaceVariant.toArgb() - ) - setColor(EditorColorScheme.HIGHLIGHTED_DELIMITERS_FOREGROUND, Color.Red.toArgb()) - } -} - -fun setCodeEditorLanguage(codeEditor: CodeEditor, language: Int) { - when (language) { - com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_JAVA -> { - codeEditor.apply { - setEditorLanguage(javaLanguage) - } - } - - com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_KOTLIN -> { - codeEditor.apply { - setEditorLanguage(kotlinLanguage) - } - } - - com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_JSON -> { - codeEditor.apply { - setEditorLanguage(jsonLanguage) - } - } - - else -> { - codeEditor.apply { - setEditorLanguage(EmptyLanguage()) - } - } - } - resetColorScheme(codeEditor, true) -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/Searcher.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/Searcher.kt new file mode 100644 index 00000000..c4031b51 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/Searcher.kt @@ -0,0 +1,13 @@ +package com.raival.compose.file.explorer.screen.textEditor.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.raival.compose.file.explorer.common.extension.emptyString + +class Searcher { + var query by mutableStateOf(emptyString) + var replace by mutableStateOf(emptyString) + var caseSensitive by mutableStateOf(false) + var useRegex by mutableStateOf(false) +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/WarningDialogProperties.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/WarningDialogProperties.kt new file mode 100644 index 00000000..b944cd14 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/WarningDialogProperties.kt @@ -0,0 +1,16 @@ +package com.raival.compose.file.explorer.screen.textEditor.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.raival.compose.file.explorer.common.extension.emptyString + +class WarningDialogProperties { + var showWarningDialog by mutableStateOf(false) + var title by mutableStateOf(emptyString) + var message by mutableStateOf(emptyString) + var confirmText by mutableStateOf(emptyString) + var dismissText by mutableStateOf(emptyString) + var onConfirm: () -> Unit = {} + var onDismiss: () -> Unit = {} +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/BottomBarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/BottomBarView.kt index 728077d5..55698141 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/BottomBarView.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/BottomBarView.kt @@ -7,15 +7,11 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ChevronLeft import androidx.compose.material.icons.rounded.ChevronRight -import androidx.compose.material.icons.rounded.Folder -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -29,14 +25,18 @@ import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.moveSelectionBy import com.raival.compose.file.explorer.common.ui.CustomIconButton +import com.raival.compose.file.explorer.screen.textEditor.holder.SymbolHolder import io.github.rosemoe.sora.widget.CodeEditor import io.github.rosemoe.sora.widget.SelectionMovement @OptIn(ExperimentalFoundationApi::class) @Composable -fun BottomBarView(codeEditor: CodeEditor) { - val textEditorManager = globalClass.textEditorManager - +fun BottomBarView( + codeEditor: CodeEditor, + symbolList: List, + extraButton: @Composable () -> Unit = {}, +) { + val indentChar = " " var currentCursor by remember { mutableIntStateOf(1) } Row( @@ -55,10 +55,7 @@ fun BottomBarView(codeEditor: CodeEditor) { if (codeEditor.cursor.isSelected) { codeEditor.indentSelection() } else { - codeEditor.insertText( - textEditorManager.indentChar, - textEditorManager.indentChar.length - ) + codeEditor.insertText(indentChar, indentChar.length) } }, onLongClick = { @@ -71,7 +68,7 @@ fun BottomBarView(codeEditor: CodeEditor) { ) } - items(textEditorManager.getSymbols(true)) { symbol -> + items(symbolList) { symbol -> SymbolBox( label = symbol.label, onClick = { @@ -157,16 +154,5 @@ fun BottomBarView(codeEditor: CodeEditor) { icon = Icons.Rounded.ChevronRight ) } - - IconButton(onClick = { - textEditorManager.hideSearchPanel(codeEditor) - textEditorManager.recentFileDialog.showRecentFileDialog = true - }) { - Icon( - modifier = Modifier.size(21.dp), - imageVector = Icons.Rounded.Folder, - contentDescription = null - ) - } } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/InfoBar.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/InfoBar.kt index 15d5426e..c47cddfd 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/InfoBar.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/InfoBar.kt @@ -14,12 +14,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.common.ui.Space @Composable -fun InfoBar() { - val textEditorManager = globalClass.textEditorManager +fun InfoBar( + subtitle: String +) { Row( Modifier .fillMaxWidth() @@ -32,7 +32,7 @@ fun InfoBar() { modifier = Modifier .alpha(0.65f) .animateContentSize(), - text = textEditorManager.activitySubtitle, + text = subtitle, fontSize = 10.sp ) Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/JumpToPositionDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/JumpToPositionDialog.kt index 453cc61d..ed723d4f 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/JumpToPositionDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/JumpToPositionDialog.kt @@ -1,39 +1,151 @@ package com.raival.compose.file.explorer.screen.textEditor.ui +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.ui.InputDialog -import com.raival.compose.file.explorer.common.ui.InputDialogButton -import com.raival.compose.file.explorer.common.ui.InputDialogInput +import com.raival.compose.file.explorer.common.extension.asCodeEditorCursorCoordinates +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.ui.Space import io.github.rosemoe.sora.widget.CodeEditor @Composable -fun JumpToPositionDialog(codeEditor: CodeEditor) { - val textEditorManager = globalClass.textEditorManager - if (textEditorManager.showJumpToPositionDialog) { - InputDialog( - title = stringResource(R.string.jump_to_position), - inputs = arrayListOf( - InputDialogInput(stringResource(R.string.jump_to_position_label)) +fun JumpToPositionDialog( + codeEditor: CodeEditor, + onDismiss: () -> Unit +) { + var posInput by remember { mutableStateOf("") } + var error by remember { mutableStateOf("") } + + LaunchedEffect(posInput) { + val pos = posInput.asCodeEditorCursorCoordinates() + error = if (posInput.isBlank()) { + emptyString + } else if (pos.first < 0 || pos.second < 0) { + globalClass.getString(R.string.invalid_position) + } else { + emptyString + } + } + + Dialog( + onDismissRequest = onDismiss, + ) { + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh ), - buttons = arrayListOf( - InputDialogButton(stringResource(R.string.go)) { inputs -> - val text = inputs[0].content - val position = textEditorManager.parseCursorPosition(text) - if (position.first > -1) { - runCatching { - codeEditor.setSelection(position.first - 1, position.second - 1) - }.exceptionOrNull()?.let { - globalClass.showMsg(R.string.invalid_position) - } - } else { - globalClass.showMsg(R.string.invalid_position) + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.jump_to_position), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + TextField( + modifier = Modifier.fillMaxWidth(), + value = posInput, + onValueChange = { + posInput = it + }, + label = { Text(text = stringResource(R.string.jump_to_position_label)) }, + singleLine = true, + shape = RoundedCornerShape(6.dp), + colors = TextFieldDefaults.colors( + errorIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + isError = error.isNotEmpty(), + supportingText = if (error.isNotEmpty()) { + { Text(error) } + } else null + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onDismiss, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.labelLarge + ) + } + + Button( + modifier = Modifier.weight(1f), + onClick = { + val position = posInput.asCodeEditorCursorCoordinates() + if (position.first > -1) { + runCatching { + codeEditor.setSelection(position.first - 1, position.second - 1) + }.exceptionOrNull()?.let { + globalClass.showMsg(R.string.invalid_position) + } + } else { + globalClass.showMsg(R.string.invalid_position) + } + onDismiss() + }, + enabled = error.isEmpty() && posInput.isNotBlank(), + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.go), + style = MaterialTheme.typography.labelLarge + ) } - textEditorManager.showJumpToPositionDialog = false } - ) - ) { textEditorManager.showJumpToPositionDialog = false } + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/OptionsMenu.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/OptionsMenu.kt index 2be4129d..47ec8f70 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/OptionsMenu.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/OptionsMenu.kt @@ -178,7 +178,8 @@ fun OptionsMenu(expanded: Boolean, codeEditor: CodeEditor, onDismissRequest: () preferences.deleteEmptyLineFast = (!preferences.deleteEmptyLineFast).also { newValue -> codeEditor.props.deleteEmptyLineFast = newValue - } }, + } + }, leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Backspace, null) }, trailingIcon = { Checkbox( @@ -227,9 +228,10 @@ fun OptionsMenu(expanded: Boolean, codeEditor: CodeEditor, onDismissRequest: () Checkbox( checked = preferences.autoIndent, onCheckedChange = { - preferences.autoIndent = (!preferences.autoIndent).also { newValue -> - codeEditor.props.autoIndent = newValue - } + preferences.autoIndent = + (!preferences.autoIndent).also { newValue -> + codeEditor.props.autoIndent = newValue + } } ) } diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/SearchPanel.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/SearchPanel.kt index 75553763..ce019bfb 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/SearchPanel.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/SearchPanel.kt @@ -22,25 +22,27 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.ui.CheckableText import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.screen.textEditor.model.Searcher import io.github.rosemoe.sora.widget.CodeEditor import io.github.rosemoe.sora.widget.EditorSearcher @Composable -fun SearchPanel(codeEditor: CodeEditor) { - val textEditorManager = globalClass.textEditorManager - +fun SearchPanel( + codeEditor: CodeEditor, + searcher: Searcher, + show: Boolean +) { AnimatedVisibility( - visible = textEditorManager.showSearchPanel, + visible = show, enter = expandIn(expandFrom = Alignment.TopCenter) + slideInVertically(initialOffsetY = { it }), exit = shrinkOut(shrinkTowards = Alignment.BottomCenter) + slideOutVertically(targetOffsetY = { it }) ) { - val searcher = textEditorManager.getFileInstance()!!.searcher fun codeEditorSearcher() = codeEditor.searcher - fun hasQuery() = searcher.query.isNotEmpty() && codeEditorSearcher().matchedPositionCount > 0 + fun hasQuery() = + searcher.query.isNotEmpty() && codeEditorSearcher().matchedPositionCount > 0 Column( modifier = Modifier diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/WarningDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/WarningDialog.kt index e183cf0f..6c5a72cc 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/WarningDialog.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/WarningDialog.kt @@ -4,23 +4,21 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.screen.textEditor.model.WarningDialogProperties @Composable -fun WarningDialog() { - globalClass.textEditorManager.warningDialogProperties.run { - if (showWarningDialog) { - AlertDialog( - title = { Text(text = title) }, - text = { Text(text = message) }, - dismissButton = { - TextButton(onClick = { onDismiss() }) { Text(text = dismissText) } - }, - confirmButton = { - TextButton(onClick = { onConfirm() }) { Text(text = confirmText) } - }, - onDismissRequest = { onDismiss() } - ) - } +fun WarningDialog(properties: WarningDialogProperties) { + with(properties) { + AlertDialog( + title = { Text(text = title) }, + text = { Text(text = message) }, + dismissButton = { + TextButton(onClick = { onDismiss() }) { Text(text = dismissText) } + }, + confirmButton = { + TextButton(onClick = { onConfirm() }) { Text(text = confirmText) } + }, + onDismissRequest = { onDismiss() } + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/ViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/ViewerActivity.kt index 64a7bf89..af37ad04 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/ViewerActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/ViewerActivity.kt @@ -5,11 +5,12 @@ import android.os.Bundle import androidx.activity.enableEdgeToEdge import com.raival.compose.file.explorer.App.Companion.globalClass import com.raival.compose.file.explorer.base.BaseActivity +import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.common.extension.randomString abstract class ViewerActivity : BaseActivity() { private var uri: Uri? = null - private var uid: String = "" + private var uid: String = emptyString private var currentInstance: ViewerInstance? = null abstract fun onCreateNewInstance(uri: Uri, uid: String): ViewerInstance diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt new file mode 100644 index 00000000..9e1224b2 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt @@ -0,0 +1,28 @@ +package com.raival.compose.file.explorer.screen.viewer.audio + +import android.net.Uri +import androidx.activity.compose.setContent +import com.raival.compose.file.explorer.screen.viewer.ViewerActivity +import com.raival.compose.file.explorer.screen.viewer.ViewerInstance +import com.raival.compose.file.explorer.screen.viewer.audio.ui.MusicPlayerScreen +import com.raival.compose.file.explorer.theme.FileExplorerTheme + +class AudioPlayerActivity : ViewerActivity() { + override fun onCreateNewInstance( + uri: Uri, + uid: String + ): ViewerInstance { + return AudioPlayerInstance(uri, uid) + } + + override fun onReady(instance: ViewerInstance) { + setContent { + FileExplorerTheme { + MusicPlayerScreen( + audioPlayerInstance = instance as AudioPlayerInstance, + onClosed = { onBackPressedDispatcher.onBackPressed() } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt new file mode 100644 index 00000000..2c5e8ef9 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt @@ -0,0 +1,210 @@ +package com.raival.compose.file.explorer.screen.viewer.audio + +import android.content.Context +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.C.TIME_UNSET +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.viewer.ViewerInstance +import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioMetadata +import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioPlayerColorScheme +import com.raival.compose.file.explorer.screen.viewer.audio.model.PlayerState +import com.raival.compose.file.explorer.screen.viewer.audio.ui.extractColorsFromBitmap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class AudioPlayerInstance( + override val uri: Uri, + override val id: String +) : ViewerInstance { + private val _playerState = MutableStateFlow(PlayerState()) + val playerState: StateFlow = _playerState.asStateFlow() + + private val _metadata = MutableStateFlow(AudioMetadata()) + val metadata: StateFlow = _metadata.asStateFlow() + + private val _isEqualizerVisible = MutableStateFlow(false) + val isEqualizerVisible: StateFlow = _isEqualizerVisible.asStateFlow() + + private val _isVolumeVisible = MutableStateFlow(false) + val isVolumeVisible: StateFlow = _isVolumeVisible.asStateFlow() + + private val _colorScheme = MutableStateFlow(AudioPlayerColorScheme()) + val audioPlayerColorScheme: StateFlow = _colorScheme.asStateFlow() + + private var exoPlayer: ExoPlayer? = null + private var positionTrackingJob: Job? = null + + @OptIn(UnstableApi::class) + suspend fun initializePlayer(context: Context, uri: Uri) { + withContext(Dispatchers.Main) { + exoPlayer = ExoPlayer.Builder(context).build().apply { + val mediaItem = MediaItem.Builder() + .setUri(uri) + .build() + + setMediaItem(mediaItem) + prepare() + + addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + _playerState.value = _playerState.value.copy(isPlaying = isPlaying) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + _playerState.value = _playerState.value.copy( + isLoading = playbackState == Player.STATE_BUFFERING + ) + + if (playbackState == Player.STATE_READY) { + _playerState.value = _playerState.value.copy( + duration = duration + ) + } + } + }) + } + } + + extractMetadata(context, uri) + startPositionTracking() + } + + fun setDefaultColorScheme(colorScheme: AudioPlayerColorScheme) { + _colorScheme.value = colorScheme + } + + private suspend fun extractMetadata(context: Context, uri: Uri) { + withContext(Dispatchers.IO) { + try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, uri) + + val title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) + ?: globalClass.getString(R.string.unknown_title) + val artist = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST) + ?: globalClass.getString(R.string.unknown_artist) + val album = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM) + ?: globalClass.getString(R.string.unknown_album) + val durationStr = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + val duration = durationStr?.toLongOrNull() ?: 0L + + // Extract album art + val albumArtData = retriever.embeddedPicture + val albumArt = albumArtData?.let { data -> + BitmapFactory.decodeByteArray(data, 0, data.size) + } + + val metadata = AudioMetadata( + title = title, + artist = artist, + album = album, + duration = duration, + albumArt = albumArt + ) + + _metadata.value = metadata + + // Extract colors from album art if available + albumArt?.let { bitmap -> + val colorScheme = extractColorsFromBitmap(bitmap, _colorScheme.value) + _colorScheme.value = colorScheme + } + + retriever.release() + } catch (e: Exception) { + logger.logError(e) + // Fallback metadata + _metadata.value = AudioMetadata( + title = uri.lastPathSegment ?: globalClass.getString(R.string.unknown_title) + ) + } + } + } + + private fun startPositionTracking() { + positionTrackingJob?.cancel() + positionTrackingJob = CoroutineScope(Dispatchers.Main).launch { + while (true) { + exoPlayer?.let { player -> + _playerState.value = _playerState.value.copy( + currentPosition = player.currentPosition, + duration = player.duration.takeIf { it != TIME_UNSET } ?: 0L + ) + } + delay(100) + } + } + } + + fun playPause() { + exoPlayer?.let { player -> + if (player.isPlaying) { + player.pause() + } else { + player.play() + } + } + } + + fun seekTo(position: Long) { + exoPlayer?.seekTo(position) + } + + fun skipNext() { + exoPlayer?.seekToNext() + } + + fun skipPrevious() { + exoPlayer?.seekToPrevious() + } + + fun setPlaybackSpeed(speed: Float) { + exoPlayer?.setPlaybackSpeed(speed) + _playerState.value = _playerState.value.copy(playbackSpeed = speed) + } + + fun toggleRepeatMode() { + val newMode = when (_playerState.value.repeatMode) { + Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL + else -> Player.REPEAT_MODE_OFF + } + exoPlayer?.repeatMode = newMode + _playerState.value = _playerState.value.copy(repeatMode = newMode) + } + + fun setVolume(volume: Float) { + exoPlayer?.volume = volume + _playerState.value = _playerState.value.copy(volume = volume) + } + + fun toggleEqualizer() { + _isEqualizerVisible.value = !_isEqualizerVisible.value + } + + fun toggleVolume() { + _isVolumeVisible.value = !_isVolumeVisible.value + } + + override fun onClose() { + positionTrackingJob?.cancel() + exoPlayer?.release() + exoPlayer = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioMetaData.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioMetaData.kt new file mode 100644 index 00000000..c0e81eb6 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioMetaData.kt @@ -0,0 +1,13 @@ +package com.raival.compose.file.explorer.screen.viewer.audio.model + +import android.graphics.Bitmap +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R + +data class AudioMetadata( + val title: String = globalClass.getString(R.string.unknown_title), + val artist: String = globalClass.getString(R.string.unknown_artist), + val album: String = globalClass.getString(R.string.unknown_album), + val duration: Long = 0L, + val albumArt: Bitmap? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioPlayerColorScheme.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioPlayerColorScheme.kt new file mode 100644 index 00000000..39fa0bd3 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioPlayerColorScheme.kt @@ -0,0 +1,19 @@ +package com.raival.compose.file.explorer.screen.viewer.audio.model + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils + +data class AudioPlayerColorScheme( + val primary: Color = Color(0xFF6750A4), + val secondary: Color = Color(0xFF625B71), + val background: Color = Color(0xFF1C1B1F), + val surface: Color = Color(0xFF2B2930) +) { + val tintColor: Color by lazy { + val hsl = FloatArray(3) + ColorUtils.colorToHSL(primary.toArgb(), hsl) + hsl[2] = (hsl[2] + 0.5f).coerceIn(0f, 1f) + Color(ColorUtils.HSLToColor(hsl)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlayerState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlayerState.kt new file mode 100644 index 00000000..61d797e3 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlayerState.kt @@ -0,0 +1,13 @@ +package com.raival.compose.file.explorer.screen.viewer.audio.model + +import androidx.media3.common.Player + +data class PlayerState( + val isPlaying: Boolean = false, + val currentPosition: Long = 0L, + val duration: Long = 0L, + val isLoading: Boolean = false, + val playbackSpeed: Float = 1.0f, + val repeatMode: Int = Player.REPEAT_MODE_OFF, + val volume: Float = 1.0f +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt new file mode 100644 index 00000000..0fb8bef1 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt @@ -0,0 +1,830 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.raival.compose.file.explorer.screen.viewer.audio.ui + +import android.graphics.Bitmap +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeDown +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Equalizer +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.ColorUtils +import androidx.media3.common.Player +import androidx.palette.graphics.Palette +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.toFormattedTime +import com.raival.compose.file.explorer.common.ui.Space +import com.raival.compose.file.explorer.screen.viewer.audio.AudioPlayerInstance +import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioMetadata +import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioPlayerColorScheme +import kotlin.math.abs + +@Composable +fun MusicPlayerScreen( + audioPlayerInstance: AudioPlayerInstance, + onClosed: () -> Unit +) { + val context = LocalContext.current + val playerState by audioPlayerInstance.playerState.collectAsState() + val metadata by audioPlayerInstance.metadata.collectAsState() + val isEqualizerVisible by audioPlayerInstance.isEqualizerVisible.collectAsState() + val isVolumeVisible by audioPlayerInstance.isVolumeVisible.collectAsState() + val customColorScheme by audioPlayerInstance.audioPlayerColorScheme.collectAsState() + val defaultScheme = AudioPlayerColorScheme( + primary = MaterialTheme.colorScheme.primary, + secondary = MaterialTheme.colorScheme.secondary, + background = MaterialTheme.colorScheme.background, + surface = MaterialTheme.colorScheme.surface + ) + + // Initialize player + LaunchedEffect(audioPlayerInstance.uri) { + audioPlayerInstance.setDefaultColorScheme(defaultScheme) + audioPlayerInstance.initializePlayer(context, audioPlayerInstance.uri) + } + + // Dispose player when leaving + DisposableEffect(Unit) { + onDispose { audioPlayerInstance.onClose() } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = customColorScheme.background + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + customColorScheme.background, + customColorScheme.surface, + customColorScheme.background.copy(alpha = 0.8f) + ) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Status bar spacing + Spacer(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) + + // Top controls + TopControls( + onEqualizerClick = { audioPlayerInstance.toggleEqualizer() }, + onVolumeClick = { audioPlayerInstance.toggleVolume() }, + onCloseClick = onClosed, + audioPlayerColorScheme = customColorScheme + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Album art with rotation animation + AlbumArt( + isPlaying = playerState.isPlaying, + metadata = metadata, + audioPlayerColorScheme = customColorScheme + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Song info + SongInfo(metadata = metadata, colorScheme = customColorScheme) + + Spacer(modifier = Modifier.height(24.dp)) + + // Progress bar + ProgressBar( + currentPosition = playerState.currentPosition, + duration = playerState.duration, + onSeek = { audioPlayerInstance.seekTo(it) }, + colorScheme = customColorScheme + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Main controls + MainControls( + isPlaying = playerState.isPlaying, + isLoading = playerState.isLoading, + onPlayPause = { audioPlayerInstance.playPause() }, + onSkipNext = { audioPlayerInstance.skipNext() }, + onSkipPrevious = { audioPlayerInstance.skipPrevious() }, + colorScheme = customColorScheme + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Additional controls + AdditionalControls( + playbackSpeed = playerState.playbackSpeed, + repeatMode = playerState.repeatMode, + onSpeedChange = { audioPlayerInstance.setPlaybackSpeed(it) }, + onRepeatToggle = { audioPlayerInstance.toggleRepeatMode() }, + colorScheme = customColorScheme + ) + } + + // Volume overlay + AnimatedVisibility( + visible = isVolumeVisible, + enter = slideInVertically { -it } + fadeIn() + scaleIn(initialScale = 0.6f), + exit = slideOutVertically { -it } + fadeOut() + ) { + VolumeView( + volume = playerState.volume, + onVolumeChange = { audioPlayerInstance.setVolume(it) }, + onDismiss = { audioPlayerInstance.toggleVolume() }, + colorScheme = customColorScheme + ) + } + + // Equalizer overlay + AnimatedVisibility( + visible = isEqualizerVisible, + enter = slideInVertically { -it } + fadeIn() + scaleIn(initialScale = 0.6f), + exit = slideOutVertically { -it } + fadeOut() + ) { + EqualizerView( + onDismiss = { audioPlayerInstance.toggleEqualizer() }, + colorScheme = customColorScheme + ) + } + } + } +} + +@Composable +fun TopControls( + onEqualizerClick: () -> Unit, + onVolumeClick: () -> Unit, + onCloseClick: () -> Unit, + audioPlayerColorScheme: AudioPlayerColorScheme, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onCloseClick) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = audioPlayerColorScheme.tintColor + ) + } + + Spacer(Modifier.weight(1f)) + + Row { + IconButton(onClick = onVolumeClick) { + Icon( + Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = null, + tint = audioPlayerColorScheme.tintColor + ) + } + + IconButton(onClick = onEqualizerClick) { + Icon( + Icons.Default.Equalizer, + contentDescription = null, + tint = audioPlayerColorScheme.tintColor + ) + } + } + } +} + +@Composable +fun AlbumArt( + isPlaying: Boolean, + metadata: AudioMetadata, + audioPlayerColorScheme: AudioPlayerColorScheme +) { + RotatingContainer(isRotating = isPlaying) { + Box( + modifier = Modifier + .size(280.dp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + audioPlayerColorScheme.primary.copy(alpha = 0.6f), + audioPlayerColorScheme.primary.copy(alpha = 0.2f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + if (metadata.albumArt != null) { + Image( + bitmap = metadata.albumArt.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size(260.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } else { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + ) + } + } + } +} + +@Composable +fun RotatingContainer( + modifier: Modifier = Modifier, + isRotating: Boolean, + rotationDuration: Int = 10000, + content: @Composable () -> Unit +) { + var currentRotation by remember { mutableFloatStateOf(0f) } + val animatedRotation = remember { Animatable(0f) } + + LaunchedEffect(isRotating) { + if (isRotating) { + animatedRotation.snapTo(currentRotation) + + while (isRotating) { + animatedRotation.animateTo( + targetValue = currentRotation + 360f, + animationSpec = tween( + durationMillis = rotationDuration, + easing = LinearEasing + ) + ) + currentRotation = animatedRotation.value % 360f + animatedRotation.snapTo(currentRotation) + } + } else { + currentRotation = animatedRotation.value % 360f + animatedRotation.stop() + } + } + + LaunchedEffect(isRotating, animatedRotation.value) { + if (isRotating) { + currentRotation = animatedRotation.value % 360f + } + } + + Box( + modifier = modifier + .graphicsLayer { + rotationZ = if (isRotating) animatedRotation.value else currentRotation + } + ) { + content() + } +} + +@Composable +fun SongInfo(metadata: AudioMetadata, colorScheme: AudioPlayerColorScheme) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = metadata.title, + color = colorScheme.tintColor, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.headlineSmall + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = metadata.artist, + color = colorScheme.tintColor.copy(alpha = 0.8f), + fontSize = 16.sp, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = metadata.album, + color = colorScheme.tintColor.copy(alpha = 0.6f), + fontSize = 14.sp, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +fun ProgressBar( + currentPosition: Long, + duration: Long, + onSeek: (Long) -> Unit, + colorScheme: AudioPlayerColorScheme +) { + var manualPosition by remember { mutableLongStateOf(0L) } + var manualSeek by remember { mutableFloatStateOf(0f) } + var isDragging by remember { mutableStateOf(false) } + val progress = if (duration > 0) { + if (abs(currentPosition - manualPosition) < 1000) { + (currentPosition.toFloat() / duration.toFloat()).also { + manualPosition = currentPosition + } + } else manualSeek + } else 0f + + Column { + Slider( + value = if (isDragging) manualSeek else progress, + onValueChange = { + isDragging = true + manualSeek = it + }, + onValueChangeFinished = { + (manualSeek * duration).toLong().let { newPosition -> + manualPosition = newPosition + onSeek(newPosition) + } + isDragging = false + }, + colors = SliderDefaults.colors( + thumbColor = colorScheme.tintColor, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.tintColor.copy(alpha = 0.3f) + ), + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = (if (isDragging) (manualSeek * duration).toLong() else manualPosition).toFormattedTime(), + color = colorScheme.tintColor.copy(alpha = 0.8f), + fontSize = 12.sp, + style = MaterialTheme.typography.bodySmall + ) + + Text( + text = duration.toFormattedTime(), + color = colorScheme.tintColor.copy(alpha = 0.8f), + fontSize = 12.sp, + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +@Composable +fun MainControls( + isPlaying: Boolean, + isLoading: Boolean, + onPlayPause: () -> Unit, + onSkipNext: () -> Unit, + onSkipPrevious: () -> Unit, + colorScheme: AudioPlayerColorScheme +) { + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Previous button + IconButton( + onClick = onSkipPrevious, + modifier = Modifier.size(56.dp) + ) { + Icon( + Icons.Default.SkipPrevious, + contentDescription = null, + tint = colorScheme.tintColor, + modifier = Modifier.size(32.dp) + ) + } + + // Play/Pause button + Card( + modifier = Modifier + .size(72.dp) + .clickable { onPlayPause() }, + shape = CircleShape, + colors = CardDefaults.cardColors( + containerColor = colorScheme.primary + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + CircularProgressIndicator( + color = colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + } else { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = null, + tint = colorScheme.tintColor, + modifier = Modifier.size(40.dp) + ) + } + } + } + + // Next button + IconButton( + onClick = onSkipNext, + modifier = Modifier.size(56.dp) + ) { + Icon( + Icons.Default.SkipNext, + contentDescription = null, + tint = colorScheme.tintColor, + modifier = Modifier.size(32.dp) + ) + } + } +} + +@Composable +fun AdditionalControls( + playbackSpeed: Float, + repeatMode: Int, + onSpeedChange: (Float) -> Unit, + onRepeatToggle: () -> Unit, + colorScheme: AudioPlayerColorScheme +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + // Speed control + Column(horizontalAlignment = Alignment.CenterHorizontally) { + IconButton( + onClick = { + val newSpeed = when (playbackSpeed) { + 0.5f -> 1.0f + 1.0f -> 1.25f + 1.25f -> 1.5f + 1.5f -> 2.0f + else -> 0.5f + } + onSpeedChange(newSpeed) + } + ) { + Icon( + Icons.Default.Speed, + contentDescription = null, + tint = colorScheme.tintColor + ) + } + Text( + text = "${playbackSpeed}x", + color = colorScheme.tintColor.copy(alpha = 0.8f), + fontSize = 12.sp, + style = MaterialTheme.typography.bodySmall + ) + } + + // Repeat control (only two modes: off and all) + IconButton(onClick = onRepeatToggle) { + Icon( + Icons.Default.Repeat, + contentDescription = null, + tint = if (repeatMode == Player.REPEAT_MODE_OFF) + colorScheme.tintColor.copy(alpha = 0.5f) + else + colorScheme.tintColor, + ) + } + } +} + +@Composable +fun VolumeView( + volume: Float, + onVolumeChange: (Float) -> Unit, + onDismiss: () -> Unit, + colorScheme: AudioPlayerColorScheme +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .windowInsetsPadding(WindowInsets.statusBars), + colors = CardDefaults.cardColors( + containerColor = colorScheme.surface.copy(alpha = 0.95f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.volume), + color = colorScheme.tintColor, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge + ) + + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = colorScheme.tintColor + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.AutoMirrored.Filled.VolumeDown, + contentDescription = null, + tint = colorScheme.tintColor.copy(alpha = 0.7f) + ) + + Space(8.dp) + + Slider( + value = volume, + onValueChange = onVolumeChange, + modifier = Modifier.weight(1f), + colors = SliderDefaults.colors( + thumbColor = colorScheme.tintColor, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.tintColor.copy(alpha = 0.3f) + ) + ) + + Space(8.dp) + + Icon( + Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = null, + tint = colorScheme.tintColor.copy(alpha = 0.7f) + ) + } + } + } +} + +@Composable +fun EqualizerView( + onDismiss: () -> Unit, + colorScheme: AudioPlayerColorScheme +) { + val frequencies = listOf("60Hz", "230Hz", "910Hz", "4kHz", "14kHz") + val gains = remember { mutableStateListOf(0f, 0f, 0f, 0f, 0f) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .windowInsetsPadding(WindowInsets.statusBars), + colors = CardDefaults.cardColors( + containerColor = colorScheme.surface.copy(alpha = 0.95f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.equalizer), + color = colorScheme.tintColor, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge + ) + + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = colorScheme.tintColor + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + frequencies.forEachIndexed { index, frequency -> + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "+15", + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + fontSize = 10.sp, + style = MaterialTheme.typography.labelSmall + ) + + Space(8.dp) + + Slider( + value = gains[index], + onValueChange = { gains[index] = it }, + valueRange = -15f..15f, + modifier = Modifier.weight(1f), + colors = SliderDefaults.colors( + thumbColor = colorScheme.tintColor, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.tintColor.copy(alpha = 0.3f) + ) + ) + + Space(8.dp) + + Text( + text = "-15", + color = colorScheme.tintColor.copy(alpha = 0.6f), + fontSize = 10.sp, + style = MaterialTheme.typography.labelSmall + ) + + Space(8.dp) + + Text( + text = frequency, + color = colorScheme.tintColor, + fontSize = 12.sp, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Button( + onClick = { + for (i in gains.indices) { + gains[i] = 0f + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary + ) + ) { + Text( + text = stringResource(R.string.reset), + color = colorScheme.tintColor + ) + } + + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary + ) + ) { + Text( + text = stringResource(R.string.done), + color = colorScheme.tintColor + ) + } + } + } + } +} + +// Extract colors from bitmap +fun extractColorsFromBitmap( + bitmap: Bitmap, + defaultScheme: AudioPlayerColorScheme +): AudioPlayerColorScheme { + return try { + val palette = Palette.from(bitmap).generate() + val primaryColor = palette.getDominantColor(0xFF6750A4.toInt()) + val vibrantColor = palette.getVibrantColor(primaryColor) + val mutedColor = palette.getMutedColor(0xFF625B71.toInt()) + + // Create darker variants for background + val primaryHsl = FloatArray(3) + ColorUtils.colorToHSL(primaryColor, primaryHsl) + primaryHsl[2] = 0.1f // Very dark + val backgroundColor = ColorUtils.HSLToColor(primaryHsl) + + primaryHsl[2] = 0.2f // Slightly lighter + val surfaceColor = ColorUtils.HSLToColor(primaryHsl) + + AudioPlayerColorScheme( + primary = Color(vibrantColor), + secondary = Color(mutedColor), + background = Color(backgroundColor), + surface = Color(surfaceColor) + ) + } catch (_: Exception) { + defaultScheme // Fallback to default colors + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerActivity.kt index e4bdd724..abaa0b0d 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerActivity.kt @@ -1,24 +1,100 @@ package com.raival.compose.file.explorer.screen.viewer.image +import android.content.Intent +import android.graphics.Bitmap import android.net.Uri import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.RotateRight +import androidx.compose.material.icons.filled.BorderOuter +import androidx.compose.material.icons.filled.Crop +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.FilterCenterFocus +import androidx.compose.material.icons.filled.FitScreen +import androidx.compose.material.icons.filled.Height +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.InvertColors +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.WidthFull +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.palette.graphics.Palette +import coil3.Image +import coil3.asDrawable import coil3.compose.AsyncImage +import coil3.toBitmap +import com.anggrayudi.storage.extension.toDocumentFile +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString import com.raival.compose.file.explorer.common.extension.read +import com.raival.compose.file.explorer.common.extension.showMsg +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.common.extension.toFormattedSize import com.raival.compose.file.explorer.common.ui.SafeSurface import com.raival.compose.file.explorer.screen.viewer.ViewerActivity import com.raival.compose.file.explorer.screen.viewer.ViewerInstance -import com.raival.compose.file.explorer.screen.viewer.image.instance.ImageViewerInstance import com.raival.compose.file.explorer.theme.FileExplorerTheme +import kotlinx.coroutines.launch import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable @@ -30,33 +106,503 @@ class ImageViewerActivity : ViewerActivity() { override fun onReady(instance: ViewerInstance) { setContent { FileExplorerTheme { - SafeSurface { + SafeSurface(enableStatusBarsPadding = false) { + ImageViewerScreen(instance) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ImageViewerScreen(instance: ViewerInstance) { + val defaultColor = MaterialTheme.colorScheme.surface + var dominantColor by remember { mutableStateOf(defaultColor) } + var secondaryColor by remember { mutableStateOf(defaultColor) } + val imageBackgroundColors = arrayListOf( + Color.Transparent, + Color.White, + Color.Gray, + Color.Black + ) + var currentImageBackgroundColorIndex by remember { mutableIntStateOf(0) } + var imageData by remember { mutableStateOf(ByteArray(0)) } + var isLoading by remember { mutableStateOf(true) } + var isError by remember { mutableStateOf(false) } + var showControls by remember { mutableStateOf(true) } + var showInfo by remember { mutableStateOf(false) } + var imageInfo by remember { mutableStateOf(null) } + var rotationAngle by remember { mutableFloatStateOf(0f) } + var imageDimensions by remember { mutableStateOf("" to "") } + var contentScale by remember { mutableStateOf(ContentScale.Fit) } + val context = LocalContext.current + val zoomState = rememberZoomState() + val scope = rememberCoroutineScope() + + // Load image data + LaunchedEffect(instance.uri) { + try { + imageData = instance.uri.read() + isLoading = false + } catch (e: Exception) { + logger.logError(e) + isError = true + isLoading = false + } + } + + // Extract image info when image is loaded + LaunchedEffect(imageData, imageDimensions.first) { + if (imageData.isNotEmpty() && imageDimensions.first.isNotEmpty()) { + imageInfo = + extractImageInfo(instance.uri, imageDimensions.first, imageDimensions.second) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + dominantColor.copy(alpha = 0.8f), + secondaryColor.copy(alpha = 0.8f) + ) + ) + ) + ) { + when { + isLoading -> LoadingState() + isError -> ErrorState(onRetry = { + scope.launch { + try { + imageData = instance.uri.read() + isLoading = false + } catch (e: Exception) { + logger.logError(e) + isError = true + isLoading = false + } + } + }) + + else -> { + // Main image with zoom and rotation + var image by remember { mutableStateOf(null) } + + AsyncImage( + model = imageData, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { rotationZ = rotationAngle } + .zoomable( + zoomState = zoomState, + onTap = { showControls = !showControls }, + ) + .background(imageBackgroundColors[currentImageBackgroundColorIndex]), + onSuccess = { state -> + image = state.result.image + val drawable = state.result.image.asDrawable(context.resources) + imageDimensions = + "${drawable.intrinsicWidth}" to "${drawable.intrinsicHeight}" + } + ) + + // Extract dominant color + LaunchedEffect(image) { + if (image != null) { + val bitmap = image!!.toBitmap().copy(Bitmap.Config.ARGB_8888, false) + val palette = Palette.from(bitmap).generate() + dominantColor = Color(palette.getDominantColor(defaultColor.toArgb())) + secondaryColor = Color(palette.getMutedColor(defaultColor.toArgb())) + } + } + + // Animated gradient overlay for controls + AnimatedVisibility( + visible = showControls, + enter = fadeIn(spring(stiffness = Spring.StiffnessMedium)), + exit = fadeOut(spring(stiffness = Spring.StiffnessMedium)) + ) { Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - val image = remember { mutableStateOf(ByteArray(0)) } - var isLoaded by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - image.value = instance.uri.read() - isLoaded = true - } - - if (!isLoaded) { - CircularProgressIndicator() - } - - AsyncImage( - modifier = Modifier - .fillMaxSize() - .zoomable(rememberZoomState()), - model = image.value, - contentDescription = null + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + defaultColor.copy(alpha = 0.9f), + Color.Transparent, + Color.Transparent, + Color.Transparent, + defaultColor.copy(alpha = 0.9f) + ) + ) + ) + ) + } + + // Top bar + AnimatedVisibility( + visible = showControls, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut() + ) { + TopAppBar( + title = { + Column { + Text( + text = imageInfo?.name ?: stringResource(R.string.unknown), + color = Color.White, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + imageInfo?.let { info -> + Text( + text = "${info.size} • ${info.dimensions}", + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { + (context as? ViewerActivity)?.finish() + }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = Color.White + ) + } + }, + actions = { + IconButton(onClick = { showInfo = true }) { + Icon( + Icons.Default.Info, + contentDescription = null, + tint = Color.White + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) + } + + // Bottom controls + AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), + visible = showControls, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut() + ) { + BottomControls( + onInvertBackgroundColors = { + currentImageBackgroundColorIndex = + (currentImageBackgroundColorIndex + 1) % imageBackgroundColors.size + }, + onRotate = { + rotationAngle = (rotationAngle + 90f) % 360f + }, + onEdit = { + val editIntent = Intent(Intent.ACTION_EDIT) + val mimeType = + context.contentResolver.getType(instance.uri) ?: "image/*" + editIntent.setDataAndType(instance.uri, mimeType) + editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + val chooser = Intent.createChooser( + editIntent, + globalClass.getString(R.string.edit_with) + ) + if (editIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(chooser) + } else { + showMsg(context.getString(R.string.no_app_found_to_edit_this_image)) + } + }, + onContentScale = { + contentScale = it + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + + // Info bottom sheet + if (showInfo) { + imageInfo?.let { info -> + ImageInfoBottomSheet( + imageInfo = info, + onDismiss = { showInfo = false } ) } } } } } +} + +@Composable +private fun LoadingState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 4.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.loading_image), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } +} + +@Composable +private fun ErrorState(onRetry: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.failed_to_load_image), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.check_file_exists_and_try_again), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.retry)) + } + } + } +} + +@Composable +private fun BottomControls( + onInvertBackgroundColors: () -> Unit, + onRotate: () -> Unit, + onEdit: () -> Unit, + onContentScale: (contentScale: ContentScale) -> Unit, + modifier: Modifier = Modifier, +) { + val contentScales = arrayListOf().apply { + add(ContentScale.Fit) + add(ContentScale.Crop) + add(ContentScale.FillWidth) + add(ContentScale.FillHeight) + add(ContentScale.FillBounds) + add(ContentScale.Inside) + } + var selectedContentScale by remember { mutableStateOf(contentScales[0]) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + // Invert background colors + ActionButton( + icon = Icons.Default.InvertColors, + onClick = onInvertBackgroundColors, + backgroundColor = Color.White.copy(alpha = 0.15f) + ) + + // Fit to screen + ActionButton( + icon = when (selectedContentScale) { + ContentScale.Fit -> Icons.Default.FitScreen + ContentScale.Crop -> Icons.Default.Crop + ContentScale.FillWidth -> Icons.Default.WidthFull + ContentScale.FillHeight -> Icons.Default.Height + ContentScale.FillBounds -> Icons.Default.BorderOuter + else -> Icons.Default.FilterCenterFocus + }, + onClick = { + val currentScaleIndex = contentScales.indexOf(selectedContentScale) + selectedContentScale = contentScales[ + if (currentScaleIndex == contentScales.lastIndex) 0 + else currentScaleIndex + 1 + ] + onContentScale(selectedContentScale) + }, + backgroundColor = Color.White.copy(alpha = 0.15f) + ) + + // Rotate + ActionButton( + icon = Icons.AutoMirrored.Filled.RotateRight, + onClick = onRotate, + backgroundColor = Color.White.copy(alpha = 0.15f) + ) + + // Edit + ActionButton( + icon = Icons.Default.Edit, + onClick = onEdit, + backgroundColor = Color.White.copy(alpha = 0.15f) + ) + } +} + +@Composable +private fun ActionButton( + icon: ImageVector, + onClick: () -> Unit, + tint: Color = Color.White, + backgroundColor: Color = Color.White.copy(alpha = 0.1f) +) { + IconButton( + onClick = onClick, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(backgroundColor) + ) { + Icon( + icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(24.dp) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ImageInfoBottomSheet( + imageInfo: ImageInfo, + onDismiss: () -> Unit +) { + val bottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = bottomSheetState, + dragHandle = { + Surface( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(16.dp) + ) { + Box(modifier = Modifier.size(width = 32.dp, height = 4.dp)) + } + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "Image Details", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + InfoRow(stringResource(R.string.name), imageInfo.name) + InfoRow(stringResource(R.string.size), imageInfo.size) + InfoRow(stringResource(R.string.dimensions), imageInfo.dimensions) + InfoRow(stringResource(R.string.format), imageInfo.format) + InfoRow(stringResource(R.string.last_modified), imageInfo.lastModified) + InfoRow(stringResource(R.string.path), imageInfo.path) + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(2f) + ) + } +} + +// Data class for image information +data class ImageInfo( + val name: String, + val size: String, + val dimensions: String, + val format: String, + val lastModified: String, + val path: String +) + +// Helper function to extract image information +private fun extractImageInfo(uri: Uri, width: String, height: String): ImageInfo { + val file = uri.toDocumentFile(globalClass) + + return ImageInfo( + name = file?.name ?: emptyString, + size = (file?.length() ?: 0).toFormattedSize(), + dimensions = if (width.isNotEmpty() && height.isNotEmpty()) "$width × $height" else globalClass.getString( + R.string.unknown + ), + format = globalClass.contentResolver.getType(uri) + ?.substringAfter("image/", globalClass.getString(R.string.not_available)) + ?.uppercase() + ?: globalClass.getString(R.string.not_available), + lastModified = (file?.lastModified() ?: 0).toFormattedDate(), + path = uri.path.toString() + ) } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerInstance.kt new file mode 100644 index 00000000..a412095e --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerInstance.kt @@ -0,0 +1,13 @@ +package com.raival.compose.file.explorer.screen.viewer.image + +import android.net.Uri +import com.raival.compose.file.explorer.screen.viewer.ViewerInstance + +class ImageViewerInstance( + override val uri: Uri, + override val id: String +) : ViewerInstance { + override fun onClose() { + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/instance/ImageViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/instance/ImageViewerInstance.kt deleted file mode 100644 index 8b2c521d..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/instance/ImageViewerInstance.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.image.instance - -import android.net.Uri -import com.raival.compose.file.explorer.screen.viewer.ViewerInstance - -class ImageViewerInstance(override val uri: Uri, override val id: String) : ViewerInstance { - override fun onClose() { - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/MediaViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/MediaViewerActivity.kt deleted file mode 100644 index 1ffe5625..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/MediaViewerActivity.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.media - -import android.net.Uri -import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import androidx.media3.common.Player -import androidx.media3.ui.PlayerView -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.ui.KeepScreenOn -import com.raival.compose.file.explorer.common.ui.SafeSurface -import com.raival.compose.file.explorer.screen.viewer.ViewerActivity -import com.raival.compose.file.explorer.screen.viewer.ViewerInstance -import com.raival.compose.file.explorer.screen.viewer.media.instance.MediaViewerInstance -import com.raival.compose.file.explorer.screen.viewer.media.misc.MediaSource -import com.raival.compose.file.explorer.screen.viewer.media.ui.AudioPlayer -import com.raival.compose.file.explorer.theme.FileExplorerTheme - -class MediaViewerActivity : ViewerActivity() { - override fun onCreateNewInstance(uri: Uri, uid: String): ViewerInstance { - return MediaViewerInstance(uri, uid) - } - - override fun onReady(instance: ViewerInstance) { - if (instance is MediaViewerInstance) { - setContent { - KeepScreenOn() - FileExplorerTheme { - SafeSurface { - if (instance.mediaSource is MediaSource.AudioSource) { - AudioPlayer(instance) - } else { - Box( - Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - LaunchedEffect(Unit) { - instance.player.play() - } - - AndroidView( - modifier = Modifier - .fillMaxSize(), - factory = { context -> - PlayerView(context).apply { - useController = - instance.mediaSource is MediaSource.VideoSource - player = instance.player.apply { - repeatMode = Player.REPEAT_MODE_ONE - } - } - }, - update = { }, - onRelease = { - it.player?.release() - } - ) - } - } - } - } - } - } else { - globalClass.showMsg(getString(R.string.invalid_media_file)) - finish() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/instance/MediaViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/instance/MediaViewerInstance.kt deleted file mode 100644 index 30bbd52a..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/instance/MediaViewerInstance.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.media.instance - -import android.content.Context -import android.net.Uri -import androidx.media3.common.MediaItem -import androidx.media3.exoplayer.ExoPlayer -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.screen.viewer.ViewerInstance -import com.raival.compose.file.explorer.screen.viewer.media.misc.AudioPlayerManager -import com.raival.compose.file.explorer.screen.viewer.media.misc.MediaSource - -class MediaViewerInstance(override val uri: Uri, override val id: String) : ViewerInstance { - val player = ExoPlayer.Builder(globalClass).build() - val mediaItem = MediaItem.fromUri(uri) - val mediaSource = getMediaSource(globalClass, uri) - - val audioManager = AudioPlayerManager(globalClass) - - init { - player.setMediaItem(mediaItem) - player.prepare() - - if (mediaSource is MediaSource.AudioSource) { - audioManager.prepare(uri) - } - } - - private fun getMediaSource(context: Context, uri: Uri): MediaSource { - val contentResolver = context.contentResolver - val mimeType = contentResolver.getType(uri) ?: return MediaSource.UnknownSource - - return when { - mimeType.startsWith("audio/") -> { - MediaSource.AudioSource - } - - mimeType.startsWith("video/") -> { - MediaSource.VideoSource - } - - else -> { - MediaSource.UnknownSource - } - } - } - - override fun onClose() { - player.release() - audioManager.release() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/AudioPlayerManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/AudioPlayerManager.kt deleted file mode 100644 index 759af7fd..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/AudioPlayerManager.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.media.misc - -import android.content.Context -import android.net.Uri -import android.os.Handler -import android.os.Looper -import androidx.media3.common.MediaItem -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer - -/** - * A simple audio manager using Media3 ExoPlayer. - */ -class AudioPlayerManager(context: Context) { - - private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context).build() - private var listener: AudioPlayerManagerListener? = null - - // Handler to update playback progress every second. - private val updateHandler = Handler(Looper.getMainLooper()) - private val updateRunnable = object : Runnable { - override fun run() { - val currentPosition = exoPlayer.currentPosition - val duration = exoPlayer.duration.takeIf { it > 0 } ?: 0L - val remaining = if (duration > currentPosition) duration - currentPosition else 0L - listener?.onProgressUpdated(currentPosition, remaining) - updateHandler.postDelayed(this, 1000) - } - } - - /** - * Set a listener to receive callbacks. - */ - fun setListener(listener: AudioPlayerManagerListener) { - this.listener = listener - } - - /** - * Prepares the player with the given audio URI. - */ - fun prepare(uri: Uri) { - val mediaItem = MediaItem.fromUri(uri) - exoPlayer.setMediaItem(mediaItem) - exoPlayer.repeatMode = Player.REPEAT_MODE_ONE - exoPlayer.prepare() - - // Listen for playback and metadata events. - exoPlayer.addListener(object : Player.Listener { - override fun onPlaybackStateChanged(state: Int) { - // For simplicity, we consider the player "playing" when playWhenReady is true and state is ready. - listener?.onPlaybackStateChanged(exoPlayer.playWhenReady && state == Player.STATE_READY) - } - - override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { - val title = mediaMetadata.title?.toString() - val album = mediaMetadata.albumTitle?.toString() - val artist = mediaMetadata.artist?.toString() - val artwork = mediaMetadata.artworkData ?: mediaMetadata.artworkUri - val duration = exoPlayer.duration.takeIf { it > 0 } ?: 0L - - val metadata = AudioMetadata(title, album, artist, artwork, duration) - listener?.onMetadataChanged(metadata) - } - }) - } - - /** - * Starts playback and begins periodic progress updates. - */ - fun play() { - exoPlayer.playWhenReady = true - updateHandler.post(updateRunnable) - } - - /** - * Pauses playback and stops progress updates. - */ - fun pause() { - exoPlayer.playWhenReady = false - updateHandler.removeCallbacks(updateRunnable) - } - - /** - * Stops playback completely. - */ - fun stop() { - exoPlayer.stop() - updateHandler.removeCallbacks(updateRunnable) - } - - /** - * Fast-forwards playback by 5 seconds. - */ - fun forward() { - val newPosition = exoPlayer.currentPosition + 2000L - exoPlayer.seekTo(newPosition) - } - - /** - * Rewinds playback by 5 seconds. - */ - fun backward() { - val newPosition = (exoPlayer.currentPosition - 2000L).coerceAtLeast(0L) - exoPlayer.seekTo(newPosition) - } - - /** - * Seek to a new position - */ - fun seekTo(position: Long) { - val newPosition = position.coerceIn(0, exoPlayer.duration) - exoPlayer.seekTo(position) - } - - /** - * Releases player resources. - */ - fun release() { - updateHandler.removeCallbacks(updateRunnable) - exoPlayer.release() - } - - /** - * Callback interface for playback state, progress, and metadata updates. - */ - interface AudioPlayerManagerListener { - /** - * Called when playback state changes. - * @param isPlaying true if the audio is playing. - */ - fun onPlaybackStateChanged(isPlaying: Boolean) - - /** - * Called periodically with the current position and remaining time (in milliseconds). - */ - fun onProgressUpdated(currentPosition: Long, remainingTime: Long) - - /** - * Called when metadata for the current media item is available. - */ - fun onMetadataChanged(metadata: AudioMetadata) - } - - /** - * Data class to hold basic audio metadata. - */ - data class AudioMetadata( - val title: String?, - val album: String?, - val artist: String?, - val artwork: Any?, - val duration: Long - ) -} diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/MediaSource.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/MediaSource.kt deleted file mode 100644 index 6e632472..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/MediaSource.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.media.misc - -sealed class MediaSource { - data object AudioSource : MediaSource() - data object VideoSource : MediaSource() - data object UnknownSource : MediaSource() -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/AudioPlayer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/AudioPlayer.kt deleted file mode 100644 index f9176cf4..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/AudioPlayer.kt +++ /dev/null @@ -1,235 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.media.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Pause -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.name -import com.raival.compose.file.explorer.common.ui.Space -import com.raival.compose.file.explorer.screen.viewer.media.instance.MediaViewerInstance -import com.raival.compose.file.explorer.screen.viewer.media.misc.AudioPlayerManager -import kotlin.time.DurationUnit -import kotlin.time.toDuration - -@Composable -fun AudioPlayer(instance: MediaViewerInstance) { - var playing by remember { mutableStateOf(false) } - var title by remember { mutableStateOf("") } - var artist by remember { mutableStateOf("") } - var album by remember { mutableStateOf("") } - var currentTime by remember { mutableLongStateOf(0L) } - var duration by remember { mutableLongStateOf(0L) } - var isDraggingSlider by remember { mutableStateOf(false) } - var manualSeek by remember { mutableFloatStateOf(0f) } - var artwork by remember { mutableStateOf(null) } - - val playerManager = instance.audioManager.apply { - setListener(object : AudioPlayerManager.AudioPlayerManagerListener { - override fun onPlaybackStateChanged(isPlaying: Boolean) { - playing = isPlaying - } - - override fun onProgressUpdated(currentPosition: Long, remainingTime: Long) { - currentTime = currentPosition - } - - override fun onMetadataChanged(metadata: AudioPlayerManager.AudioMetadata) { - title = metadata.title ?: "" - artist = metadata.artist ?: "" - album = metadata.album ?: "" - duration = metadata.duration - artwork = metadata.artwork - } - }) - } - - Column( - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - Modifier - .weight(1f) - .padding(52.dp), - ) { - if (artwork != null) { - AsyncImage( - model = artwork, - contentDescription = null, - modifier = Modifier - .align(Alignment.Center) - .fillMaxSize() - ) - } else { - RotatingDisk( - modifier = Modifier - .align(Alignment.Center) - .fillMaxSize(), - enabled = playing - ) - } - } - - Text( - text = title.ifEmpty { - instance.uri.name ?: globalClass.getString(R.string.unknown) - }, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - Space(12.dp) - - Text( - text = artist.ifEmpty { album.ifEmpty { globalClass.getString(R.string.unknown_artist) } }, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium - ) - - Space(16.dp) - - Slider( - modifier = Modifier.fillMaxWidth(), - value = if (isDraggingSlider) manualSeek else currentTime.toFloat(), - onValueChange = { - isDraggingSlider = true - manualSeek = it - }, - onValueChangeFinished = { - playerManager.seekTo(manualSeek.toLong()) - currentTime = manualSeek.toLong() - isDraggingSlider = false - }, - valueRange = 0f..duration.toFloat() - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - ) { - Text( - text = buildString { - val value = - if (isDraggingSlider) manualSeek else currentTime.toFloat() - value.toLong().toDuration(DurationUnit.MILLISECONDS) - .toComponents { hours, minutes, seconds, nanoseconds -> - if (hours > 0) { - append("%02d:".format(hours)) - } - append("%02d:".format(minutes)) - append("%02d".format(seconds)) - } - }, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodySmall - ) - Spacer(Modifier.weight(1f)) - Text( - text = buildString { - duration.toDuration(DurationUnit.MILLISECONDS) - .toComponents { hours, minutes, seconds, nanoseconds -> - if (hours > 0) { - append("%02d:".format(hours)) - } - append("%02d:".format(minutes)) - append("%02d".format(seconds)) - } - }, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodySmall - ) - } - - Space(8.dp) - - Row( - Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - FloatingActionButton( - onClick = { - playerManager.backward() - currentTime - 2 - } - ) { - Text("-2s") - } - - Space(16.dp) - - FloatingActionButton( - modifier = Modifier.size(FloatingActionButtonDefaults.LargeIconSize + 42.dp), - shape = FloatingActionButtonDefaults.largeShape, - onClick = { - playing = !playing - - if (playing) { - playerManager.play() - } else { - playerManager.pause() - } - - duration = instance.player.duration - } - ) { - Icon( - modifier = Modifier.size( - FloatingActionButtonDefaults.LargeIconSize - ), - imageVector = if (!playing) Icons.Rounded.PlayArrow - else Icons.Rounded.Pause, - contentDescription = null - ) - } - - Space(16.dp) - - FloatingActionButton( - onClick = { - playerManager.forward() - currentTime + 2 - } - ) { - Text("+2s") - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/RotatingDisk.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/RotatingDisk.kt deleted file mode 100644 index 157f1b22..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/RotatingDisk.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.media.ui - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.Easing -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.rotate -import androidx.compose.ui.graphics.graphicsLayer -import kotlinx.coroutines.launch -import kotlin.math.cos -import kotlin.math.sin -import kotlin.random.Random - -@Composable -fun RotatingDisk( - modifier: Modifier = Modifier, - enabled: Boolean = true, - rotationEasing: Easing = LinearEasing, - diskColor: Color = colorScheme.surfaceContainerHighest, - dotColor: Color = colorScheme.surfaceContainer -) { - val rotation = remember { Animatable(0f) } - val sizeAnimation = remember { Animatable(1f) } - val dotRadiusFraction = remember { 0.056f } - val dotMargin = remember { 15f } - - LaunchedEffect(enabled) { - launch { - while (enabled) { - rotation.animateTo( - targetValue = rotation.value + 1, - animationSpec = tween(durationMillis = 50, easing = rotationEasing) - ) - if (rotation.value == 360f) { - rotation.snapTo(0f) - } - } - } - } - - LaunchedEffect(enabled) { - launch { - while (enabled) { - sizeAnimation.animateTo( - targetValue = Random.nextInt(95, 105) / 100f, - animationSpec = tween( - durationMillis = Random.nextInt(50, 250), - easing = FastOutSlowInEasing - ) - ) - } - } - } - - Canvas( - modifier = modifier.graphicsLayer { - scaleX = sizeAnimation.value - scaleY = sizeAnimation.value - }, - onDraw = { - val radius = size.minDimension / 2 - val dotRadius = radius * dotRadiusFraction - val dotAngle = 45f - - drawRotatingDisk(diskColor, radius, rotation.value) - drawRotatingDot(dotColor, radius, dotRadius, dotAngle, dotMargin, rotation.value) - } - ) -} - -fun DrawScope.drawRotatingDisk(color: Color, radius: Float, rotationValue: Float) { - rotate(rotationValue, pivot = center) { - drawCircle( - color = color, - radius = radius, - center = center - ) - } -} - -fun DrawScope.drawRotatingDot( - color: Color, - radius: Float, - dotRadius: Float, - angle: Float, - margin: Float, - rotationValue: Float -) { - val dotX = center.x + (radius - dotRadius - margin) * cos(Math.toRadians(angle.toDouble())).toFloat() - val dotY = center.y - (radius - dotRadius - margin) * sin(Math.toRadians(angle.toDouble())).toFloat() - - rotate(rotationValue, pivot = center) { - drawCircle( - color = color, - radius = dotRadius, - center = Offset(dotX, dotY) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerActivity.kt index 7a54172f..9176171a 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerActivity.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerActivity.kt @@ -4,204 +4,517 @@ import android.net.Uri import android.util.Size import androidx.activity.compose.setContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R import com.raival.compose.file.explorer.common.extension.dp import com.raival.compose.file.explorer.common.ui.SafeSurface import com.raival.compose.file.explorer.screen.viewer.ViewerActivity import com.raival.compose.file.explorer.screen.viewer.ViewerInstance -import com.raival.compose.file.explorer.screen.viewer.pdf.instance.PdfViewerInstance -import com.raival.compose.file.explorer.screen.viewer.pdf.ui.BottomBarView -import com.raival.compose.file.explorer.screen.viewer.pdf.ui.TopBarView +import com.raival.compose.file.explorer.screen.viewer.pdf.misc.PdfPageHolder +import com.raival.compose.file.explorer.screen.viewer.pdf.ui.InfoDialog import com.raival.compose.file.explorer.theme.FileExplorerTheme -import my.nanihadesuka.compose.LazyColumnScrollbar +import my.nanihadesuka.compose.InternalLazyColumnScrollbar import my.nanihadesuka.compose.ScrollbarLayoutSide import my.nanihadesuka.compose.ScrollbarSettings +import net.engawapg.lib.zoomable.ExperimentalZoomableApi import net.engawapg.lib.zoomable.rememberZoomState -import net.engawapg.lib.zoomable.zoomable +import net.engawapg.lib.zoomable.zoomableWithScroll class PdfViewerActivity : ViewerActivity() { override fun onCreateNewInstance(uri: Uri, uid: String): ViewerInstance { return PdfViewerInstance(uri, uid) } + @OptIn(ExperimentalZoomableApi::class) override fun onReady(instance: ViewerInstance) { if (instance is PdfViewerInstance) { setContent { FileExplorerTheme { - SafeSurface { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter - ) { - var showToolbar by remember { mutableStateOf(false) } - - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - var pages by remember { mutableStateOf(emptyList()) } - var pageSize = remember { Size(-1, -1) } - - LaunchedEffect(Unit) { - if (!instance.isLoaded) { - instance.load( - dimension = Size( - constraints.maxWidth, - constraints.maxHeight - ) - ) - } - - pages = instance.pages - } - - DisposableEffect(Unit) { - onDispose { - instance.onClose() - } - } - - val listState = rememberLazyListState() - - LazyColumnScrollbar( - state = listState, - settings = ScrollbarSettings.Default.copy( - thumbUnselectedColor = colorScheme.surfaceContainerHigh, - thumbSelectedColor = colorScheme.surfaceContainerHighest, - side = ScrollbarLayoutSide.Start - ), - indicatorContent = { index, isPressed -> - Box( - modifier = Modifier - .background( - color = if (isPressed) colorScheme.surfaceContainerHighest else colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape( - 0.dp, - 12.dp, - 12.dp, - 0.dp - ) - ) - .padding(8.dp) - ) { - Text( - text = "${index + 1}", - fontSize = 12.sp, - textAlign = TextAlign.Center, - modifier = Modifier.align(Alignment.Center) - ) - } - } - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .zoomable( - zoomState = rememberZoomState(), - onTap = { - showToolbar = !showToolbar - } - ), - state = listState, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - items(pages, key = { it.index }) { page -> - DisposableEffect(Unit) { - page.load() - - pageSize = Size( - page.dimension.width, - page.dimension.height - ) - - onDispose { - page.recycle(false) - } - } - - when (val pageContent = page.pageContent) { - is PdfViewerInstance.PageContent.BlankPage -> { - val width = - if (page.isTemporaryPageSize && pageSize.width > 0) - pageSize.width.dp() else pageContent.width.dp() - val height = - if (page.isTemporaryPageSize && pageSize.height > 0) - pageSize.height.dp() else pageContent.height.dp() - - Box( - modifier = Modifier - .size(width, height) - .background(color = Color.White) - ) - } - - is PdfViewerInstance.PageContent.BitmapPage -> { - Image( - modifier = Modifier.size( - pageContent.bitmap.width.dp(), - pageContent.bitmap.height.dp() - ), - bitmap = pageContent.bitmap.asImageBitmap(), - contentDescription = null, - ) - } - } - } - } - } - } - - AnimatedVisibility( - visible = showToolbar, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() - ) { - TopBarView(instance) - } - - AnimatedVisibility( - modifier = Modifier.align(Alignment.BottomCenter), - visible = showToolbar, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - BottomBarView(instance) - } - } + SafeSurface(false) { + PdfViewerContent(instance = instance) } } } } else { - globalClass.showMsg("Invalid PDF") + globalClass.showMsg(getString(R.string.invalid_pdf)) finish() } } + + @OptIn(ExperimentalZoomableApi::class) + @Composable + private fun PdfViewerContent(instance: PdfViewerInstance) { + BoxWithConstraints( + Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + val constraints = this.constraints + var showToolbars by remember { mutableStateOf(true) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + val pdfPages = remember { mutableStateListOf() } + var showInfoDialog by remember { mutableStateOf(false) } + + val listState = rememberLazyListState() + val zoomState = rememberZoomState() + var defaultPageSize by remember { mutableStateOf(Size(0, 0)) } + val isFirstItemVisible by remember { + derivedStateOf { + listState.firstVisibleItemIndex == 0 + } + } + + val visiblePageNumbers by remember { + derivedStateOf { + val visiblePages = listState.layoutInfo.visibleItemsInfo.map { visibleItem -> + visibleItem.index + }.filter { it > 0 } + buildString { + append(visiblePages.firstOrNull() ?: 1) + if (visiblePages.size > 1) { + append("-") + append(visiblePages.lastOrNull() ?: 1) + } + } + } + } + + LaunchedEffect(isFirstItemVisible) { + showToolbars = isFirstItemVisible + } + + LaunchedEffect(Unit) { + instance.prepare { success -> + if (success) { + defaultPageSize = Size( + constraints.maxWidth, + instance.defaultPageSize.height * constraints.maxWidth / instance.defaultPageSize.width + ) + pdfPages.addAll(instance.pages) + isLoading = false + } else { + errorMessage = getString(R.string.failed_to_load_pdf) + isLoading = false + } + } + } + + DisposableEffect(Unit) { + onDispose { + instance.onClose() + } + } + + if (showInfoDialog) { + InfoDialog( + title = globalClass.getString(R.string.pdf_info), + properties = instance.getInfo(), + onDismiss = { showInfoDialog = false } + ) + } + + when { + isLoading -> LoadingScreen() + errorMessage != null -> ErrorScreen( + message = errorMessage!!, + onRetry = { finish() } + ) + + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .zoomableWithScroll( + zoomState = zoomState, + onTap = { if (!isFirstItemVisible) showToolbars = !showToolbars } + ), + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + item { Spacer(modifier = Modifier.height(100.dp)) } + + items( + items = pdfPages, + key = { it.index } + ) { page -> + PdfPageItem( + page = page, + pageSize = defaultPageSize, + instance = instance + ) + } + } + InternalLazyColumnScrollbar( + modifier = Modifier.padding(top = 110.dp), + state = listState, + settings = ScrollbarSettings.Default.copy( + thumbUnselectedColor = colorScheme.surfaceContainerHigh, + thumbSelectedColor = colorScheme.primary, + side = ScrollbarLayoutSide.Start, + thumbThickness = 6.dp, + scrollbarPadding = 12.dp + ), + indicatorContent = { index, isPressed -> + PageIndicator( + pageNumber = visiblePageNumbers, + isPressed = isPressed, + totalPages = pdfPages.size + ) + } + ) + + TopToolbar( + visible = showToolbars, + title = instance.metadata.name, + onBackClick = { onBackPressedDispatcher.onBackPressed() }, + onInfoClick = { + showInfoDialog = true + } + ) + } + } + } + } + + @Composable + private fun PdfPageItem( + page: PdfPageHolder, + pageSize: Size, + instance: PdfViewerInstance + ) { + var bitmap by remember(page.index) { mutableStateOf(page.bitmap) } + var isLoading by remember(page.index) { mutableStateOf(false) } + + DisposableEffect(page.index) { + if (bitmap == null && !isLoading) { + isLoading = true + instance.fetchPage(page) { readyPage -> + bitmap = readyPage.bitmap + isLoading = false + } + } + onDispose { + instance.recycle(page) + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + shape = RoundedCornerShape(4.dp), + colors = CardDefaults.cardColors( + containerColor = colorScheme.surfaceContainerLowest + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(pageSize.height.dp()) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + when { + bitmap != null -> { + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = bitmap!!, + contentDescription = stringResource(R.string.page, page.index + 1), + contentScale = ContentScale.Fit + ) + } + + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = colorScheme.primary, + strokeWidth = 3.dp + ) + } + + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.page, page.index + 1), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + } + } + + // Page number overlay + Surface( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + color = colorScheme.primary.copy(alpha = 0.9f), + shape = RoundedCornerShape(6.dp), + shadowElevation = 2.dp + ) { + Text( + text = "${page.index + 1}", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = colorScheme.onPrimary + ) + } + } + } + } + + @Composable + private fun LoadingScreen() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = colorScheme.primary, + strokeWidth = 4.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.loading_pdf), + style = MaterialTheme.typography.bodyLarge, + color = colorScheme.onSurface + ) + } + } + } + + @Composable + private fun ErrorScreen( + message: String, + onRetry: () -> Unit + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + Icon( + imageVector = Icons.Rounded.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.error), + style = MaterialTheme.typography.headlineSmall, + color = colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurface, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors( + containerColor = colorScheme.primary + ) + ) { + Text(stringResource(R.string.go_back)) + } + } + } + } + + @Composable + private fun PageIndicator( + pageNumber: String, + isPressed: Boolean, + totalPages: Int, + ) { + Card( + modifier = Modifier + .padding(start = 4.dp) + .alpha(0.8f), + colors = CardDefaults.cardColors( + containerColor = if (isPressed) + colorScheme.primaryContainer + else + colorScheme.surfaceContainer + ), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isPressed) 8.dp else 4.dp + ) + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = pageNumber, + style = MaterialTheme.typography.titleMedium, + color = if (isPressed) + colorScheme.onPrimaryContainer + else + colorScheme.onSurface + ) + Text( + text = "of $totalPages", + style = MaterialTheme.typography.labelSmall, + color = if (isPressed) + colorScheme.onPrimaryContainer + else + colorScheme.onSurfaceVariant + ) + } + } + } + + @Composable + private fun TopToolbar( + visible: Boolean, + title: String, + onBackClick: () -> Unit, + onInfoClick: () -> Unit + ) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(500) + ) + fadeIn(animationSpec = tween(500)), + exit = slideOutVertically( + targetOffsetY = { -it }, + animationSpec = tween(500) + ) + fadeOut(animationSpec = tween(500)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = colorScheme.surfaceContainer) + ) { + Spacer( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .windowInsetsPadding(WindowInsets.statusBars) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null, + tint = colorScheme.onSurface + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = colorScheme.onSurface + ) + } + + IconButton(onClick = onInfoClick) { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null, + tint = colorScheme.onSurface + ) + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerInstance.kt new file mode 100644 index 00000000..a7fbb6cf --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerInstance.kt @@ -0,0 +1,124 @@ +package com.raival.compose.file.explorer.screen.viewer.pdf + +import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.text.format.Formatter +import android.util.Size +import com.anggrayudi.storage.extension.toDocumentFile +import com.raival.compose.file.explorer.App +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.name +import com.raival.compose.file.explorer.common.extension.toFormattedDate +import com.raival.compose.file.explorer.screen.viewer.ViewerInstance +import com.raival.compose.file.explorer.screen.viewer.pdf.misc.PdfMetadata +import com.raival.compose.file.explorer.screen.viewer.pdf.misc.PdfPageHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex + +class PdfViewerInstance( + override val uri: Uri, + override val id: String +) : ViewerInstance { + private val fileDescriptor = try { + App.Companion.globalClass.contentResolver.openFileDescriptor(uri, "r") + } catch (e: Exception) { + logger.logError(e) + null + } + + private val pdfRenderer = fileDescriptor?.let { + try { + PdfRenderer(it) + } catch (e: Exception) { + logger.logError(e) + null + } + } + + val pages = arrayListOf() + var defaultPageSize = Size(0, 0) + private set + + private val scope = CoroutineScope(Dispatchers.IO) + private val mutex = Mutex() + + val metadata by lazy { + PdfMetadata( + name = uri.name ?: App.Companion.globalClass.getString(R.string.unknown), + path = uri.toString(), + size = fileDescriptor?.statSize ?: 0L, + lastModified = uri.toDocumentFile(App.Companion.globalClass)?.lastModified() ?: 0L, + pages = pdfRenderer?.pageCount ?: 0 + ) + } + + private var isReady = false + + fun prepare(onPrepared: (success: Boolean) -> Unit) { + try { + if (!isValid()) { + onPrepared(false) + return + } + + if (!isReady) { + pages.clear() + + for (i in 0 until pdfRenderer!!.pageCount) { + pages.add(PdfPageHolder(index = i)) + } + + val samplePageIndex = if (pages.size == 1) 0 else minOf(1, pages.size - 1) + pdfRenderer.openPage(samplePageIndex).use { page -> + defaultPageSize = Size(page.width, page.height) + } + + isReady = true + } + onPrepared(true) + } catch (e: Exception) { + logger.logError(e) + onPrepared(false) + } + } + + fun getInfo(): List> = listOf( + App.Companion.globalClass.getString(R.string.name) to metadata.name, + App.Companion.globalClass.getString(R.string.page_count) to metadata.pages.toString(), + App.Companion.globalClass.getString(R.string.size) to Formatter.formatFileSize( + App.Companion.globalClass, + metadata.size + ), + App.Companion.globalClass.getString(R.string.path) to metadata.path, + App.Companion.globalClass.getString(R.string.last_modified) to metadata.lastModified.toFormattedDate() + ) + + fun fetchPage(page: PdfPageHolder, scale: Float = 2f, onFinished: (PdfPageHolder) -> Unit) { + if (pdfRenderer != null) { + page.fetch(scale, scope, mutex, pdfRenderer, onFinished = { onFinished(page) }) + } + } + + fun recycle(page: PdfPageHolder) { + page.recycle() + } + + fun isValid(): Boolean = pdfRenderer != null + + override fun onClose() { + runBlocking { + try { + scope.cancel() + pages.forEach { it.recycle() } + pdfRenderer?.close() + fileDescriptor?.close() + } catch (e: Exception) { + logger.logError(e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/instance/PdfViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/instance/PdfViewerInstance.kt deleted file mode 100644 index 0b35d812..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/instance/PdfViewerInstance.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.pdf.instance - -import android.graphics.Bitmap -import android.graphics.pdf.PdfRenderer -import android.net.Uri -import android.util.Size -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.raival.compose.file.explorer.App.Companion.globalClass -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.common.extension.name -import com.raival.compose.file.explorer.screen.viewer.ViewerInstance -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class PdfViewerInstance(override val uri: Uri, override val id: String) : ViewerInstance { - sealed interface PageContent { - data class BlankPage(val width: Int, val height: Int) : PageContent - data class BitmapPage(val bitmap: Bitmap) : PageContent - } - - class PdfPage( - val index: Int, - val pdfRenderer: PdfRenderer, - val mutex: Mutex, - val scope: CoroutineScope, - val initDimension: Size - ) { - var isLoaded = false - var job: Job? = null - var dimension = Size(initDimension.width, initDimension.height) - var isTemporaryPageSize = true - - var pageContent by mutableStateOf( - PageContent.BlankPage( - width = dimension.width, - height = dimension.height - ) - ) - - private fun createBitmap() = Bitmap.createBitmap( - dimension.width, - dimension.height, - Bitmap.Config.ARGB_8888 - ).apply { - eraseColor(android.graphics.Color.WHITE) - } - - fun load(forceLoad: Boolean = false) { - if (forceLoad || !isLoaded) { - job = scope.launch { - mutex.withLock { - pdfRenderer.let { renderer -> - renderer.openPage(index).use { page -> - if (isTemporaryPageSize) dimension = Size( - dimension.width, - (page.height * (initDimension.width.toFloat() / page.width)).toInt() - ) - - isTemporaryPageSize = false - - val bitmap = createBitmap() - - page.render( - bitmap, - null, - null, - PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY - ) - - isLoaded = true - - pageContent = PageContent.BitmapPage(bitmap) - } - } - } - } - } - } - - fun recycle(terminal: Boolean) { - val image = (pageContent as? PageContent.BitmapPage)?.bitmap - - if (!terminal && pageContent is PageContent.BitmapPage) { - pageContent = PageContent.BlankPage( - width = dimension.width, - height = dimension.height - ) - } - - image?.recycle() - - isLoaded = false - } - } - - private val fileDescriptor = globalClass.contentResolver.openFileDescriptor(uri, "r") - private val pdfRenderer = fileDescriptor?.let { PdfRenderer(it) } - val isValidPdf = pdfRenderer != null - var isLoaded = false - var pdfFileName = uri.name ?: globalClass.getString(R.string.unknown) - private val scope = CoroutineScope(Dispatchers.Default) - private val mutex = Mutex() - val pageCount = pdfRenderer?.pageCount ?: 0 - val pages = arrayListOf() - - fun load(dimension: Size, reload: Boolean = false) { - if (isValidPdf && (!isLoaded || reload)) { - if (pages.isNotEmpty()) { - pages.forEach { - it.recycle(true) - } - pages.clear() - } - - repeat(pageCount) { index -> - pages.add( - PdfPage( - index = index, - pdfRenderer = pdfRenderer!!, - mutex = mutex, - scope = scope, - initDimension = dimension - ) - ) - } - - isLoaded = true - } - } - - override fun onClose() { - runBlocking { - pages.forEach { - runCatching { - it.recycle(true) - pdfRenderer?.close() - fileDescriptor?.close() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfMetadata.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfMetadata.kt new file mode 100644 index 00000000..ca498a46 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfMetadata.kt @@ -0,0 +1,9 @@ +package com.raival.compose.file.explorer.screen.viewer.pdf.misc + +data class PdfMetadata( + val path: String, + val name: String, + val size: Long, + val lastModified: Long, + val pages: Int, +) \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfPageHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfPageHolder.kt new file mode 100644 index 00000000..210a811a --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfPageHolder.kt @@ -0,0 +1,75 @@ +package com.raival.compose.file.explorer.screen.viewer.pdf.misc + +import android.graphics.Bitmap +import android.graphics.pdf.PdfRenderer +import android.graphics.pdf.PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY +import android.util.Size +import androidx.core.graphics.createBitmap +import com.raival.compose.file.explorer.App.Companion.logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class PdfPageHolder( + val index: Int, + var size: Size = Size(0, 0), + var bitmap: Bitmap? = null, + var isLoading: Boolean = false +) { + private var job: Job? = null + + fun fetch( + scale: Float, + scope: CoroutineScope, + mutex: Mutex, + pdfRenderer: PdfRenderer, + debounceMs: Long = 150L, + onFinished: () -> Unit + ) { + if (isLoading || bitmap != null) return + + job?.cancel() + job = scope.launch { + // Small debounce to avoid loading during fast scroll + delay(debounceMs) + + isLoading = true + try { + mutex.withLock { + if (job?.isCancelled == false && bitmap == null) { + pdfRenderer.openPage(index).use { page -> + size = Size(page.width, page.height) + val newBitmap = createBitmap( + (size.width * scale).toInt(), + (size.height * scale).toInt() + ) + newBitmap.eraseColor(android.graphics.Color.WHITE) + page.render(newBitmap, null, null, RENDER_MODE_FOR_DISPLAY) + + if (job?.isCancelled == false) { + bitmap = newBitmap + onFinished() + } else { + recycle() + } + } + } + } + } catch (e: Exception) { + logger.logError(e) + } finally { + isLoading = false + } + } + } + + fun recycle() { + job?.cancel() + bitmap?.recycle() + bitmap = null + isLoading = false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/BottomBarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/BottomBarView.kt deleted file mode 100644 index 319913d0..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/BottomBarView.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.pdf.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.raival.compose.file.explorer.R -import com.raival.compose.file.explorer.screen.viewer.pdf.instance.PdfViewerInstance - -@Composable -fun BottomBarView(instance: PdfViewerInstance) { - Row( - modifier = Modifier - .padding(12.dp) - .background( - color = colorScheme.surfaceContainer.copy(alpha = 0.6f), - shape = RoundedCornerShape(16.dp) - ) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.page_count, instance.pageCount), - maxLines = 2, - style = typography.titleMedium, - overflow = TextOverflow.Ellipsis - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/PdfInfoDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/PdfInfoDialog.kt new file mode 100644 index 00000000..a92f57f9 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/PdfInfoDialog.kt @@ -0,0 +1,150 @@ +package com.raival.compose.file.explorer.screen.viewer.pdf.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.Space + +@Composable +fun InfoDialog( + title: String, + properties: List>, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + Surface( + modifier = modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier.padding(24.dp) + ) { + // Title + Column { + Text( + modifier = Modifier.fillMaxWidth(), + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + Space(8.dp) + HorizontalDivider( + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + } + + Space(12.dp) + + // Properties list + if (properties.isNotEmpty()) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(properties) { (property, value) -> + PropertyItem( + property = property, + value = value + ) + } + } + } else { + Text( + text = stringResource(R.string.no_information_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Action button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + modifier = Modifier.weight(1f), + onClick = onDismiss, + shape = RoundedCornerShape(6.dp) + ) { + Text( + text = stringResource(R.string.close), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + } +} + +@Composable +private fun PropertyItem( + property: String, + value: String, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = property, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 4.dp) + ) + Text( + text = value, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + overflow = TextOverflow.Ellipsis + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/TopBarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/TopBarView.kt deleted file mode 100644 index 55f250a7..00000000 --- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/TopBarView.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.raival.compose.file.explorer.screen.viewer.pdf.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.MaterialTheme.typography -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.raival.compose.file.explorer.screen.viewer.pdf.instance.PdfViewerInstance - -@Composable -fun TopBarView(instance: PdfViewerInstance) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - .background( - color = colorScheme.surfaceContainer.copy(alpha = 0.6f), - shape = RoundedCornerShape(16.dp) - ) - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = instance.pdfFileName, - maxLines = 2, - style = typography.titleMedium, - overflow = TextOverflow.Ellipsis - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerActivity.kt new file mode 100644 index 00000000..45ab974b --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerActivity.kt @@ -0,0 +1,158 @@ +package com.raival.compose.file.explorer.screen.viewer.text + +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.ui.SafeSurface +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsonFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType +import com.raival.compose.file.explorer.screen.textEditor.holder.SymbolHolder +import com.raival.compose.file.explorer.screen.textEditor.language.json.JsonCodeLanguage +import com.raival.compose.file.explorer.screen.textEditor.language.kotlin.KotlinCodeLanguage +import com.raival.compose.file.explorer.screen.textEditor.ui.BottomBarView +import com.raival.compose.file.explorer.screen.textEditor.ui.InfoBar +import com.raival.compose.file.explorer.screen.textEditor.ui.JumpToPositionDialog +import com.raival.compose.file.explorer.screen.textEditor.ui.SearchPanel +import com.raival.compose.file.explorer.screen.textEditor.ui.WarningDialog +import com.raival.compose.file.explorer.screen.viewer.ViewerActivity +import com.raival.compose.file.explorer.screen.viewer.ViewerInstance +import com.raival.compose.file.explorer.screen.viewer.text.ui.ToolbarView +import com.raival.compose.file.explorer.theme.FileExplorerTheme +import io.github.rosemoe.sora.event.ContentChangeEvent +import io.github.rosemoe.sora.event.PublishSearchResultEvent +import io.github.rosemoe.sora.event.SelectionChangeEvent +import io.github.rosemoe.sora.lang.EmptyLanguage +import io.github.rosemoe.sora.langs.java.JavaLanguage +import io.github.rosemoe.sora.widget.CodeEditor +import io.github.rosemoe.sora.widget.subscribeAlways + +class TextViewerActivity : ViewerActivity() { + private lateinit var codeEditor: CodeEditor + + override fun onCreateNewInstance( + uri: Uri, + uid: String + ): ViewerInstance { + return TextViewerInstance(uri, uid) + } + + override fun onReady(instance: ViewerInstance) { + val textViewerInstance = instance as TextViewerInstance + codeEditor = textViewerInstance.createCodeEditorView(this) + setContent { + FileExplorerTheme { + SafeSurface { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val symbols = remember { mutableStateListOf() } + val activityScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + textViewerInstance.readContent(scope = activityScope) { + codeEditor.apply { + setText(textViewerInstance.content, false, null) + subscribeAlways { + textViewerInstance.selectionChangeListener( + this + ) + } + subscribeAlways { + textViewerInstance.selectionChangeListener( + this + ) + } + subscribeAlways { + textViewerInstance.content = it.editor.text.toString() + textViewerInstance.requireSave = true + textViewerInstance.canUndo = canUndo() + textViewerInstance.canRedo = canRedo() + } + } + val name = textViewerInstance.uriContent.name + if (name != null) { + if (name.endsWith(javaFileType)) { + codeEditor.setEditorLanguage(JavaLanguage()) + textViewerInstance.changeFontStyle(codeEditor) + } else if (name.endsWith(kotlinFileType)) { + codeEditor.setEditorLanguage(KotlinCodeLanguage()) + textViewerInstance.changeFontStyle(codeEditor) + } else if (name.endsWith(jsonFileType)) { + codeEditor.setEditorLanguage(JsonCodeLanguage()) + textViewerInstance.changeFontStyle(codeEditor) + } else { + codeEditor.setEditorLanguage(EmptyLanguage()) + } + } + } + + symbols.addAll(textViewerInstance.updateSymbols()) + } + + BackHandler(enabled = textViewerInstance.showSearchPanel) { + textViewerInstance.hideSearchPanel(codeEditor) + } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + textViewerInstance.checkActiveFileValidity( + onSourceReload = { + codeEditor.setText(it, true, null) + }, + onFileNotFound = { + globalClass.showMsg(getString(R.string.file_no_longer_exists)) + finish() + } + ) + } + + if (textViewerInstance.warningDialogProperties.showWarningDialog) { + WarningDialog(textViewerInstance.warningDialogProperties) + } + + if (textViewerInstance.showJumpToPositionDialog) { + JumpToPositionDialog(codeEditor) { + textViewerInstance.showJumpToPositionDialog = false + } + } + + ToolbarView(textViewerInstance, codeEditor, onBackPressedDispatcher) + HorizontalDivider() + InfoBar(textViewerInstance.activitySubtitle) + AndroidView( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + factory = { codeEditor }, + update = {}, + onRelease = { codeEditor.release() } + ) + HorizontalDivider() + BottomBarView(codeEditor, symbols) + SearchPanel( + codeEditor, + textViewerInstance.searcher, + textViewerInstance.showSearchPanel + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerInstance.kt new file mode 100644 index 00000000..78f6f3ed --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerInstance.kt @@ -0,0 +1,355 @@ +package com.raival.compose.file.explorer.screen.viewer.text + +import android.content.Context +import android.graphics.Typeface +import android.net.Uri +import android.os.Build +import android.view.ViewGroup +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.App.Companion.logger +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.emptyString +import com.raival.compose.file.explorer.common.extension.exists +import com.raival.compose.file.explorer.common.extension.getUriInfo +import com.raival.compose.file.explorer.common.extension.isDarkTheme +import com.raival.compose.file.explorer.common.extension.lastModified +import com.raival.compose.file.explorer.common.extension.whiteSpace +import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsonFileType +import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType +import com.raival.compose.file.explorer.screen.textEditor.holder.SymbolHolder +import com.raival.compose.file.explorer.screen.textEditor.model.Searcher +import com.raival.compose.file.explorer.screen.textEditor.model.WarningDialogProperties +import com.raival.compose.file.explorer.screen.textEditor.scheme.DarkScheme +import com.raival.compose.file.explorer.screen.textEditor.scheme.LightScheme +import com.raival.compose.file.explorer.screen.viewer.ViewerInstance +import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme +import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry +import io.github.rosemoe.sora.widget.CodeEditor +import io.github.rosemoe.sora.widget.component.EditorAutoCompletion +import io.github.rosemoe.sora.widget.component.Magnifier +import io.github.rosemoe.sora.widget.schemes.EditorColorScheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.File +import java.io.InputStreamReader +import java.io.OutputStreamWriter + +class TextViewerInstance( + override val uri: Uri, + override val id: String +) : ViewerInstance { + val uriContent = uri.getUriInfo(globalClass) + + val warningDialogProperties = WarningDialogProperties() + val searcher = Searcher() + + var activityTitle by mutableStateOf(uriContent.name ?: globalClass.getString(R.string.unknown)) + var activitySubtitle by mutableStateOf(emptyString) + var canUndo by mutableStateOf(false) + var canRedo by mutableStateOf(false) + var canFormatFile by mutableStateOf(false) + var requireSave by mutableStateOf(false) + var isSaving by mutableStateOf(false) + var showSearchPanel by mutableStateOf(false) + var showJumpToPositionDialog by mutableStateOf(false) + var content by mutableStateOf(emptyString) + var lastModified by mutableLongStateOf(uriContent.lastModified ?: 0L) + + private val textEditorDir = LocalFileHolder( + File(globalClass.appFiles.uniquePath, "textEditor").apply { if (!exists()) mkdirs() } + ) + + private val customSymbolsFile = LocalFileHolder( + File(textEditorDir.file, "symbols.txt") + ) + + private val defaultSymbolHolders = listOf( + "_", "=", "{", "}", File.separator, "\\", "<", ">", "|", "?", "+", "-", "*" + ).map { SymbolHolder(it) } + private var customSymbolHolders = arrayListOf() + + fun readContent(scope: CoroutineScope, onLoaded: () -> Unit) { + analyseFile() + scope.launch { + content = readSourceFile(this) + onLoaded() + } + } + + private suspend fun readSourceFile(scope: CoroutineScope): String { + return withContext(scope.coroutineContext) { + globalClass.contentResolver.openInputStream(uri)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { reader -> + reader.readText() + } + } ?: emptyString + } + } + + fun getLightScheme() = LightScheme() + + fun getDarkScheme() = DarkScheme() + + fun resetColorScheme(codeEditor: CodeEditor, isTextmate: Boolean) { + codeEditor.apply { + if (isTextmate) { + ensureTextmateTheme(codeEditor) + if (globalClass.isDarkTheme()) { + ThemeRegistry.getInstance().setTheme("dark") + } else { + ThemeRegistry.getInstance().setTheme("light") + } + adaptCodeEditorScheme(colorScheme) + } else { + colorScheme = if (globalClass.isDarkTheme()) getDarkScheme() else getLightScheme() + adaptCodeEditorScheme(colorScheme) + } + } + } + + fun ensureTextmateTheme(codeEditor: CodeEditor) { + try { + var editorColorScheme = codeEditor.colorScheme + if (editorColorScheme !is TextMateColorScheme) { + editorColorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance()) + codeEditor.colorScheme = editorColorScheme + } + } catch (_: Exception) { + } + } + + fun adaptCodeEditorScheme(scheme: EditorColorScheme) { + val colorScheme = if (globalClass.isDarkTheme()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicDarkColorScheme(globalClass) + } else { + darkColorScheme() + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicLightColorScheme(globalClass) + } else { + lightColorScheme() + } + } + scheme.apply { + setColor( + EditorColorScheme.LINE_NUMBER_CURRENT, + colorScheme.onSurface.toArgb() + ) + setColor( + EditorColorScheme.SELECTION_HANDLE, + colorScheme.primary.toArgb() + ) + setColor( + EditorColorScheme.SELECTION_INSERT, + colorScheme.primary.toArgb() + ) + setColor( + EditorColorScheme.SELECTED_TEXT_BACKGROUND, + colorScheme.primary.copy(alpha = 0.3f).toArgb() + ) + setColor( + EditorColorScheme.CURRENT_LINE, + colorScheme.surfaceContainerHigh.toArgb() + ) + setColor( + EditorColorScheme.WHOLE_BACKGROUND, + colorScheme.surfaceContainerLowest.toArgb() + ) + setColor( + EditorColorScheme.LINE_NUMBER_BACKGROUND, + colorScheme.surfaceContainer.toArgb() + ) + setColor( + EditorColorScheme.LINE_NUMBER, + colorScheme.onSurface.copy(alpha = 0.5f).toArgb() + ) + setColor( + EditorColorScheme.MATCHED_TEXT_BACKGROUND, + colorScheme.surfaceVariant.toArgb() + ) + setColor(EditorColorScheme.HIGHLIGHTED_DELIMITERS_FOREGROUND, Color.Red.toArgb()) + } + } + + private fun analyseFile() { + activityTitle = uriContent.name ?: globalClass.getString(R.string.unknown) + + canFormatFile = uriContent.extension.let { + it == jsonFileType || it == javaFileType || it == kotlinFileType + } + } + + fun hideSearchPanel(codeEditor: CodeEditor) { + if (showSearchPanel) toggleSearchPanel(false, codeEditor) + } + + fun toggleSearchPanel(value: Boolean = !showSearchPanel, codeEditor: CodeEditor) { + showSearchPanel = value + if (!value) codeEditor.searcher.stopSearch() + } + + fun checkActiveFileValidity( + onSourceReload: (newText: String) -> Unit, + onFileNotFound: () -> Unit + ) { + if (!uri.exists(globalClass)) { + onFileNotFound() + return + } + + if (uri.lastModified(globalClass) != lastModified) { + CoroutineScope(Dispatchers.IO).launch { + val newText = readSourceFile(this) + showSourceFileWarningDialog { onSourceReload(newText) } + } + } + } + + fun showSourceFileWarningDialog(onConfirm: () -> Unit) { + warningDialogProperties.apply { + title = globalClass.getString(R.string.warning) + message = globalClass.getString(R.string.changed_source_file) + confirmText = globalClass.getString(R.string.reload) + dismissText = globalClass.getString(R.string.cancel) + onDismiss = { showWarningDialog = false } + warningDialogProperties.onConfirm = { + lastModified = uri.lastModified(globalClass) + onConfirm() + requireSave = false + showWarningDialog = false + } + showWarningDialog = true + } + } + + fun updateSymbols(): List { + if (!customSymbolsFile.exists()) { + customSymbolsFile.writeText( + GsonBuilder().setPrettyPrinting().create().toJson(defaultSymbolHolders) + ) + } + + try { + customSymbolHolders = Gson().fromJson( + customSymbolsFile.readText(), + object : TypeToken>() {}.type + ) + } catch (e: Exception) { + logger.logError(e) + globalClass.showMsg(R.string.failed_to_load_symbols_file) + } + + return customSymbolHolders.ifEmpty { defaultSymbolHolders } + } + + fun save(onSaved: () -> Unit, onFailed: () -> Unit) { + isSaving = true + CoroutineScope(Dispatchers.IO).launch { + globalClass.contentResolver.openOutputStream(uri, "wt")?.use { outputStream -> + BufferedWriter(OutputStreamWriter(outputStream)).use { writer -> + writer.write(content) + } + withContext(Dispatchers.Main) { onSaved().also { isSaving = false } } + } ?: withContext(Dispatchers.Main) { onFailed().also { isSaving = false } } + } + } + + fun createCodeEditorView(context: Context): CodeEditor { + return CodeEditor(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + props.apply { + globalClass.preferencesManager.textEditorPrefs.let { + useICULibToSelectWords = it.useICULibToSelectWords + symbolPairAutoCompletion = it.symbolPairAutoCompletion + deleteEmptyLineFast = it.deleteEmptyLineFast + deleteMultiSpaces = if (it.deleteMultiSpaces) -1 else 1 + autoIndent = it.autoIndent + boldMatchingDelimiters = false + formatPastedText = true + isWordwrap = it.wordWrap + } + } + + globalClass.preferencesManager.textEditorPrefs.let { + editable = !it.readOnly + setPinLineNumber(it.pinLineNumber) + getComponent(Magnifier::class.java).isEnabled = it.enableMagnifier + } + + getComponent(EditorAutoCompletion::class.java).isEnabled = false + + resetColorScheme(this, true) + } + } + + fun selectionChangeListener(codeEditor: CodeEditor) { + activitySubtitle = getFormattedCursorPosition(codeEditor) + } + + fun changeFontStyle(codeEditor: CodeEditor) { + codeEditor.typefaceText = + Typeface.createFromAsset(globalClass.assets, "font/JetBrainsMono-Regular.ttf") + } + + private fun getFormattedCursorPosition(codeEditor: CodeEditor) = buildString { + val cursor = codeEditor.cursor + + append( + globalClass.getString( + R.string.cursor_position, + cursor.leftLine + 1, + cursor.leftColumn + 1 + ) + ) + + append(whiteSpace) + + if (cursor.isSelected) { + val selectionCount = cursor.right - cursor.left + append("($selectionCount)") + } + + val searcher = codeEditor.searcher + if (searcher.hasQuery()) { + val idx = searcher.currentMatchedPositionIndex + + append(whiteSpace) + append(globalClass.getString(R.string.text_editor_search_result)) + + if (idx == -1) { + append("${searcher.matchedPositionCount}") + } else { + append("${idx + 1}/${searcher.matchedPositionCount}") + } + } + } + + override fun onClose() { + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/OptionsMenu.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/OptionsMenu.kt new file mode 100644 index 00000000..8d1b4b40 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/OptionsMenu.kt @@ -0,0 +1,271 @@ +package com.raival.compose.file.explorer.screen.viewer.text.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Backspace +import androidx.compose.material.icons.automirrored.rounded.FormatAlignLeft +import androidx.compose.material.icons.automirrored.rounded.FormatIndentIncrease +import androidx.compose.material.icons.automirrored.rounded.WrapText +import androidx.compose.material.icons.rounded.AirlineStops +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material.icons.rounded.RemoveRedEye +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.SpaceBar +import androidx.compose.material.icons.rounded.TextRotationNone +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.viewer.text.TextViewerInstance +import io.github.rosemoe.sora.widget.CodeEditor +import io.github.rosemoe.sora.widget.component.Magnifier +import me.saket.cascade.CascadeDropdownMenu + +@Composable +fun OptionsMenu( + textViewerInstance: TextViewerInstance, + expanded: Boolean, + codeEditor: CodeEditor, + onDismissRequest: () -> Unit +) { + val preferences = globalClass.preferencesManager.textEditorPrefs + + CascadeDropdownMenu( + expanded = expanded, + onDismissRequest = { onDismissRequest() }, + fixedWidth = 250.dp + ) { + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(id = R.string.search)) }, + onClick = { + textViewerInstance.toggleSearchPanel(codeEditor = codeEditor) + onDismissRequest() + }, + leadingIcon = { Icon(Icons.Rounded.Search, null) } + ) + + if (textViewerInstance.canFormatFile) { + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.format)) }, + onClick = { + codeEditor.formatCodeAsync() + onDismissRequest() + }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.FormatAlignLeft, null) } + ) + } + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.word_wrap)) }, + onClick = { + preferences.wordWrap = (!preferences.wordWrap).also { newValue -> + codeEditor.isWordwrap = newValue + onDismissRequest() + } + }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.WrapText, null) }, + trailingIcon = { + Checkbox(preferences.wordWrap, { + preferences.wordWrap = (!preferences.wordWrap).also { newValue -> + codeEditor.isWordwrap = newValue + onDismissRequest() + } + }) + } + ) + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.read_only)) }, + onClick = { + preferences.readOnly = (!preferences.readOnly).also { newValue -> + codeEditor.editable = !newValue + onDismissRequest() + } + }, + leadingIcon = { Icon(Icons.Rounded.RemoveRedEye, null) }, + trailingIcon = { + Checkbox(preferences.readOnly, { + preferences.readOnly = (!preferences.readOnly).also { newValue -> + codeEditor.editable = !newValue + onDismissRequest() + } + }) + } + ) + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.jump_to_line)) }, + onClick = { + textViewerInstance.showJumpToPositionDialog = true + onDismissRequest() + }, + leadingIcon = { Icon(Icons.Rounded.AirlineStops, null) } + ) + + DropdownMenuItem( + text = { Text(text = stringResource(R.string.preferences)) }, + leadingIcon = { Icon(Icons.Rounded.Settings, null) }, + children = { + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.use_icu_selection)) }, + onClick = { + preferences.useICULibToSelectWords = + (!preferences.useICULibToSelectWords).also { newValue -> + codeEditor.props.useICULibToSelectWords = newValue + } + }, + leadingIcon = { Icon(Icons.Rounded.TextRotationNone, null) }, + trailingIcon = { + Checkbox( + checked = preferences.useICULibToSelectWords, + onCheckedChange = { + preferences.useICULibToSelectWords = + !preferences.useICULibToSelectWords.also { newValue -> + codeEditor.props.useICULibToSelectWords = newValue + } + } + ) + } + ) + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.pin_numbers_line)) }, + onClick = { + preferences.pinLineNumber = + (!preferences.pinLineNumber).also { newValue -> + codeEditor.setPinLineNumber(newValue) + } + }, + leadingIcon = { Icon(Icons.Rounded.Numbers, null) }, + trailingIcon = { + Checkbox( + checked = preferences.pinLineNumber, + onCheckedChange = { + preferences.pinLineNumber = + (!preferences.pinLineNumber).also { newValue -> + codeEditor.setPinLineNumber(newValue) + } + } + ) + } + ) + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.auto_symbol_pair)) }, + onClick = { + preferences.symbolPairAutoCompletion = + (!preferences.symbolPairAutoCompletion).also { newValue -> + codeEditor.props.symbolPairAutoCompletion = newValue + } + }, + leadingIcon = { Icon(Icons.Rounded.Code, null) }, + trailingIcon = { + Checkbox( + checked = preferences.symbolPairAutoCompletion, + onCheckedChange = { + preferences.symbolPairAutoCompletion = + (!preferences.symbolPairAutoCompletion).also { newValue -> + codeEditor.props.symbolPairAutoCompletion = newValue + } + } + ) + } + ) + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.delete_empty_lines)) }, + onClick = { + preferences.deleteEmptyLineFast = + (!preferences.deleteEmptyLineFast).also { newValue -> + codeEditor.props.deleteEmptyLineFast = newValue + } + }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Backspace, null) }, + trailingIcon = { + Checkbox( + checked = preferences.deleteEmptyLineFast, + onCheckedChange = { + preferences.deleteEmptyLineFast = + (!preferences.deleteEmptyLineFast).also { newValue -> + codeEditor.props.deleteEmptyLineFast = newValue + } + } + ) + } + ) + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.delete_tabs)) }, + onClick = { + preferences.deleteMultiSpaces = + (!preferences.deleteMultiSpaces).also { newValue -> + codeEditor.props.deleteMultiSpaces = if (newValue) -1 else 1 + } + }, + leadingIcon = { Icon(Icons.Rounded.SpaceBar, null) }, + trailingIcon = { + Checkbox( + checked = preferences.deleteMultiSpaces, + onCheckedChange = { + preferences.deleteMultiSpaces = + (!preferences.deleteMultiSpaces).also { newValue -> + codeEditor.props.deleteMultiSpaces = if (newValue) -1 else 1 + } + } + ) + } + ) + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.auto_indentation)) }, + onClick = { + preferences.autoIndent = (!preferences.autoIndent).also { newValue -> + codeEditor.props.autoIndent = newValue + } + }, + leadingIcon = { Icon(Icons.AutoMirrored.Rounded.FormatIndentIncrease, null) }, + trailingIcon = { + Checkbox( + checked = preferences.autoIndent, + onCheckedChange = { + preferences.autoIndent = + (!preferences.autoIndent).also { newValue -> + codeEditor.props.autoIndent = newValue + } + } + ) + } + ) + + androidx.compose.material3.DropdownMenuItem( + text = { Text(text = stringResource(R.string.magnifier)) }, + onClick = { + preferences.enableMagnifier = + (!preferences.enableMagnifier).also { newValue -> + codeEditor.getComponent(Magnifier::class.java).isEnabled = + newValue + } + }, + leadingIcon = { Icon(Icons.Rounded.Search, null) }, + trailingIcon = { + Checkbox( + checked = preferences.enableMagnifier, + onCheckedChange = { + preferences.enableMagnifier = + (!preferences.enableMagnifier).also { newValue -> + codeEditor.getComponent(Magnifier::class.java).isEnabled = + newValue + } + } + ) + } + ) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/ToolbarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/ToolbarView.kt new file mode 100644 index 00000000..3405ca1c --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/ToolbarView.kt @@ -0,0 +1,108 @@ +package com.raival.compose.file.explorer.screen.viewer.text.ui + +import androidx.activity.OnBackPressedDispatcher +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Redo +import androidx.compose.material.icons.automirrored.rounded.Undo +import androidx.compose.material.icons.rounded.Menu +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material.icons.rounded.SaveAs +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.screen.viewer.text.TextViewerInstance +import io.github.rosemoe.sora.widget.CodeEditor + +@Composable +fun ToolbarView( + textViewerInstance: TextViewerInstance, + codeEditor: CodeEditor, + onBackPressedDispatcher: OnBackPressedDispatcher +) { + var showOptionsMenu by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(color = colorScheme.surfaceContainer) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { onBackPressedDispatcher.onBackPressed() }) { + Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null) + } + + Column( + Modifier + .weight(1f) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = textViewerInstance.activityTitle, + fontSize = 21.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + IconButton(enabled = textViewerInstance.canUndo, onClick = { codeEditor.undo() }) { + Icon(imageVector = Icons.AutoMirrored.Rounded.Undo, contentDescription = null) + } + + IconButton(enabled = textViewerInstance.canRedo, onClick = { codeEditor.redo() }) { + Icon(imageVector = Icons.AutoMirrored.Rounded.Redo, contentDescription = null) + } + + IconButton( + onClick = { + textViewerInstance.save( + onSaved = { + globalClass.showMsg(R.string.saved) + textViewerInstance.requireSave = false + }, + onFailed = { globalClass.showMsg(R.string.failed_to_save) } + ) + } + ) { + Icon( + imageVector = if (textViewerInstance.requireSave) { + Icons.Rounded.SaveAs + } else { + Icons.Rounded.Save + }, contentDescription = null + ) + } + + IconButton(onClick = { showOptionsMenu = !showOptionsMenu }) { + Icon(imageVector = Icons.Rounded.Menu, contentDescription = null) + OptionsMenu( + textViewerInstance = textViewerInstance, + expanded = showOptionsMenu, + codeEditor = codeEditor + ) { + showOptionsMenu = false + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerActivity.kt new file mode 100644 index 00000000..828baed1 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerActivity.kt @@ -0,0 +1,31 @@ +package com.raival.compose.file.explorer.screen.viewer.video + +import android.net.Uri +import androidx.activity.compose.setContent +import com.raival.compose.file.explorer.screen.viewer.ViewerActivity +import com.raival.compose.file.explorer.screen.viewer.ViewerInstance +import com.raival.compose.file.explorer.screen.viewer.video.ui.VideoPlayerScreen +import com.raival.compose.file.explorer.theme.FileExplorerTheme + +class VideoPlayerActivity : ViewerActivity() { + override fun onCreateNewInstance( + uri: Uri, + uid: String + ): ViewerInstance { + return VideoPlayerInstance(uri, uid) + } + + override fun onReady(instance: ViewerInstance) { + val videoPlayerInstance = instance as VideoPlayerInstance + setContent { + FileExplorerTheme { + VideoPlayerScreen( + videoUri = videoPlayerInstance.uri, + videoPlayerInstance = videoPlayerInstance, + onBackPressed = { finish() } + ) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerInstance.kt new file mode 100644 index 00000000..12700431 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerInstance.kt @@ -0,0 +1,138 @@ +package com.raival.compose.file.explorer.screen.viewer.video + +import android.content.Context +import android.net.Uri +import androidx.media3.common.C.TIME_UNSET +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.raival.compose.file.explorer.App.Companion.globalClass +import com.raival.compose.file.explorer.R +import com.raival.compose.file.explorer.common.extension.name +import com.raival.compose.file.explorer.screen.viewer.ViewerInstance +import com.raival.compose.file.explorer.screen.viewer.video.model.VideoPlayerState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class VideoPlayerInstance( + override val uri: Uri, + override val id: String +) : ViewerInstance { + private val _playerState = MutableStateFlow(VideoPlayerState()) + val playerState: StateFlow = _playerState.asStateFlow() + private var exoPlayer: ExoPlayer? = null + private var positionTrackingJob: Job? = null + + suspend fun initializePlayer(context: Context, uri: Uri) { + _playerState.value = _playerState.value.copy(isLoading = true) + + withContext(Dispatchers.Main) { + exoPlayer = ExoPlayer.Builder(context).build().apply { + val mediaItem = MediaItem.Builder() + .setUri(uri) + .build() + + setMediaItem(mediaItem) + prepare() + + volume = 0f + + _playerState.value = _playerState.value.copy( + title = uri.name ?: globalClass.getString(R.string.unknown) + ) + + addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + _playerState.value = _playerState.value.copy(isPlaying = isPlaying) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + _playerState.value = _playerState.value.copy( + isLoading = playbackState == Player.STATE_BUFFERING + ) + + if (playbackState == Player.STATE_READY) { + _playerState.value = _playerState.value.copy( + duration = duration, + isLoading = false + ) + } + } + }) + } + } + startPositionTracking() + } + + private fun startPositionTracking() { + positionTrackingJob?.cancel() + positionTrackingJob = CoroutineScope(Dispatchers.Main).launch { + while (true) { + exoPlayer?.let { player -> + _playerState.value = _playerState.value.copy( + currentPosition = player.currentPosition, + duration = player.duration.takeIf { it != TIME_UNSET } ?: 0L + ) + } + delay(100) + } + } + } + + fun playPause() { + exoPlayer?.let { player -> + if (player.isPlaying) { + player.pause() + } else { + player.play() + } + } + } + + fun seekTo(position: Long) { + exoPlayer?.seekTo(position) + } + + fun setPlaybackSpeed(speed: Float) { + exoPlayer?.setPlaybackSpeed(speed) + _playerState.value = _playerState.value.copy(playbackSpeed = speed) + } + + fun toggleMute() { + exoPlayer?.let { player -> + val currentVolume = player.volume + val newVolume = if (currentVolume > 0f) 0f else 1f + player.volume = newVolume + _playerState.value = _playerState.value.copy(isMuted = newVolume == 0f) + } + } + + fun toggleControls() { + _playerState.value = + _playerState.value.copy(showControls = !_playerState.value.showControls) + } + + fun toggleRepeatMode() { + val newMode = when (_playerState.value.repeatMode) { + Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE + else -> Player.REPEAT_MODE_OFF + } + exoPlayer?.repeatMode = newMode + _playerState.value = _playerState.value.copy(repeatMode = newMode) + } + + fun getPlayer() = exoPlayer + + override fun onClose() { + positionTrackingJob?.cancel() + exoPlayer?.release() + exoPlayer = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/model/VideoPlayerState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/model/VideoPlayerState.kt new file mode 100644 index 00000000..baa4e618 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/model/VideoPlayerState.kt @@ -0,0 +1,15 @@ +package com.raival.compose.file.explorer.screen.viewer.video.model + +import androidx.media3.common.Player + +data class VideoPlayerState( + val isPlaying: Boolean = false, + val isLoading: Boolean = true, + val currentPosition: Long = 0L, + val duration: Long = 0L, + val playbackSpeed: Float = 1.0f, + val repeatMode: Int = Player.REPEAT_MODE_OFF, + val isMuted: Boolean = true, + val showControls: Boolean = true, + val title: String = "" +) diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/ui/VideoPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/ui/VideoPlayerScreen.kt new file mode 100644 index 00000000..44f85e47 --- /dev/null +++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/ui/VideoPlayerScreen.kt @@ -0,0 +1,392 @@ +package com.raival.compose.file.explorer.screen.viewer.video.ui + +import android.net.Uri +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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.ui.PlayerView +import com.raival.compose.file.explorer.common.extension.toFormattedTime +import com.raival.compose.file.explorer.screen.viewer.video.VideoPlayerInstance +import com.raival.compose.file.explorer.screen.viewer.video.model.VideoPlayerState +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable +import kotlin.math.abs + +@Composable +fun VideoPlayerScreen( + videoUri: Uri, + videoPlayerInstance: VideoPlayerInstance, + onBackPressed: () -> Unit +) { + val context = LocalContext.current + val playerState by videoPlayerInstance.playerState.collectAsState() + + LaunchedEffect(videoUri) { + videoPlayerInstance.initializePlayer(context, videoUri) + } + + DisposableEffect(Unit) { + onDispose { + videoPlayerInstance.onClose() + } + } + Surface( + modifier = Modifier.fillMaxSize(), + color = colorScheme.background + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + // Video Player View + if (!playerState.isLoading) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = videoPlayerInstance.getPlayer() + useController = false + } + }, + modifier = Modifier + .fillMaxSize() + .zoomable( + zoomState = rememberZoomState(), + onTap = { + videoPlayerInstance.toggleControls() + } + ) + ) + } + + // Controls Overlay + AnimatedVisibility( + visible = playerState.showControls && !playerState.isLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + VideoControls( + state = playerState, + onPlayPause = { videoPlayerInstance.playPause() }, + onSeekForward = { + val newPos = + (playerState.currentPosition + 10000).coerceAtMost(playerState.duration) + videoPlayerInstance.seekTo(newPos) + }, + onSeekBackward = { + val newPos = (playerState.currentPosition - 10000).coerceAtLeast(0) + videoPlayerInstance.seekTo(newPos) + }, + onSeek = { position -> + videoPlayerInstance.seekTo(position) + }, + onBackPressed = onBackPressed, + onToggleMute = { videoPlayerInstance.toggleMute() }, + ) + } + + // Loading indicator + if (playerState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + } + } + } + } +} + +@Composable +fun VideoControls( + state: VideoPlayerState, + onToggleMute: () -> Unit, + onPlayPause: () -> Unit, + onSeekForward: () -> Unit, + onSeekBackward: () -> Unit, + onSeek: (Long) -> Unit, + onBackPressed: () -> Unit +) { + val defaultColor = colorScheme.surface + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + defaultColor.copy(alpha = 0.9f), + Color.Transparent, + Color.Transparent, + Color.Transparent, + defaultColor.copy(alpha = 0.9f) + ) + ) + ) + ) { + // Top bar with title and back button + TopBar( + playerState = state, + onBackPressed = onBackPressed, + onToggleMute = onToggleMute, + modifier = Modifier.align(Alignment.TopStart) + ) + + // Center controls + CenterControls( + isPlaying = state.isPlaying, + onPlayPause = onPlayPause, + onSeekForward = onSeekForward, + onSeekBackward = onSeekBackward, + modifier = Modifier.align(Alignment.Center) + ) + + // Bottom progress bar + BottomControls( + currentPosition = state.currentPosition, + duration = state.duration, + onSeek = onSeek, + modifier = Modifier.align(Alignment.BottomStart) + ) + } +} + +@Composable +fun TopBar( + playerState: VideoPlayerState, + onBackPressed: () -> Unit, + onToggleMute: () -> Unit, + modifier: Modifier, +) { + Row( + modifier = modifier + .windowInsetsPadding(WindowInsets.statusBars) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBackPressed, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = playerState.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.width(16.dp)) + + IconButton( + onClick = onToggleMute, + ) { + Icon( + imageVector = if (playerState.isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp, + contentDescription = null, + tint = colorScheme.onSurface + ) + } + } +} + +@Composable +fun CenterControls( + isPlaying: Boolean, + onPlayPause: () -> Unit, + onSeekForward: () -> Unit, + onSeekBackward: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Seek backward + IconButton( + onClick = onSeekBackward, + modifier = Modifier + .size(56.dp) + .background( + colorScheme.surface.copy(alpha = 0.6f), + CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.Replay10, + contentDescription = null, + tint = colorScheme.onSurface, + modifier = Modifier.size(28.dp) + ) + } + + // Play/Pause + IconButton( + onClick = onPlayPause, + modifier = Modifier + .size(72.dp) + .background( + colorScheme.primary, + CircleShape + ) + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = null, + tint = colorScheme.onPrimary.copy(alpha = 0.7f), + modifier = Modifier.size(36.dp) + ) + } + + // Seek forward + IconButton( + onClick = onSeekForward, + modifier = Modifier + .size(56.dp) + .background( + colorScheme.surface.copy(alpha = 0.6f), + CircleShape + ) + ) { + Icon( + imageVector = Icons.Default.Forward10, + contentDescription = null, + tint = colorScheme.onSurface, + modifier = Modifier.size(28.dp) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomControls( + currentPosition: Long, + duration: Long, + onSeek: (Long) -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = Color.Transparent + ) { + Column( + modifier = Modifier + .padding(16.dp) + .padding(bottom = 24.dp), + ) { + var manualPosition by remember { mutableLongStateOf(0L) } + var manualSeek by remember { mutableFloatStateOf(0f) } + var isDragging by remember { mutableStateOf(false) } + val progress = if (duration > 0) { + if (abs(currentPosition - manualPosition) < 1000) { + (currentPosition.toFloat() / duration.toFloat()).also { + manualPosition = currentPosition + } + } else manualSeek + } else 0f + + Slider( + value = if (isDragging) manualSeek else progress, + onValueChange = { + isDragging = true + manualSeek = it + }, + onValueChangeFinished = { + (manualSeek * duration).toLong().let { newPosition -> + manualPosition = newPosition + onSeek(newPosition) + } + isDragging = false + }, + modifier = Modifier.fillMaxWidth(), + colors = SliderDefaults.colors( + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.onSurface.copy(alpha = 0.3f) + ) + ) + + // Time labels + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = (if (isDragging) (manualSeek * duration).toLong() else manualPosition).toFormattedTime(), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface + ) + Text( + text = duration.toFormattedTime(), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/raival/compose/file/explorer/theme/Theme.kt b/app/src/main/java/com/raival/compose/file/explorer/theme/Theme.kt index 5bb73cdd..1c24a0d5 100644 --- a/app/src/main/java/com/raival/compose/file/explorer/theme/Theme.kt +++ b/app/src/main/java/com/raival/compose/file/explorer/theme/Theme.kt @@ -33,17 +33,17 @@ fun FileExplorerTheme( ) { val context = LocalContext.current val manager = globalClass.preferencesManager - val darkTheme: Boolean = if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - if (manager.displayPrefs.theme == ThemePreference.SYSTEM.ordinal) { + val darkTheme: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (manager.appearancePrefs.theme == ThemePreference.SYSTEM.ordinal) { isSystemInDarkTheme() - } else manager.displayPrefs.theme == ThemePreference.DARK.ordinal + } else manager.appearancePrefs.theme == ThemePreference.DARK.ordinal } else { - manager.displayPrefs.theme == ThemePreference.DARK.ordinal + manager.appearancePrefs.theme == ThemePreference.DARK.ordinal } fun getTheme(): ColorScheme { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - when (manager.displayPrefs.theme) { + when (manager.appearancePrefs.theme) { ThemePreference.LIGHT.ordinal -> dynamicLightColorScheme(context) ThemePreference.DARK.ordinal -> dynamicDarkColorScheme(context) else -> if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme( @@ -51,7 +51,7 @@ fun FileExplorerTheme( ) } } else { - when (manager.displayPrefs.theme) { + when (manager.appearancePrefs.theme) { ThemePreference.LIGHT.ordinal -> LightColorScheme ThemePreference.DARK.ordinal -> DarkColorScheme else -> if (darkTheme) DarkColorScheme else LightColorScheme @@ -63,7 +63,7 @@ fun FileExplorerTheme( mutableStateOf(getTheme()) } - LaunchedEffect(manager.displayPrefs.theme) { + LaunchedEffect(manager.appearancePrefs.theme) { colorScheme = getTheme() } @@ -71,8 +71,10 @@ fun FileExplorerTheme( if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + (view.context as? Activity)?.window?.let { + WindowCompat.getInsetsController(it, view).isAppearanceLightStatusBars = !darkTheme + } + } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 4ee702a4..2af555ea 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -1,219 +1,328 @@ - - پرونده کاوشگر + مدیر فایل ریشه - ذخیره داخلی - اجازه دسترسی به ذخیره سازی لازم است - فحش - پلاگین - پرونده کاوشگر - پرش به مسیر + حافظه داخلی + مجوز دسترسی به حافظه مورد نیاز است + ویرایشگر متن + مدیر فایل + رفتن به مسیر مسیر مقصد - باز + باز کردن مسیر نامعتبر - برگه جدید - پرونده ها + تب جدید سطل بازیافت - صرفه جویی + در حال ذخیره… هشدار - برخی از پرونده ها در ویرایشگر متن ذخیره نمی شوند. آیا می خواهید اکنون آنها را نجات دهید؟ + برخی فایل‌ها در ویرایشگر متن ذخیره نشده‌اند. آیا می‌خواهید آن‌ها را الان ذخیره کنید؟ نادیده گرفتن - پس انداز کردن + ذخیره پوشه خالی - "(٪ S انتخاب شده)" - پرونده ها - کار جدید اضافه شده است + " (%s انتخاب شده)" + فایل‌ها + کار جدید اضافه شد نام بسته نام نسخه - رمز نسخه + کد نسخه اندازه - کاوش کردن - نصب کردن - نشانه ها - حذف کردن + کشف + نصب + نشانک‌ها + حذف خالی - وظیفه + کار جستجو - ایجاد کردن - مرتب - همه را انتخاب کنید - گزینه - جدید ایجاد کنید + ایجاد + مرتب‌سازی + انتخاب همه + گزینه‌ها + محتوای جدید نام - نام پرونده نامعتبر - پرونده - ایجاد پرونده انجام نشد - پرونده با نام مشابه از قبل وجود دارد + نام فایل نامعتبر + فایل + ایجاد فایل ناموفق بود + فایل با نام مشابه وجود دارد پوشه - ایجاد پوشه انجام نشد + ایجاد پوشه ناموفق بود نام پوشه نامعتبر - تأیید کردن - برکناری - تأیید را حذف کنید - حرکت به سطل بازیافت - بایگانی ایجاد کنید - ایجاد پرونده امکان پذیر نیست - لغو کردن - به نشانک ها اضافه شده است - در برگه جدید باز کنید - باز کردن - به صفحه اصلی اضافه کنید - با ویرایشگر متن ویرایش کنید - فشردن - فشردن - جزئیات - خواص - مسیر - اری - تاریخ اصلاح - والدین + تأیید + بستن + تأیید حذف + انتقال به سطل بازیافت + ایجاد آرشیو + لغو + به نشانک‌ها اضافه شد + باز کردن در تب جدید + باز کردن با + اضافه کردن به صفحه اصلی + ویرایش با ویرایشگر متن + فشرده‌سازی انتخاب شده - محتوا - کل: ٪ 1 $ D (پرونده ها: ٪ 2 $ D | پوشه ها: ٪ 3 $ D) - کل: ٪ 1 $ D (پرونده ها: ٪ 2 $ D | پوشه ها: ٪ 3 $ D) - کپی شده در کلیپ بورد - مرتب - فقط روی این پوشه اقدام کنید - نام (A-Z) + در کلیپ‌بورد کپی شد + مرتب‌سازی بر اساس + فقط روی این پوشه اعمال کن + نام (الف-ی) تاریخ (جدیدتر) - اندازه (کوچکتر) - پوشه ها اول + اندازه (کوچک‌تر) + پوشه‌ها اول معکوس - نوع تقلید - نزدیک - دیگران را ببندید - همه را ببندید - نام بردن - مسیر کپی - در محل قرار گرفتن + نوع MIME + بستن + بستن بقیه + تغییر نام + کپی مسیر + یافتن نتایج - پرس و جو جستجو - وظایف - وظیفه نامعتبر - هیچ برنامه ای پیدا نکرد که بتواند این نوع پرونده ها را باز کند - این پرونده را باز نکرد - کنترل مکان نما سمت چپ - کنترل مکان نما درست - پرش به موقعیت - خط: ستون (به عنوان مثال 23:11) - رفتن + درخواست جستجو + کارها + هیچ برنامه‌ای برای باز کردن این نوع فایل یافت نشد + باز کردن این فایل ناموفق بود + کنترل نشانگر چپ + کنترل نشانگر راست + رفتن به موقعیت + خط:ستون (مثلاً 23:11) + برو موقعیت نامعتبر قالب - بسته بندی - فقط بخوانید - پرش به خط - ترجیحات - از انتخاب ICU استفاده کنید - خط شماره پین + بستن کلمات + فقط خواندنی + رفتن به خط + تنظیمات + استفاده از انتخاب ICU + سنجاق کردن خط شماره‌ها جفت نماد خودکار - خطوط خالی را حذف کنید - زبانه ها را حذف کنید + حذف خطوط خالی + حذف تب‌ها تورفتگی خودکار - بزرگ کننده - پرونده های اخیر - این پرونده در حال حاضر باز است - پیدا کردن - تعویض کردن - مورد حساس - مگس + ذره‌بین + فایل‌های اخیر + این فایل در حال حاضر باز است + یافتن + جایگزین کردن + عبارت منظم + حساس به حروف کوچک و بزرگ + جایگزین همه - سعادت - طرف دیگر - ذخیره شده - پس انداز نشد - ایجاد پرونده موقت انجام نشد - بارگیری پرونده نمادها انجام نشد - پرونده منبع تغییر کرده است ، آیا می خواهید آن را بارگیری مجدد کنید؟ - بارگیری مجدد + قبلی + بعدی + ذخیره شد + ذخیره ناموفق بود + بارگذاری فایل نمادها ناموفق بود + فایل منبع تغییر کرده است، آیا می‌خواهید آن را دوباره بارگذاری کنید؟ + بارگذاری مجدد نادیده گرفتن تغییرات - این پرونده ذخیره نشده است ، آیا می خواهید آن را ذخیره کنید؟ - پرونده یافت نشد - پرونده دیگر وجود ندارد - حرکت - آماده سازی ... - شروع کار را شروع کنید - پیشرفت: ٪ 1 $ d/٪ 2 $ d - در حال حرکت: ٪ 1 $ s \ ndestination: ٪ 2 $ s - حذف پرونده های منبع… - انجام شده - کپی کردن - کپی: ٪ 1 $ s \ ndestination: ٪ 2 $ s - پیشرفت: ٪ 1 $ D - حذف: ٪ 1 $ s - فشرده سازی: ٪ 1 $ s - فشرده سازی: $ 1 $ s - ترجیحات - نمایش - موضوع - ترجیح تم را انتخاب کنید - سبک - تاریک - سیستم را دنبال کنید + این فایل ذخیره نشده است، آیا می‌خواهید آن را ذخیره کنید؟ + فایل یافت نشد + فایل دیگر وجود ندارد + انتقال + در حال آماده‌سازی + در حال حذف فایل‌های منبع… + کپی + در حال حذف + در حال فشرده‌سازی + تنظیمات + پوسته + انتخاب ترجیح پوسته + روشن + تیره + دنبال کردن سیستم ویرایشگر متن - پرونده های اخیر محدود است - حداکثر تعداد پرونده های اخیر را برای نمایش انتخاب کنید - اندازه لیست پرونده + محدودیت فایل‌های اخیر + حداکثر تعداد فایل‌های اخیر برای نمایش را انتخاب کنید + اندازه فهرست فایل کوچک - واسطه + متوسط بزرگ - فوق العاده بزرگ - یک اندازه مناسب برای لیست پرونده ها انتخاب کنید - برچسب های نوار گزینه های پایین را نشان دهید + خیلی بزرگ + اندازه مناسب برای فهرست فایل انتخاب کنید + نمایش برچسب‌های نوار پایین عمومی - پرونده ها محدودیت جستجو - تعداد نتایج نشان داده شده هنگام جستجو را محدود کنید - شمارش ستون لیست پرونده ها - تعداد ستون ها را انتخاب کنید - بیننده PDF - ناشناخته - تعداد صفحه: ٪ 1 $ D - تصویر - بیننده رسانه - پرونده رسانه ای نامعتبر - هنرمند ناشناخته - جستجو به پایان رسید - شمارش محتوای پوشه را نشان دهید - نمایش پرونده های پنهان - آیا واقعاً می خواهید پرونده های انتخاب شده را حذف کنید؟ - اندازه: ٪ 1 $ D - ٪ 1 $ D: ٪ 2 $ D - "نتیجه جستجو:" - پوشه ها: ٪ 1 $ D - پرونده ها: ٪ 1 $ d + محدودیت جستجو در فایل‌ها + محدود کردن تعداد نتایج نمایش داده شده هنگام جستجو + تعداد ستون فهرست فایل‌ها + تعداد ستون‌ها را انتخاب کنید + نمایشگر PDF + نامشخص + تعداد صفحه + نمایشگر تصویر + نمایشگر رسانه + فایل رسانه نامعتبر + هنرمند نامشخص + نمایش تعداد محتوای پوشه + نمایش فایل‌های مخفی + آیا واقعاً می‌خواهید فایل‌های انتخاب شده را حذف کنید؟ + محتوا: %1$d + %1$d:%2$d + "نتیجه جستجو: " + پوشه‌ها: %1$d + فایل‌ها: %1$d خانه خانه - دسته + دسته‌بندی‌ها تصاویر - فیلم - قصور + ویدیوها + صوتی‌ها اسناد - بایگانی - انباره - وظایف در اینجا قابل اجرا نیست - برنامه - برنامه - برنامه + آرشیوها + حافظه + کارها نمی‌توانند اینجا اجرا شوند + برنامه‌ها + برنامه‌ها + برنامه‌ها کاربر سیستم بیشتر - تبدیل به APK - AUTO SIGN APK - پس از عملیات APK APK را امضا کنید - ناموفق - ادغام موفقیت آمیز! - ادغام کامل - اولیه سازی ادغام ... - استخراج پرونده ها - ماژول های ادغام ... - نهایی کردن ادغام ... - ادغام ... - ادغام apks ... - ادغام موفقیت آمیز نبود! - امضای APK ... - امضا شکست خورد! - ادغام شکست خورد: ٪ 1 $ s - لوب - درخواست کردن - هیچ پرونده اخیر وجود ندارد + گیت‌هاب + اعمال + هیچ فایل اخیری نیست این انتخاب را به خاطر بسپار - + ریشه + در حال شمارش فایل‌ها… + در حال کپی + فایل %1$s در پوشه مقصد وجود دارد. + مخفی کردن + تعارض + رد کردن + کار آماده نیست. + کار یافت نشد. + اعمال روی تعارض‌های بعدی + این پوشه غیرقابل دسترس است + فایل‌های مخفی را در تنظیمات نمایش دهید تا همه فایل‌ها را ببینید + هیچ فایل منبعی یافت نشد. + مشکلی پیش آمد: \n%1$s + مقصد وجود نداشت. + "در حال استخراج " + دایرکتوری مقصد نامعتبر. + ایجاد و باز کردن + "در حال تغییر نام" + تغییر نام دسته‌ای + نام جدید + متن برای یافتن + جایگزین با + عبارت منظم + پیش‌نمایش + پسوند + آخرین تغییر + افزایش عددی + افزایش عددی با صفر + یافتن و جایگزین کردن + نحو + پسوند با نقطه + خطا در ایجاد پوشه سطل بازیافت + فایل zip نامعتبر + فشار طولانی برای نمایش منوی گزینه‌های فایل + ویژگی‌های فایل + ویژگی‌های انتخاب + در حال بارگذاری ویژگی‌ها… + در حال تجزیه و تحلیل فایل‌ها… + مکان + نوع + تغییر یافته + سیستم + مالک + مجوزها + تجزیه و تحلیل + محتویات + چک‌سام MD5 + خلاصه انتخاب + %1$d مورد + اندازه کل + محتویات کل + در دسترس نیست + در حال محاسبه… + %1$d فایل، %2$d پوشه + خطا در محاسبه + اضافه کردن به نشانک‌ها + عملیات فایل + ظاهر + غیرفعال کردن کشیدن پایین برای تازه‌سازی + رد کردن صفحه اصلی هنگام بستن تب‌ها + فهرست فایل + رفتار + نامحدود + خیلی کوچک + کارهای نامعتبر + کارهای در انتظار + کارهای ناموفق + کارهای متوقف شده + این کار در حال اجرا است + کارهای در حال اجرا + هیچ کاری در دسترس نیست + هیچ نشانکی در دسترس نیست + توقف + کار ناموفق بود + کار متوقف شد + کار تکمیل شد + در حال توقف… + دسترسی به حافظه مورد نیاز است + برای مدیریت فایل‌هایتان، ما به دسترسی به حافظه دستگاهتان نیاز داریم. این به شما امکان ذخیره، سازماندهی و دسترسی یکپارچه به محتوایتان را می‌دهد. + ذخیره فایل‌ها + ذخیره و سازماندهی اسنادتان + دسترسی به پوشه‌ها + مرور و مدیریت ساختار فایل‌هایتان + اعطای دسترسی + فعلاً رد کن + مسیر + PDF نامعتبر + بارگذاری PDF ناموفق بود + صفحه %1$d + در حال بارگذاری PDF… + خطا + برگشت + اطلاعات PDF + نمایشگر متن + ویرایش با… + هیچ برنامه‌ای برای ویرایش این تصویر یافت نشد + در حال بارگذاری تصویر… + بارگذاری تصویر ناموفق بود + وجود فایل را بررسی کنید و دوباره تلاش کنید + تلاش مجدد + ابعاد + هیچ اطلاعاتی در دسترس نیست + پخش‌کننده صوتی + عنوان نامشخص + آلبوم نامشخص + صدا + اکولایزر + بازنشانی + انجام شد + گزارش‌ها + پاک کردن گزارش‌ها + آیا مطمئن هستید که می‌خواهید همه گزارش‌ها را پاک کنید؟ این عمل قابل بازگشت نیست. + پاک کردن + هیچ گزارشی در دسترس نیست + هیچ خطایی ثبت نشده + هیچ هشداری ثبت نشده + هیچ گزارش اطلاعاتی در دسترس نیست + عالی! هیچ خطایی رخ نداده است + هیچ هشداری ثبت نشده است + هیچ گزارش اطلاعاتی ثبت نشده است + در حال پاک کردن گزارش‌ها… + ترکیب منبع/مقصد پشتیبانی نمی‌شود + خطای نامشخص + کپی فایل‌ها به فایل زیپ ناموفق بود + استخراج فایل‌ها از فایل زیپ ناموفق بود + کپی ورودی‌های فایل زیپ ناموفق بود + تبدیل به APK + فایل بسته APK نامعتبر + در حال ادغام + در حال امضا + فایل بسته APK با موفقیت به APK تبدیل شد + تبدیل بسته APK به APK ناموفق بود + امضای فایل APK ادغام شده ناموفق بود + امضای خودکار فایل‌های بسته APK ادغام شده + امضای خودکار فایل‌های بسته APK ادغام شده با کلید آزمایشی + سفارشی کردن تب خانه + سفارشی کردن تب خانه + فایل‌های اخیراً تغییر یافته + دسته‌بندی‌های دسترسی سریع + دستگاه‌های ذخیره‌سازی و مکان‌ها + فایل‌ها و پوشه‌های نشانک‌گذاری شده + فایل‌های حذف شده + ناوبری سریع مسیر + اعلان اجرای کار + کارها در حال اجرا هستند… + کار نمی‌تواند از وضعیت فعلی ادامه یابد: %1$s + اعتبارسنجی کار ناموفق بود + کار در حالت انتظار نیست + اقدام مورد نیاز + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4d779950..b6790626 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,219 +1,328 @@ - File Explorer - Raiz - Armazenamento Interno - É necessário permissão de acesso ao armazenamento - Editor de Texto - Plugins - Explorador de Arquivos - Ir para o caminho - Caminho de destino - Abrir - Caminho inválido - Nova Aba - Arquivos do App - Lixeira - Salvando… - Aviso - Alguns arquivos no editor de texto não estão salvos. Deseja salvá-los agora? - Ignorar - Salvar - pasta vazia - " (%s selecionados)" + Explorador de Arquivos + Raiz + Armazenamento Interno + Permissão de acesso ao armazenamento é necessária + Editor de Texto + Explorador de Arquivos + Ir para caminho + Caminho de destino + Abrir + Caminho inválido + Nova Aba + Lixeira + Salvando… + Aviso + Alguns arquivos no editor de texto não foram salvos. Deseja salvá-los agora? + Ignorar + Salvar + pasta vazia + " (%s selecionados)" Arquivos - Nova tarefa foi adicionada - Nome do Pacote - Nome da Versão - Código da Versão - Tamanho - Explorar - Instalar - Favoritos - Excluir - Vazio - Tarefa - Buscar - Criar - Ordenar - Selecionar Todos - Opções - Criar Novo - Nome - Nome de arquivo inválido - Arquivo - Falha ao criar arquivo - Um arquivo com nome semelhante já existe - Pasta - Falha ao criar pasta - Nome de pasta inválido - Confirmar - Descartar - Confirmação de Exclusão - Mover para a Lixeira - Criar Arquivo - Não foi possível criar o arquivo - Cancelar - Adicionado aos favoritos - Abrir em nova aba - Abrir com - Adicionar à tela inicial - Editar com editor de texto - Comprimir - Descomprimir - Detalhes - Propriedades - caminho - uri - data de modificação - pai - selecionado - conteúdo - total: %1$d (arquivos: %2$d | pastas: %3$d) - total: %1$d (arquivos: %2$d | pastas: %3$d) - Copiado para a área de transferência - Ordenar por - Aplicar apenas a esta pasta - Nome (A-Z) - Data (Mais recente) - Tamanho (menor) - Pastas primeiro - Inverter - Tipo MIME - Fechar - Fechar outros - Fechar tudo - Renomear - Copiar caminho - Localizar - Resultados - Consulta de Pesquisa - Tarefas - Tarefa inválida - Nenhum app pode abrir este tipo de arquivo - Falha ao abrir este arquivo - Controlando cursor esquerdo - Controlando cursor direito - Ir para posição - Linha:Coluna (ex.: 23:11) - Ir - Posição inválida - Formatar - Quebra automática de linha - Somente leitura - Ir para linha - Preferências - Usar seleção ICU - Fixar linha de números - Par de símbolos automático - Excluir linhas vazias - Excluir tabulações - Indentações automáticas - Lupa - Arquivos Recentes - Este arquivo está atualmente aberto - Localizar - Substituir - Regex - Sensível a maiúsculas e minúsculas - REP - TODOS - ANTERIOR - PRÓXIMO - Salvo - Falha ao salvar - Falha ao criar arquivo temporário - Falha ao carregar arquivo de símbolos - O arquivo fonte foi alterado, deseja recarregá-lo? - Recarregar - Ignorar alterações - Este arquivo não foi salvo, deseja salvá-lo? - Arquivo - O arquivo não existe mais - Mover - Preparando… - Iniciando tarefa de movimentação… - Progresso: %1$d/%2$d - Movendo: %1$s\nDestino: %2$s - Excluindo arquivos de origem… - Concluído - Copiar - Copiando: %1$s\nDestino: %2$s - Progresso: %1$d - Excluindo: %1$s - Descompactando: %1$s - Compactando: %1$s - Preferências - Exibição - Tema - Selecione a preferência de tema - Claro - Escuro - Seguir sistema - Editor de Texto - Limite de arquivos recentes - Escolha o número máximo de arquivos recentes para exibir - Tamanho da lista de arquivos - Pequeno - Médio - Grande - Extra Grande - Selecione um tamanho adequado para a lista de arquivos - Mostrar rótulos na barra inferior de opções - Geral - Limite de pesquisa em arquivos - Limite de resultados exibidos ao pesquisar - Número de colunas da lista de arquivos - Escolha o número de colunas - Visualizador de PDF - Desconhecido - Contagem de Páginas: %1$d - Visualizador de Imagens - Visualizador de Mídia - Arquivo de mídia inválido - Artista Desconhecido - Busca finalizada - Mostrar contagem de conteúdo da pasta - Mostrar arquivos ocultos - Você realmente deseja excluir os arquivos selecionados? - Tamanho: %1$d - %1$d:%2$d - "resultado da pesquisa: " - pastas: %1$d - arquivos: %1$d - Início - Início - Categorias - Imagens - Vídeos - Áudios - Documentos - Arquivos - Armazenamento - As tarefas não podem ser executadas aqui - Aplicativos - Aplicativos - Aplicativos - Usuário - Sistema - Mais - Converter para APK - Assinar APK automaticamente - Assinar APK após operações de APK - Falha - Mesclagem bem-sucedida! - Mesclagem concluída - Inicializando mesclagem… - Extraindo arquivos… - Mesclando módulos… - Finalizando mesclagem… - Mesclando… - Mesclando APKs… - A mesclagem não foi bem-sucedida! - Assinando APK… - Falha ao assinar! - Falha na mesclagem: %1$s - Github - Aplicar - Nenhum Arquivo Recente - Lembre-se desta escolha - + Nova tarefa foi adicionada + Nome do Pacote + Nome da Versão + Código da Versão + Tamanho + Explorar + Instalar + Favoritos + Excluir + Vazio + Tarefa + Pesquisar + Criar + Classificar + Selecionar Tudo + Opções + Novo Conteúdo + Nome + Nome de arquivo inválido + Arquivo + Falha ao criar arquivo + Arquivo com nome similar já existe + Pasta + Falha ao criar pasta + Nome de pasta inválido + Confirmar + Dispensar + Confirmação de Exclusão + Mover para a Lixeira + Criar Arquivo + Cancelar + Adicionado aos favoritos + Abrir em nova aba + Abrir com + Adicionar à tela inicial + Editar com editor de texto + Comprimir + selecionado + Copiado para a área de transferência + Classificar por + Aplicar apenas a esta pasta + Nome (A-Z) + Data (Mais Recente) + Tamanho (menor) + Pastas primeiro + Reverso + Tipo MIME + Fechar + Fechar outros + Renomear + Copiar caminho + Localizar + Resultados + Consulta de Pesquisa + Tarefas + Não foi possível encontrar um aplicativo que possa abrir este tipo de arquivo + Falha ao abrir este arquivo + Controlando cursor esquerdo + Controlando cursor direito + Ir para posição + Linha:Coluna (ex. 23:11) + Ir + Posição inválida + Formato + Quebra de linha + Somente leitura + Ir para linha + Preferências + Usar seleção ICU + Fixar linha de números + Par de símbolos automático + Excluir linhas vazias + Excluir abas + Indentação automática + Lupa + Arquivos Recentes + Este arquivo está atualmente aberto + Encontrar + Substituir + Regex + Sensível a maiúsculas + SUBST + TODOS + ANTERIOR + PRÓXIMO + Salvo + Falha ao salvar + Falha ao carregar arquivo de símbolos + O arquivo fonte foi alterado, deseja recarregá-lo? + Recarregar + Ignorar alterações + Este arquivo não foi salvo, deseja salvá-lo? + Arquivo não encontrado + Arquivo não existe mais + Mover + Preparando + Excluindo arquivos fonte… + Copiar + Excluindo + Comprimindo + Preferências + Tema + Selecionar preferência de tema + Claro + Escuro + Seguir sistema + Editor de Texto + Limite de arquivos recentes + Escolha o número máximo de arquivos recentes para mostrar + Tamanho da lista de arquivos + Pequeno + Médio + Grande + Extra grande + Selecione um tamanho adequado para a lista de arquivos + Mostrar rótulos da barra inferior + Geral + Limite de busca em arquivos + Limitar o número de resultados mostrados na busca + Contagem de colunas da lista de arquivos + Escolher número de colunas + Visualizador de PDF + Desconhecido + Contagem de Páginas + Visualizador de Imagens + Visualizador de Mídia + Arquivo de mídia inválido + Artista Desconhecido + Mostrar contagem de conteúdo da pasta + Mostrar arquivos ocultos + Você realmente deseja excluir os arquivos selecionados? + Conteúdo: %1$d + %1$d:%2$d + "resultado da pesquisa: " + pastas: %1$d + arquivos: %1$d + Início + Início + Categorias + Imagens + Vídeos + Áudios + Documentos + Arquivos + Armazenamento + Tarefas não podem ser executadas aqui + Apps + Apps + Apps + Usuário + Sistema + Mais + Github + Aplicar + Nenhum Arquivo Recente + Lembrar desta escolha + Raiz + Contando arquivos… + Copiando + Arquivo %1$s já existe na pasta de destino. + Ocultar + Conflito + Pular + Tarefa não está pronta. + Tarefa não encontrada. + Aplicar a conflitos subsequentes + Esta pasta está inacessível + Mostrar arquivos ocultos nas configurações para ver todos os arquivos + Nenhum arquivo fonte foi encontrado. + Algo deu errado: \n%1$s + O destino estava ausente. + "Extraindo " + Diretório de destino inválido. + Criar e abrir + "Renomeando" + Renomear em Lote + Novo Nome + Texto para encontrar + Substituir por + Regex + Visualizar + Extensão + Última modificação + Incremento numérico + Incremento numérico com zeros + Encontrar e substituir + Sintaxe + Extensão com ponto + Erro ao criar pasta da lixeira + Arquivo zip inválido + Pressione longamente para mostrar menu de opções do arquivo + Propriedades do Arquivo + Propriedades da Seleção + Carregando Propriedades… + Analisando arquivos… + Localização + Tipo + Modificado + Sistema + Proprietário + Permissões + Análise + Conteúdo + Checksum MD5 + Resumo da Seleção + %1$d itens + Tamanho Total + Conteúdo Total + N/D + Calculando… + %1$d arquivos, %2$d pastas + Erro ao calcular + Adicionar aos favoritos + Operação de Arquivo + Aparência + Desabilitar puxar para atualizar + Pular tela inicial ao fechar abas + Lista de Arquivos + Comportamento + Ilimitado + Extra pequeno + Tarefas inválidas + Tarefas pendentes + Tarefas falhadas + Tarefas pausadas + Esta tarefa está sendo executada + Tarefas em execução + Nenhuma tarefa disponível + Nenhum favorito disponível + Parar + Tarefa falhou + Tarefa foi pausada + Tarefa concluída + Parando… + Acesso ao Armazenamento Necessário + Para gerenciar seus arquivos, precisamos acessar o armazenamento do seu dispositivo. Isso permite salvar, organizar e acessar seu conteúdo perfeitamente. + Salvar Arquivos + Salvar e organizar seus documentos + Acessar Pastas + Navegar e gerenciar sua estrutura de arquivos + Conceder Acesso + Pular por agora + Caminho + PDF inválido + Falha ao carregar PDF + Página %1$d + Carregando PDF… + Erro + Voltar + Informações do PDF + Visualizador de Texto + Editar com… + Nenhum app encontrado para editar esta imagem + Carregando imagem… + Falha ao carregar imagem + Verifique se o arquivo existe e tente novamente + Tentar Novamente + Dimensões + Nenhuma informação disponível + Player de Áudio + Título Desconhecido + Álbum Desconhecido + Volume + Equalizador + Redefinir + Concluído + Logs + Limpar logs + Tem certeza de que deseja limpar todos os logs? Esta ação não pode ser desfeita. + Limpar + Nenhum log disponível + Nenhum erro registrado + Nenhum aviso registrado + Nenhum log de informação disponível + Ótimo! Nenhum erro foi encontrado + Nenhum aviso foi registrado + Nenhum log de informação foi registrado + Limpando logs… + Combinação origem/destino não suportada + Erro desconhecido + Falha ao copiar arquivos para o zip + Falha ao extrair arquivos do zip + Falha ao copiar entradas do zip + Converter para APK + Arquivo de pacote APK inválido + Mesclando + Assinando + Arquivo de pacote APK convertido com sucesso para APK + Falha ao converter pacote APK para APK + Falha ao assinar o arquivo APK mesclado + Assinar automaticamente arquivos de pacote APK mesclados + Assinar automaticamente arquivos de pacote APK mesclados com uma chave de teste + Personalizar aba inicial + Personalizar Aba Inicial + Arquivos modificados recentemente + Categorias de acesso rápido + Dispositivos de armazenamento e locais + Arquivos e pastas marcados + Arquivos excluídos + Navegação rápida de caminho + Notificação de Tarefa em Execução + Tarefas estão em execução… + A tarefa não pode ser continuada do estado atual: %1$s + Falha na validação da tarefa + A tarefa não está em estado pendente + Ação necessária + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d2741b21..eae9dc7f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,219 +1,328 @@ - - File Explorer - Корневой раздел - Внутренняя память - Требуется разрешение на доступ к хранилищу - Текстовый редактор - Плагины - File Explorer - Перейти по пути - Путь назначения - Открыть - Путь некорректен - Новая вкладка - Файлы приложения - Корзина - Сохранение… - Внимание - Некоторые файлы в текстовом редакторе не были сохранены. Хотите сделать это сейчас? - Игнорировать - Сохранить - пустая папка - " (выбрано %s)" - Файлы - Добавлена новая задача - Название пакета - Название версии - Номер сборки - Размер - Исследовать - Установить - Закладки - Удалить - Пусто - Задача - Поиск - Создать - Порядок - Выбрать все - Опции - Создать - Название - Название файла некорректно - Файл - Не удалось создать файл - Файл с таким же названием уже существует - Папку - Не удалось создать папку - Название папки некорректно - Подтвердить - Отменить - Подтвердите удаление - Поместить в Корзину - Создать архив - Не удалось создать файл - Отмена - Добавлено в закладки - Открыть в новой вкладке - Открыть с помощью... - Добавить на главный экран - Открыть в редакторе текста - Сжать - Извлечь - Подробности - Свойства - Путь - Ссылка - Дата изменения - Родительская директория - Выбрано - Содержит - Всего: %1$d (файлов: %2$d | папок: %3$d) - Всего: %1$d (файлов: %2$d | папок: %3$d) - Скопировано в буфер обмена - Упорядочить по - Только для этой папки - Названию (А-Я) - Дате (сначала новые) - Размеру (сначала малые) - Показывать папки первыми - Обратный порядок - MIME-тип - Закрыть - Закрыть остальные - Закрыть все - Переименовать - Скопировать путь - К расположению - Результаты - Поисковой запрос - Задачи - Задача некорректна - Не найдено ни одного приложения, способного открыть файл этого типа - Не удалось открыть файл - Controlling left cursor - Controlling right cursor - Перейти на позицию - Строка:Столбец (например 23:11) - Перейти - Позиция некорректна - Отформатировать - Перенос строк - Режим просмотра - Перейти к строке - Предпочтения - Использовать выделение ICU - Закрепить номера строк - Авто-связка парных символов - Удалить пустые строки - Удалить табуляции - Авто-добавление отступов - Лупа при выделении - Недавние файлы - Этот файл уже открыт - Найти - Заменить - Регулярное выражение - Учитывать регистр - ЗАМ - ВСЕ - ПРЕД - СЛЕД - Сохранено - Не удалось сохранить - Не удалось создать временный файл - Не удалось загрузить файл символов - Целевой файл был изменён, хотите его перезагрузить? - Перезагрузить - Игнорировать изменения - Файл не был сохранён, хотите его сохранить? - Файл не найден - Файла больше не существует - Перемещение - Подготовка… - Запуск задачи на перемещение… - Прогресс: %1$d/%2$d - Перемещается: %1$s\nНазначение: %2$s - Удаление исходных файлов… - Сделано - Копирование - Копируется: %1$s\nНазначение: %2$s - Прогресс: %1$d - Удаляется: %1$s - Извлекается: %1$s - Архивируется: $%1$s - Предпочтения - Оформление - Тема - Выберите предпочтительную тему - Светлая - Тёмная - Как в системе - Редактор текста - Лимит нелавних файлов - Выберите максимальное кол-во недавних файлов для показа - Размер шрифта в списке файлов - Малый - Средний - Большой - Огромный - Выберите подходящий размер для списка файлов - Подписывать кнопки в нижней панели - Основное - Предел результатов поиска - Ограничить кол-во показываемых при поиске результатов - Столбцов в списке файлов - Выберите количество столбцов - Просмотр PDF - Неизвестно - Кол-во страниц: %1$d - Просмотр фото - Просмотр медиа - Медиафайл повреждён или не поддерживается - Неизвестный исполнитель - Поиск завершён - Показывать кол-во содержимого папок - Показывать скрытые файлы - Вы действительно хотите удалить выбранные файлы? - Размер: %1$d - %1$d:%2$d - "результат поиска: " - папок: %1$d - файлов: %1$d - Главная - Главная - Категории - Изображения - Видео - Аудио - Документы - Архивы - Хранилище - Задачи не могут быть выполнены здесь - Приложения - Приложения - Приложения - Пользователь - Система - Ещё - Конвертировать в APK - Автоподпись APK - Подписать APK после операций с APK - Ошибка - Объединение успешно! - Объединение завершено - Инициализация объединения… - Извлечение файлов… - Объединение модулей… - Завершение объединения… - Объединение… - Объединение APK… - Объединение не удалось! - Подписывание APK… - Ошибка подписания! - Ошибка объединения: %1$s - Github - Применить - Нет недавних файлов - Запомни этот выбор + + Проводник файлов + Корень + Внутреннее хранилище + Требуется разрешение на доступ к хранилищу + Текстовый редактор + Проводник файлов + Перейти к пути + Путь назначения + Открыть + Неверный путь + Новая вкладка + Корзина + Сохранение… + Предупреждение + Некоторые файлы в текстовом редакторе не сохранены. Хотите сохранить их сейчас? + Игнорировать + Сохранить + пустая папка + " (%s выбрано)" + Файлы + Новая задача добавлена + Имя пакета + Название версии + Код версии + Размер + Обзор + Установить + Закладки + Удалить + Пусто + Задача + Поиск + Создать + Сортировка + Выбрать все + Параметры + Новое содержимое + Имя + Недопустимое имя файла + Файл + Не удалось создать файл + Файл с похожим именем уже существует + Папка + Не удалось создать папку + Недопустимое имя папки + Подтвердить + Отклонить + Подтверждение удаления + Переместить в корзину + Создать архив + Отмена + Добавлено в закладки + Открыть в новой вкладке + Открыть с помощью + Добавить на главный экран + Редактировать в текстовом редакторе + Сжать + выбрано + Скопировано в буфер обмена + Сортировать по + Применить только к этой папке + Имя (А-Я) + Дата (новее) + Размер (меньше) + Папки первыми + Обратный порядок + MIME тип + Закрыть + Закрыть остальные + Переименовать + Копировать путь + Найти + Результаты + Поисковый запрос + Задачи + Не удалось найти приложение для открытия этого типа файлов + Не удалось открыть этот файл + Управление левым курсором + Управление правым курсором + Перейти к позиции + Строка:Столбец (например, 23:11) + Перейти + Недопустимая позиция + Формат + Перенос слов + Только чтение + Перейти к строке + Настройки + Использовать ICU выделение + Закрепить строку номеров + Автопарные символы + Удалить пустые строки + Удалить табуляции + Автоотступы + Лупа + Недавние файлы + Этот файл сейчас открыт + Найти + Заменить + Регулярное выражение + Учитывать регистр + ЗАМЕН + ВСЕ + ПРЕД + СЛЕД + Сохранено + Не удалось сохранить + Не удалось загрузить файл символов + Исходный файл был изменен, хотите перезагрузить его? + Перезагрузить + Игнорировать изменения + Этот файл не был сохранен, хотите сохранить его? + Файл не найден + Файл больше не существует + Переместить + Подготовка + Удаление исходных файлов… + Копировать + Удаление + Сжатие + Настройки + Тема + Выбрать предпочтение темы + Светлая + Темная + Следовать системе + Текстовый редактор + Лимит недавних файлов + Выберите максимальное количество недавних файлов для отображения + Размер списка файлов + Маленький + Средний + Большой + Очень большой + Выберите подходящий размер для списка файлов + Показать подписи нижней панели + Общие + Лимит поиска файлов + Ограничить количество результатов при поиске + Количество колонок списка файлов + Выберите количество колонок + Просмотр PDF + Неизвестно + Количество страниц + Просмотр изображений + Медиапроигрыватель + Недопустимый медиафайл + Неизвестный исполнитель + Показать количество содержимого папки + Показать скрытые файлы + Вы действительно хотите удалить выбранные файлы? + Содержимое: %1$d + %1$d:%2$d + "результат поиска: " + папки: %1$d + файлы: %1$d + Главная + Главная + Категории + Изображения + Видео + Аудио + Документы + Архивы + Хранилище + Задачи не могут быть выполнены здесь + Приложения + Приложения + Приложения + Пользователь + Система + Еще + Github + Применить + Нет недавних файлов + Запомнить этот выбор + Корень + Подсчет файлов… + Копирование + Файл %1$s уже существует в папке назначения. + Скрыть + Конфликт + Пропустить + Задача не готова. + Задача не найдена. + Применить к последующим конфликтам + Эта папка недоступна + Включите показ скрытых файлов в настройках + Исходные файлы не найдены. + Что-то пошло не так: \n%1$s + Отсутствует место назначения. + "Извлечение " + Недопустимая папка назначения. + Создать и открыть + "Переименование" + Массовое переименование + Новое имя + Текст для поиска + Заменить на + Регулярное выражение + Предпросмотр + Расширение + Последнее изменение + Числовой инкремент + Числовой инкремент с нулями + Найти и заменить + Синтаксис + Расширение с точкой + Ошибка создания папки корзины + Недопустимый zip файл + Долгое нажатие для показа меню файла + Свойства файла + Свойства выделения + Загрузка свойств… + Анализ файлов… + Расположение + Тип + Изменен + Система + Владелец + Разрешения + Анализ + Содержимое + MD5 контрольная сумма + Сводка выделения + %1$d элементов + Общий размер + Общее содержимое + Н/Д + Вычисление… + %1$d файлов, %2$d папок + Ошибка вычисления + Добавить в закладки + Операция с файлом + Внешний вид + Отключить потягивание для обновления + Пропустить главную при закрытии вкладок + Список файлов + Поведение + Неограниченно + Очень маленький + Недопустимые задачи + Ожидающие задачи + Неудачные задачи + Приостановленные задачи + Эта задача сейчас выполняется + Выполняющиеся задачи + Нет доступных задач + Нет доступных закладок + Остановить + Задача не выполнена + Задача приостановлена + Задача завершена + Остановка… + Требуется доступ к хранилищу + Для управления файлами нам нужен доступ к хранилищу устройства. Это позволит сохранять, организовывать и получать доступ к содержимому. + Сохранить файлы + Сохранить и организовать документы + Доступ к папкам + Просматривать и управлять структурой файлов + Предоставить доступ + Пропустить сейчас + Путь + Недопустимый PDF + Не удалось загрузить PDF + Страница %1$d + Загрузка PDF… + Ошибка + Назад + Информация PDF + Просмотр текста + Редактировать с помощью… + Не найдено приложение для редактирования изображения + Загрузка изображения… + Не удалось загрузить изображение + Проверьте существование файла и попробуйте снова + Повторить + Размеры + Информация недоступна + Аудиоплеер + Неизвестное название + Неизвестный альбом + Громкость + Эквалайзер + Сброс + Готово + Логи + Очистить логи + Вы уверены, что хотите очистить все логи? Это действие нельзя отменить. + Очистить + Логи недоступны + Ошибки не зарегистрированы + Предупреждения не зарегистрированы + Информационные логи недоступны + Отлично! Ошибки не обнаружены + Предупреждения не зарегистрированы + Информационные логи не записаны + Очистка логов… + Неподдерживаемая комбинация источника/назначения + Неизвестная ошибка + Не удалось скопировать файлы в zip + Не удалось извлечь файлы из zip + Не удалось скопировать записи zip + Конвертировать в APK + Недопустимый файл пакета APK + Слияние + Подписание + Файл пакета APK успешно конвертирован в APK + Не удалось конвертировать пакет APK в APK + Не удалось подписать объединенный APK файл + Автоподпись объединенных файлов пакета APK + Автоматически подписывать объединенные файлы пакета APK тестовым ключом + Настроить домашнюю вкладку + Настроить Домашнюю Вкладку + Недавно измененные файлы + Категории быстрого доступа + Устройства хранения и местоположения + Файлы и папки в закладках + Удаленные файлы + Быстрая навигация по путям + Уведомление о выполнении задач + Задачи выполняются… + Задача не может быть продолжена из текущего состояния: %1$s + Проверка задачи не удалась + Задача не находится в состоянии ожидания + Требуется действие \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml deleted file mode 100644 index 04b7479c..00000000 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ /dev/null @@ -1,220 +0,0 @@ - - - File Explorer - 根目录 - 内部存储 - 需要授予存储空间权限 - 文本编辑器 - 插件 - 文件浏览器 - 跳转至路径 - 目标路径 - 打开 - 路径无效 - 新标签页 - 应用程序文件 - 回收站 - 正在保存… - 警告 - 文本编辑器修改的内容未保存. 需要现在保存吗? - 忽略 - 保存 - 空文件夹 - " (已选中 %s 个项目)" - 文件 - 已添加至任务队列 - 应用包名 - 版本 - 版本号 - 大小 - 查看内容 - 安装 - 书签 - 删除 - 什么都没有¯\\_(ツ)_/¯ - 任务队列 - 搜索 - 创建 - 分类 - 全选 - 选项 - 新建 - 名称 - 名称无效 - 文件 - 文件创建失败 - 同名文件已存在 - 文件夹 - 文件夹创建失败 - 文件夹命名无效 - 确认 - 取消 - 确认删除 - 移动至回收站 - 创建压缩包 - 无法创建文件 - 取消 - 已添加至书签 - 在新标签页中打开 - 打开方式… - 添加桌面快捷方式 - 使用文本编辑器打开 - 压缩 - 解压 - 详细信息 - 属性 - 路径 - URI - 修改日期 - 父目录 - 选中的项目 - 包含子目录内的项目 - 共计: %1$d 项(文件: %2$d 个 | 文件夹: %3$d个) - 共计: %1$d 项(文件: %2$d 个 | 文件夹: %3$d个) - 已复制到剪贴板 - 排序方式 - 只针对此文件夹设置 - 名称(A-Z) - 日期(从新到旧) - 体积(从小到大) - 目录排前 - 反转排序顺序 - MIME - 关闭 - 关闭右侧全部标签 - 全部关闭 - 重命名 - 复制路径 - 定位至此文件 - 搜索结果 - 键入要搜索的内容 - 任务清单 - 无效任务 - 找不到可用于打开此类文件的程序 - 打开此文件失败 - 光标左移 - 光标右移 - 跳转到指定位置 - 格式 行:列 (例如 23:11) - 出发 - 位置无效 - 格式 - 自动换行 - 只读模式 - 跳转位置 - 首选项 - 使用国际Unicode组件 - 置顶行数栏 - 自动配对标点 - 删除空行 - 删除制表符 - 自动缩进 - 移动光标启动放大镜 - 最近文件 - 此文件目前处于被打开状态 - 查找 - 替换 - 正则表达式 - 区分大小写 - 替换 - 全部替换 - 上一个 - 下一个 - 已保存 - 保存失败 - 未能创建临时文件 - 未能加载符号文件 - 检测到源文件已经改变,重载编辑器? - 重载 - 忽略 - 文件为未保存,你想要保存它吗? - 文件未找到 - 文件已不存在 - 移动 - 准备中… - 开始移动… - 进度: %1$d/%2$d - 正在移动: %1$s\n移动至: %2$s - 正在删除源文件 - 已完成 - 复制 - 正在复制: %1$s\n复制至: %2$s - 进度: %1$d - 正在删除: %1$s - 解压中: %1$s - 压缩中: $%1$s - 首选项 - 显示和外观 - 主题 - 选择偏好的主题 - 亮色 - 暗色 - 跟随系统 - 文本编辑器 - “最近文件”显示限制 - 设置“最近文件”的文件数量 - 文件图标大小 - - 适中 - - 很大 - 选择文件浏览界面的图标大小 - 显示底部导航栏的标签 - 通用 - 文件搜索结果数量 - 在搜索文件时展示结果的上限 - 文件列数 - 选择显示文件的列数 - PDF阅读器 - 未知 - 页码: %1$d - 图片查看器 - 媒体查看器 - 媒体文件无效 - 未知作者 - 搜索完成 - 显示文件夹内容计数器 - 显示隐藏文件 - 您确定要删除选定的文件吗? - 大小:%1$d - %1$d:%2$d - "搜索结果:" - 文件夹:%1$d - 文件:%1$d - 首页 - 首页 - 分类 - 图片 - 视频 - 音频 - 文档 - 压缩包 - 存储 - 无法在此运行任务 - 应用 - 应用 - 应用 - 用户 - 系统 - 更多 - 转换为 APK - 自动签名 APK - 在 APK 操作后签名 APK - 失败 - 合并成功! - 合并完成 - 正在初始化合并… - 正在提取文件… - 正在合并模块… - 正在完成合并… - 正在合并… - 正在合并 APK… - 合并未成功! - 正在签名 APK… - 签名失败! - 合并失败: %1$s - Github - 应用 - 没有最近文件 - 记住这个选择 - \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml deleted file mode 100644 index 6aebde36..00000000 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ /dev/null @@ -1,219 +0,0 @@ - - File Explorer - 根目錄 - 內部儲存空間 - 需要儲存空間存取權限 - 文字編輯器 - 外掛程式 - 檔案瀏覽器 - 跳轉至路徑 - 目標路徑 - 開啟 - 無效的路徑 - 新分頁 - 應用程式檔案 - 回收桶 - 正在儲存… - 警告 - 文字編輯器中的一些檔案尚未儲存。您是否想要現在儲存? - 忽略 - 儲存 - 空資料夾 - " (%s 個已選)" - 檔案 - 新任務已被列入 - 套件名稱 - 版本名稱 - 版本代號 - 大小 - 瀏覽封裝內容 - 安裝 - 書籤 - 刪除 - 空無一物 - 任務 - 搜尋 - 建立 - 排序 - 全選 - 選項 - 建立新項目 - 名稱 - 無效的檔案名稱 - 檔案 - 檔案建立失敗 - 相同的檔案名稱已經存在 - 資料夾 - 資料夾建立失敗 - 無效的資料夾名稱 - 確認 - 取消 - 確認刪除 - 移動到回收筒 - 建立壓縮檔 - 無法建立檔案 - 取消 - 已新增至書籤 - 開啟於新分頁 - 開啟方式 - 新增至主畫面 - 使用文字編輯器進行編輯 - 壓縮 - 解壓縮 - 詳細資料 - 屬性 - 路徑 - URI - 修改日期 - 父目錄 - 已選擇 - 內容 - 總共: %1$d (檔案: %2$d | 資料夾: %3$d) - 總共: %1$d (檔案: %2$d | 資料夾: %3$d) - 複製至剪貼簿 - 排序依據 - 僅應用於此資料夾 - 名稱 (A-Z) - 日期 (新的優先) - 大小 (小的優先) - 資料夾優先 - 反向排序 - MIME 型式 - 關閉分頁 - 關閉右方分頁 - 關閉全部分頁 - 重新命名 - 複製路徑 - 前往所在位置 - 搜尋結果 - 搜尋字詞 - 任務清單 - 無效的任務 - 找不到任何可以開啟此檔案類型的應用程式 - 開啟此檔案失敗 - 游標左移 - 游標右移 - 跳至指定位置 - 行:欄 (例如 23:11) - 前往 - 無效的位置 - 格式 - 自動換行 - 唯讀 - 跳至指定行 - 偏好設定 - 使用國際統一碼部件 - 固定顯示行數欄 - 自動符號配對 - 刪除空行 - 刪除跳欄(Tab)字元 - 自動縮行 - 放大鏡 - 最近的檔案 - 此檔案正在使用中 - 尋找 - 取代 - 正規表示法 - 區分大小寫 - 取代 - 全部取代 - 上一個 - 下一個 - 已儲存 - 儲存失敗 - 建立暫存檔案失敗 - 載入符號檔案失敗 - 來源檔已變更,您是否想要重新載入內容? - 重新載入 - 忽略變更 - 此檔案尚未被儲存,您是否想要儲存它? - 找不到檔案 - 檔案已不存在 - 移動 - 正在準備… - 開始移動任務… - 進度: %1$d/%2$d - 正在移動: %1$s\n目標位置: %2$s - 刪除來源檔案… - 已完成 - 複製 - 正在複製: %1$s\n目標位置: %2$s - 進度: %1$d - 正在刪除: %1$s - 正在解壓縮: %1$s - 正在壓縮: $%1$s - 偏好設定 - 顯示 - 主題 - 選擇偏好主題 - 淺色 - 深色 - 遵循系統 - 文字編輯器 - 最近的檔案限制 - 選擇最近的檔案要顯示的最大數量 - 檔案清單大小 - - - - 超大 - 為檔案清單選擇合適的顯示大小 - 顯示底部選項列標籤 - 一般 - 檔案搜尋限制 - 搜尋時顯示結果的數量限制 - 檔案清單欄數 - 選擇欄位的數量 - PDF 檢視器 - 未知 - 頁碼: %1$d - 圖片檢視器 - 媒體檢視器 - 無效的媒體檔案 - 未知作者 - 搜尋完成 - 顯示資料夾的子項目數量 - 顯示隱藏的檔案 - 您是否確定要刪除選取的檔案? - 大小: %1$d - %1$d:%2$d - "搜尋結果: " - 資料夾: %1$d - 檔案: %1$d - 首頁 - 首頁 - 分類 - 圖片 - 影片 - 音訊 - 文件 - 壓縮檔 - 儲存空間 - 無法在此執行任務 - 應用程式 - 應用程式 - 應用程式 - 使用者 - 系統 - 更多 - 轉換為 APK - 自動簽署 APK - 在 APK 操作後簽署 APK - 失敗 - 合併成功! - 合併完成 - 正在初始化合併… - 正在匯出檔案… - 正在合併模組… - 正在完成合併… - 正在合併… - 正在合併 APK… - 合併不成功! - 正在簽署 APK… - 簽署失敗! - 合併失敗: %1$s - Github - 套用 - 沒有最近使用的檔案 - 記住這個選擇 - diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 00000000..7601ee9e --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,328 @@ + + 文件管理器 + 根目录 + 内部存储 + 需要存储访问权限 + 文本编辑器 + 文件管理器 + 跳转到路径 + 目标路径 + 打开 + 无效路径 + 新标签页 + 回收站 + 保存中… + 警告 + 文本编辑器中的一些文件未保存。您想现在保存它们吗? + 忽略 + 保存 + 空文件夹 + " (已选择 %s)" + 文件 + 已添加新任务 + 包名 + 版本名称 + 版本代码 + 大小 + 浏览 + 安装 + 书签 + 删除 + + 任务 + 搜索 + 创建 + 排序 + 全选 + 选项 + 新内容 + 名称 + 无效文件名 + 文件 + 创建文件失败 + 已存在相似名称的文件 + 文件夹 + 创建文件夹失败 + 无效文件夹名 + 确认 + 取消 + 删除确认 + 移动到回收站 + 创建压缩包 + 取消 + 已添加到书签 + 在新标签页中打开 + 打开方式 + 添加到主屏幕 + 用文本编辑器编辑 + 压缩 + 已选择 + 已复制到剪贴板 + 排序方式 + 仅应用于此文件夹 + 名称 (A-Z) + 日期 (较新) + 大小 (较小) + 文件夹优先 + 倒序 + MIME类型 + 关闭 + 关闭其他 + 重命名 + 复制路径 + 定位 + 结果 + 搜索查询 + 任务 + 找不到可以打开此类型文件的应用 + 打开此文件失败 + 控制左光标 + 控制右光标 + 跳转到位置 + 行:列 (例如 23:11) + 前往 + 无效位置 + 格式 + 自动换行 + 只读 + 跳转到行 + 首选项 + 使用ICU选择 + 固定行号 + 自动符号配对 + 删除空行 + 删除制表符 + 自动缩进 + 放大镜 + 最近文件 + 此文件当前已打开 + 查找 + 替换 + 正则表达式 + 区分大小写 + 替换 + 全部 + 上一个 + 下一个 + 已保存 + 保存失败 + 加载符号文件失败 + 源文件已更改,您想重新加载吗? + 重新加载 + 忽略更改 + 此文件尚未保存,您想保存吗? + 文件未找到 + 文件不再存在 + 移动 + 准备中 + 删除源文件… + 复制 + 删除中 + 压缩中 + 首选项 + 主题 + 选择主题偏好 + 浅色 + 深色 + 跟随系统 + 文本编辑器 + 最近文件限制 + 选择显示的最大最近文件数 + 文件列表大小 + + + + 超大 + 为文件列表选择合适的大小 + 显示底部栏标签 + 常规 + 文件搜索限制 + 限制搜索时显示的结果数量 + 文件列表列数 + 选择列数 + PDF查看器 + 未知 + 页数 + 图片查看器 + 媒体查看器 + 无效媒体文件 + 未知艺术家 + 显示文件夹内容计数 + 显示隐藏文件 + 您真的要删除选中的文件吗? + 内容: %1$d + %1$d:%2$d + "搜索结果: " + 文件夹: %1$d + 文件: %1$d + 主页 + 主页 + 分类 + 图片 + 视频 + 音频 + 文档 + 压缩包 + 存储 + 无法在此处运行任务 + 应用 + 应用 + 应用 + 用户 + 系统 + 更多 + Github + 应用 + 没有最近文件 + 记住此选择 + 根目录 + 计算文件中… + 复制中 + 文件 %1$s 在目标文件夹中已存在。 + 隐藏 + 冲突 + 跳过 + 任务未准备好。 + 任务未找到。 + 应用于后续冲突 + 此文件夹无法访问 + 在设置中显示隐藏文件以查看所有文件 + 未找到源文件。 + 出现错误: \n%1$s + 目标缺失。 + "解压中 " + 无效目标目录。 + 创建并打开 + "重命名中" + 批量重命名 + 新名称 + 要查找的文本 + 替换为 + 正则表达式 + 预览 + 扩展名 + 最后修改 + 数字递增 + 零填充数字递增 + 查找和替换 + 语法 + 带点的扩展名 + 创建回收站文件夹出错 + 无效的zip文件 + 长按显示文件选项菜单 + 文件属性 + 选择属性 + 加载属性中… + 分析文件中… + 位置 + 类型 + 修改时间 + 系统 + 所有者 + 权限 + 分析 + 内容 + MD5校验和 + 选择摘要 + %1$d 项 + 总大小 + 总内容 + 不可用 + 计算中… + %1$d 个文件,%2$d 个文件夹 + 计算错误 + 添加到书签 + 文件操作 + 外观 + 禁用下拉刷新 + 关闭标签页时跳过主页 + 文件列表 + 行为 + 无限制 + 超小 + 无效任务 + 待处理任务 + 失败任务 + 暂停任务 + 此任务正在运行 + 运行中任务 + 没有可用任务 + 没有可用书签 + 停止 + 任务失败 + 任务已暂停 + 任务完成 + 停止中… + 需要存储访问权限 + 为了管理您的文件,我们需要访问您的设备存储。这使您能够无缝保存、组织和访问您的内容。 + 保存文件 + 保存并整理您的文档 + 访问文件夹 + 浏览和管理您的文件结构 + 授予访问权限 + 暂时跳过 + 路径 + 无效PDF + 加载PDF失败 + 第 %1$d 页 + 加载PDF中… + 错误 + 返回 + PDF信息 + 文本查看器 + 编辑方式… + 找不到编辑此图片的应用 + 加载图片中… + 加载图片失败 + 检查文件是否存在并重试 + 重试 + 尺寸 + 没有可用信息 + 音频播放器 + 未知标题 + 未知专辑 + 音量 + 均衡器 + 重置 + 完成 + 日志 + 清除日志 + 您确定要清除所有日志吗?此操作无法撤销。 + 清除 + 没有可用日志 + 没有错误日志 + 没有警告日志 + 没有信息日志 + 太好了!没有遇到错误 + 没有警告日志 + 没有记录信息日志 + 清除日志中… + 不支持的源/目标组合 + 未知错误 + 复制文件到zip失败 + 从zip解压文件失败 + 复制zip条目失败 + 转换为APK + 无效的APK包文件 + 合并中 + 签名中 + APK包文件成功转换为APK + APK包转换为APK失败 + 合并APK文件签名失败 + 自动签名合并的APK包文件 + 使用测试密钥自动签名合并的APK包文件 + 自定义主页标签 + 自定义主页标签 + 最近修改的文件 + 快速访问分类 + 存储设备和位置 + 书签文件和文件夹 + 已删除文件 + 快速路径导航 + 任务运行通知 + 任务正在运行… + 任务无法从当前状态继续:%1$s + 任务验证失败 + 任务不在待处理状态 + 需要操作 + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index f8c6127d..00000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ab9f132..ddca7133 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,14 +4,12 @@ Internal Storage Storage access permission is required TextEditorActivity - Plugins File Explorer Jump to path Destination path Open Invalid path New Tab - App files Recycle bin Saving… Warning @@ -37,7 +35,7 @@ Sort Select All Options - Create New + New Content Name Invalid file name File @@ -51,7 +49,6 @@ Delete Confirmation Move to Recycle Bin Create Archive - Unable to create file Cancel Added to bookmarks Open in new tab @@ -59,17 +56,7 @@ Add to home screen Edit with text editor Compress - Decompress - Details - Properties - path - uri - modification date - parent selected - content - total: %1$d (files: %2$d | folders: %3$d) - total: %1$d (files: %2$d | folders: %3$d) Copied to clipboard Sort by Apply to this folder only @@ -81,14 +68,12 @@ MIME Type Close Close others - Close all Rename Copy path Locate Results Search Query Tasks - Invalid task Could not find any app that can open this type of files Failed to open this file Controlling left cursor @@ -121,7 +106,6 @@ NEXT Saved Failed to save - Failed to create temporary file Failed to load symbols file The source file has been changed, do you want to reload it? Reload @@ -130,20 +114,12 @@ File not found File no longer exists Move - Preparing… - Start moving task… - Progress: %1$d/%2$d - Moving: %1$s\nDestination: %2$s + Preparing Deleting source files… - Done Copy - Copying: %1$s\nDestination: %2$s - Progress: %1$d - Deleting: %1$s - Decompressing: %1$s - Compressing: $%1$s + Deleting + Compressing Preferences - Display Theme Select theme preference Light @@ -156,7 +132,7 @@ Small Medium Large - Extra Large + Extra large Select a suitable size for the file list Show bottom options bar labels General @@ -166,16 +142,15 @@ Choose number of columns PDF Viewer Unknown - Page Count: %1$d + Page Count Image Viewer Media Viewer Invalid media file Unknown Artist - Search finished Show folder\'s content count Show hidden files Do you really want to delete the selected files? - Size: %1$d + Content: %1$d %1$d:%2$d "search result: " folders: %1$d @@ -196,24 +171,159 @@ User System More - Convert to APK - Auto sign APK - Sign APK after APK Operations - Failed - Merge Successful! - Merge Completed - Initializing merge… - Extracting files… - Merging modules… - Finalizing merge… - Merging… - Merging APKs… - Merge was not successful! - Signing APK… - Signing failed! - Merge failed: %1$s Github Apply No Recent Files Remember this choice + Root + Counting files… + Copying + File %1$s already exists in destination folder. + Hide + Conflict + Skip + Task is not ready. + Task not found. + Apply to subsequent conflicts + This folder is inaccessible + Show hidden files in settings to see all files + No source files were found. + Something went wrong: \n%1$s + The destination was missing. + "Extracting " + Invalid destination directory. + Create and open + "Renaming" + Batch Rename + New Name + Text to find + Replace with + Regex + Preview + Extension + Last modified + Number increment + Zero-padded number increment + Find and replace + file name without extension (e.g., \"document\" from \"document.txt\")\n\n{s} -> file extension (e.g., \"txt\" from \"document.txt\")\n\n{e} -> file extension with a dot (e.g., \".txt\" from \"document.txt\")\n\n{t} -> file\'s last modified time (formatted as \"yyyyMMdd_HHmmss\")\n\n{n} -> number increment (e.g., {0} -> 0, 1, 2…; {1} -> 1, 2, 3…)\n\n{zn} -> zero-padded number increment (e.g., {z0} -> 0, 1…; {zz0} -> 00, 01…; {zzz1} -> 001, 002…)]]> + Syntax + Extension with a dot + Error creating recycle bin folder + Invalid zip file + Long click to show file\'s options menu + File Properties + Selection Properties + Loading Properties… + Analyzing files… + Location + Type + Modified + System + Owner + Permissions + Analysis + Contents + MD5 Checksum + Selection Summary + %1$d items + Total Size + Total Contents + N/A + Calculating… + %1$d files, %2$d folders + Error calculating + Add to bookmarks + File Operation + Appearance + Disable pull down to refresh + Skip home screen when closing tabs + File List + Behavior + Unlimited + Extra small + Invalid tasks + Pending tasks + Failed tasks + Paused tasks + This task is currently running + Running tasks + No tasks available + No bookmarks available + Stop + Task failed + Task has been paused + Task completed + Stopping… + Storage Access Required + To manage your files, we need access to your device storage. This allows you to save, organize, and access your content seamlessly. + Save Files + Save and organize your documents + Access Folders + Browse and manage your file structure + Grant Access + Skip for now + Path + Invalid PDF + Failed to load PDF + Page %1$d + Loading PDF… + Error + Go Back + PDF Info + Text Viewer + Edit with… + No app found to edit this image + Loading image… + Failed to load image + Check if the file exists and try again + Retry + Dimensions + No information available + Audio Player + Unknown Title + Unknown Album + Volume + Equalizer + Reset + Done + Logs + Clear logs + Are you sure you want to clear all logs? This action cannot be undone. + Clear + No logs available + No errors logged + No warnings logged + No info logs available + Great! No errors have been encountered + No warnings have been logged + No information logs have been recorded + Clearing logs… + Unsupported source/destination combination + Unknown error + Failed to copy files to zip + Failed to extract files from zip + Failed to copy zip entries + Convert to APK + Invalid APK bundle file + Merging + Signing + APK bundle file successfully converted to APK + Failed to convert APK bundle to APK + Failed to sign the merged APK file + Auto sign merged APK bundle files + Automatically sign merged APK bundle files with a test key + Customize home tab + Customize Home Tab + Recently modified files + Quick access categories + Storage devices and locations + Bookmarked files and folders + Deleted files + Quick path navigation + Task Running Notification + Tasks are running... + Task cannot be continued from current state: %1$s + Task validation failed + Task is not in pending state + Action required \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997b..6cebe057 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,7 +5,7 @@ --> - diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..6d1696f0 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e07de1ff..b6092d6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,42 +1,51 @@ [versions] +accompanistSystemuicontroller = "0.36.0" agp = "8.6.1" +apksig = "8.11.0" +commonsNet = "3.11.1" desugar_jdk_libs = "2.1.5" -grid = "2.2.1" -kotlin = "2.0.0" +grid = "2.3.2" +kotlin = "2.2.0" coreKtx = "1.16.0" -appcompat = "1.7.0" +appcompat = "1.7.1" lazycolumnscrollbar = "2.2.0" material = "1.12.0" -androidxComposeBom = "2025.05.01" +androidxComposeBom = "2025.06.01" media3Exoplayer = "1.7.1" -soraEditor = "0.23.5" +paletteKtx = "1.0.0" +soraEditor = "0.23.6" gson = "2.13.1" dataStore = "1.1.7" -storage = "2.0.0" +storage = "2.1.0" cascade_compose = "2.3.0" compose_swipebox = "1.4.0" -lifecycle_runtime_compose_android = "2.9.0" +lifecycle_runtime_compose_android = "2.9.1" activity_compose = "1.10.1" -lifecycle_runtime_ktx = "2.9.0" +lifecycle_runtime_ktx = "2.9.1" coilCompose = "3.2.0" -zoomable = "2.7.0" +zip4j = "2.11.5" +zoomable = "2.8.1" junit = "1.2.1" espressoCore = "3.6.1" uiautomator = "2.3.0" benchmarkMacroJunit4 = "1.3.4" baselineprofile = "1.3.4" profileinstaller = "1.4.1" -reorderable = "2.4.3" -apksig = "8.10.0" +reorderable = "2.5.1" +uiToolingPreviewAndroid = "1.8.3" [libraries] +accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" } +androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } +apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilCompose" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" } coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coilCompose" } +commons-net = { module = "commons-net:commons-net", version.ref = "commonsNet" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } grid = { module = "com.cheonjaeung.compose.grid:grid", version.ref = "grid" } lazycolumnscrollbar = { module = "com.github.nanihadesuka:LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" } @@ -57,6 +66,7 @@ cascade-compose = { group = "me.saket.cascade", name = "cascade-compose", versio compose-swipebox = { group = "io.github.kevinnzou", name = "compose-swipebox", version.ref = "compose_swipebox" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle_runtime_ktx" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose-android", version.ref = "coilCompose"} +zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" } zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -64,7 +74,7 @@ androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomato androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } -apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig"} +androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }