Frontend - directory provider

This commit is contained in:
Robin Meersman 2024-11-11 18:10:30 +00:00
parent 9f6d8570e6
commit cee3d00bb1
20 changed files with 743 additions and 203 deletions

View file

@ -13,7 +13,6 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts 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
@ -21,14 +20,11 @@ import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat 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_CODE = 100
private val EXTERNAL_STORAGE_PERMISSION = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION private val EXTERNAL_STORAGE_PERMISSION = Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
private var hasPermission = false private var hasPermission = false

View file

@ -5,6 +5,9 @@ import be.re.writand.utils.GenerateId
import be.re.writand.utils.WFileTreeAbstraction import be.re.writand.utils.WFileTreeAbstraction
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.Path.Companion.toPath
import java.io.File
import java.io.FileFilter
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.NotDirectoryException import java.nio.file.NotDirectoryException
import java.nio.file.Path import java.nio.file.Path
@ -138,4 +141,11 @@ class FileManagerLocal @Inject constructor(
val file = path.toFile() val file = path.toFile()
return file.readLines().joinToString(separator = "\n") return file.readLines().joinToString(separator = "\n")
} }
override suspend fun listFiles(path: Path, filter: FileFilter?): Array<File> {
val file = path.toFile()
val dirs = file.listFiles(filter)
return dirs ?: arrayOf()
}
} }

View file

@ -1,6 +1,8 @@
package be.re.writand.data.local.filemanager package be.re.writand.data.local.filemanager
import be.re.writand.utils.WFileTreeAbstraction import be.re.writand.utils.WFileTreeAbstraction
import java.io.File
import java.io.FileFilter
import java.io.IOException import java.io.IOException
import java.nio.file.InvalidPathException import java.nio.file.InvalidPathException
import java.nio.file.NotDirectoryException import java.nio.file.NotDirectoryException
@ -65,4 +67,11 @@ interface IFileManager {
* @throws IOException * @throws IOException
*/ */
suspend fun read(path: Path): String suspend fun read(path: Path): String
/**
* List all the files in a given directory.
* @param[path] the path to list the files in.
* @param[filter] an optional filter used to filter the items.
*/
suspend fun listFiles(path: Path, filter: FileFilter?): Array<File>
} }

View file

@ -0,0 +1,23 @@
package be.re.writand.domain.files
import be.re.writand.data.local.filemanager.IFileManager
import java.io.File
import java.io.FileFilter
import java.nio.file.Paths
import javax.inject.Inject
/**
* Use case to list all the directories for a given path.
* @param[manager] an object implementing the [IFileManager] interface.
*/
class ListDirectoriesUseCase @Inject constructor(
private val manager: IFileManager
) {
private val filter = FileFilter {
it.isDirectory
}
suspend operator fun invoke(path: String): Array<File> {
return manager.listFiles(Paths.get(path), filter)
}
}

View file

