diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/FileNavBar.kt b/filepicker/src/main/java/com/github/jing332/filepicker/FileNavBar.kt index dbcd0af..70ad7b6 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/FileNavBar.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/FileNavBar.kt @@ -1,8 +1,11 @@ package com.github.jing332.filepicker +import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed @@ -10,16 +13,39 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlin.math.max data class NavBarItem(val name: String, val path: String) +fun toNavBarItems( + rootPath: String, + rootName: String = "root", + path: String, + separator: String = "/" +): List { + val items = mutableListOf() + var currentPath = rootPath + items.add(NavBarItem(rootName, rootPath)) + + val paths = path.removePrefix(rootPath).split(separator).filter { it.isNotEmpty() } + for (p in paths) { + currentPath += separator + p + items.add(NavBarItem(p, currentPath)) + } + return items +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun FileNavBar( @@ -29,20 +55,36 @@ fun FileNavBar( onClick: (NavBarItem) -> Unit, ) { LaunchedEffect(key1 = list.size) { - state.animateScrollToItem(list.size - 1) + state.animateScrollToItem(max(0, list.size - 1)) } - + val context = LocalContext.current LazyRow(modifier, state = state) { itemsIndexed(list) { index, item -> Row( modifier = Modifier.animateItemPlacement(), verticalAlignment = Alignment.CenterVertically ) { - TextButton( - onClick = { onClick(item) }, - contentPadding = PaddingValues(horizontal = 2.dp) + Surface( + modifier = Modifier + .clip(MaterialTheme.shapes.extraSmall) + .combinedClickable( + onClick = { + onClick(item) + }, + onLongClick = { + Toast + .makeText(context, item.path, Toast.LENGTH_SHORT) + .show() + } + ), ) { - Text(text = item.name) + Text( + modifier = Modifier + .padding(2.dp) + .defaultMinSize(minWidth = 48.dp, minHeight = 24.dp), + text = item.name, + textAlign = TextAlign.Center + ) } if (index != list.size - 1) { diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/FilePicker.kt b/filepicker/src/main/java/com/github/jing332/filepicker/FilePicker.kt index 8694f19..7dadeb8 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/FilePicker.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/FilePicker.kt @@ -7,6 +7,7 @@ import android.os.Build import android.os.Environment import android.provider.Settings import android.util.Log +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -71,9 +72,17 @@ private fun PermissionGrant() { @Composable fun FilePicker( modifier: Modifier = Modifier, - initialPath: String = Environment.getExternalStorageDirectory().path, + rootName: String = "root", + rootPath: String = Environment.getExternalStorageDirectory().path, + initialPath: String = rootPath, state: FilePickerState = rememberNavController().run { - remember { FilePickerState(initialPath, this) } + remember { + FilePickerState( + rootPath = rootPath, + initialPath = initialPath, + navController = this + ) + } }, config: FilePickerConfiguration = remember { FilePickerConfiguration() }, onConfirmSelect: (List) -> Unit, @@ -87,11 +96,11 @@ fun FilePicker( } }, ) { + val context = LocalContext.current val navController = state.navController val navBarItems = remember { mutableStateListOf() } fun popBack() { - navBarItems.remove(navBarItems.last()) navController.popBackStack() } @@ -119,6 +128,23 @@ fun FilePicker( onConfirmSelect = { onConfirmSelect(state.currentListState?.items?.filter { it.isChecked.value } ?.map { it.model } ?: emptyList()) + }, + onNewFolder = { + try { + state.currentListState?.createNewFolder(it) + state.reload() + } catch (e: SecurityException) { + Log.e(TAG, "createNewFolder", e) + Toast.makeText( + context, + "权限不足:${e.localizedMessage ?: ""}", + Toast.LENGTH_LONG + ).show() + } catch (e: Exception) { + Log.e(TAG, "createNewFolder", e) + Toast.makeText(context, "失败:${e.localizedMessage ?: ""}", Toast.LENGTH_LONG) + .show() + } } ) @@ -150,6 +176,11 @@ fun FilePicker( LaunchedEffect(key1 = Unit) { state.currentPath = path + toNavBarItems( + rootPath = rootPath, + rootName = rootName, + path = path + ).also { navBarItems.clear(); navBarItems.addAll(it) } } BackHandler(path != initialPath) { popBack() @@ -159,13 +190,16 @@ fun FilePicker( } val file = File(path) - if (navBarItems.isEmpty()) - navBarItems.add(NavBarItem(name = file.name, path = file.path)) FileListPage( file = NormalFile(file), state = fileListState, config = config, - onBack = { popBack() }, + onBack = { + if (fileListState.hasChecked()) { + state.currentListState?.uncheckAll() + } else + popBack() + }, onEnter = { enterFile -> if (onEnterDirectory(enterFile)) navBarItems += NavBarItem(name = enterFile.name, path = enterFile.path) diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/FilePickerState.kt b/filepicker/src/main/java/com/github/jing332/filepicker/FilePickerState.kt index 4970318..5c74374 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/FilePickerState.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/FilePickerState.kt @@ -8,7 +8,11 @@ import androidx.navigation.NavHostController import com.github.jing332.filepicker.listpage.FileListPageState import com.github.jing332.filepicker.utils.navigate -data class FilePickerState(val initialPath: String, val navController: NavHostController) { +data class FilePickerState( + val rootPath: String, + val initialPath: String, + val navController: NavHostController +) { // var currentModel by mutableStateOf(null) var currentPath by mutableStateOf(initialPath) val fileListStates = mutableMapOf() diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/FilePickerToolbar.kt b/filepicker/src/main/java/com/github/jing332/filepicker/FilePickerToolbar.kt index f9d1e7b..c992b57 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/FilePickerToolbar.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/FilePickerToolbar.kt @@ -1,5 +1,6 @@ package com.github.jing332.filepicker +import android.util.Log import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,15 +10,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -37,6 +41,7 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +private const val TAG = "FilePicker" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,12 +53,6 @@ internal fun BasicToolbar( ) { Column(modifier) { TopAppBar(title = title, navigationIcon = navigationIcon, actions = actions) - Divider( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp) - .shadow(4.dp) - ) } } @@ -70,75 +69,103 @@ fun FilePickerToolbar( selectedCount: Int, onCancelSelect: () -> Unit, onConfirmSelect: () -> Unit, + + onNewFolder: (String) -> Unit ) { - Crossfade(targetState = selectedCount > 0, label = "") { - if (it) - BasicToolbar(title = { Text(text = "$selectedCount") }, modifier = modifier, - navigationIcon = { - IconButton(onClick = onCancelSelect) { - Icon(Icons.Default.Close, stringResource(R.string.cancel_select)) - } - }, - actions = { - TextButton(onClick = onConfirmSelect) { - Text(stringResource(id = R.string.select)) - } + Column { + Crossfade(targetState = selectedCount > 0, label = "") { + if (it) + BasicToolbar(title = { Text(text = "$selectedCount") }, modifier = modifier, + navigationIcon = { + IconButton(onClick = onCancelSelect) { + Icon(Icons.Default.Close, stringResource(R.string.cancel_select)) + } + }, + actions = { + TextButton(onClick = onConfirmSelect) { + Text(stringResource(id = R.string.select)) + } // IconButton(onClick = { }) { // Icon(Icons.Default.MoreVert, stringResource(R.string.more_options)) // } - }) - else - BasicToolbar(modifier = modifier, title = { Text(title) }, actions = { - var showSortConfigDialog by remember { mutableStateOf(false) } - if (showSortConfigDialog) - SortSettingsDialog( - onDismissRequest = { showSortConfigDialog = false }, - sortConfig = sortConfig, - onConfirm = onSortConfigChange - ) - var showOptions by rememberSaveable { mutableStateOf(false) } - IconButton(onClick = { showOptions = true }) { - Icon(Icons.Default.MoreVert, stringResource(R.string.more_options)) - DropdownMenu( - expanded = showOptions, - onDismissRequest = { showOptions = false }) { - RadioDropdownMenuItem( - text = { - Text(stringResource(id = R.string.list)) - }, - checked = viewType == ViewType.LIST, - onClick = { - showOptions = false - onSwitchViewType(if (viewType == ViewType.LIST) ViewType.GRID else ViewType.LIST) - } - ) - RadioDropdownMenuItem( - text = { - Text(stringResource(id = R.string.grid)) - }, - checked = viewType == ViewType.GRID, - onClick = { - showOptions = false - onSwitchViewType(if (viewType == ViewType.LIST) ViewType.GRID else ViewType.LIST) - } + }) + else + BasicToolbar(modifier = modifier, title = { Text(title) }, actions = { + var showSortConfigDialog by remember { mutableStateOf(false) } + if (showSortConfigDialog) + SortSettingsDialog( + onDismissRequest = { showSortConfigDialog = false }, + sortConfig = sortConfig, + onConfirm = onSortConfigChange ) - Divider(Modifier.fillMaxWidth()) - DropdownMenuItem( - text = { - Row { - Icon(Icons.AutoMirrored.Filled.Sort, null) - Text(stringResource(id = R.string.sort_by)) + var addFolderDialog by remember { mutableStateOf(false) } + if (addFolderDialog) + NewFolderDialog( + onDismissRequest = { addFolderDialog = false }, + onConfirm = { + runCatching { + onNewFolder(it) + }.onFailure { t -> + Log.e(TAG, "newFolder", t) } - }, - onClick = { - showOptions = false - showSortConfigDialog = true } ) + + IconButton(onClick = { addFolderDialog = true }) { + Icon(Icons.Default.CreateNewFolder, stringResource(R.string.new_folder)) + } + + var showOptions by rememberSaveable { mutableStateOf(false) } + IconButton(onClick = { showOptions = true }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.more_options)) + DropdownMenu( + expanded = showOptions, + onDismissRequest = { showOptions = false }) { + RadioDropdownMenuItem( + text = { + Text(stringResource(id = R.string.list)) + }, + checked = viewType == ViewType.LIST, + onClick = { + showOptions = false + onSwitchViewType(if (viewType == ViewType.LIST) ViewType.GRID else ViewType.LIST) + } + ) + RadioDropdownMenuItem( + text = { + Text(stringResource(id = R.string.grid)) + }, + checked = viewType == ViewType.GRID, + onClick = { + showOptions = false + onSwitchViewType(if (viewType == ViewType.LIST) ViewType.GRID else ViewType.LIST) + } + ) + + Divider(Modifier.fillMaxWidth()) + DropdownMenuItem( + text = { + Row { + Icon(Icons.AutoMirrored.Filled.Sort, null) + Text(stringResource(id = R.string.sort_by)) + } + }, + onClick = { + showOptions = false + showSortConfigDialog = true + } + ) + } } - } - }) + }) + } + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp) + .shadow(4.dp) + ) } } @@ -166,4 +193,30 @@ internal fun RadioDropdownMenuItem( Icon(Icons.Default.RadioButtonUnchecked, null) } ) +} + +@Composable +private fun NewFolderDialog(onDismissRequest: () -> Unit, onConfirm: (String) -> Unit) { + var text by remember { mutableStateOf("") } + AlertDialog(onDismissRequest = onDismissRequest, + title = { Text(stringResource(id = R.string.new_folder)) }, + text = { + OutlinedTextField(value = text, onValueChange = { text = it }) + }, + confirmButton = { + TextButton( + enabled = text.isNotBlank(), + onClick = { + onConfirm(text) + onDismissRequest() + }) { + Text(stringResource(id = android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(id = android.R.string.cancel)) + } + } + ) } \ No newline at end of file diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/listpage/FileListPage.kt b/filepicker/src/main/java/com/github/jing332/filepicker/listpage/FileListPage.kt index 75298af..f5271d7 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/listpage/FileListPage.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/listpage/FileListPage.kt @@ -1,6 +1,5 @@ package com.github.jing332.filepicker.listpage -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -10,8 +9,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Folder import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -21,7 +18,6 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import com.github.jing332.filepicker.FilePickerConfiguration import com.github.jing332.filepicker.R -import com.github.jing332.filepicker.SortConfig import com.github.jing332.filepicker.ViewType import com.github.jing332.filepicker.model.IFileModel import com.github.jing332.filepicker.utils.performLongPress @@ -42,8 +38,9 @@ fun FileListPage( val view = LocalView.current LaunchedEffect(key1 = file) { - if (state.items.isEmpty()){ + if (state.items.isEmpty()) { state.config = config + state.file = file state.updateFiles(file) } } @@ -74,27 +71,16 @@ fun FileListPage( fileType.IconContent() } }, - title = { - Text(text = item.name, style = MaterialTheme.typography.titleMedium) - }, - subtitle = { - val text = if (item.isBackType) - stringResource(R.string.back_to_previous_dir) - else - "${item.fileLastModified.value} | " + - if (item.isDirectory) stringResource( - R.string.item_desc, - item.fileCount.intValue - ) - else item.fileSize.value - Row { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, + title = item.name, + subtitle = if (item.isBackType) + stringResource(R.string.back_to_previous_dir) + else + "${item.fileLastModified.value} | " + + if (item.isDirectory) stringResource( + R.string.item_desc, + item.fileCount.intValue + ) + else item.fileSize.value, onCheckedChange = { _ -> state.selector(item) }, diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/listpage/FileListPageState.kt b/filepicker/src/main/java/com/github/jing332/filepicker/listpage/FileListPageState.kt index f5925ff..1cdcedb 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/listpage/FileListPageState.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/listpage/FileListPageState.kt @@ -45,6 +45,7 @@ class FileListPageState( } } + lateinit var file: IFileModel var viewType by mutableIntStateOf(ViewType.LIST) var sortConfig by mutableStateOf(SortConfig()) @@ -143,6 +144,10 @@ class FileListPageState( } } } + + fun createNewFolder(name: String) { + file.createDirectory(name) + } } internal data class FileItem( diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/listpage/Item.kt b/filepicker/src/main/java/com/github/jing332/filepicker/listpage/Item.kt index da2da40..146a1f9 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/listpage/Item.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/listpage/Item.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -21,6 +22,8 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable @@ -30,8 +33,8 @@ internal fun Item( onCheckedChange: (Boolean) -> Unit, isCheckable: Boolean = true, icon: @Composable () -> Unit, - title: @Composable () -> Unit, - subtitle: @Composable () -> Unit, + title: String, + subtitle: String, onClick: () -> Unit, onLongClick: () -> Unit, gridType: Boolean @@ -44,7 +47,6 @@ internal fun Item( isCheckable = isCheckable, icon = icon, title = title, - subtitle = subtitle, onClick = onClick, onLongClick = onLongClick ) @@ -70,8 +72,8 @@ internal fun LongItem( onCheckedChange: (Boolean) -> Unit, isCheckable: Boolean = true, icon: @Composable () -> Unit, - title: @Composable () -> Unit, - subtitle: @Composable () -> Unit, + title: String, + subtitle: String, onClick: () -> Unit, onLongClick: () -> Unit ) { @@ -98,13 +100,13 @@ internal fun LongItem( Row(Modifier.padding(8.dp)) { icon() } - Column(Modifier.weight(1f)) { - title() - subtitle() - } - - AnimatedVisibility(visible = isCheckable) { - Checkbox(checked = isChecked, onCheckedChange = onCheckedChange) + Column(Modifier.weight(1f).padding(start = 4.dp)) { + Title( + modifier = Modifier, + title = title, + isChecked = isChecked + ) + Text(subtitle, style = MaterialTheme.typography.bodyMedium) } } } @@ -117,8 +119,7 @@ internal fun ShortItem( onCheckedChange: (Boolean) -> Unit, isCheckable: Boolean = true, icon: @Composable () -> Unit, - title: @Composable () -> Unit, - subtitle: @Composable () -> Unit, + title: String, onClick: () -> Unit, onLongClick: () -> Unit ) { @@ -146,14 +147,25 @@ internal fun ShortItem( verticalAlignment = Alignment.CenterVertically ) { icon() - Column(Modifier.weight(1f)) { - title() -// subtitle() - } - AnimatedVisibility(visible = isCheckable) { - Checkbox(checked = isChecked, onCheckedChange = onCheckedChange) - } + Title( + modifier = Modifier + .weight(1f) + .padding(start = 4.dp), + title = title, + isChecked = isChecked + ) } - } +} + +@Composable +fun Title(modifier: Modifier = Modifier, title: String, isChecked: Boolean) { + Text( + modifier = modifier, + text = title, + fontWeight = if (isChecked) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis + ) } \ No newline at end of file diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/model/IFileModel.kt b/filepicker/src/main/java/com/github/jing332/filepicker/model/IFileModel.kt index a7722e7..d03808e 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/model/IFileModel.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/model/IFileModel.kt @@ -8,5 +8,8 @@ abstract class IFileModel { open val time: Long = 0 open val size: Long = 0 + open fun createDirectory(name: String) {} + open fun createFile(name: String) {} + open fun files(): List = emptyList() } \ No newline at end of file diff --git a/filepicker/src/main/java/com/github/jing332/filepicker/model/NormalFile.kt b/filepicker/src/main/java/com/github/jing332/filepicker/model/NormalFile.kt index 69c1dfa..7123a35 100644 --- a/filepicker/src/main/java/com/github/jing332/filepicker/model/NormalFile.kt +++ b/filepicker/src/main/java/com/github/jing332/filepicker/model/NormalFile.kt @@ -21,4 +21,8 @@ data class NormalFile( override fun files(): List { return file.listFiles()?.map { NormalFile(it) } ?: emptyList() } + + override fun createDirectory(name: String) { + file.resolve(name ).mkdir() + } } \ No newline at end of file diff --git a/filepicker/src/main/res/values-zh/strings.xml b/filepicker/src/main/res/values-zh/strings.xml index b4a2f5b..74ab2c5 100644 --- a/filepicker/src/main/res/values-zh/strings.xml +++ b/filepicker/src/main/res/values-zh/strings.xml @@ -16,4 +16,5 @@ 按类型 列表 网格 + 新建文件夹 \ No newline at end of file diff --git a/filepicker/src/main/res/values/strings.xml b/filepicker/src/main/res/values/strings.xml index da7d01e..4a8cc92 100644 --- a/filepicker/src/main/res/values/strings.xml +++ b/filepicker/src/main/res/values/strings.xml @@ -16,4 +16,5 @@ Select List Grid + 新建文件夹 \ No newline at end of file