From c381040efc8fec3e3ff35437462a0c5360dd1b69 Mon Sep 17 00:00:00 2001 From: Robin Meersman Date: Thu, 5 Sep 2024 19:18:43 +0000 Subject: [PATCH] feat: frontend/Filetree --- app/build.gradle | 6 +- app/src/main/AndroidManifest.xml | 12 +- .../main/java/be/re/writand/MainActivity.kt | 89 +++- .../re/writand/data/local/FileManagerLocal.kt | 86 ---- .../local/filemanager/FileManagerLocal.kt | 141 ++++++ .../data/local/filemanager/FileType.kt | 9 + .../local/{ => filemanager}/IFileManager.kt | 48 +- .../java/be/re/writand/di/DatabaseModule.kt | 8 + .../java/be/re/writand/di/GeneratorModule.kt | 20 + .../writand/domain/files/CreateFileUseCase.kt | 24 + .../domain/files/InitFiletreeUseCase.kt | 22 + .../writand/domain/files/MoveFileUseCase.kt | 23 + .../domain/files/ReadFileContentsUseCase.kt | 18 + .../writand/domain/files/RemoveFileUseCase.kt | 17 + .../re/writand/screens/components/Buttons.kt | 62 +++ .../writand/screens/components/DragAndDrop.kt | 183 ++++++++ .../screens/components/WLoadingIndicator.kt | 6 +- .../re/writand/screens/components/WPopup.kt | 70 +++ .../re/writand/screens/editor/EditorScreen.kt | 35 +- .../screens/editor/EditorScreenViewModel.kt | 20 + .../screens/editor/editorspace/EditorSpace.kt | 134 +++--- .../editorspace/EditorSpaceViewModel.kt | 28 +- .../screens/editor/top/TopEditorBar.kt | 38 +- .../editor/top/TopEditorBarViewModel.kt | 3 - .../re/writand/screens/filetree/Filetree.kt | 163 +++++++ .../screens/filetree/WFiletreeUiState.kt | 15 + .../screens/filetree/WFiletreeViewModel.kt | 115 +++++ .../screens/filetree/WTreeComponent.kt | 417 ++++++++++++++++++ .../java/be/re/writand/utils/GenerateId.kt | 49 ++ app/src/main/java/be/re/writand/utils/Node.kt | 16 + .../re/writand/utils/WFileTreeAbstraction.kt | 109 +++++ .../utils/directorypicker/DirectoryPicker.kt | 44 ++ .../IDirectoryPickerListener.kt | 7 + .../be/re/writand/FileManagerLocalTest.kt | 2 +- build.gradle | 4 +- docs/permissions.md | 28 ++ 36 files changed, 1855 insertions(+), 216 deletions(-) delete mode 100644 app/src/main/java/be/re/writand/data/local/FileManagerLocal.kt create mode 100644 app/src/main/java/be/re/writand/data/local/filemanager/FileManagerLocal.kt create mode 100644 app/src/main/java/be/re/writand/data/local/filemanager/FileType.kt rename app/src/main/java/be/re/writand/data/local/{ => filemanager}/IFileManager.kt (50%) create mode 100644 app/src/main/java/be/re/writand/di/GeneratorModule.kt create mode 100644 app/src/main/java/be/re/writand/domain/files/CreateFileUseCase.kt create mode 100644 app/src/main/java/be/re/writand/domain/files/InitFiletreeUseCase.kt create mode 100644 app/src/main/java/be/re/writand/domain/files/MoveFileUseCase.kt create mode 100644 app/src/main/java/be/re/writand/domain/files/ReadFileContentsUseCase.kt create mode 100644 app/src/main/java/be/re/writand/domain/files/RemoveFileUseCase.kt create mode 100644 app/src/main/java/be/re/writand/screens/components/DragAndDrop.kt create mode 100644 app/src/main/java/be/re/writand/screens/components/WPopup.kt create mode 100644 app/src/main/java/be/re/writand/screens/editor/EditorScreenViewModel.kt create mode 100644 app/src/main/java/be/re/writand/screens/filetree/Filetree.kt create mode 100644 app/src/main/java/be/re/writand/screens/filetree/WFiletreeUiState.kt create mode 100644 app/src/main/java/be/re/writand/screens/filetree/WFiletreeViewModel.kt create mode 100644 app/src/main/java/be/re/writand/screens/filetree/WTreeComponent.kt create mode 100644 app/src/main/java/be/re/writand/utils/GenerateId.kt create mode 100644 app/src/main/java/be/re/writand/utils/Node.kt create mode 100644 app/src/main/java/be/re/writand/utils/WFileTreeAbstraction.kt create mode 100644 app/src/main/java/be/re/writand/utils/directorypicker/DirectoryPicker.kt create mode 100644 app/src/main/java/be/re/writand/utils/directorypicker/IDirectoryPickerListener.kt create mode 100644 docs/permissions.md diff --git a/app/build.gradle b/app/build.gradle index 5dc4160..dc84850 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,6 +5,7 @@ plugins { id 'kotlinx-serialization' id "kotlin-kapt" id 'com.google.protobuf' version '0.9.4' + id "kotlin-parcelize" } android { @@ -13,7 +14,7 @@ android { defaultConfig { applicationId "be.re.writand" - minSdk 26 + minSdk 30 targetSdk 34 versionCode 1 versionName "1.0" @@ -65,9 +66,10 @@ dependencies { implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" implementation "androidx.compose.material3:material3:1.2.1" implementation 'androidx.test:core-ktx:1.6.1' - implementation 'com.google.ar:core:1.44.0' + implementation 'com.google.ar:core:1.45.0' implementation 'androidx.navigation:navigation-runtime-ktx:2.7.7' implementation 'androidx.navigation:navigation-compose:2.7.7' + implementation 'androidx.documentfile:documentfile:1.0.1' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7228303..284cf51 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,20 +2,26 @@ + + + + + + + tools:targetApi="31" + android:hardwareAccelerated="true"> diff --git a/app/src/main/java/be/re/writand/MainActivity.kt b/app/src/main/java/be/re/writand/MainActivity.kt index abe9b87..69db411 100644 --- a/app/src/main/java/be/re/writand/MainActivity.kt +++ b/app/src/main/java/be/re/writand/MainActivity.kt @@ -1,22 +1,109 @@ package be.re.writand +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.os.Environment +import android.provider.Settings +import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier +import androidx.core.app.ActivityCompat import androidx.navigation.compose.rememberNavController import be.re.writand.navigation.WNavGraph +import be.re.writand.screens.filetree.WFiletree import be.re.writand.ui.theme.WritandTheme +import be.re.writand.utils.directorypicker.DirectoryPicker import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint -class MainActivity: ComponentActivity() { +class MainActivity : ComponentActivity() { + private val picker = DirectoryPicker(this) + private val EXTERNAL_STORAGE_PERMISSION_CODE = 100 + private val EXTERNAL_STORAGE_PERMISSION = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION + private var hasPermission = false + + private fun requestPermission() { + when { + Environment.isExternalStorageManager() -> { + Toast.makeText(this, "Permission granted", Toast.LENGTH_LONG).show() + hasPermission = true + } + + ActivityCompat.shouldShowRequestPermissionRationale( + this, + EXTERNAL_STORAGE_PERMISSION + ) -> { + val builder = AlertDialog.Builder(this) + builder.setMessage("This app requires access to files to do its job") + .setTitle("Permission required") + .setCancelable(false) + .setPositiveButton("Ok") { dialog, _ -> + ActivityCompat.requestPermissions( + this, + arrayOf(EXTERNAL_STORAGE_PERMISSION), + EXTERNAL_STORAGE_PERMISSION_CODE + ) + + dialog.dismiss() + } + .setNegativeButton("Cancel") { dialog, _ -> + dialog.dismiss() + } + .show() + } + + // permission is not yet granted, ask user for permission + else -> { + val launcher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + Toast.makeText( + this, + "Permission: ${Environment.isExternalStorageManager()}", + Toast.LENGTH_LONG + ).show() + hasPermission = Environment.isExternalStorageManager() + } + + try { + val intent = + Intent(EXTERNAL_STORAGE_PERMISSION, Uri.parse("package:$packageName")) + val builder = AlertDialog.Builder(this) + builder.setTitle("Permission is required") + .setMessage("Writand needs permission to access the filesystem.") + .setCancelable(false) + .setPositiveButton("Ok") { dialog, _ -> + launcher.launch(intent) + dialog.dismiss() + } + .setNegativeButton("Deny") { dialog, _ -> + dialog.dismiss() + } + .show() + + } catch (e: ActivityNotFoundException) { + Log.e("PERMISSION", "${e.message}") + TODO("Permission: support legacy operation when $EXTERNAL_STORAGE_PERMISSION is not supported") + } + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + requestPermission() + setContent { WritandTheme { // A surface container using the 'background' color from the theme diff --git a/app/src/main/java/be/re/writand/data/local/FileManagerLocal.kt b/app/src/main/java/be/re/writand/data/local/FileManagerLocal.kt deleted file mode 100644 index 1905324..0000000 --- a/app/src/main/java/be/re/writand/data/local/FileManagerLocal.kt +++ /dev/null @@ -1,86 +0,0 @@ -package be.re.writand.data.local - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.nio.file.Files -import java.nio.file.NotDirectoryException -import java.nio.file.Path -import java.nio.file.Paths -import java.util.PriorityQueue -import java.util.Queue -import kotlin.io.path.deleteExisting -import kotlin.io.path.exists -import kotlin.io.path.isDirectory -import kotlin.io.path.name - -class FileManagerLocal : IFileManager { - - /** - * Checks if a directory is empty. - * Requires API 26! - * - * @param[path] The path of the directory to be checked. - * @return returns true if the directory is empty. If [path] points to a file, - * false is immediately returned. - */ - fun isDirectoryEmpty(path: Path): Boolean { - if (!path.isDirectory()) return false - return Files.list(path).count() == 0L - } - - override suspend fun create(name: String, basePath: Path) { - if (!basePath.isDirectory()) throw NotDirectoryException(basePath.name) - - val fullPath = Paths.get(basePath.toString(), name) - withContext(Dispatchers.IO) { - val res = fullPath.toFile().createNewFile() - if (!res) throw FileAlreadyExistsException( - fullPath.toFile(), - reason = "File already exists." - ) - } - } - - override suspend fun delete(path: Path) { - // path is a directory and is not empty, delete everything in this directory - if (path.isDirectory() && !isDirectoryEmpty(path)) { - val queue: Queue = PriorityQueue() - queue.add(path) - - while (!queue.isEmpty()) { - val top = queue.poll()!! - if (top.isDirectory() && !isDirectoryEmpty(top)) { - - // add all the elements of the current directory to the queue - for (dir in top.iterator()) { - queue.add(dir) - } - - // add the directory back into the queue (top is not an empty directory) - queue.add(top) - } else { - - // top is a file or an empty directory - top.deleteExisting() - } - } - } else { - path.deleteExisting() - } - } - - override suspend fun move(from: Path, to: Path) { - withContext(Dispatchers.IO) { - Files.move(from, to) - } - } - - override suspend fun rename(path: Path, newName: String) { - move(path, Paths.get(path.parent.toString(), newName)) - } - - override suspend fun walk(root: Path): FileTreeWalk { - return File(root.toUri()).walkTopDown() - } -} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/data/local/filemanager/FileManagerLocal.kt b/app/src/main/java/be/re/writand/data/local/filemanager/FileManagerLocal.kt new file mode 100644 index 0000000..f7e9547 --- /dev/null +++ b/app/src/main/java/be/re/writand/data/local/filemanager/FileManagerLocal.kt @@ -0,0 +1,141 @@ +package be.re.writand.data.local.filemanager + +import be.re.writand.utils.FileNode +import be.re.writand.utils.GenerateId +import be.re.writand.utils.WFileTreeAbstraction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Files +import java.nio.file.NotDirectoryException +import java.nio.file.Path +import java.nio.file.Paths +import java.util.LinkedList +import java.util.Queue +import java.util.Stack +import javax.inject.Inject +import kotlin.io.path.deleteExisting +import kotlin.io.path.isDirectory +import kotlin.io.path.name + +class FileManagerLocal @Inject constructor( + private val generator: GenerateId +) : IFileManager { + + /** + * Checks if a directory is empty. + * Requires API 26! + * + * @param[path] The path of the directory to be checked. + * @return returns true if the directory is empty. If [path] points to a file, + * false is immediately returned. + */ + fun isDirectoryEmpty(path: Path): Boolean { + if (!path.isDirectory()) return false + return Files.list(path).count() == 0L + } + + override suspend fun create(name: String, fileType: FileType, basePath: Path): ULong { + if (!basePath.isDirectory()) throw NotDirectoryException(basePath.name) + val id = generator.generateUUID(name) + + val fullPath = Paths.get(basePath.toString(), name) + withContext(Dispatchers.IO) { + val res = when (fileType) { + FileType.FILE -> { + fullPath.toFile().createNewFile() + } + + FileType.DIRECTORY -> { + fullPath.toFile().mkdirs() + } + } + if (!res) throw FileAlreadyExistsException( + fullPath.toFile(), + reason = "File already exists." + ) + } + return id + } + + override suspend fun delete(path: Path) { + if (path.isDirectory() && !isDirectoryEmpty(path)) { + val queue: Queue = LinkedList() + queue.add(path) + + while (!queue.isEmpty()) { + val top = queue.poll()!! + if (top.isDirectory() && !isDirectoryEmpty(top)) { + val files = top.toFile()!!.listFiles()!! + + // add all the elements of the current directory to the queue + for (dir in files) { + queue.add(dir.toPath()) + } + + // add the directory back into the queue (top is not an empty directory) + queue.add(top) + } else { + + // top is a file or an empty directory + top.deleteExisting() + } + } + } else { + path.deleteExisting() + } + } + + override suspend fun move(from: Path, to: Path) { + withContext(Dispatchers.IO) { + Files.move(from, to) + } + } + + override suspend fun rename(path: Path, newName: String) { + move(path, Paths.get(path.parent.toString(), newName)) + } + + override suspend fun initFiletree(root: Path): WFileTreeAbstraction { + val index: HashMap = HashMap() + if (!root.isDirectory()) throw IllegalArgumentException("Provided argument is not a directory.") + val stack = Stack>() + val rootId = generator.generateUUID(root.name) + val rootNode = FileNode(item = rootId, parent = null) + stack.push(Pair(root, rootNode)) + + index[rootId] = root + + while (!stack.isEmpty()) { + val (path, node) = stack.pop() + // if it is a directory, we can go 1 level deeper + // else we hit a leaf node and can stop this "recursive call" + if (path.isDirectory()) { + val files = path.toFile()!!.listFiles()!! + for (child in files) { + val id = generator.generateUUID(child.name) + val childPath = child.toPath() + + // add this file to the index + index[id] = childPath + + val childNode = FileNode(item = id, parent = node) + node.children.add(childNode) + stack.push(Pair(childPath, childNode)) + } + + // sort the children to follow the structure: alphabetically sorted and directories before files + node.children.sortWith( + compareBy { index[it.item]?.isDirectory() } + .reversed() + .thenBy { index[it.item]?.name } + ) + } + } + return WFileTreeAbstraction(index, rootNode) + } + + override suspend fun read(path: Path): String { + val file = path.toFile() + return file.readLines().joinToString(separator = "\n") + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/data/local/filemanager/FileType.kt b/app/src/main/java/be/re/writand/data/local/filemanager/FileType.kt new file mode 100644 index 0000000..143720c --- /dev/null +++ b/app/src/main/java/be/re/writand/data/local/filemanager/FileType.kt @@ -0,0 +1,9 @@ +package be.re.writand.data.local.filemanager + +/** + * Simple enum to show which type a given object represents. + */ +enum class FileType { + FILE, + DIRECTORY +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/data/local/IFileManager.kt b/app/src/main/java/be/re/writand/data/local/filemanager/IFileManager.kt similarity index 50% rename from app/src/main/java/be/re/writand/data/local/IFileManager.kt rename to app/src/main/java/be/re/writand/data/local/filemanager/IFileManager.kt index a8376c7..9b89dc6 100644 --- a/app/src/main/java/be/re/writand/data/local/IFileManager.kt +++ b/app/src/main/java/be/re/writand/data/local/filemanager/IFileManager.kt @@ -1,39 +1,39 @@ -package be.re.writand.data.local +package be.re.writand.data.local.filemanager -import java.nio.file.Path +import be.re.writand.utils.WFileTreeAbstraction +import java.io.IOException import java.nio.file.InvalidPathException import java.nio.file.NotDirectoryException -import java.io.IOException -import java.io.IOError +import java.nio.file.Path /** - * An interface to handle the File trees. - * File is used to mention both real files and directories at the same time. + * An interface to handle file operations. + * "file" is used to mention both real files and directories at the same time. */ interface IFileManager { /** - * Create a new File. - * @param[name] the name of the new File. - * @param[basePath] the path where the new File should be created in. + * Create a new file and assign an id to it. + * @param[name] the name of the new file. + * @param[basePath] the path where the new file should be created in. * @throws InvalidPathException * @throws FileAlreadyExistsException * @throws NotDirectoryException * @throws IOException * @throws SecurityException - * */ - suspend fun create(name: String, basePath: Path) + * */ + suspend fun create(name: String, fileType: FileType, basePath: Path): ULong /** - * Delete a File. + * Delete a file. * Symbolic links are followed. - * @param[path] the path pointing to the File to be deleted. + * @param[path] the path pointing to the file to be deleted. * @throws NoSuchFileException */ suspend fun delete(path: Path) /** - * Move a File to a different location. - * If the File to be moved is a symbolic link, then the link itself is moved instead of the + * Move a file to a different location. + * If the file to be moved is a symbolic link, then the link itself is moved instead of the * target of the link. * @param[from] the start location. * @param[to] the destination. @@ -45,7 +45,7 @@ interface IFileManager { /** * Rename a file to a new name - * @param[path] the path to the File to rename + * @param[path] the path to the file to rename * @param[newName] the new name to be given to [path] * @throws FileAlreadyExistsException * @throws IOException @@ -54,11 +54,15 @@ interface IFileManager { suspend fun rename(path: Path, newName: String) /** - * Walk the directory and build a File tree. - * @throws NullPointerException - * @throws IllegalArgumentException - * @throws IOError - * @throws SecurityException + * Walk the directory and build a file tree together with an index to enable fast searching. + * @throws IOException */ - suspend fun walk(root: Path): FileTreeWalk + suspend fun initFiletree(root: Path): WFileTreeAbstraction + + /** + * Read a file in memory. + * @param[path] the path to the file to be read + * @throws IOException + */ + suspend fun read(path: Path): String } \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/di/DatabaseModule.kt b/app/src/main/java/be/re/writand/di/DatabaseModule.kt index 5fc206c..c65ad2c 100644 --- a/app/src/main/java/be/re/writand/di/DatabaseModule.kt +++ b/app/src/main/java/be/re/writand/di/DatabaseModule.kt @@ -1,12 +1,15 @@ package be.re.writand.di import android.content.Context +import be.re.writand.data.local.filemanager.FileManagerLocal +import be.re.writand.data.local.filemanager.IFileManager import be.re.writand.data.local.db.ProjectsDao import be.re.writand.data.local.db.ProjectsDatabase import be.re.writand.data.repos.settings.IUserSettingsRepository import be.re.writand.data.repos.settings.UserSettingsRepository import be.re.writand.data.repos.tos.ITOSRepository import be.re.writand.data.repos.tos.TOSRepository +import be.re.writand.utils.GenerateId import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -47,4 +50,9 @@ class DatabaseModule { fun provideTOSRepository(@ApplicationContext context: Context): ITOSRepository { return TOSRepository(context) } + + @Provides + fun provideFileManager(generateId: GenerateId): IFileManager { + return FileManagerLocal(generateId) + } } \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/di/GeneratorModule.kt b/app/src/main/java/be/re/writand/di/GeneratorModule.kt new file mode 100644 index 0000000..6f20614 --- /dev/null +++ b/app/src/main/java/be/re/writand/di/GeneratorModule.kt @@ -0,0 +1,20 @@ +package be.re.writand.di + +import be.re.writand.utils.GenerateId +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Enable dependency injection for the generator class. + */ +@InstallIn(SingletonComponent::class) +@Module +class GeneratorModule { + + @Provides + fun provideGenerator(): GenerateId { + return GenerateId() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/domain/files/CreateFileUseCase.kt b/app/src/main/java/be/re/writand/domain/files/CreateFileUseCase.kt new file mode 100644 index 0000000..9a44813 --- /dev/null +++ b/app/src/main/java/be/re/writand/domain/files/CreateFileUseCase.kt @@ -0,0 +1,24 @@ +package be.re.writand.domain.files + +import be.re.writand.data.local.filemanager.FileType +import be.re.writand.data.local.filemanager.IFileManager +import java.nio.file.Path +import javax.inject.Inject + +/** + * Use case to create a new file / directory on the filesystem. + * @param[manager] an object implementing the [IFileManager] interface. + */ +class CreateFileUseCase @Inject constructor( + private val manager: IFileManager +) { + + /** + * @param[name] the name of the new file / directory. + * @param[type] the type conforming [FileType]. + * @param[root] the root directory where the new file / directory must be placed in. + */ + suspend operator fun invoke(name: String, type: FileType, root: Path): ULong { + return manager.create(name, type, root) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/domain/files/InitFiletreeUseCase.kt b/app/src/main/java/be/re/writand/domain/files/InitFiletreeUseCase.kt new file mode 100644 index 0000000..8f208ac --- /dev/null +++ b/app/src/main/java/be/re/writand/domain/files/InitFiletreeUseCase.kt @@ -0,0 +1,22 @@ +package be.re.writand.domain.files + +import android.content.Context +import be.re.writand.data.local.filemanager.IFileManager +import be.re.writand.utils.WFileTreeAbstraction +import java.nio.file.Paths +import javax.inject.Inject + +/** + * Use case to read the contents of a directory. + * @param[fileManager] an object implementing the [IFileManager] interface. + * @param[context] the context of the application. + */ +class InitFiletreeUseCase @Inject constructor( + private val fileManager: IFileManager, + private val context: Context +) { + suspend operator fun invoke(pathString: String): WFileTreeAbstraction { + val path = Paths.get(pathString) + return fileManager.initFiletree(path) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/domain/files/MoveFileUseCase.kt b/app/src/main/java/be/re/writand/domain/files/MoveFileUseCase.kt new file mode 100644 index 0000000..c702086 --- /dev/null +++ b/app/src/main/java/be/re/writand/domain/files/MoveFileUseCase.kt @@ -0,0 +1,23 @@ +package be.re.writand.domain.files + +import be.re.writand.data.local.filemanager.IFileManager +import java.nio.file.Path +import javax.inject.Inject + +/** + * Use case to move a file to a new directory. + * @param[manager] an object implementing the [IFileManager] interface to handle the filesystem operation. + */ +class MoveFileUseCase @Inject constructor( + private val manager: IFileManager +) { + + /** + * Perform the move action. + * @param[item] the item which is moved. + * @param[destination] the destination directory. + */ + suspend operator fun invoke(item: Path, destination: Path) { + manager.move(item, destination) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/domain/files/ReadFileContentsUseCase.kt b/app/src/main/java/be/re/writand/domain/files/ReadFileContentsUseCase.kt new file mode 100644 index 0000000..a6bd0e6 --- /dev/null +++ b/app/src/main/java/be/re/writand/domain/files/ReadFileContentsUseCase.kt @@ -0,0 +1,18 @@ +package be.re.writand.domain.files + +import be.re.writand.data.local.filemanager.IFileManager +import java.nio.file.Path +import javax.inject.Inject + +/** + * Use case for reading in the contents of a file. + * @param[manager] an object implementing the [IFileManager] interface to handle the filesystem operation. + */ +class ReadFileContentsUseCase @Inject constructor( + private val manager: IFileManager +) { + + suspend operator fun invoke(path: Path): String { + return manager.read(path) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/domain/files/RemoveFileUseCase.kt b/app/src/main/java/be/re/writand/domain/files/RemoveFileUseCase.kt new file mode 100644 index 0000000..aa3e930 --- /dev/null +++ b/app/src/main/java/be/re/writand/domain/files/RemoveFileUseCase.kt @@ -0,0 +1,17 @@ +package be.re.writand.domain.files + +import be.re.writand.data.local.filemanager.IFileManager +import java.nio.file.Path +import javax.inject.Inject + +/** + * Use case for reading in the contents of a file. + * @param[manager] an object implementing the [IFileManager] interface to handle the filesystem operation. + */ +class RemoveFileUseCase @Inject constructor( + private val manager: IFileManager +) { + suspend operator fun invoke(root: Path) { + manager.delete(root) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/screens/components/Buttons.kt b/app/src/main/java/be/re/writand/screens/components/Buttons.kt index b3def35..8e077da 100644 --- a/app/src/main/java/be/re/writand/screens/components/Buttons.kt +++ b/app/src/main/java/be/re/writand/screens/components/Buttons.kt @@ -1,9 +1,11 @@ package be.re.writand.screens.components +import androidx.compose.foundation.border import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -33,4 +35,64 @@ fun WButton( ) { WText(text = text, color = Color.Black, modifier = Modifier.padding(5.dp)) } +} + +/** + * A button to represent an action with consequences. + * This buttons has a red highlight to make sure the user is sure when confirming the action. + * @param[text] the text to show inside the button. + * @param[modifier] the modifier applied to this button. + * @param[onClick] a function to be executed when confirmation is given. + * @param[enabled] controls if this button is enabled or not. + */ +@Composable +fun WDangerButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean = true +) { + Button( + onClick = onClick, + modifier = modifier, + colors = ButtonDefaults.buttonColors(containerColor = Color.Red), + shape = RoundedCornerShape(10.dp), + enabled = enabled + ) { + WText(text = text, color = Color.White, modifier = Modifier.padding(5.dp)) + } +} + +/** + * A button without a fill color. + * @param[text] the text to show inside the button. + * @param[modifier] the modifier applied to this button. + * @param[onClick] a function to be executed when confirmation is given. + * @param[enabled] controls if this button is enabled or not. + */ +@Composable +fun WBorderButton( + text: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + enabled: Boolean = true +) { + Button( + onClick = onClick, + modifier = modifier.border( + width = 2.dp, + color = MainGreen, + shape = RoundedCornerShape(10.dp) + ), + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), + shape = RoundedCornerShape(10.dp), + enabled = enabled + ) { + + WText( + text = text, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(5.dp) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/screens/components/DragAndDrop.kt b/app/src/main/java/be/re/writand/screens/components/DragAndDrop.kt new file mode 100644 index 0000000..a7821bf --- /dev/null +++ b/app/src/main/java/be/re/writand/screens/components/DragAndDrop.kt @@ -0,0 +1,183 @@ +package be.re.writand.screens.components + +import android.util.Log +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +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.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex + +/** + * Currently this is unused, but the goal is to implement this feature into the filetree. + * based on: [this video](https://www.youtube.com/watch?v=ST99k8qK6SM) + */ + +internal class DragTargetInfo { + var isDragging: Boolean by mutableStateOf(false) + var dragPosition by mutableStateOf(Offset.Zero) + var dragOffset by mutableStateOf(Offset.Zero) + var draggableComposable by mutableStateOf<(@Composable () -> Unit)?>(null) + var dataToDrop by mutableStateOf(null) +} + +internal val LocalDragTargetInfo = compositionLocalOf { DragTargetInfo() } + +@Composable +fun DragTarget( + modifier: Modifier = Modifier, + dataToDrop: T, + onStartDragging: () -> Unit, + onStopDragging: () -> Unit, + content: @Composable () -> Unit +) { + var currentPosition by remember { mutableStateOf(Offset.Zero) } + val currentState = LocalDragTargetInfo.current + + Box( + modifier = modifier + .onGloballyPositioned { + currentPosition = it.windowToLocal(Offset.Zero) + } + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { + onStartDragging() + currentState.dataToDrop = dataToDrop + currentState.isDragging = true + currentState.dragPosition = currentPosition + it + currentState.draggableComposable = content + + Log.d("DRAGGING", "states has been set") + }, + onDrag = { change, dragAmount -> + change.consume() + currentState.dragOffset += dragAmount + }, + onDragEnd = { + onStopDragging() + currentState.dragPosition = Offset.Zero + currentState.isDragging = false + }, + onDragCancel = { + onStopDragging() + currentState.dragPosition = Offset.Zero + currentState.isDragging = false + } + ) + } + ) { + content() + } +} + +@Composable +fun DropItem( + modifier: Modifier = Modifier, + content: @Composable (BoxScope.(isInBound: Boolean, data: T?) -> Unit) +) { + val dragInfo = LocalDragTargetInfo.current + val dragPosition = dragInfo.dragPosition + val dragOffset = dragInfo.dragOffset + var isCurrentDropTarget by remember { mutableStateOf(false) } + + Box( + modifier = modifier.onGloballyPositioned { + it.boundsInWindow().let { rect -> + isCurrentDropTarget = rect.contains(dragPosition + dragOffset) + } + } + ) { + val data = + if (isCurrentDropTarget && !dragInfo.isDragging) dragInfo.dataToDrop as T? else null + content(isCurrentDropTarget, data) + } +} + +@Composable +fun DraggableScreen( + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + val state = remember { DragTargetInfo() } + CompositionLocalProvider( + LocalDragTargetInfo provides state + ) { + Box(modifier = modifier.fillMaxSize()) { + content() + if (state.isDragging) { + var targetSize by remember { mutableStateOf(IntSize.Zero) } + Box(modifier = Modifier + .graphicsLayer { + val offset = state.dragPosition + state.dragOffset + scaleX = 1.3f + scaleY = 1.3f + alpha = if (targetSize == IntSize.Zero) 0f else .9f + translationX = offset.x.minus(targetSize.width / 2) + translationY = offset.y.minus(targetSize.height / 2) + } + .onGloballyPositioned { + targetSize = it.size + }) { + state.draggableComposable?.invoke() + } + } + } + } +} + +@Composable +fun DraggableLazyColumnScreen( + modifier: Modifier = Modifier, + content: LazyListScope.() -> Unit +) { + val state = remember { DragTargetInfo() } + CompositionLocalProvider( + LocalDragTargetInfo provides state + ) { + val listState = rememberLazyListState() + LazyColumn( + state = listState, + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(10.dp) + ) { + content() + } + if(state.isDragging) { + var targetSize by remember { mutableStateOf(IntSize.Zero) } + Box( + modifier = Modifier + .zIndex(1.0f) + .graphicsLayer { + val offset = state.dragPosition + state.dragOffset - Offset(0f, listState.firstVisibleItemScrollOffset.toFloat()) + alpha = if (targetSize == IntSize.Zero) 0f else .9f + translationX = offset.x - targetSize.width / 2 + translationY = offset.y - targetSize.height / 2 + } + .onGloballyPositioned { + Log.d("DRAGGING", "original targetsize = $targetSize, new value = ${it.size}") + targetSize = it.size + }) { + state.draggableComposable?.invoke() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/screens/components/WLoadingIndicator.kt b/app/src/main/java/be/re/writand/screens/components/WLoadingIndicator.kt index 2161452..9f2ded5 100644 --- a/app/src/main/java/be/re/writand/screens/components/WLoadingIndicator.kt +++ b/app/src/main/java/be/re/writand/screens/components/WLoadingIndicator.kt @@ -1,13 +1,17 @@ package be.re.writand.screens.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme 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.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -23,7 +27,7 @@ fun WLoadingIndicator() { verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { - CircularProgressIndicator(modifier = Modifier.padding(vertical = 10.dp)) + CircularProgressIndicator(modifier = Modifier.padding(vertical = 10.dp), color = MaterialTheme.colorScheme.tertiary) WText(text = "while( !( succeed = try() ) );", textAlign = TextAlign.Center) } diff --git a/app/src/main/java/be/re/writand/screens/components/WPopup.kt b/app/src/main/java/be/re/writand/screens/components/WPopup.kt new file mode 100644 index 0000000..684cd0e --- /dev/null +++ b/app/src/main/java/be/re/writand/screens/components/WPopup.kt @@ -0,0 +1,70 @@ +package be.re.writand.screens.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import be.re.writand.ui.theme.MainGreen + +/** + * A custom pop up which lets you style it however you want. + * @param[titleBar] a composable for the title bar of this popup. + * @param[bottomBar] a composable for the bottom bar. + * @param[width] the width of the entire pop up. + * @param[height] the height for this pop up. + * @param[onDismiss] a function to be executed when the pop up must hide. + * @param[modifier] the modifier for further styling, applied on the outer box of this component. + * @param[children] a composable with the content inside the pop up. + */ +@Composable +fun WPopup( + titleBar: @Composable () -> Unit, + width: Dp, + height: Dp, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + bottomBar: (@Composable () -> Unit) = {}, + children: @Composable (PaddingValues) -> Unit = {} +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .width(width) + .height(height) + ) { + Scaffold( + topBar = titleBar, + bottomBar = bottomBar, + content = children, + modifier = Modifier + .fillMaxSize() + .border( + width = 1.dp, + color = MainGreen, + shape = RoundedCornerShape(10.dp) + ) + .clip(RoundedCornerShape(10.dp)) + ) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/screens/editor/EditorScreen.kt b/app/src/main/java/be/re/writand/screens/editor/EditorScreen.kt index 873c84a..1ba38a5 100644 --- a/app/src/main/java/be/re/writand/screens/editor/EditorScreen.kt +++ b/app/src/main/java/be/re/writand/screens/editor/EditorScreen.kt @@ -1,37 +1,60 @@ package be.re.writand.screens.editor import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import be.re.writand.screens.editor.bottom.BottomEditorBar import be.re.writand.screens.editor.editorspace.EditorSpace import be.re.writand.screens.editor.top.TopEditorBar +import be.re.writand.screens.filetree.WFiletree +import kotlin.io.path.name import be.re.writand.screens.settings.SettingsPopup /** * Composable presenting the full screen when IDE is open. This holds all the different composables * that come together on the screen. + * @param[vm] the viewmodel of this component, used for managing the general state for the entire screen. */ @Composable fun EditorScreen( - navHostController: NavHostController + navHostController: NavHostController, + vm: EditorScreenViewModel = hiltViewModel() ) { + // state for filetree view + val (isOpened, setIsOpened) = remember { mutableStateOf(false) } + + // state for currently opened filepath + val openedFile by vm.openedFile Scaffold( topBar = { - TopEditorBar() + TopEditorBar(currentFile = openedFile?.name ?: "") { + setIsOpened(!isOpened) + } }, bottomBar = { BottomEditorBar() } ) { - Row(modifier = Modifier.padding(it)) { - // TODO: show filetree when open - EditorSpace() - SettingsPopup() + Row(modifier = Modifier.padding(it).padding(10.dp)) { + if(isOpened) { + WFiletree( + root = "/storage/emulated/0/Documents/webserver", + modifier = Modifier.weight(1F), + onSelect = { path -> + vm.setOpenedFile(path) + }) + } + EditorSpace(fileState = openedFile, modifier = Modifier.weight(2F).fillMaxSize()) } } diff --git a/app/src/main/java/be/re/writand/screens/editor/EditorScreenViewModel.kt b/app/src/main/java/be/re/writand/screens/editor/EditorScreenViewModel.kt new file mode 100644 index 0000000..9d3d965 --- /dev/null +++ b/app/src/main/java/be/re/writand/screens/editor/EditorScreenViewModel.kt @@ -0,0 +1,20 @@ +package be.re.writand.screens.editor + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import be.re.writand.screens.WViewModel +import java.nio.file.Path + +/** + * The viewmodel for the editor screen. + * This manages the general shared state between all the components of the editor screen. + */ +class EditorScreenViewModel : WViewModel() { + + private val _openedFile = mutableStateOf(null) + val openedFile: State = _openedFile + + fun setOpenedFile(path: Path?) { + _openedFile.value = path + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/screens/editor/editorspace/EditorSpace.kt b/app/src/main/java/be/re/writand/screens/editor/editorspace/EditorSpace.kt index b51c21f..17cb4db 100644 --- a/app/src/main/java/be/re/writand/screens/editor/editorspace/EditorSpace.kt +++ b/app/src/main/java/be/re/writand/screens/editor/editorspace/EditorSpace.kt @@ -1,5 +1,6 @@ package be.re.writand.screens.editor.editorspace +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -12,8 +13,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key @@ -26,78 +25,87 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import be.re.writand.screens.components.WLoadingIndicator import be.re.writand.screens.components.WText +import java.nio.file.Path /** * The editor space itself where the user can edit the currently open file. * @param[vM] the viewmodel used to handle input changes to the open file. + * @param[fileState] the currently opened file in de editor, comes from EditorScreen. + * @param[modifier] the modifier for the outer row of this component. */ @Composable fun EditorSpace( + fileState: Path?, + modifier: Modifier = Modifier, vM: EditorSpaceViewModel = hiltViewModel() ) { val uiState by vM.uiState.collectAsState() + var linesText by vM.lineCount - when (val value = uiState) { - EditorSpaceUiState.Loading -> { - WLoadingIndicator() - } - - is EditorSpaceUiState.Success -> { - // using a mutable state to store the number of lines - var linesText by remember { mutableIntStateOf(value.fileContent.count { it == '\n' } + 1) } - - // the scrolling state of both text fields - val linesTextScroll = rememberScrollState() - val scriptTextScroll = rememberScrollState() - - // synchronize scrolling - LaunchedEffect(linesTextScroll.value) { - scriptTextScroll.scrollTo(linesTextScroll.value) - } - LaunchedEffect(scriptTextScroll.value) { - linesTextScroll.scrollTo(scriptTextScroll.value) - } - - BasicTextField( - modifier = Modifier - .fillMaxHeight() - .padding(start = 25.dp) - .width(12.dp * linesText.toString().length) - .verticalScroll(linesTextScroll), - value = IntRange(1, linesText).joinToString(separator = "\n"), - readOnly = true, - textStyle = TextStyle(color = MaterialTheme.colorScheme.secondary), - onValueChange = {} - ) - - VerticalDivider( - modifier = Modifier - .fillMaxHeight() - .padding(horizontal = 8.dp), - color = MaterialTheme.colorScheme.secondary - ) - - // actual textField - BasicTextField( - modifier = Modifier - .fillMaxHeight() - // this is a hack to prevent this https://stackoverflow.com/questions/76287857/when-parent-of-textfield-is-clickable-hardware-enter-return-button-triggers-its - .onKeyEvent { it.type == KeyEventType.KeyUp && it.key == Key.Enter } - .verticalScroll(scriptTextScroll), - value = value.fileContent, - readOnly = false, - textStyle = TextStyle(color = MaterialTheme.colorScheme.onPrimary), - onValueChange = { textFieldValue -> - val nbLines = textFieldValue.count { it == '\n' } + 1 - if (nbLines != linesText) linesText = nbLines - vM.onTextChange(textFieldValue) - }, - ) - } - - is EditorSpaceUiState.Failed -> { - WText(text = value.message) - } + LaunchedEffect(fileState) { + vM.readContents(fileState) } + Row(modifier = modifier) { + when (val value = uiState) { + EditorSpaceUiState.Loading -> { + WLoadingIndicator() + } + + is EditorSpaceUiState.Success -> { + // the scrolling state of both text fields + val linesTextScroll = rememberScrollState() + val scriptTextScroll = rememberScrollState() + + // synchronize scrolling + LaunchedEffect(linesTextScroll.value) { + scriptTextScroll.scrollTo(linesTextScroll.value) + } + LaunchedEffect(scriptTextScroll.value) { + linesTextScroll.scrollTo(scriptTextScroll.value) + } + + BasicTextField( + modifier = Modifier + .fillMaxHeight() + .padding(start = 25.dp) + .width(12.dp * linesText.toString().length) + .verticalScroll(linesTextScroll), + value = IntRange(1, linesText).joinToString(separator = "\n"), + readOnly = true, + textStyle = TextStyle(color = MaterialTheme.colorScheme.secondary), + onValueChange = {} + ) + + VerticalDivider( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.secondary + ) + + // actual textField + BasicTextField( + modifier = Modifier + .fillMaxHeight() + // this is a hack to prevent this https://stackoverflow.com/questions/76287857/when-parent-of-textfield-is-clickable-hardware-enter-return-button-triggers-its + .onKeyEvent { it.type == KeyEventType.KeyUp && it.key == Key.Enter } + .verticalScroll(scriptTextScroll), + value = value.fileContent, + readOnly = false, + textStyle = TextStyle(color = MaterialTheme.colorScheme.onPrimary), + onValueChange = { textFieldValue -> + val nbLines = textFieldValue.count { it == '\n' } + 1 + if (nbLines != linesText) linesText = nbLines + vM.onTextChange(textFieldValue) + }, + ) + + } + + is EditorSpaceUiState.Failed -> { + WText(text = value.message) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/screens/editor/editorspace/EditorSpaceViewModel.kt b/app/src/main/java/be/re/writand/screens/editor/editorspace/EditorSpaceViewModel.kt index 52f2b2d..1b388d2 100644 --- a/app/src/main/java/be/re/writand/screens/editor/editorspace/EditorSpaceViewModel.kt +++ b/app/src/main/java/be/re/writand/screens/editor/editorspace/EditorSpaceViewModel.kt @@ -1,30 +1,40 @@ package be.re.writand.screens.editor.editorspace +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableIntStateOf +import be.re.writand.domain.files.ReadFileContentsUseCase import be.re.writand.screens.WViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import java.nio.file.Path import javax.inject.Inject /** * Viewmodel to be used by EditorSpace to handle file changes and keep the ui state in memory. + * File changes include changing to a new file and reading its contents, and editing this file. + * @param[readFileContentsUseCase] the use case used to read in the newly selected file. */ -class EditorSpaceViewModel : WViewModel() { +@HiltViewModel +class EditorSpaceViewModel @Inject constructor( + private val readFileContentsUseCase: ReadFileContentsUseCase +) : WViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(EditorSpaceUiState.Loading) val uiState: StateFlow = _uiState - init { - launchCatching { - // TODO: call use-case that gets the String of the file - val file: String = "print('Hello World!')\nprint('second line')\n" - if (file.isNotEmpty()) { - _uiState.value = EditorSpaceUiState.Success(file) - } else { - _uiState.value = EditorSpaceUiState.Failed("The file could not be loaded.") + val lineCount = mutableIntStateOf(1) + + fun readContents(path: Path?) { + path?.let { + launchCatching { + val contents = readFileContentsUseCase(path) + lineCount.intValue = contents.count { it == '\n' } + 1 + _uiState.value = EditorSpaceUiState.Success(contents) } } + } fun onTextChange(newValue: String) { diff --git a/app/src/main/java/be/re/writand/screens/editor/top/TopEditorBar.kt b/app/src/main/java/be/re/writand/screens/editor/top/TopEditorBar.kt index 7c0bb99..59b8c82 100644 --- a/app/src/main/java/be/re/writand/screens/editor/top/TopEditorBar.kt +++ b/app/src/main/java/be/re/writand/screens/editor/top/TopEditorBar.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import be.re.writand.R import be.re.writand.screens.components.WText +import java.nio.file.Path /** * Composable representing the top bar of the editor. @@ -31,9 +32,10 @@ import be.re.writand.screens.components.WText */ @Composable fun TopEditorBar( - vM: TopEditorBarViewModel = hiltViewModel() + vM: TopEditorBarViewModel = hiltViewModel(), + currentFile: String, + onOpenFileTree: () -> Unit ) { - val openFile = vM.currentFile.collectAsState() val isSaved = vM.isSaved.collectAsState() Row( @@ -52,7 +54,7 @@ fun TopEditorBar( verticalAlignment = Alignment.CenterVertically ) { IconButton( - onClick = { vM.onFileMenuClick() }, + onClick = onOpenFileTree, modifier = Modifier.padding(start = 10.dp), ) { Icon( @@ -65,20 +67,22 @@ fun TopEditorBar( Row( verticalAlignment = Alignment.CenterVertically ) { - WText(text = openFile.value) - IconButton(onClick = { vM.onSaveFile() }) { - if (isSaved.value) { - Icon( - modifier = Modifier.size(25.dp), - painter = painterResource(id = R.drawable.saved), - contentDescription = "Save" - ) - } else { - Icon( - modifier = Modifier.size(25.dp), - painter = painterResource(id = R.drawable.not_saved), - contentDescription = "Save" - ) + if(currentFile.isNotEmpty()) { + WText(text = currentFile) + IconButton(onClick = { vM.onSaveFile() }) { + if (isSaved.value) { + Icon( + modifier = Modifier.size(25.dp), + painter = painterResource(id = R.drawable.saved), + contentDescription = "Save" + ) + } else { + Icon( + modifier = Modifier.size(25.dp), + painter = painterResource(id = R.drawable.not_saved), + contentDescription = "Save" + ) + } } } } diff --git a/app/src/main/java/be/re/writand/screens/editor/top/TopEditorBarViewModel.kt b/app/src/main/java/be/re/writand/screens/editor/top/TopEditorBarViewModel.kt index eb332c9..49d0ef7 100644 --- a/app/src/main/java/be/re/writand/screens/editor/top/TopEditorBarViewModel.kt +++ b/app/src/main/java/be/re/writand/screens/editor/top/TopEditorBarViewModel.kt @@ -13,9 +13,6 @@ class TopEditorBarViewModel : WViewModel() { private val _saved = MutableStateFlow(true) val isSaved: StateFlow = _saved.asStateFlow() - private val _file = MutableStateFlow("hello_world.py") - var currentFile: StateFlow = _file.asStateFlow() - fun onFileMenuClick() { // TODO: Open or close the filetree } diff --git a/app/src/main/java/be/re/writand/screens/filetree/Filetree.kt b/app/src/main/java/be/re/writand/screens/filetree/Filetree.kt new file mode 100644 index 0000000..63211f6 --- /dev/null +++ b/app/src/main/java/be/re/writand/screens/filetree/Filetree.kt @@ -0,0 +1,163 @@ +package be.re.writand.screens.filetree + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import be.re.writand.screens.components.WLoadingIndicator +import be.re.writand.screens.components.WText +import be.re.writand.ui.theme.MainGreen +import java.nio.file.Path + +/** + * internal variable to set the height + */ +private val barHeight = 45.dp + +/** + * The top bar for the filetree. + * @param[title] the title to be shown. + */ +@Composable +fun TopScaffoldBar(title: String) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(barHeight) + .background(MainGreen) + .border( + width = 1.dp, + color = MainGreen, + shape = RoundedCornerShape( + topStart = 10.dp, topEnd = 10.dp, + bottomStart = 0.dp, bottomEnd = 0.dp + ) + ) + ) { + WText(text = title) + } +} + +/** + * Bottom bar for the filetree. + * This holds the info and about buttons. + */ +@Composable +fun BottomScaffoldBar() { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(barHeight) + .background(MainGreen) + .border( + width = 1.dp, + color = MainGreen, + shape = RoundedCornerShape( + topStart = 0.dp, topEnd = 0.dp, + bottomStart = 10.dp, bottomEnd = 10.dp + ) + ) + .padding(horizontal = 10.dp) + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Open settings", + modifier = Modifier.size(24.dp).clickable { /* TODO: link settings popup */ } + ) + Icon( + imageVector = Icons.Default.Info, + contentDescription = "More information", + modifier = Modifier.size(24.dp).clickable { /* TODO: link about popup */ } + ) + } +} + +/** + * The main composable for the filetree. + * Brings all of the subcomponents together. + * @param[vm] the viewmodel for this filetree, manages all of the operations and states. + * @param[modifier] the modifier applied to the scaffold. + * @param[root] the root path (as a string) for the filetree to start from. + * @param[onSelect] a function telling the filetree what to do if the user selects (clicks once) a file. + */ +@Composable +fun WFiletree( + root: String, + onSelect: (Path?) -> Unit, + modifier: Modifier = Modifier, + vm: WFiletreeViewModel = hiltViewModel() +) { + val uiState by vm.uiState.collectAsState() + + LaunchedEffect(Unit) { + vm.loadDirectory(root) + } + + Scaffold( + topBar = { TopScaffoldBar("Filetree") }, + bottomBar = { BottomScaffoldBar() }, + containerColor = MaterialTheme.colorScheme.primary, + modifier = modifier + .border( + width = 1.dp, + color = MainGreen, + shape = RoundedCornerShape(10.dp) + ) + .clip(RoundedCornerShape(10.dp)) + ) { + + when (val s = uiState) { + WFiletreeUiState.Loading -> { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + WLoadingIndicator() + } + } + is WFiletreeUiState.Success -> { + WTreeComponent( + root = s.tree.value, // guaranteed not null because the state is set in loading the directory + modifier = Modifier.padding(it), + getFilename = { id -> vm.getFilename(id) }, + isDirectory = { id -> vm.isDirectory(id) }, + getIsOpened = { id -> vm.getIsOpened(id) }, + getPath = { id -> vm.getPath(id) }, + toggleIsOpened = { id -> vm.toggleOpen(id) }, + onMove = { from, to -> vm.moveFile(from, to) }, + onCreate = { node, name, type -> vm.createFile(name, type, node) }, + onDelete = { node -> vm.removeFile(node) }, + onSelect = onSelect + ) + } + + is WFiletreeUiState.Failed -> WText("Show error pop up to notify the user") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/screens/filetree/WFiletreeUiState.kt b/app/src/main/java/be/re/writand/screens/filetree/WFiletreeUiState.kt new file mode 100644 index 0000000..e8702f1 --- /dev/null +++ b/app/src/main/java/be/re/writand/screens/filetree/WFiletreeUiState.kt @@ -0,0 +1,15 @@ +package be.re.writand.screens.filetree + +import be.re.writand.utils.WFileTreeAbstraction + +/** + * The ui state for the filetree. + * - [Loading] the content is still loading in. + * - [Success] the filetree is ready, given as an object of [WFileTreeAbstraction]. + * - [Failed] the tree could not be build, TODO: add error handling here. + */ +sealed interface WFiletreeUiState { + data object Loading: WFiletreeUiState + data class Success(val tree: WFileTreeAbstraction): WFiletreeUiState + data class Failed(val placeHolder: Int): WFiletreeUiState +} diff --git a/app/src/main/java/be/re/writand/screens/filetree/WFiletreeViewModel.kt b/app/src/main/java/be/re/writand/screens/filetree/WFiletreeViewModel.kt new file mode 100644 index 0000000..9019037 --- /dev/null +++ b/app/src/main/java/be/re/writand/screens/filetree/WFiletreeViewModel.kt @@ -0,0 +1,115 @@ +package be.re.writand.screens.filetree + +import androidx.compose.runtime.mutableStateMapOf +import be.re.writand.data.local.filemanager.FileType +import be.re.writand.domain.files.CreateFileUseCase +import be.re.writand.domain.files.InitFiletreeUseCase +import be.re.writand.domain.files.MoveFileUseCase +import be.re.writand.domain.files.RemoveFileUseCase +import be.re.writand.screens.WViewModel +import be.re.writand.utils.FileNode +import be.re.writand.utils.WFileTreeAbstraction +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import java.nio.file.Path +import java.nio.file.Paths +import javax.inject.Inject +import kotlin.io.path.isDirectory +import kotlin.io.path.name + +/** + * Viewmodel for the filetree. + * Manages the states and all operations. + * @param[initFiletreeUseCase] the use case to load in the filetree. + * @param[moveFileUseCase] use case which is responsible to move a file from a to be. + * @param[createFileUseCase] use case to create a new file. + * @param[removeFileUseCase] use case to delete a file. + */ +@HiltViewModel +class WFiletreeViewModel @Inject constructor( + private val initFiletreeUseCase: InitFiletreeUseCase, + private val moveFileUseCase: MoveFileUseCase, + private val createFileUseCase: CreateFileUseCase, + private val removeFileUseCase: RemoveFileUseCase +) : WViewModel() { + private val _uiState: MutableStateFlow = + MutableStateFlow(WFiletreeUiState.Loading) + + val uiState: StateFlow = _uiState + + private val isOpenedState = mutableStateMapOf() + private lateinit var fileTree: WFileTreeAbstraction + + fun toggleOpen(id: ULong) { + isOpenedState[id] = isOpenedState[id]?.not() ?: true + } + + fun getIsOpened(id: ULong): Boolean { + return isOpenedState[id] == true + } + + + fun getFilename(id: ULong): String? { + if (!this::fileTree.isInitialized) return null + return fileTree.getPath(id)?.name + } + + fun getPath(id: ULong): Path? { + return fileTree.getPath(id) + } + + fun isDirectory(id: ULong): Boolean { + if (!this::fileTree.isInitialized) return false + return fileTree.getPath(id)?.isDirectory() ?: false + } + + fun loadDirectory(path: String) { + launchCatching { + withContext(Dispatchers.IO) { + fileTree = initFiletreeUseCase(path) + _uiState.value = WFiletreeUiState.Success(fileTree) + } + } + } + + fun removeFile(node: FileNode) { + if (!this::fileTree.isInitialized) return + val path = + fileTree.getPath(node.item)!! // TODO: if path does not exist (error is thrown) show error to user + fileTree.delete(node) + launchCatching { + withContext(Dispatchers.IO) { + removeFileUseCase(path) + } + } + } + + fun createFile(name: String, fileType: FileType, root: FileNode) { + if (name.isBlank()) return + if (!this::fileTree.isInitialized) return + val path = + fileTree.getPath(root.item)!! // TODO: if path does not exist (error is thrown) show error to user + launchCatching { + withContext(Dispatchers.IO) { + val id = createFileUseCase(name, fileType, path) + fileTree.create(Paths.get(path.toString(), name), root, id) + } + } + } + + fun moveFile(from: FileNode, to: FileNode) { + if (!this::fileTree.isInitialized) return + val fromPath = fileTree.getPath(from.item)!! + val toPath = fileTree.getPath(to.item)!! + + fileTree.move(from, to) + launchCatching { + withContext(Dispatchers.IO) { + moveFileUseCase(fromPath, toPath) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/screens/filetree/WTreeComponent.kt b/app/src/main/java/be/re/writand/screens/filetree/WTreeComponent.kt new file mode 100644 index 0000000..41a682d --- /dev/null +++ b/app/src/main/java/be/re/writand/screens/filetree/WTreeComponent.kt @@ -0,0 +1,417 @@ +package be.re.writand.screens.filetree + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import be.re.writand.data.local.filemanager.FileType +import be.re.writand.screens.components.WBorderButton +import be.re.writand.screens.components.WButton +import be.re.writand.screens.components.WCheckbox +import be.re.writand.screens.components.WDangerButton +import be.re.writand.screens.components.WLabelAndTextField +import be.re.writand.screens.components.WPopup +import be.re.writand.screens.components.WText +import be.re.writand.ui.theme.MainGreen +import be.re.writand.utils.FileNode +import java.nio.file.Path + +// TODO: add drag and drop functionality to move files from 1 place to another + +// based on: https://stackoverflow.com/a/71709816 + +// constant for standard indentation. +private val defaultSpacing = 10.dp + +// constant to fix the icon size. +private val iconSize = 16.dp + +/** + * The main component of the filetree itself. + * @param[root] the root node of the filetree. + * @param[modifier] the modifier applied to the outer column of the component. + * @param[getFilename] the function to obtain the filename of a node. + * @param[isDirectory] a function to determine if the node stands for a file or a directory. + * @param[getIsOpened] a function to access the state for a given node representing a directory. + * @param[getPath] a function to retrieve the path of a node. + * @param[toggleIsOpened] the state changer function, toggles between opened and closed. + * @param[onMove] the action function to move a file / directory from a to b. + * @param[onCreate] the action function to create a new file / directory. + * @param[onDelete] the action function to delete a file / directory. + * @param[onSelect] the action function to select a file (directory is not supported). + */ +@Composable +fun WTreeComponent( + root: FileNode, + modifier: Modifier = Modifier, + getFilename: (ULong) -> String?, + isDirectory: (ULong) -> Boolean, + getIsOpened: (ULong) -> Boolean, + getPath: (ULong) -> Path?, + toggleIsOpened: (ULong) -> Unit, + onMove: (FileNode, FileNode) -> Unit, + onCreate: (FileNode, String, FileType) -> Unit, + onDelete: (FileNode) -> Unit, + onSelect: (Path?) -> Unit +) { + LazyColumn(modifier = modifier, contentPadding = PaddingValues(10.dp)) { + WTreeItem( + root = root, + depth = 0, + getFilename = getFilename, + getPath = getPath, + isDirectory = isDirectory, + getIsOpened = getIsOpened, + toggleIsOpened = toggleIsOpened, + onMove = onMove, + onCreate = onCreate, + onDelete = onDelete, + onSelect = onSelect + ) + } +} + +/** + * Helper function in the recursive call to loop over a list of nodes and create + * LazyColumn items for each of them. + * @param[depth] the current depth in the tree, used to calculate the indentation. + * @see[WTreeComponent] for the parameters. + */ +fun LazyListScope.WTreeItems( + root: List, + depth: Int, + getFilename: (ULong) -> String?, + getPath: (ULong) -> Path?, + isDirectory: (ULong) -> Boolean, + getIsOpened: (ULong) -> Boolean, + toggleIsOpened: (ULong) -> Unit, + onMove: (FileNode, FileNode) -> Unit, + onCreate: (FileNode, String, FileType) -> Unit, + onDelete: (FileNode) -> Unit, + onSelect: (Path?) -> Unit +) { + root.forEach { + WTreeItem( + root = it, + depth = depth, + getFilename = getFilename, + getPath = getPath, + isDirectory = isDirectory, + getIsOpened = getIsOpened, + toggleIsOpened = toggleIsOpened, + onMove = onMove, + onCreate = onCreate, + onDelete = onDelete, + onSelect = onSelect + ) + } +} + +/** + * Helper function to create 1 LazyColumn item. + * @param[depth] the current depth in the tree, used to calculate the indentation. + * @see[WTreeComponent] for the parameters. + */ +fun LazyListScope.WTreeItem( + root: FileNode, + depth: Int, + getFilename: (ULong) -> String?, + getPath: (ULong) -> Path?, + isDirectory: (ULong) -> Boolean, + getIsOpened: (ULong) -> Boolean, + toggleIsOpened: (ULong) -> Unit, + onMove: (FileNode, FileNode) -> Unit, + onCreate: (FileNode, String, FileType) -> Unit, + onDelete: (FileNode) -> Unit, + onSelect: (Path?) -> Unit +) { + val filename = getFilename(root.item) + val isDir = isDirectory(root.item) + if (filename != null) { + item { + val (showAddPopUp, setShowAddPopUp) = remember { mutableStateOf(false) } + val (showRemovePopUp, setShowRemovePopUp) = remember { mutableStateOf(false) } + Row { + Filename( + root = root, + filename = filename, + isDirectory = isDir, + depth = depth, + getIsOpened = getIsOpened, + getPath = getPath, + toggleIsOpened = toggleIsOpened, + onSelect = onSelect + ) + if (isDir) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add a file or directory", + modifier = Modifier + .size(iconSize) + .clickable { + setShowAddPopUp(true) + } + ) + } + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete the current file or directory", + modifier = Modifier + .size(iconSize) + .clickable { + setShowRemovePopUp(true) + } + ) + } + + if (showAddPopUp) CreateFile( + hidePopUp = { setShowAddPopUp(false) }, + onConfirm = { name, type -> onCreate(root, name, type) } + ) + + if (showRemovePopUp) RemoveFilePopUp( + filename = filename, + hidePopUp = { setShowRemovePopUp(false) }, + onConfirm = { onDelete(root) } + ) + } + if (getIsOpened(root.item)) { + WTreeItems( + root = root.children, + depth = depth + 1, + getFilename = getFilename, + getPath = getPath, + isDirectory = isDirectory, + getIsOpened = getIsOpened, + toggleIsOpened = toggleIsOpened, + onMove = onMove, + onCreate = onCreate, + onDelete = onDelete, + onSelect = onSelect + ) + } + } +} + +/** + * The composable for a filename. + * This shows the filename and the necessary buttons for this node (add, open, ...). + * @see[WTreeComponent] for the parameters. + */ +@Composable +fun Filename( + root: FileNode, + filename: String, + isDirectory: Boolean, + depth: Int, + getIsOpened: (ULong) -> Boolean, + getPath: (ULong) -> Path?, + toggleIsOpened: (ULong) -> Unit, + onSelect: (Path?) -> Unit +) { + Row { + if (isDirectory) { + Icon( + imageVector = if (getIsOpened(root.item)) { + Icons.Default.KeyboardArrowDown + } else { + Icons.AutoMirrored.Default.KeyboardArrowRight + }, + modifier = Modifier + .padding(start = defaultSpacing * depth) + .size(iconSize) + .clickable( + onClick = { toggleIsOpened(root.item) }, + role = Role.Switch + ), + contentDescription = "Open/close the directory" + ) + Text(text = filename) + } else { + Text( + text = filename, + modifier = Modifier + .padding(start = defaultSpacing * depth + iconSize) + .clickable { + onSelect(getPath(root.item)) + } + ) + } + } +} + +/** + * Pop up screen to create a file / directory. + * @param[hidePopUp] a function telling this component what to do when closing this pop up is requested. + * @param[onConfirm] a function performing the action when the user confirms the creation of a new file / directory. + */ +@Composable +fun CreateFile(hidePopUp: () -> Unit, onConfirm: (String, FileType) -> Unit) { + val (fileName, setFileName) = remember { mutableStateOf("") } + WPopup( + titleBar = { TopPopUpBar(title = "Create a new file / directory", onDismiss = hidePopUp) }, + bottomBar = { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + val (isDirectory, setIsDirectory) = remember { mutableStateOf(false) } + + WCheckbox(text = "make directory", checked = isDirectory) { + setIsDirectory(!isDirectory) + } + + WButton(text = "Create", onClick = { + val type = if (isDirectory) FileType.DIRECTORY else FileType.FILE + hidePopUp() // TODO: show error if field is empty + onConfirm(fileName, type) + }) + } + }, + width = 500.dp, + height = 200.dp, + onDismiss = hidePopUp + ) { padding -> + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + Row( + modifier = Modifier.padding(10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + WLabelAndTextField( + title = "Filename", + value = fileName, + onTextChange = { setFileName(it) } + ) + } + } + } +} + +/** + * The pop up asking for confirmation when about to delete a file / directory. + * @param[filename] the name of the file / directory which is about to be deleted. + * @param[hidePopUp] a function implementing the logic to hide this pop up. + * @param[onConfirm] a function implementing the logic to actually delete this file / directory. + */ +@Composable +fun RemoveFilePopUp(filename: String, hidePopUp: () -> Unit, onConfirm: () -> Unit) { + WPopup( + titleBar = { + TopPopUpBar( + title = "Are you sure?", + showExit = false + ) + }, + bottomBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + WBorderButton( + text = "Cancel", + onClick = hidePopUp + ) + + WDangerButton( + text = "Delete", + onClick = { + onConfirm() + hidePopUp() + } + ) + } + }, + height = 200.dp, + width = 500.dp, + onDismiss = hidePopUp + ) { padding -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + WText("Do you really want to delete $filename?") + } + } +} + +/** + * The top bar used in the pop ups. + * @see[RemoveFilePopUp] + * @see[CreateFile] for the actual pop ups. + * @param[title] the title of the pop up. + * @param[showExit] a boolean (default to true) whether or not the exit button needs to be shown. + * @param[onDismiss] a function belonging to the exit button if it needs to be shown (default is the empty function). + */ +@Composable +private fun TopPopUpBar(title: String, showExit: Boolean = true, onDismiss: () -> Unit = {}) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(45.dp) + .background(MainGreen) + .border( + width = 1.dp, + color = MainGreen, + shape = RoundedCornerShape( + topStart = 10.dp, topEnd = 10.dp, + bottomStart = 0.dp, bottomEnd = 0.dp + ) + ) + .padding(5.dp) + ) { + Spacer(modifier = Modifier.width(0.dp)) + WText(text = title) + if (showExit) { + Icon(imageVector = Icons.Default.Close, contentDescription = "", modifier = Modifier.clickable { + onDismiss() + }) + } else { + Spacer(modifier = Modifier.width(0.dp)) + } + } +} diff --git a/app/src/main/java/be/re/writand/utils/GenerateId.kt b/app/src/main/java/be/re/writand/utils/GenerateId.kt new file mode 100644 index 0000000..0d3b9a4 --- /dev/null +++ b/app/src/main/java/be/re/writand/utils/GenerateId.kt @@ -0,0 +1,49 @@ +package be.re.writand.utils + +import java.time.LocalDateTime +import java.time.ZoneOffset +import kotlin.random.Random +import kotlin.random.nextULong + +class GenerateId { + private val CHARACTERS: List = initChars() + private val characterMap = HashMap(CHARACTERS.size) + private val random = Random(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + + init { + for ((i, c) in CHARACTERS.withIndex()) { + characterMap[c] = i.toUByte() + } + } + + private fun initChars(): List { + val alphaNumeric = (('a'..'z') + ('A'..'Z') + ('0'..'9')).toMutableList() + val specialChars = listOf( + '.', '!', ',', '&', '%', '^', '#', '@', + '[', ']', '{', '}', '(', ')', '-', '+', + '$', '=', '_', ';' + ) + + alphaNumeric.addAll(specialChars) + + return alphaNumeric + } + + /** + * Generates a random UUID corresponding to following scheme: + * - the first 2 digits of the ID correspond to the first letter of the filename. + * - the rest of the ID is random. + * @param[filename] the filename of the current file needing an ID. + * @return[ULong] the 8 byte ID. + */ + fun generateUUID(filename: String): ULong { + var code = random.nextULong() + val charCode = characterMap[filename[0]] + + // code &= 111...111 00000000: set last byte to 0 to or in the code for this specific letter + code = code and (255UL).inv() + return charCode?.let { + code or it.toULong() + } ?: 0UL + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/utils/Node.kt b/app/src/main/java/be/re/writand/utils/Node.kt new file mode 100644 index 0000000..9a84a57 --- /dev/null +++ b/app/src/main/java/be/re/writand/utils/Node.kt @@ -0,0 +1,16 @@ +package be.re.writand.utils + +/** + * Describe an abstract node of type T used for a tree. + * @param[item] the item in this node of the tree. + * @param[children] a list of the children of this node. + */ +data class FileNode( + var item: ULong, + var parent: FileNode?, + var children: MutableList = mutableListOf() +) { + override fun toString(): String { + return "FileNode(item = $item, parent = ${parent?.item}, children = $children)" + } +} diff --git a/app/src/main/java/be/re/writand/utils/WFileTreeAbstraction.kt b/app/src/main/java/be/re/writand/utils/WFileTreeAbstraction.kt new file mode 100644 index 0000000..683e9ff --- /dev/null +++ b/app/src/main/java/be/re/writand/utils/WFileTreeAbstraction.kt @@ -0,0 +1,109 @@ +package be.re.writand.utils + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.snapshots.MutableSnapshot +import java.nio.file.Path +import java.nio.file.Paths +import java.util.AbstractQueue +import java.util.LinkedList +import java.util.PriorityQueue +import java.util.Queue +import javax.inject.Inject +import kotlin.io.path.isDirectory +import kotlin.io.path.name + + +/** + * Abstract representation of the filetree starting at the root of the project. + * All operations performed here are also performed on disk. + * + * @param[index] a hashmap containing the mappings between an id and its path. + * @param[root] the root of the filetree. + */ +class WFileTreeAbstraction @Inject constructor( + private val index: HashMap, + root: FileNode, +) : MutableState { + private val _root = mutableStateOf(root, neverEqualPolicy()) + + private fun triggerRerender() { + _root.value = _root.value + } + + private val comparator = compareBy { index[it.item]?.isDirectory() } + .reversed() + .thenBy { index[it.item]?.name } + + fun getPath(id: ULong): Path? { + return index[id] + } + + /** + * Deletes a node in the filetree, also calling to the manager to delete the file on disk. + * @param[node] the node to delete. + * @throws IllegalArgumentException if the node points to a non-existent file / directory. + */ + fun delete(node: FileNode) { + // if the node is not the root node, remove the node from the children list + node.parent?.children?.remove(node) + + val queue = LinkedList() + queue.add(node) + + while(!queue.isEmpty()) { + val top = queue.poll()!! + queue.addAll(top.children) + index.remove(top.item) + } + + triggerRerender() + } + + /** + * Create a new file / directory in the current directory. + * @param[path] the path of the file. + * @param[relativeRoot] the current directory. + * @throws IllegalArgumentException if the node points to a non-existent file / directory. + */ + fun create(path: Path, relativeRoot: FileNode, id: ULong) { + index[id] = path + relativeRoot.children.add(FileNode(id, relativeRoot)) + relativeRoot.children.sortWith(comparator) + + triggerRerender() + } + + /** + * Move a file from 1 location to another. + * @param[from] the source location to be moved. + * @param[to] the destination directory. + */ + fun move(from: FileNode, to: FileNode) { + from.parent?.children?.remove(from) + to.children.add(from) + from.parent = to + to.children.sortWith(comparator) + + // update the path stored in the index + val parentPath = index[to.item] ?: throw IllegalArgumentException() + val fromPath = index[from.item] ?: throw IllegalArgumentException() + index[from.item] = Paths.get(parentPath.toString(), fromPath.name) + + triggerRerender() + } + + override var value: FileNode + get() = _root.value + set(value) {_root.value = value} + + override fun component1(): FileNode { + return _root.value + } + + override fun component2(): (FileNode) -> Unit { + return { _root.value = it } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/utils/directorypicker/DirectoryPicker.kt b/app/src/main/java/be/re/writand/utils/directorypicker/DirectoryPicker.kt new file mode 100644 index 0000000..9137b77 --- /dev/null +++ b/app/src/main/java/be/re/writand/utils/directorypicker/DirectoryPicker.kt @@ -0,0 +1,44 @@ +package be.re.writand.utils.directorypicker + +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts + +// TODO: make this class a builder-pattern +class DirectoryPicker(private var activity: ComponentActivity) { + private lateinit var listener: IDirectoryPickerListener + private var launcher = registerActivityResult() + + private fun registerActivityResult(): ActivityResultLauncher { + return activity.registerForActivityResult( + contract = ActivityResultContracts.OpenDocumentTree(), + callback = { handleResult(it) } + ) + } + + fun setActivity(activity: ComponentActivity) { + this.activity = activity + launcher = registerActivityResult() + } + + fun setListener(listener: IDirectoryPickerListener) { + this.listener = listener + } + + fun openPicker() { + launcher.launch(null) + } + + private fun handleResult(data: Uri?) { + Log.d("Filetree", "data = ${data.toString()}") + data?.let { + val flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + activity.contentResolver.takePersistableUriPermission(data, flags) + if(this::listener.isInitialized) listener.notify(data) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/re/writand/utils/directorypicker/IDirectoryPickerListener.kt b/app/src/main/java/be/re/writand/utils/directorypicker/IDirectoryPickerListener.kt new file mode 100644 index 0000000..325c6b4 --- /dev/null +++ b/app/src/main/java/be/re/writand/utils/directorypicker/IDirectoryPickerListener.kt @@ -0,0 +1,7 @@ +package be.re.writand.utils.directorypicker + +import android.net.Uri + +interface IDirectoryPickerListener { + fun notify(uri: Uri) +} \ No newline at end of file diff --git a/app/src/test/java/be/re/writand/FileManagerLocalTest.kt b/app/src/test/java/be/re/writand/FileManagerLocalTest.kt index 953fa19..bf6ce89 100644 --- a/app/src/test/java/be/re/writand/FileManagerLocalTest.kt +++ b/app/src/test/java/be/re/writand/FileManagerLocalTest.kt @@ -1,7 +1,7 @@ package be.re.writand import android.annotation.SuppressLint -import be.re.writand.data.local.FileManagerLocal +import be.re.writand.data.local.filemanager.FileManagerLocal import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll diff --git a/build.gradle b/build.gradle index 69a84fb..ac951a5 100644 --- a/build.gradle +++ b/build.gradle @@ -5,8 +5,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.5.1' apply false - id 'com.android.library' version '8.5.1' apply false + id 'com.android.application' version '8.6.0' apply false + id 'com.android.library' version '8.6.0' apply false id 'org.jetbrains.kotlin.android' version '1.9.0' apply false id 'com.google.dagger.hilt.android' version '2.49' apply false id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21' diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..49b55b9 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,28 @@ +# Documentation for permissions +This file explains how to request permissions and how to check if the app has them. + +## Manage all files +This permission allows the application to work with the shared storage (normal user storage) without +general limitations except for sdcard/android/ and storage/android/ and all subdirectories. All other +directories can be accessed using normal file paths using the java.io.file classes and related. + +### Invoking the request +we need: +```kotlin +ACTION_MANAGE_ALL_APP_FILES_ACCESS_PERMISSION // (String) +``` +to invoke the intent. We can invoke it as follows: + +```kotlin +fun requestStoragePermission() { + val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val result = Environment.isExternalStorageManager() + } + + val intent = Intent(EXTERNAL_STORAGE_PERMISSION, Uri.parse("package:$packageName")) + launcher.launch(intent) +} +``` + +The result received from this activity is useless. It's only needed to know when the user has exited +the activity. \ No newline at end of file