feat: frontend/Filetree

This commit is contained in:
Robin Meersman 2024-09-05 19:18:43 +00:00
parent 7609fd4d9d
commit c381040efc
36 changed files with 1855 additions and 216 deletions

View file

@ -5,6 +5,7 @@ plugins {
id 'kotlinx-serialization'
id "kotlin-kapt"
id 'com.google.protobuf' version '0.9.4'
id "kotlin-parcelize"
}
android {
@ -13,7 +14,7 @@ android {
defaultConfig {
applicationId "be.re.writand"
minSdk 26
minSdk 30
targetSdk 34
versionCode 1
versionName "1.0"
@ -65,9 +66,10 @@ dependencies {
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation "androidx.compose.material3:material3:1.2.1"
implementation 'androidx.test:core-ktx:1.6.1'
implementation 'com.google.ar:core:1.44.0'
implementation 'com.google.ar:core:1.45.0'
implementation 'androidx.navigation:navigation-runtime-ktx:2.7.7'
implementation 'androidx.navigation:navigation-compose:2.7.7'
implementation 'androidx.documentfile:documentfile:1.0.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'

View file

@ -2,20 +2,26 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Manage storage permission: we should fall into the category where this is allowed -->
<!-- https://developer.android.com/training/data-storage/manage-all-files#all-files-access-google-play -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:name=".WApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:label="Writand"
android:supportsRtl="true"
android:theme="@style/Theme.Writand"
tools:targetApi="31">
tools:targetApi="31"
android:hardwareAccelerated="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Writand">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -1,22 +1,109 @@
package be.re.writand
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.navigation.compose.rememberNavController
import be.re.writand.navigation.WNavGraph
import be.re.writand.screens.filetree.WFiletree
import be.re.writand.ui.theme.WritandTheme
import be.re.writand.utils.directorypicker.DirectoryPicker
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity: ComponentActivity() {
class MainActivity : ComponentActivity() {
private val picker = DirectoryPicker(this)
private val EXTERNAL_STORAGE_PERMISSION_CODE = 100
private val EXTERNAL_STORAGE_PERMISSION = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
private var hasPermission = false
private fun requestPermission() {
when {
Environment.isExternalStorageManager() -> {
Toast.makeText(this, "Permission granted", Toast.LENGTH_LONG).show()
hasPermission = true
}
ActivityCompat.shouldShowRequestPermissionRationale(
this,
EXTERNAL_STORAGE_PERMISSION
) -> {
val builder = AlertDialog.Builder(this)
builder.setMessage("This app requires access to files to do its job")
.setTitle("Permission required")
.setCancelable(false)
.setPositiveButton("Ok") { dialog, _ ->
ActivityCompat.requestPermissions(
this,
arrayOf(EXTERNAL_STORAGE_PERMISSION),
EXTERNAL_STORAGE_PERMISSION_CODE
)
dialog.dismiss()
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
}
.show()
}
// permission is not yet granted, ask user for permission
else -> {
val launcher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
Toast.makeText(
this,
"Permission: ${Environment.isExternalStorageManager()}",
Toast.LENGTH_LONG
).show()
hasPermission = Environment.isExternalStorageManager()
}
try {
val intent =
Intent(EXTERNAL_STORAGE_PERMISSION, Uri.parse("package:$packageName"))
val builder = AlertDialog.Builder(this)
builder.setTitle("Permission is required")
.setMessage("Writand needs permission to access the filesystem.")
.setCancelable(false)
.setPositiveButton("Ok") { dialog, _ ->
launcher.launch(intent)
dialog.dismiss()
}
.setNegativeButton("Deny") { dialog, _ ->
dialog.dismiss()
}
.show()
} catch (e: ActivityNotFoundException) {
Log.e("PERMISSION", "${e.message}")
TODO("Permission: support legacy operation when $EXTERNAL_STORAGE_PERMISSION is not supported")
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestPermission()
setContent {
WritandTheme {
// A surface container using the 'background' color from the theme

View file

@ -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()
}
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -1,39 +1,39 @@
package be.re.writand.data.local
package be.re.writand.data.local.filemanager
import java.nio.file.Path
import be.re.writand.utils.WFileTreeAbstraction
import java.io.IOException
import java.nio.file.InvalidPathException
import java.nio.file.NotDirectoryException
import java.io.IOException
import java.io.IOError
import java.nio.file.Path
/**
* An interface to handle the File trees.
* File is used to mention both real files and directories at the same time.
* An interface to handle file operations.
* "file" is used to mention both real files and directories at the same time.
*/
interface IFileManager {
/**
* Create a new File.
* @param[name] the name of the new File.
* @param[basePath] the path where the new File should be created in.
* Create a new file and assign an id to it.
* @param[name] the name of the new file.
* @param[basePath] the path where the new file should be created in.
* @throws InvalidPathException
* @throws FileAlreadyExistsException
* @throws NotDirectoryException
* @throws IOException
* @throws SecurityException
* */
suspend fun create(name: String, basePath: Path)
* */
suspend fun create(name: String, fileType: FileType, basePath: Path): ULong
/**
* Delete a File.
* Delete a file.
* Symbolic links are followed.
* @param[path] the path pointing to the File to be deleted.
* @param[path] the path pointing to the file to be deleted.
* @throws NoSuchFileException
*/
suspend fun delete(path: Path)
/**
* Move a File to a different location.
* If the File to be moved is a symbolic link, then the link itself is moved instead of the
* Move a file to a different location.
* If the file to be moved is a symbolic link, then the link itself is moved instead of the
* target of the link.
* @param[from] the start location.
* @param[to] the destination.
@ -45,7 +45,7 @@ interface IFileManager {
/**
* Rename a file to a new name
* @param[path] the path to the File to rename
* @param[path] the path to the file to rename
* @param[newName] the new name to be given to [path]
* @throws FileAlreadyExistsException
* @throws IOException
@ -54,11 +54,15 @@ interface IFileManager {
suspend fun rename(path: Path, newName: String)
/**
* Walk the directory and build a File tree.
* @throws NullPointerException
* @throws IllegalArgumentException
* @throws IOError
* @throws SecurityException
* Walk the directory and build a file tree together with an index to enable fast searching.
* @throws IOException
*/
suspend fun walk(root: Path): FileTreeWalk
suspend fun initFiletree(root: Path): WFileTreeAbstraction
/**
* Read a file in memory.
* @param[path] the path to the file to be read
* @throws IOException
*/
suspend fun read(path: Path): String
}

View file

@ -1,12 +1,15 @@
package be.re.writand.di
import android.content.Context
import be.re.writand.data.local.filemanager.FileManagerLocal
import be.re.writand.data.local.filemanager.IFileManager
import be.re.writand.data.local.db.ProjectsDao
import be.re.writand.data.local.db.ProjectsDatabase
import be.re.writand.data.repos.settings.IUserSettingsRepository
import be.re.writand.data.repos.settings.UserSettingsRepository
import be.re.writand.data.repos.tos.ITOSRepository
import be.re.writand.data.repos.tos.TOSRepository
import be.re.writand.utils.GenerateId
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -47,4 +50,9 @@ class DatabaseModule {
fun provideTOSRepository(@ApplicationContext context: Context): ITOSRepository {
return TOSRepository(context)
}
@Provides
fun provideFileManager(generateId: GenerateId): IFileManager {
return FileManagerLocal(generateId)
}
}

View 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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -1,9 +1,11 @@
package be.re.writand.screens.components
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -33,4 +35,64 @@ fun WButton(
) {
WText(text = text, color = Color.Black, modifier = Modifier.padding(5.dp))
}
}
/**
* A button to represent an action with consequences.
* This buttons has a red highlight to make sure the user is sure when confirming the action.
* @param[text] the text to show inside the button.
* @param[modifier] the modifier applied to this button.
* @param[onClick] a function to be executed when confirmation is given.
* @param[enabled] controls if this button is enabled or not.
*/
@Composable
fun WDangerButton(
text: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
enabled: Boolean = true
) {
Button(
onClick = onClick,
modifier = modifier,
colors = ButtonDefaults.buttonColors(containerColor = Color.Red),
shape = RoundedCornerShape(10.dp),
enabled = enabled
) {
WText(text = text, color = Color.White, modifier = Modifier.padding(5.dp))
}
}
/**
* A button without a fill color.
* @param[text] the text to show inside the button.
* @param[modifier] the modifier applied to this button.
* @param[onClick] a function to be executed when confirmation is given.
* @param[enabled] controls if this button is enabled or not.
*/
@Composable
fun WBorderButton(
text: String,
modifier: Modifier = Modifier,
onClick: () -> Unit,
enabled: Boolean = true
) {
Button(
onClick = onClick,
modifier = modifier.border(
width = 2.dp,
color = MainGreen,
shape = RoundedCornerShape(10.dp)
),
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
shape = RoundedCornerShape(10.dp),
enabled = enabled
) {
WText(
text = text,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(5.dp)
)
}
}

View file

@ -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()
}
}
}
}

View file

@ -1,13 +1,17 @@
package be.re.writand.screens.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -23,7 +27,7 @@ fun WLoadingIndicator() {
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(modifier = Modifier.padding(vertical = 10.dp))
CircularProgressIndicator(modifier = Modifier.padding(vertical = 10.dp), color = MaterialTheme.colorScheme.tertiary)
WText(text = "while( !( succeed = try() ) );", textAlign = TextAlign.Center)
}

View 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))
)
}
}
}