@ -0,0 +1,205 @@
package be.re.writand.screens.components
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.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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
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.unit.dp
import be.re.writand.data.local.filemanager.FileType
import be.re.writand.screens.WUIGlobals
import be.re.writand.ui.theme.MainGreen
/**
* Enum for the create file pop up to switch between different options.
* Controls what the create file pop up stands for: creating files, directories or both.
*/
enum class CreationType {
FILE,
DIRECTORY,
ALL
}
/**
* The top bar used in the pop ups.
* TODO: change with component after merging with main.
* @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 = WUIGlobals.cornerRadius, topEnd = WUIGlobals.cornerRadius,
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))
}
}
}
/**
* Pop up screen to create a file / directory.
* @param[type] a value telling the popup what should be created. Values can be FILE, DIRECTORY or
* ALL to allow both files and directories (user can switch by clicking a checkbox.
* @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 CreateFilePopUp(
type: CreationType,
hidePopUp: () -> Unit,
onConfirm: (String, FileType) -> Unit
) {
val (fileName, setFileName) = remember { mutableStateOf("") }
val title = when (type) {
CreationType.FILE -> "Create a new file"
CreationType.DIRECTORY -> "Create a new directory"
CreationType.ALL -> "Create a new file / directory"
}
WPopup(
titleBar = { TopPopUpBar(title = title, onDismiss = hidePopUp) },
bottomBar = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
val (isDirectory, setIsDirectory) = remember { mutableStateOf(false) }
if (type == CreationType.ALL) {
WCheckbox(text = "make directory", checked = isDirectory) {
setIsDirectory(!isDirectory)
}
} else {
Spacer(modifier = Modifier.width(0.dp)) // just to split this row into 2
}
WButton(text = "Create", onClick = {
val fileType = if (isDirectory) FileType.DIRECTORY else FileType.FILE
hidePopUp() // TODO: show error if field is empty
onConfirm(fileName, fileType)
})
}
},
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?")
}
}
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.AbsoluteAlignment
@ -31,6 +32,8 @@ import be.re.writand.ui.theme.MainGreen
* @param[onDismiss] a function to be executed when the pop up must hide. * @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[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. * @param[children] a composable with the content inside the pop up.
* @param[floatingButton] a composable to represent the floating action button for the Scaffold.
* @param[floatingButtonPosition] the position for the floating button composable.
*/ */
@Composable @Composable
fun WPopup( fun WPopup(
@ -40,6 +43,8 @@ fun WPopup(
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
bottomBar: (@Composable () -> Unit) = {}, bottomBar: (@Composable () -> Unit) = {},
floatingButton: @Composable () -> Unit = {},
floatingButtonPosition: FabPosition = FabPosition.End,
children: @Composable (PaddingValues) -> Unit = {} children: @Composable (PaddingValues) -> Unit = {}
) { ) {
Dialog( Dialog(
@ -56,6 +61,8 @@ fun WPopup(
topBar = titleBar, topBar = titleBar,
bottomBar = bottomBar, bottomBar = bottomBar,
content = children, content = children,
floatingActionButton = floatingButton,
floatingActionButtonPosition = floatingButtonPosition,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.border( .border(

View file

@ -0,0 +1,323 @@
package be.re.writand.screens.directoryprovider
import android.os.Environment
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import be.re.writand.R
import be.re.writand.screens.WUIGlobals
import be.re.writand.screens.components.CreateFilePopUp
import be.re.writand.screens.components.CreationType
import be.re.writand.screens.components.WButton
import be.re.writand.screens.components.WLoadingIndicator
import be.re.writand.screens.components.WPopup
import be.re.writand.screens.components.WText
import be.re.writand.ui.theme.MainGreen
@Composable
private fun TopBar(onDismiss: () -> Unit, onBack: () -> Unit) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(MainGreen)
.border(
width = 1.dp,
color = MainGreen,
shape = RoundedCornerShape(
topStart = WUIGlobals.cornerRadius, topEnd = WUIGlobals.cornerRadius,
bottomStart = 0.dp, bottomEnd = 0.dp
)
)
.padding(10.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go up a directory",
modifier = Modifier
.size(WUIGlobals.iconSize)
.clickable(onClick = onBack)
)
WText(text = "Choose a directory")
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close this pop up",
modifier = Modifier
.size(WUIGlobals.iconSize)
.clickable(onClick = onDismiss)
)
}
}
@Composable
private fun DirectoryIcon(
name: String,
isSelected: Boolean,
onSelect: () -> Unit,
onDoubleClick: () -> Unit
) {
var modifier = Modifier.size(width = 80.dp, height = 130.dp)
if (isSelected) {
modifier = modifier
.clip(RoundedCornerShape(WUIGlobals.cornerRadius))
.background(Color.White)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = { onSelect() },
onDoubleTap = { onDoubleClick() }
)
}
) {
Icon(
painter = painterResource(R.drawable.round_folder_16),
contentDescription = "Folder icon",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.size(60.dp)
)
WText(text = name)
}
}
@Composable
private fun FloatingButton(onClick: () -> Unit) {
FloatingActionButton(
onClick = onClick,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.onSecondary,
modifier = Modifier
.size(60.dp)
.offset(x = 0.dp, y = (-70).dp)
) {
Icon(
painter = painterResource(R.drawable.baseline_create_new_folder_16),
contentDescription = "Create a new folder",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(40.dp)
)
}
}
@Composable
private fun ShortcutColumnItem(name: String, icon: Int, onClick: () -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(space = 10.dp, alignment = Alignment.Start),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(10.dp)
.clickable(onClick = onClick)
) {
Icon(
painter = painterResource(icon),
contentDescription = "Shortcut icon",
modifier = Modifier.size(WUIGlobals.iconSize),
tint = MaterialTheme.colorScheme.tertiary
)
WText(text = name, fontWeight = FontWeight.SemiBold)
}
}
@Composable
fun DirectoryProvider(
vm: DirectoryProviderViewModel = hiltViewModel(),
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
// The shortcuts used in the sidebar, each element is a pair that links the name to its path
val shortcuts = mapOf(
Pair("Documents", Environment.DIRECTORY_DOCUMENTS),
Pair("Downloads", Environment.DIRECTORY_DOWNLOADS),
Pair("Music", Environment.DIRECTORY_MUSIC),
Pair("Pictures", Environment.DIRECTORY_PICTURES),
Pair("Videos", Environment.DIRECTORY_MOVIES)
)
val icons = listOf(
R.drawable.baseline_insert_drive_file_16,
R.drawable.baseline_download_16,
R.drawable.baseline_library_music_16,
R.drawable.baseline_image_16,
R.drawable.baseline_video_file_16
)
val currentPath = vm.currentPath.joinToString(vm.separator)
val uiState by vm.uiState.collectAsState()
val selectedItem by vm.selectedItem
val (showCreateDirectory, setShowCreateDirectory) = remember { mutableStateOf(false) }
WPopup(
width = 1000.dp,
height = 800.dp,
onDismiss = onDismiss,
titleBar = { TopBar(onDismiss = onDismiss, onBack = { vm.moveUp() }) },
floatingButton = {
FloatingButton(onClick = {
setShowCreateDirectory(true)
})
},
bottomBar = {
HorizontalDivider(color = MainGreen)
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
val path = currentPath.ifEmpty {
"<No path selected yet>"
}
WText("Current path: $path", maxLines = 1, minLines = 1)
}
}
) { padding ->
// Pop ups
if (showCreateDirectory) CreateFilePopUp(
type = CreationType.DIRECTORY,
hidePopUp = { setShowCreateDirectory(false) },
onConfirm = { name, _ -> vm.createNewDirectory(name) }
)
// Main UI element
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Row {
// Sidebar
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth(fraction = 1f / 5)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary)
.padding(10.dp)
) {
ShortcutColumnItem(name = "Home", icon = R.drawable.baseline_home_16) {
vm.resetPath()
}
shortcuts.entries.zip(icons).forEach { (entry, icon) ->
ShortcutColumnItem(name = entry.key, icon = icon) {
vm.setShortcut(entry.value)
}
}
}
VerticalDivider(color = MainGreen)
// Directory contents
when (val s = uiState) {
DirectoryProviderUiState.Empty -> {
WText("No path is opened yet...")
}
DirectoryProviderUiState.Loading -> {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
WLoadingIndicator()
}
}
is DirectoryProviderUiState.Failed -> {
WText(s.message)
}
is DirectoryProviderUiState.Success -> {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.secondary)) {
LazyVerticalGrid(
columns = GridCells.Fixed(5),
contentPadding = PaddingValues(10.dp),
horizontalArrangement = Arrangement.spacedBy(
10.dp,
Alignment.Start
),
verticalArrangement = Arrangement.spacedBy(
10.dp,
Alignment.Top
),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.90f)
.background(MaterialTheme.colorScheme.secondary)
) {
items(s.files, key = { file -> file.name }) { f ->
DirectoryIcon(
f.name,
isSelected = f.name == selectedItem,
onSelect = { vm.replaceLast(f.name) },
onDoubleClick = { vm.enterDirectory(f.name) }
)
}
}
// Row for confirmation button
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(10.dp)
.background(MaterialTheme.colorScheme.secondary)
) {
WButton(text = "Select", onClick = { onConfirm(currentPath) })
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,27 @@
package be.re.writand.screens.directoryprovider
import java.io.File
sealed interface DirectoryProviderUiState {
data object Empty : DirectoryProviderUiState
data object Loading : DirectoryProviderUiState
data class Success(val files: Array<File>) : DirectoryProviderUiState {
// Required to have custom implementations for these because of the Array parameter
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Success
return files.contentEquals(other.files)
}
override fun hashCode(): Int {
return files.contentHashCode()
}
}
data class Failed(val message: String) : DirectoryProviderUiState
}

