forked from Writand/writand
Merge branch 'frontend/filetree' into 'main'
feat: frontend/Filetree Closes #2 See merge request EmmaVandewalle/writand!37
This commit is contained in:
commit
aab1891835
36 changed files with 1855 additions and 216 deletions
|
@ -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'
|
||||
|
|
|
@ -2,20 +2,26 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Manage storage permission: we should fall into the category where this is allowed -->
|
||||
<!-- https://developer.android.com/training/data-storage/manage-all-files#all-files-access-google-play -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:name=".WApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:label="Writand"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Writand"
|
||||
tools:targetApi="31">
|
||||
tools:targetApi="31"
|
||||
android:hardwareAccelerated="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Writand">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
|
|
@ -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() {
|
||||
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
|
||||
|
|
|
@ -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<Path> = 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()
|
||||
}
|
||||
}
|
|
@ -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<Path> = 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<ULong, Path> = HashMap()
|
||||
if (!root.isDirectory()) throw IllegalArgumentException("Provided argument is not a directory.")
|
||||
val stack = Stack<Pair<Path, FileNode>>()
|
||||
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<FileNode> { 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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
20
app/src/main/java/be/re/writand/di/GeneratorModule.kt
Normal file
20
app/src/main/java/be/re/writand/di/GeneratorModule.kt
Normal file
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
@ -34,3 +36,63 @@ 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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<Any?>(null)
|
||||
}
|
||||
|
||||
internal val LocalDragTargetInfo = compositionLocalOf { DragTargetInfo() }
|
||||
|
||||
@Composable
|
||||
fun <T> 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 <T> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
70
app/src/main/java/be/re/writand/screens/components/WPopup.kt
Normal file
70
app/src/main/java/be/re/writand/screens/components/WPopup.kt
Normal file
|
@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Path?>(null)
|
||||
val openedFile: State<Path?> = _openedFile
|
||||
|
||||
fun setOpenedFile(path: Path?) {
|
||||
_openedFile.value = path
|
||||
}
|
||||
}
|
|
@ -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,26 +25,34 @@ 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
|
||||
|
||||
LaunchedEffect(fileState) {
|
||||
vM.readContents(fileState)
|
||||
}
|
||||
|
||||
Row(modifier = modifier) {
|
||||
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()
|
||||
|
@ -93,11 +100,12 @@ fun EditorSpace(
|
|||
vM.onTextChange(textFieldValue)
|
||||
},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
is EditorSpaceUiState.Failed -> {
|
||||
WText(text = value.message)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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<EditorSpaceUiState> =
|
||||
MutableStateFlow(EditorSpaceUiState.Loading)
|
||||
val uiState: StateFlow<EditorSpaceUiState> = _uiState
|
||||
|
||||
init {
|
||||
val lineCount = mutableIntStateOf(1)
|
||||
|
||||
fun readContents(path: Path?) {
|
||||
path?.let {
|
||||
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 contents = readFileContentsUseCase(path)
|
||||
lineCount.intValue = contents.count { it == '\n' } + 1
|
||||
_uiState.value = EditorSpaceUiState.Success(contents)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun onTextChange(newValue: String) {
|
||||
|
|
|
@ -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,7 +67,8 @@ fun TopEditorBar(
|
|||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
WText(text = openFile.value)
|
||||
if(currentFile.isNotEmpty()) {
|
||||
WText(text = currentFile)
|
||||
IconButton(onClick = { vM.onSaveFile() }) {
|
||||
if (isSaved.value) {
|
||||
Icon(
|
||||
|
@ -82,6 +85,7 @@ fun TopEditorBar(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { vM.onTerminalClick() },
|
||||
|
|
|
@ -13,9 +13,6 @@ class TopEditorBarViewModel : WViewModel() {
|
|||
private val _saved = MutableStateFlow(true)
|
||||
val isSaved: StateFlow<Boolean> = _saved.asStateFlow()
|
||||
|
||||
private val _file = MutableStateFlow("hello_world.py")
|
||||
var currentFile: StateFlow<String> = _file.asStateFlow()
|
||||
|
||||
fun onFileMenuClick() {
|
||||
// TODO: Open or close the filetree
|
||||
}
|
||||
|
|
163
app/src/main/java/be/re/writand/screens/filetree/Filetree.kt
Normal file
163
app/src/main/java/be/re/writand/screens/filetree/Filetree.kt
Normal file
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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<WFiletreeUiState> =
|
||||
MutableStateFlow(WFiletreeUiState.Loading)
|
||||
|
||||
val uiState: StateFlow<WFiletreeUiState> = _uiState
|
||||
|
||||
private val isOpenedState = mutableStateMapOf<ULong, Boolean>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<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
|
||||
) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
49
app/src/main/java/be/re/writand/utils/GenerateId.kt
Normal file
49
app/src/main/java/be/re/writand/utils/GenerateId.kt
Normal file
|
@ -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<Char> = initChars()
|
||||
private val characterMap = HashMap<Char, UByte>(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<Char> {
|
||||
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
|
||||
}
|
||||
}
|
16
app/src/main/java/be/re/writand/utils/Node.kt
Normal file
16
app/src/main/java/be/re/writand/utils/Node.kt
Normal file
|
@ -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<FileNode> = mutableListOf()
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return "FileNode(item = $item, parent = ${parent?.item}, children = $children)"
|
||||
}
|
||||
}
|
109
app/src/main/java/be/re/writand/utils/WFileTreeAbstraction.kt
Normal file
109
app/src/main/java/be/re/writand/utils/WFileTreeAbstraction.kt
Normal file
|
@ -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<ULong, Path>,
|
||||
root: FileNode,
|
||||
) : MutableState<FileNode> {
|
||||
private val _root = mutableStateOf(root, neverEqualPolicy())
|
||||
|
||||
private fun triggerRerender() {
|
||||
_root.value = _root.value
|
||||
}
|
||||
|
||||
private val comparator = compareBy<FileNode> { 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<FileNode>()
|
||||
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 }
|
||||
}
|
||||
}
|
|
@ -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<Uri?> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package be.re.writand.utils.directorypicker
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
interface IDirectoryPickerListener {
|
||||
fun notify(uri: Uri)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
28
docs/permissions.md
Normal file
28
docs/permissions.md
Normal file
|
@ -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.
|
Loading…
Reference in a new issue