View file

@ -1,37 +1,60 @@
package be.re.writand.screens.editor
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import be.re.writand.screens.editor.bottom.BottomEditorBar
import be.re.writand.screens.editor.editorspace.EditorSpace
import be.re.writand.screens.editor.top.TopEditorBar
import be.re.writand.screens.filetree.WFiletree
import kotlin.io.path.name
import be.re.writand.screens.settings.SettingsPopup
/**
* Composable presenting the full screen when IDE is open. This holds all the different composables
* that come together on the screen.
* @param[vm] the viewmodel of this component, used for managing the general state for the entire screen.
*/
@Composable
fun EditorScreen(
navHostController: NavHostController
navHostController: NavHostController,
vm: EditorScreenViewModel = hiltViewModel()
) {
// state for filetree view
val (isOpened, setIsOpened) = remember { mutableStateOf(false) }
// state for currently opened filepath
val openedFile by vm.openedFile
Scaffold(
topBar = {
TopEditorBar()
TopEditorBar(currentFile = openedFile?.name ?: "") {
setIsOpened(!isOpened)
}
},
bottomBar = {
BottomEditorBar()
}
) {
Row(modifier = Modifier.padding(it)) {
// TODO: show filetree when open
EditorSpace()
SettingsPopup()
Row(modifier = Modifier.padding(it).padding(10.dp)) {
if(isOpened) {
WFiletree(
root = "/storage/emulated/0/Documents/webserver",
modifier = Modifier.weight(1F),
onSelect = { path ->
vm.setOpenedFile(path)
})
}
EditorSpace(fileState = openedFile, modifier = Modifier.weight(2F).fillMaxSize())
}
}

View file

@ -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
}
}