View file

@ -0,0 +1,94 @@
package be.re.writand.screens.directoryprovider
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import be.re.writand.data.local.filemanager.FileType
import be.re.writand.domain.files.CreateFileUseCase
import be.re.writand.domain.files.ListDirectoriesUseCase
import be.re.writand.screens.WViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
import java.nio.file.Paths
import javax.inject.Inject
/**
* Viewmodel for the directory provider pop up.
* Holds the currently selected path in a state, and gives this back as a result.
* @param[listDirectoriesUseCase] use case to list only the directories in a given path.
* @param[createFileUseCase] use case to make a new file (in this case: directory).
*/
@HiltViewModel
class DirectoryProviderViewModel @Inject constructor(
private val listDirectoriesUseCase: ListDirectoriesUseCase,
private val createFileUseCase: CreateFileUseCase
) : WViewModel() {
private val root = "/storage/emulated/0"
val separator: String = File.separator
private val _uiState =
MutableStateFlow<DirectoryProviderUiState>(DirectoryProviderUiState.Empty)
val uiState: StateFlow<DirectoryProviderUiState> = _uiState
val currentPath = mutableStateListOf(root)
val selectedItem = mutableStateOf<String?>(null)
init {
listCurrentDirectory()
}
fun createNewDirectory(name: String) {
launchCatching {
val path = Paths.get(currentPath.joinToString(separator))
createFileUseCase(name, FileType.DIRECTORY, path)
}
listCurrentDirectory()
}
private fun listCurrentDirectory() {
launchCatching {
val files = listDirectoriesUseCase(currentPath.joinToString(separator))
_uiState.value = DirectoryProviderUiState.Success(files)
}
}
fun replaceLast(name: String) {
if (currentPath.size > 1 && selectedItem.value != null) {
currentPath[currentPath.lastIndex] = name
} else {
currentPath.add(name)
}
selectedItem.value = name
}
fun enterDirectory(name: String) {
if (selectedItem.value == null) {
currentPath.add(name)
} else if (currentPath.last() != name) {
replaceLast(name)
}
listCurrentDirectory()
}
fun moveUp() {
if (currentPath.size > 1) {
currentPath.removeLast()
selectedItem.value = null
listCurrentDirectory()
}
}
fun setShortcut(shortcut: String) {
currentPath.removeRange(1, currentPath.size)
currentPath.add(shortcut)
selectedItem.value = null
listCurrentDirectory()
}
fun resetPath() {
currentPath.removeRange(1, currentPath.size)
selectedItem.value = null
listCurrentDirectory()
}
}

