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 'kotlinx-serialization'
|
||||||
id "kotlin-kapt"
|
id "kotlin-kapt"
|
||||||
id 'com.google.protobuf' version '0.9.4'
|
id 'com.google.protobuf' version '0.9.4'
|
||||||
|
id "kotlin-parcelize"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
@ -13,7 +14,7 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "be.re.writand"
|
applicationId "be.re.writand"
|
||||||
minSdk 26
|
minSdk 30
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
|
@ -65,9 +66,10 @@ dependencies {
|
||||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||||
implementation "androidx.compose.material3:material3:1.2.1"
|
implementation "androidx.compose.material3:material3:1.2.1"
|
||||||
implementation 'androidx.test:core-ktx:1.6.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-runtime-ktx:2.7.7'
|
||||||
implementation 'androidx.navigation:navigation-compose:2.7.7'
|
implementation 'androidx.navigation:navigation-compose:2.7.7'
|
||||||
|
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||||
|
|
|
@ -2,20 +2,26 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<application
|
||||||
android:name=".WApplication"
|
android:name=".WApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="Writand"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Writand"
|
android:theme="@style/Theme.Writand"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31"
|
||||||
|
android:hardwareAccelerated="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
|
||||||
android:theme="@style/Theme.Writand">
|
android:theme="@style/Theme.Writand">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
|
@ -1,22 +1,109 @@
|
||||||
package be.re.writand
|
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.Bundle
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import be.re.writand.navigation.WNavGraph
|
import be.re.writand.navigation.WNavGraph
|
||||||
|
import be.re.writand.screens.filetree.WFiletree
|
||||||
import be.re.writand.ui.theme.WritandTheme
|
import be.re.writand.ui.theme.WritandTheme
|
||||||
|
import be.re.writand.utils.directorypicker.DirectoryPicker
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
requestPermission()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
WritandTheme {
|
WritandTheme {
|
||||||
// A surface container using the 'background' color from the theme
|
// 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.InvalidPathException
|
||||||
import java.nio.file.NotDirectoryException
|
import java.nio.file.NotDirectoryException
|
||||||
import java.io.IOException
|
import java.nio.file.Path
|
||||||
import java.io.IOError
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An interface to handle the File trees.
|
* An interface to handle file operations.
|
||||||
* File is used to mention both real files and directories at the same time.
|
* "file" is used to mention both real files and directories at the same time.
|
||||||
*/
|
*/
|
||||||
interface IFileManager {
|
interface IFileManager {
|
||||||
/**
|
/**
|
||||||
* Create a new File.
|
* Create a new file and assign an id to it.
|
||||||
* @param[name] the name of the new File.
|
* @param[name] the name of the new file.
|
||||||
* @param[basePath] the path where the new File should be created in.
|
* @param[basePath] the path where the new file should be created in.
|
||||||
* @throws InvalidPathException
|
* @throws InvalidPathException
|
||||||
* @throws FileAlreadyExistsException
|
* @throws FileAlreadyExistsException
|
||||||
* @throws NotDirectoryException
|
* @throws NotDirectoryException
|
||||||
* @throws IOException
|
* @throws IOException
|
||||||
* @throws SecurityException
|
* @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.
|
* 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
|
* @throws NoSuchFileException
|
||||||
*/
|
*/
|
||||||
suspend fun delete(path: Path)
|
suspend fun delete(path: Path)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move a File to a different location.
|
* 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
|
* If the file to be moved is a symbolic link, then the link itself is moved instead of the
|
||||||
* target of the link.
|
* target of the link.
|
||||||
* @param[from] the start location.
|
* @param[from] the start location.
|
||||||
* @param[to] the destination.
|
* @param[to] the destination.
|
||||||
|
@ -45,7 +45,7 @@ interface IFileManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename a file to a new name
|
* 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]
|
* @param[newName] the new name to be given to [path]
|
||||||
* @throws FileAlreadyExistsException
|
* @throws FileAlreadyExistsException
|
||||||
* @throws IOException
|
* @throws IOException
|
||||||
|
@ -54,11 +54,15 @@ interface IFileManager {
|
||||||
suspend fun rename(path: Path, newName: String)
|
suspend fun rename(path: Path, newName: String)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walk the directory and build a File tree.
|
* Walk the directory and build a file tree together with an index to enable fast searching.
|
||||||
* @throws NullPointerException
|
* @throws IOException
|
||||||
* @throws IllegalArgumentException
|
|
||||||
* @throws IOError
|
|
||||||
* @throws SecurityException
|
|
||||||
*/
|
*/
|
||||||
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
|
package be.re.writand.di
|
||||||
|
|
||||||
import android.content.Context
|
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.ProjectsDao
|
||||||
import be.re.writand.data.local.db.ProjectsDatabase
|
import be.re.writand.data.local.db.ProjectsDatabase
|
||||||
import be.re.writand.data.repos.settings.IUserSettingsRepository
|
import be.re.writand.data.repos.settings.IUserSettingsRepository
|
||||||
import be.re.writand.data.repos.settings.UserSettingsRepository
|
import be.re.writand.data.repos.settings.UserSettingsRepository
|
||||||
import be.re.writand.data.repos.tos.ITOSRepository
|
import be.re.writand.data.repos.tos.ITOSRepository
|
||||||
import be.re.writand.data.repos.tos.TOSRepository
|
import be.re.writand.data.repos.tos.TOSRepository
|
||||||
|
import be.re.writand.utils.GenerateId
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
@ -47,4 +50,9 @@ class DatabaseModule {
|
||||||
fun provideTOSRepository(@ApplicationContext context: Context): ITOSRepository {
|
fun provideTOSRepository(@ApplicationContext context: Context): ITOSRepository {
|
||||||
return TOSRepository(context)
|
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
|
package be.re.writand.screens.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
@ -34,3 +36,63 @@ fun WButton(
|
||||||
WText(text = text, color = Color.Black, modifier = Modifier.padding(5.dp))
|
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
|
package be.re.writand.screens.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@ -23,7 +27,7 @@ fun WLoadingIndicator() {
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
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)
|
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
|
package be.re.writand.screens.editor
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
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.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import be.re.writand.screens.editor.bottom.BottomEditorBar
|
import be.re.writand.screens.editor.bottom.BottomEditorBar
|
||||||
import be.re.writand.screens.editor.editorspace.EditorSpace
|
import be.re.writand.screens.editor.editorspace.EditorSpace
|
||||||
import be.re.writand.screens.editor.top.TopEditorBar
|
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
|
import be.re.writand.screens.settings.SettingsPopup
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable presenting the full screen when IDE is open. This holds all the different composables
|
* Composable presenting the full screen when IDE is open. This holds all the different composables
|
||||||
* that come together on the screen.
|
* that come together on the screen.
|
||||||
|
* @param[vm] the viewmodel of this component, used for managing the general state for the entire screen.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun EditorScreen(
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopEditorBar()
|
TopEditorBar(currentFile = openedFile?.name ?: "") {
|
||||||
|
setIsOpened(!isOpened)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
BottomEditorBar()
|
BottomEditorBar()
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Row(modifier = Modifier.padding(it)) {
|
Row(modifier = Modifier.padding(it).padding(10.dp)) {
|
||||||
// TODO: show filetree when open
|
if(isOpened) {
|
||||||
EditorSpace()
|
WFiletree(
|
||||||
SettingsPopup()
|
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
|
package be.re.writand.screens.editor.editorspace
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
@ -12,8 +13,6 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
|
@ -26,26 +25,34 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import be.re.writand.screens.components.WLoadingIndicator
|
import be.re.writand.screens.components.WLoadingIndicator
|
||||||
import be.re.writand.screens.components.WText
|
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.
|
* 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[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
|
@Composable
|
||||||
fun EditorSpace(
|
fun EditorSpace(
|
||||||
|
fileState: Path?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
vM: EditorSpaceViewModel = hiltViewModel()
|
vM: EditorSpaceViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by vM.uiState.collectAsState()
|
val uiState by vM.uiState.collectAsState()
|
||||||
|
var linesText by vM.lineCount
|
||||||
|
|
||||||
|
LaunchedEffect(fileState) {
|
||||||
|
vM.readContents(fileState)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = modifier) {
|
||||||
when (val value = uiState) {
|
when (val value = uiState) {
|
||||||
EditorSpaceUiState.Loading -> {
|
EditorSpaceUiState.Loading -> {
|
||||||
WLoadingIndicator()
|
WLoadingIndicator()
|
||||||
}
|
}
|
||||||
|
|
||||||
is EditorSpaceUiState.Success -> {
|
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
|
// the scrolling state of both text fields
|
||||||
val linesTextScroll = rememberScrollState()
|
val linesTextScroll = rememberScrollState()
|
||||||
val scriptTextScroll = rememberScrollState()
|
val scriptTextScroll = rememberScrollState()
|
||||||
|
@ -93,11 +100,12 @@ fun EditorSpace(
|
||||||
vM.onTextChange(textFieldValue)
|
vM.onTextChange(textFieldValue)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is EditorSpaceUiState.Failed -> {
|
is EditorSpaceUiState.Failed -> {
|
||||||
WText(text = value.message)
|
WText(text = value.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,30 +1,40 @@
|
||||||
package be.re.writand.screens.editor.editorspace
|
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 be.re.writand.screens.WViewModel
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import java.nio.file.Path
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Viewmodel to be used by EditorSpace to handle file changes and keep the ui state in memory.
|
* 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> =
|
private val _uiState: MutableStateFlow<EditorSpaceUiState> =
|
||||||
MutableStateFlow(EditorSpaceUiState.Loading)
|
MutableStateFlow(EditorSpaceUiState.Loading)
|
||||||
val uiState: StateFlow<EditorSpaceUiState> = _uiState
|
val uiState: StateFlow<EditorSpaceUiState> = _uiState
|
||||||
|
|
||||||
init {
|
val lineCount = mutableIntStateOf(1)
|
||||||
|
|
||||||
|
fun readContents(path: Path?) {
|
||||||
|
path?.let {
|
||||||
launchCatching {
|
launchCatching {
|
||||||
// TODO: call use-case that gets the String of the file
|
val contents = readFileContentsUseCase(path)
|
||||||
val file: String = "print('Hello World!')\nprint('second line')\n"
|
lineCount.intValue = contents.count { it == '\n' } + 1
|
||||||
if (file.isNotEmpty()) {
|
_uiState.value = EditorSpaceUiState.Success(contents)
|
||||||
_uiState.value = EditorSpaceUiState.Success(file)
|
|
||||||
} else {
|
|
||||||
_uiState.value = EditorSpaceUiState.Failed("The file could not be loaded.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onTextChange(newValue: String) {
|
fun onTextChange(newValue: String) {
|
||||||
|
|
|
@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import be.re.writand.R
|
import be.re.writand.R
|
||||||
import be.re.writand.screens.components.WText
|
import be.re.writand.screens.components.WText
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable representing the top bar of the editor.
|
* Composable representing the top bar of the editor.
|
||||||
|
@ -31,9 +32,10 @@ import be.re.writand.screens.components.WText
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TopEditorBar(
|
fun TopEditorBar(
|
||||||
vM: TopEditorBarViewModel = hiltViewModel()
|
vM: TopEditorBarViewModel = hiltViewModel(),
|
||||||
|
currentFile: String,
|
||||||
|
onOpenFileTree: () -> Unit
|
||||||
) {
|
) {
|
||||||
val openFile = vM.currentFile.collectAsState()
|
|
||||||
val isSaved = vM.isSaved.collectAsState()
|
val isSaved = vM.isSaved.collectAsState()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
@ -52,7 +54,7 @@ fun TopEditorBar(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { vM.onFileMenuClick() },
|
onClick = onOpenFileTree,
|
||||||
modifier = Modifier.padding(start = 10.dp),
|
modifier = Modifier.padding(start = 10.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
@ -65,7 +67,8 @@ fun TopEditorBar(
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
WText(text = openFile.value)
|
if(currentFile.isNotEmpty()) {
|
||||||
|
WText(text = currentFile)
|
||||||
IconButton(onClick = { vM.onSaveFile() }) {
|
IconButton(onClick = { vM.onSaveFile() }) {
|
||||||
if (isSaved.value) {
|
if (isSaved.value) {
|
||||||
Icon(
|
Icon(
|
||||||
|
@ -82,6 +85,7 @@ fun TopEditorBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { vM.onTerminalClick() },
|
onClick = { vM.onTerminalClick() },
|
||||||
|
|
|
@ -13,9 +13,6 @@ class TopEditorBarViewModel : WViewModel() {
|
||||||
private val _saved = MutableStateFlow(true)
|
private val _saved = MutableStateFlow(true)
|
||||||
val isSaved: StateFlow<Boolean> = _saved.asStateFlow()
|
val isSaved: StateFlow<Boolean> = _saved.asStateFlow()
|
||||||
|
|
||||||
private val _file = MutableStateFlow("hello_world.py")
|
|
||||||
var currentFile: StateFlow<String> = _file.asStateFlow()
|
|
||||||
|
|
||||||
fun onFileMenuClick() {
|
fun onFileMenuClick() {
|
||||||
// TODO: Open or close the filetree
|
// 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
|
package be.re.writand
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
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.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.BeforeAll
|
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.
|
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application' version '8.5.1' apply false
|
id 'com.android.application' version '8.6.0' apply false
|
||||||
id 'com.android.library' version '8.5.1' apply false
|
id 'com.android.library' version '8.6.0' apply false
|
||||||
id 'org.jetbrains.kotlin.android' version '1.9.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 'com.google.dagger.hilt.android' version '2.49' apply false
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21'
|
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