View file

@ -1,5 +1,6 @@
package be.re.writand.screens.editor.editorspace
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@ -12,8 +13,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
@ -26,78 +25,87 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import be.re.writand.screens.components.WLoadingIndicator
import be.re.writand.screens.components.WText
import java.nio.file.Path
/**
* The editor space itself where the user can edit the currently open file.
* @param[vM] the viewmodel used to handle input changes to the open file.
* @param[fileState] the currently opened file in de editor, comes from EditorScreen.
* @param[modifier] the modifier for the outer row of this component.
*/
@Composable
fun EditorSpace(
fileState: Path?,
modifier: Modifier = Modifier,
vM: EditorSpaceViewModel = hiltViewModel()
) {
val uiState by vM.uiState.collectAsState()
var linesText by vM.lineCount
when (val value = uiState) {
EditorSpaceUiState.Loading -> {
WLoadingIndicator()
}
is EditorSpaceUiState.Success -> {
// using a mutable state to store the number of lines
var linesText by remember { mutableIntStateOf(value.fileContent.count { it == '\n' } + 1) }
// the scrolling state of both text fields
val linesTextScroll = rememberScrollState()
val scriptTextScroll = rememberScrollState()
// synchronize scrolling
LaunchedEffect(linesTextScroll.value) {
scriptTextScroll.scrollTo(linesTextScroll.value)
}
LaunchedEffect(scriptTextScroll.value) {
linesTextScroll.scrollTo(scriptTextScroll.value)
}
BasicTextField(
modifier = Modifier
.fillMaxHeight()
.padding(start = 25.dp)
.width(12.dp * linesText.toString().length)
.verticalScroll(linesTextScroll),
value = IntRange(1, linesText).joinToString(separator = "\n"),
readOnly = true,
textStyle = TextStyle(color = MaterialTheme.colorScheme.secondary),
onValueChange = {}
)
VerticalDivider(
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.secondary
)
// actual textField
BasicTextField(
modifier = Modifier
.fillMaxHeight()
// this is a hack to prevent this https://stackoverflow.com/questions/76287857/when-parent-of-textfield-is-clickable-hardware-enter-return-button-triggers-its
.onKeyEvent { it.type == KeyEventType.KeyUp && it.key == Key.Enter }
.verticalScroll(scriptTextScroll),
value = value.fileContent,
readOnly = false,
textStyle = TextStyle(color = MaterialTheme.colorScheme.onPrimary),
onValueChange = { textFieldValue ->
val nbLines = textFieldValue.count { it == '\n' } + 1
if (nbLines != linesText) linesText = nbLines
vM.onTextChange(textFieldValue)
},
)
}
is EditorSpaceUiState.Failed -> {
WText(text = value.message)
}
LaunchedEffect(fileState) {
vM.readContents(fileState)
}
Row(modifier = modifier) {
when (val value = uiState) {
EditorSpaceUiState.Loading -> {
WLoadingIndicator()
}
is EditorSpaceUiState.Success -> {
// the scrolling state of both text fields
val linesTextScroll = rememberScrollState()
val scriptTextScroll = rememberScrollState()
// synchronize scrolling
LaunchedEffect(linesTextScroll.value) {
scriptTextScroll.scrollTo(linesTextScroll.value)
}
LaunchedEffect(scriptTextScroll.value) {
linesTextScroll.scrollTo(scriptTextScroll.value)
}
BasicTextField(
modifier = Modifier
.fillMaxHeight()
.padding(start = 25.dp)
.width(12.dp * linesText.toString().length)
.verticalScroll(linesTextScroll),
value = IntRange(1, linesText).joinToString(separator = "\n"),
readOnly = true,
textStyle = TextStyle(color = MaterialTheme.colorScheme.secondary),
onValueChange = {}
)
VerticalDivider(
modifier = Modifier
.fillMaxHeight()
.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.secondary
)
// actual textField
BasicTextField(
modifier = Modifier
.fillMaxHeight()
// this is a hack to prevent this https://stackoverflow.com/questions/76287857/when-parent-of-textfield-is-clickable-hardware-enter-return-button-triggers-its
.onKeyEvent { it.type == KeyEventType.KeyUp && it.key == Key.Enter }
.verticalScroll(scriptTextScroll),
value = value.fileContent,
readOnly = false,
textStyle = TextStyle(color = MaterialTheme.colorScheme.onPrimary),
onValueChange = { textFieldValue ->
val nbLines = textFieldValue.count { it == '\n' } + 1
if (nbLines != linesText) linesText = nbLines
vM.onTextChange(textFieldValue)
},
)
}
is EditorSpaceUiState.Failed -> {
WText(text = value.message)
}
}
}
}

View file

@ -1,30 +1,40 @@
package be.re.writand.screens.editor.editorspace
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableIntStateOf
import be.re.writand.domain.files.ReadFileContentsUseCase
import be.re.writand.screens.WViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.nio.file.Path
import javax.inject.Inject
/**
* Viewmodel to be used by EditorSpace to handle file changes and keep the ui state in memory.
* File changes include changing to a new file and reading its contents, and editing this file.
* @param[readFileContentsUseCase] the use case used to read in the newly selected file.
*/
class EditorSpaceViewModel : WViewModel() {
@HiltViewModel
class EditorSpaceViewModel @Inject constructor(
private val readFileContentsUseCase: ReadFileContentsUseCase
) : WViewModel() {
private val _uiState: MutableStateFlow<EditorSpaceUiState> =
MutableStateFlow(EditorSpaceUiState.Loading)
val uiState: StateFlow<EditorSpaceUiState> = _uiState
init {
launchCatching {
// TODO: call use-case that gets the String of the file
val file: String = "print('Hello World!')\nprint('second line')\n"
if (file.isNotEmpty()) {
_uiState.value = EditorSpaceUiState.Success(file)
} else {
_uiState.value = EditorSpaceUiState.Failed("The file could not be loaded.")
val lineCount = mutableIntStateOf(1)
fun readContents(path: Path?) {
path?.let {
launchCatching {
val contents = readFileContentsUseCase(path)
lineCount.intValue = contents.count { it == '\n' } + 1
_uiState.value = EditorSpaceUiState.Success(contents)
}
}
}
fun onTextChange(newValue: String) {

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import be.re.writand.R
import be.re.writand.screens.components.WText
import java.nio.file.Path
/**
* Composable representing the top bar of the editor.
@ -31,9 +32,10 @@ import be.re.writand.screens.components.WText
*/
@Composable
fun TopEditorBar(
vM: TopEditorBarViewModel = hiltViewModel()
vM: TopEditorBarViewModel = hiltViewModel(),
currentFile: String,
onOpenFileTree: () -> Unit
) {
val openFile = vM.currentFile.collectAsState()
val isSaved = vM.isSaved.collectAsState()
Row(
@ -52,7 +54,7 @@ fun TopEditorBar(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { vM.onFileMenuClick() },
onClick = onOpenFileTree,
modifier = Modifier.padding(start = 10.dp),
) {
Icon(
@ -65,20 +67,22 @@ fun TopEditorBar(
Row(
verticalAlignment = Alignment.CenterVertically
) {
WText(text = openFile.value)
IconButton(onClick = { vM.onSaveFile() }) {
if (isSaved.value) {
Icon(
modifier = Modifier.size(25.dp),
painter = painterResource(id = R.drawable.saved),
contentDescription = "Save"
)
} else {
Icon(
modifier = Modifier.size(25.dp),
painter = painterResource(id = R.drawable.not_saved),
contentDescription = "Save"
)
if(currentFile.isNotEmpty()) {
WText(text = currentFile)
IconButton(onClick = { vM.onSaveFile() }) {
if (isSaved.value) {
Icon(
modifier = Modifier.size(25.dp),
painter = painterResource(id = R.drawable.saved),
contentDescription = "Save"
)
} else {
Icon(
modifier = Modifier.size(25.dp),
painter = painterResource(id = R.drawable.not_saved),
contentDescription = "Save"
)
}
}
}
}

View file

@ -13,9 +13,6 @@ class TopEditorBarViewModel : WViewModel() {
private val _saved = MutableStateFlow(true)
val isSaved: StateFlow<Boolean> = _saved.asStateFlow()
private val _file = MutableStateFlow("hello_world.py")
var currentFile: StateFlow<String> = _file.asStateFlow()
fun onFileMenuClick() {
// TODO: Open or close the filetree
}

View 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")
}
}
}

View file

@ -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
}

View file

@ -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)
}
}
}
}

View file

@ -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))
}
}
}

View 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
}
}

View 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)"
}
}

View 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 }
}
}

View file

@ -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)
}
}
}

View file

@ -0,0 +1,7 @@
package be.re.writand.utils.directorypicker
import android.net.Uri
interface IDirectoryPickerListener {
fun notify(uri: Uri)
}

View file

@ -1,7 +1,7 @@
package be.re.writand
import android.annotation.SuppressLint
import be.re.writand.data.local.FileManagerLocal
import be.re.writand.data.local.filemanager.FileManagerLocal
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeAll

View file

@ -5,8 +5,8 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.5.1' apply false
id 'com.android.library' version '8.5.1' apply false
id 'com.android.application' version '8.6.0' apply false
id 'com.android.library' version '8.6.0' apply false
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
id 'com.google.dagger.hilt.android' version '2.49' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.21'

28
docs/permissions.md Normal file
View 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.