View file

@ -35,6 +35,9 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import be.re.writand.data.local.filemanager.FileType import be.re.writand.data.local.filemanager.FileType
import be.re.writand.screens.WUIGlobals import be.re.writand.screens.WUIGlobals
import be.re.writand.screens.components.CreateFilePopUp
import be.re.writand.screens.components.CreationType
import be.re.writand.screens.components.RemoveFilePopUp
import be.re.writand.screens.components.WBorderButton import be.re.writand.screens.components.WBorderButton
import be.re.writand.screens.components.WButton import be.re.writand.screens.components.WButton
import be.re.writand.screens.components.WCheckbox import be.re.writand.screens.components.WCheckbox
@ -188,7 +191,8 @@ fun LazyListScope.WTreeItem(
) )
} }
if (showAddPopUp) CreateFile( if (showAddPopUp) CreateFilePopUp(
type = CreationType.ALL,
hidePopUp = { setShowAddPopUp(false) }, hidePopUp = { setShowAddPopUp(false) },
onConfirm = { name, type -> onCreate(root, name, type) } onConfirm = { name, type -> onCreate(root, name, type) }
) )
@ -263,150 +267,3 @@ fun Filename(
} }
} }
} }
/**
* 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 = WUIGlobals.cornerRadius, topEnd = WUIGlobals.cornerRadius,
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

@ -1,44 +0,0 @@
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

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

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="@android:color/white" android:pathData="M20,6h-8l-2,-2L4,4c-1.11,0 -1.99,0.89 -1.99,2L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2zM19,14h-3v3h-2v-3h-3v-2h3L14,9h2v3h3v2z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="@android:color/white" android:pathData="M5,20h14v-2H5V20zM19,9h-4V3H9v6H5l7,7L19,9z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="@android:color/white" android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="@android:color/white" android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="16dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="@android:color/white" android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="@android:color/white" android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM18,7h-3v5.5c0,1.38 -1.12,2.5 -2.5,2.5S10,13.88 10,12.5s1.12,-2.5 2.5,-2.5c0.57,0 1.08,0.19 1.5,0.51L14,5h4v2zM4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="@android:color/white" android:pathData="M14,2H6.01c-1.1,0 -2,0.89 -2,2L4,20c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V8L14,2zM13,9V3.5L18.5,9H13zM14,14l2,-1.06v4.12L14,16v1c0,0.55 -0.45,1 -1,1H9c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1h4c0.55,0 1,0.45 1,1V14z"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="16dp" android:tint="#FFFFFF" android:viewportHeight="24" android:viewportWidth="24" android:width="16dp">
<path android:fillColor="@android:color/white" android:pathData="M10.59,4.59C10.21,4.21 9.7,4 9.17,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-1.41,-1.41z"/>
</vector>