diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt b/app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt index f249599..622e2ed 100644 --- a/app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt +++ b/app/src/main/java/sh/sar/isodroid/ui/components/FileBrowser.kt @@ -1,6 +1,7 @@ package sh.sar.isodroid.ui.components -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -26,6 +27,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import sh.sar.isodroid.data.IsoFile @@ -35,10 +38,13 @@ fun FileBrowser( files: List, currentPath: String, onFileClick: (IsoFile) -> Unit, + onFileLongClick: (IsoFile) -> Unit, onNavigateUp: () -> Unit, canNavigateUp: Boolean, modifier: Modifier = Modifier ) { + val hapticFeedback = LocalHapticFeedback.current + Column(modifier = modifier.fillMaxSize()) { if (files.isEmpty()) { Column( @@ -78,7 +84,8 @@ fun FileBrowser( name = "..", size = "", isDirectory = true, - onClick = onNavigateUp + onClick = onNavigateUp, + onLongClick = null ) } } @@ -88,7 +95,11 @@ fun FileBrowser( name = file.name, size = file.formattedSize, isIso = file.name.lowercase().endsWith(".iso"), - onClick = { onFileClick(file) } + onClick = { onFileClick(file) }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onFileLongClick(file) + } ) } } @@ -96,18 +107,23 @@ fun FileBrowser( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun FileItem( name: String, size: String, isDirectory: Boolean = false, isIso: Boolean = true, - onClick: () -> Unit + onClick: () -> Unit, + onLongClick: (() -> Unit)? ) { Card( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick), + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) diff --git a/app/src/main/java/sh/sar/isodroid/ui/components/FileContextMenu.kt b/app/src/main/java/sh/sar/isodroid/ui/components/FileContextMenu.kt new file mode 100644 index 0000000..72c3e22 --- /dev/null +++ b/app/src/main/java/sh/sar/isodroid/ui/components/FileContextMenu.kt @@ -0,0 +1,197 @@ +package sh.sar.isodroid.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import sh.sar.isodroid.data.IsoFile + +@Composable +fun FileContextMenu( + file: IsoFile, + onDismiss: () -> Unit, + onRename: (String) -> Unit, + onDelete: () -> Unit +) { + var showRenameDialog by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + + if (showRenameDialog) { + RenameDialog( + currentName = file.name, + onDismiss = { showRenameDialog = false }, + onConfirm = { newName -> + showRenameDialog = false + onRename(newName) + onDismiss() + } + ) + } else if (showDeleteConfirm) { + DeleteConfirmDialog( + fileName = file.name, + onDismiss = { showDeleteConfirm = false }, + onConfirm = { + showDeleteConfirm = false + onDelete() + onDismiss() + } + ) + } else { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = file.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + text = { + Column { + MenuItem( + icon = { Icon(Icons.Default.Edit, contentDescription = null) }, + text = "Rename", + onClick = { showRenameDialog = true } + ) + Spacer(modifier = Modifier.height(8.dp)) + MenuItem( + icon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + text = "Delete", + textColor = MaterialTheme.colorScheme.error, + onClick = { showDeleteConfirm = true } + ) + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) + } +} + +@Composable +private fun MenuItem( + icon: @Composable () -> Unit, + text: String, + textColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + icon() + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = textColor + ) + } +} + +@Composable +private fun RenameDialog( + currentName: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + // Extract name without extension + val extension = if (currentName.contains(".")) { + "." + currentName.substringAfterLast(".") + } else "" + val nameWithoutExtension = currentName.removeSuffix(extension) + + var newName by remember { mutableStateOf(nameWithoutExtension) } + val isValid = newName.isNotBlank() && !newName.contains("/") + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename") }, + text = { + Column { + OutlinedTextField( + value = newName, + onValueChange = { newName = it.replace("/", "") }, + label = { Text("File Name") }, + suffix = { Text(extension) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(newName + extension) }, + enabled = isValid && newName != nameWithoutExtension + ) { + Text("Rename") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun DeleteConfirmDialog( + fileName: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete File") }, + text = { + Text("Are you sure you want to delete \"$fileName\"? This action cannot be undone.") + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt b/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt index ba92760..cf7148f 100644 --- a/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt +++ b/app/src/main/java/sh/sar/isodroid/ui/screens/MainScreen.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.launch import sh.sar.isodroid.data.IsoFile import sh.sar.isodroid.data.MountOptions import sh.sar.isodroid.ui.components.CreateImgDialog +import sh.sar.isodroid.ui.components.FileContextMenu import sh.sar.isodroid.ui.components.FileBrowser import sh.sar.isodroid.ui.components.MountDialog import sh.sar.isodroid.ui.components.StatusCard @@ -58,6 +59,7 @@ fun MainScreen( var selectedFile by remember { mutableStateOf(null) } var showMountDialog by remember { mutableStateOf(false) } var showCreateImgDialog by remember { mutableStateOf(false) } + var contextMenuFile by remember { mutableStateOf(null) } // Show error messages LaunchedEffect(uiState.errorMessage) { @@ -169,6 +171,9 @@ fun MainScreen( selectedFile = file showMountDialog = true }, + onFileLongClick = { file -> + contextMenuFile = file + }, onNavigateUp = { viewModel.navigateUp() }, canNavigateUp = viewModel.canNavigateUp(), modifier = Modifier.weight(1f) @@ -214,4 +219,18 @@ fun MainScreen( } ) } + + // File context menu (long press) + contextMenuFile?.let { file -> + FileContextMenu( + file = file, + onDismiss = { contextMenuFile = null }, + onRename = { newName -> + viewModel.renameFile(file, newName) + }, + onDelete = { + viewModel.deleteFile(file) + } + ) + } } diff --git a/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt index 123302f..ad6d78a 100644 --- a/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt +++ b/app/src/main/java/sh/sar/isodroid/viewmodel/MainViewModel.kt @@ -501,6 +501,40 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { } } + fun renameFile(file: IsoFile, newName: String) { + viewModelScope.launch { + val oldPath = file.path + val newPath = "${oldPath.substringBeforeLast("/")}/$newName" + + // Check if new file already exists + val checkResult = RootManager.executeCommand("test -f \"$newPath\" && echo exists") + if (checkResult.output.trim() == "exists") { + _uiState.update { it.copy(errorMessage = "File already exists: $newName") } + return@launch + } + + val result = RootManager.executeCommand("mv \"$oldPath\" \"$newPath\"") + if (result.success) { + _uiState.update { it.copy(successMessage = "Renamed to $newName") } + loadFiles() + } else { + _uiState.update { it.copy(errorMessage = "Failed to rename file") } + } + } + } + + fun deleteFile(file: IsoFile) { + viewModelScope.launch { + val result = RootManager.executeCommand("rm -f \"${file.path}\"") + if (result.success) { + _uiState.update { it.copy(successMessage = "Deleted ${file.name}") } + loadFiles() + } else { + _uiState.update { it.copy(errorMessage = "Failed to delete file") } + } + } + } + fun clearError() { _uiState.update { it.copy(errorMessage = null) } }