@ -17,6 +17,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.ExitToApp
@ -26,8 +28,10 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialog
@ -61,7 +65,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.core.content.FileProvider
@ -86,6 +93,11 @@ fun FilesScreen(
var mkdirOpen by remember { mutableStateOf ( false ) }
var mkdirOpen by remember { mutableStateOf ( false ) }
var renameTarget by remember { mutableStateOf < FileObject ? > ( null ) }
var renameTarget by remember { mutableStateOf < FileObject ? > ( null ) }
var deleteTarget by remember { mutableStateOf < FileObject ? > ( null ) }
var deleteTarget by remember { mutableStateOf < FileObject ? > ( null ) }
var searchOpen by remember { mutableStateOf ( false ) }
var searchText by remember { mutableStateOf ( " " ) }
val searchFocus = remember { FocusRequester ( ) }
val inSearch = state . searchQuery != null
val uploadLauncher = rememberLauncherForActivityResult (
val uploadLauncher = rememberLauncherForActivityResult (
ActivityResultContracts . OpenMultipleDocuments ( )
ActivityResultContracts . OpenMultipleDocuments ( )
@ -109,18 +121,48 @@ fun FilesScreen(
LaunchedEffect ( listState ) {
LaunchedEffect ( listState ) {
snapshotFlow { listState . layoutInfo . visibleItemsInfo . lastOrNull ( ) ?. index }
snapshotFlow { listState . layoutInfo . visibleItemsInfo . lastOrNull ( ) ?. index }
. collect { last ->
. collect { last ->
if ( last != null && last >= state . files . size - 5 ) viewModel . loadMore ( )
if ( last == null ) return @collect
if ( inSearch ) {
if ( last >= state . searchResults . size - 5 ) viewModel . searchMore ( )
} else if ( last >= state . files . size - 5 ) {
viewModel . loadMore ( )
}
}
}
}
LaunchedEffect ( searchOpen ) {
if ( searchOpen ) searchFocus . requestFocus ( )
}
fun closeSearch ( ) {
viewModel . clearSearch ( )
searchOpen = false
searchText = " "
}
}
BackHandler ( enabled = ! CrUri . isRoot ( state . currentUri ) ) {
BackHandler ( enabled = inSearch || ! CrUri . isRoot ( state . currentUri ) ) {
viewModel . navigateUp ( )
if ( inSearch ) closeSearch ( ) else viewModel . navigateUp ( )
}
}
Scaffold (
Scaffold (
topBar = {
topBar = {
TopAppBar (
TopAppBar (
title = {
title = {
if ( inSearch || searchOpen ) {
OutlinedTextField (
value = searchText ,
onValueChange = { searchText = it } ,
modifier = Modifier
. fillMaxWidth ( )
. focusRequester ( searchFocus ) ,
placeholder = { Text ( " Search files " ) } ,
singleLine = true ,
keyboardOptions = KeyboardOptions ( imeAction = ImeAction . Search ) ,
keyboardActions = KeyboardActions (
onSearch = { viewModel . search ( searchText ) } ,
) ,
)
} else {
Column {
Column {
Text ( " Files " )
Text ( " Files " )
Text (
Text (
@ -131,15 +173,24 @@ fun FilesScreen(
overflow = TextOverflow . Ellipsis ,
overflow = TextOverflow . Ellipsis ,
)
)
}
}
}
} ,
} ,
navigationIcon = {
navigationIcon = {
if ( ! CrUri . isRoot ( state . currentUri ) ) {
if ( inSearch || searchOpen ) {
IconButton ( onClick = { closeSearch ( ) } ) {
Icon ( Icons . Default . Close , contentDescription = " Close search " )
}
} else if ( ! CrUri . isRoot ( state . currentUri ) ) {
IconButton ( onClick = { viewModel . navigateUp ( ) } ) {
IconButton ( onClick = { viewModel . navigateUp ( ) } ) {
Icon ( Icons . AutoMirrored . Filled . ArrowBack , contentDescription = " Up " )
Icon ( Icons . AutoMirrored . Filled . ArrowBack , contentDescription = " Up " )
}
}
}
}
} ,
} ,
actions = {
actions = {
if ( !in Search && ! searchOpen ) {
IconButton ( onClick = { searchOpen = true } ) {
Icon ( Icons . Default . Search , contentDescription = " Search " )
}
IconButton ( onClick = { mkdirOpen = true } ) {
IconButton ( onClick = { mkdirOpen = true } ) {
Icon ( Icons . Default . CreateNewFolder , contentDescription = " New folder " )
Icon ( Icons . Default . CreateNewFolder , contentDescription = " New folder " )
}
}
@ -149,6 +200,11 @@ fun FilesScreen(
IconButton ( onClick = onSignOut ) {
IconButton ( onClick = onSignOut ) {
Icon ( Icons . AutoMirrored . Filled . ExitToApp , contentDescription = " Sign out " )
Icon ( Icons . AutoMirrored . Filled . ExitToApp , contentDescription = " Sign out " )
}
}
} else if ( !in Search ) {
IconButton ( onClick = { viewModel . search ( searchText ) } ) {
Icon ( Icons . Default . Search , contentDescription = " Search " )
}
}
} ,
} ,
)
)
} ,
} ,
@ -167,6 +223,24 @@ fun FilesScreen(
. padding ( padding ) ,
. padding ( padding ) ,
) {
) {
when {
when {
inSearch -> {
SearchResults (
state = state ,
viewModel = viewModel ,
listState = listState ,
onOpenFolder = { file ->
closeSearch ( )
viewModel . navigateTo ( file )
} ,
onOpenFile = { file ->
scope . launch {
runCatching { viewModel . downloadToCache ( context , file ) }
. onSuccess { local -> openFile ( context , local ) }
. onFailure { snackbar . showSnackbar ( it . message ?: " Download failed " ) }
}
} ,
)
}
state . loading && state . files . isEmpty ( ) -> {
state . loading && state . files . isEmpty ( ) -> {
Box ( Modifier . fillMaxSize ( ) , contentAlignment = Alignment . Center ) {
Box ( Modifier . fillMaxSize ( ) , contentAlignment = Alignment . Center ) {
CircularProgressIndicator ( )
CircularProgressIndicator ( )
@ -292,6 +366,93 @@ private fun pathLabel(uri: String): String {
return if ( path . isEmpty ( ) ) " My files " else " My files / ${java.net.URLDecoder.decode(path, "UTF-8")} "
return if ( path . isEmpty ( ) ) " My files " else " My files / ${java.net.URLDecoder.decode(path, "UTF-8")} "
}
}
@Composable
private fun SearchResults (
state : FilesUiState ,
viewModel : FilesViewModel ,
listState : androidx . compose . foundation . lazy . LazyListState ,
onOpenFolder : ( FileObject ) -> Unit ,
onOpenFile : ( FileObject ) -> Unit ,
) {
when {
state . searchLoading -> {
Box ( Modifier . fillMaxSize ( ) , contentAlignment = Alignment . Center ) {
CircularProgressIndicator ( )
}
}
state . searchError != null -> {
Box ( Modifier . fillMaxSize ( ) , contentAlignment = Alignment . Center ) {
Column ( horizontalAlignment = Alignment . CenterHorizontally ) {
Text ( state . searchError ?: " Search failed " , color = MaterialTheme . colorScheme . error )
TextButton ( onClick = { viewModel . search ( state . searchQuery ?: " " ) } ) {
Text ( " Retry " )
}
}
}
}
state . searchResults . isEmpty ( ) -> {
Box ( Modifier . fillMaxSize ( ) , contentAlignment = Alignment . Center ) {
Text (
" No results for \" ${state.searchQuery} \" " ,
color = MaterialTheme . colorScheme . onSurfaceVariant ,
)
}
}
else -> {
LazyColumn ( state = listState , modifier = Modifier . fillMaxSize ( ) ) {
items ( state . searchResults , key = { it . file . id + it . file . path } ) { hit ->
val file = hit . file
ListItem (
modifier = Modifier . clickable {
if ( file . isFolder ) onOpenFolder ( file ) else onOpenFile ( file )
} ,
headlineContent = {
Text ( file . name , maxLines = 1 , overflow = TextOverflow . Ellipsis )
} ,
supportingContent = {
Column {
Text (
pathLabel ( CrUri . parent ( file . path ) ) ,
style = MaterialTheme . typography . labelMedium ,
maxLines = 1 ,
overflow = TextOverflow . Ellipsis ,
)
if ( hit . content . isNotBlank ( ) ) {
Text (
hit . content . trim ( ) ,
style = MaterialTheme . typography . labelSmall ,
color = MaterialTheme . colorScheme . onSurfaceVariant ,
maxLines = 2 ,
overflow = TextOverflow . Ellipsis ,
)
}
}
} ,
leadingContent = {
Icon (
if ( file . isFolder ) Icons . Default . Folder
else Icons . AutoMirrored . Filled . InsertDriveFile ,
contentDescription = null ,
tint = if ( file . isFolder ) MaterialTheme . colorScheme . primary
else MaterialTheme . colorScheme . onSurfaceVariant ,
)
} ,
)
HorizontalDivider ( )
}
if ( state . searchLoadingMore ) {
item {
Row (
Modifier . fillMaxWidth ( ) . padding ( 12. dp ) ,
horizontalArrangement = Arrangement . Center ,
) { CircularProgressIndicator ( ) }
}
}
}
}
}
}
private fun openFile ( context : android . content . Context , local : java . io . File ) {
private fun openFile ( context : android . content . Context , local : java . io . File ) {
val uri = FileProvider . getUriForFile ( context , " ${context.packageName} .fileprovider " , local )
val uri = FileProvider . getUriForFile ( context , " ${context.packageName} .fileprovider " , local )
val intent = Intent ( Intent . ACTION _VIEW ) . apply {
val intent = Intent ( Intent . ACTION _VIEW ) . apply {