diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..a5fcdd9 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,26 @@ +name: Android CI + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 0fc3113..e805548 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 3d8b933..5fc4159 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' + alias libs.plugins.android.application + alias libs.plugins.kotlin.android } android { @@ -11,8 +11,8 @@ android { applicationId "com.reysand.files" minSdk 33 targetSdk 33 - versionCode 1 - versionName "0.0.1" + versionCode 3 + versionName "0.1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -37,7 +37,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.4.3' + kotlinCompilerExtensionVersion '1.5.4' } packaging { resources { @@ -48,19 +48,24 @@ android { dependencies { - implementation 'androidx.core:core-ktx:1.9.0' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' - implementation 'androidx.activity:activity-compose:1.7.2' - implementation platform('androidx.compose:compose-bom:2023.03.00') - implementation 'androidx.compose.ui:ui' - implementation 'androidx.compose.ui:ui-graphics' - implementation 'androidx.compose.ui:ui-tooling-preview' - implementation 'androidx.compose.material3:material3' - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - androidTestImplementation platform('androidx.compose:compose-bom:2023.03.00') - androidTestImplementation 'androidx.compose.ui:ui-test-junit4' - debugImplementation 'androidx.compose.ui:ui-tooling' - debugImplementation 'androidx.compose.ui:ui-test-manifest' + implementation libs.core.ktx + implementation libs.lifecycle.runtime.ktx + implementation libs.activity.compose + + implementation platform(libs.compose.bom) + implementation libs.ui + implementation libs.ui.graphics + implementation libs.ui.tooling.preview + implementation libs.material3 + implementation libs.lifecycle.viewmodel.compose + implementation libs.navigation.compose + + testImplementation libs.junit + androidTestImplementation libs.androidx.test.ext.junit + androidTestImplementation libs.espresso.core + androidTestImplementation platform(libs.compose.bom) + androidTestImplementation libs.ui.test.junit4 + + debugImplementation libs.ui.tooling + debugImplementation libs.ui.test.manifest } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f469594..849ef44 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,9 +13,16 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + + + + { + // List files in the specified path + val files = File(path).listFiles() + + // Separate directories and other files + val directories = mutableListOf() + val others = mutableListOf() + + files?.forEach { file -> + val fileModel = createFileModel(file) + when (fileModel.fileType) { + FileModel.FileType.DIRECTORY -> directories.add(fileModel) + else -> others.add(fileModel) + } + } + + // Sort directories and other files separately + directories.sortBy { it.name.lowercase() } + others.sortBy { it.name.lowercase() } + + // Combine and return the sorted list + return directories + others + } + + override suspend fun moveFile(source: String, destination: String): Boolean { + return renameFile(source, destination) + } + + override suspend fun copyFile(source: String, destination: String): Boolean { + return operateOnFile(source, destination) { sourceFile, destinationFile -> + sourceFile.copyRecursively(destinationFile, true) + } + } + + override suspend fun renameFile(from: String, to: String): Boolean { + return operateOnFile(from, to) { sourceFile, destinationFile -> + sourceFile.renameTo(destinationFile) + } + } + + override suspend fun deleteFile(path: String): Boolean { + return File(path).deleteRecursively() + } + + /** + * Creates a [FileModel] object from a [File] instance. + * + * @param file The [File] instance to create a [FileModel] from. + * @return A [FileModel] object representing the given file. + */ + private fun createFileModel(file: File): FileModel { + return FileModel( + name = file.name, + path = file.path, + fileType = when (file.isDirectory) { + true -> FileModel.FileType.DIRECTORY + else -> FileModel.FileType.OTHER + }, + size = file.length(), + lastModified = file.lastModified() + ) + } + + /** + * Performs an operation on a file. + * + * @param source The path of the source file. + * @param destination The path of the destination file. + * @param operation The operation to perform on the file. + * @return A boolean value indicating the success of the operation. + */ + private fun operateOnFile( + source: String, + destination: String, + operation: (File, File) -> Boolean + ): Boolean { + return try { + val sourceFile = File(source) + val destinationFile = File(destination) + + if (operation(sourceFile, destinationFile)) { + createFileModel(destinationFile) + true + } else { + false + } + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/model/FileModel.kt b/app/src/main/java/com/reysand/files/data/model/FileModel.kt new file mode 100644 index 0000000..e8ee60c --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/model/FileModel.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.model + +import com.reysand.files.data.util.FileSizeFormatter +import java.util.Calendar +import java.util.Locale + +/** + * Data class representing a file. + * + * @property name The name of the file. + * @property path The path of the file. + * @property fileType The type of the file. + * @property size The size of the file in bytes. + * @property lastModified The timestamp of the last modification. + */ +data class FileModel( + val name: String, + val path: String, + val fileType: FileType, + val size: Long, + val lastModified: Long +) { + /** + * Enum class representing the type of file. + */ + enum class FileType { + DIRECTORY, + OTHER + } + + /** + * Gets a human-readable formatted size. + * + * @return A string representing the formatted size (e.g., "2.5 MB"). + */ + fun getFormattedSize(): String { + return FileSizeFormatter.getFormattedSize(size) + } + + /** + * Gets the last modified date in a human-readable format. + * + * @return A string representing the last modified date (e.g., "Sep 12, 2023). + */ + fun getLastModified(): String { + val current = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = lastModified } + + val displayMonth = date.getDisplayName(Calendar.MONTH, Calendar.SHORT, Locale.getDefault()) + val dayOfMonth = date.get(Calendar.DAY_OF_MONTH) + + val formattedDate = buildString { + append("$displayMonth $dayOfMonth") + if (date.get(Calendar.YEAR) != current.get(Calendar.YEAR)) { + append(", ${date.get(Calendar.YEAR)}") + } + } + return formattedDate + } +} diff --git a/app/src/main/java/com/reysand/files/data/repository/FileRepository.kt b/app/src/main/java/com/reysand/files/data/repository/FileRepository.kt new file mode 100644 index 0000000..9f6b883 --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/repository/FileRepository.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.repository + +import com.reysand.files.data.model.FileModel + +/** + * Interface defining operations for interacting with files. + */ +interface FileRepository { + + /** + * Retrieves the free space of the storage. + * + * @return A formatted string containing the free space of the storage. + */ + fun getStorageFreeSpace(): String + + /** + * Retrieves a list of [FileModel] objects from the specified path. + * + * @param path The path to the directory containing the files. + * @return A list of [FileModel] objects. + */ + suspend fun getFiles(path: String): List + + /** + * Move a file from one path to another. + * + * @param source The original path of the file. + * @param destination The new path of the file. + * @return A boolean value indicating the success of the operation. + */ + suspend fun moveFile(source: String, destination: String): Boolean + + /** + * Copy a file from one path to another. + * + * @param source The original path of the file. + * @param destination The new path of the file. + * @return A boolean value indicating the success of the operation. + */ + suspend fun copyFile(source: String, destination: String): Boolean + + /** + * Rename a file. + * + * @param from The original path of the file. + * @param to The new path of the file. + * @return A boolean value indicating the success of the operation. + */ + suspend fun renameFile(from: String, to: String): Boolean + + /** + * Delete a file. + * + * @param path The path of the file to delete. + * @return A boolean value indicating the success of the operation. + */ + suspend fun deleteFile(path: String): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/data/util/FileSizeFormatter.kt b/app/src/main/java/com/reysand/files/data/util/FileSizeFormatter.kt new file mode 100644 index 0000000..a6573de --- /dev/null +++ b/app/src/main/java/com/reysand/files/data/util/FileSizeFormatter.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.data.util + +import kotlin.math.log10 +import kotlin.math.pow + +/** + * Utility class for formatting file sizes. + */ +object FileSizeFormatter { + + /** + * Formats the given size in bytes to a human-readable format. + * + * @param size The size in bytes. + * @return A string representing the formatted size (e.g., "2.5 MB"). + */ + fun getFormattedSize(size: Long): String { + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val base = 1024.0 + + val unitIndex = (log10(size.toDouble()) / log10(base)).toInt().coerceIn(0, units.size - 1) + val convertedSize = size / base.pow(unitIndex.toDouble()) + + return String.format("%.2f %s", convertedSize, units[unitIndex]) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/FilesApp.kt b/app/src/main/java/com/reysand/files/ui/FilesApp.kt new file mode 100644 index 0000000..6733602 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/FilesApp.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.reysand.files.R +import com.reysand.files.ui.components.PathTabs +import com.reysand.files.ui.navigation.Destinations +import com.reysand.files.ui.navigation.NavGraph +import com.reysand.files.ui.viewmodel.FilesViewModel + +/** + * Composable function representing the main UI of the files application. + * + * @param filesViewModel The [FilesViewModel] providing data for the screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilesApp(filesViewModel: FilesViewModel = viewModel(factory = FilesViewModel.Factory)) { + // Create a navigation controller + val navController = rememberNavController() + + // Get the current route from the navigation stack + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + // Determine the title for the top app bar based on the current route + val topBarTitle = when (currentRoute) { + Destinations.FILE_LIST -> R.string.internal_storage + Destinations.SETTINGS -> R.string.settings_title + else -> R.string.app_name + } + + Scaffold(topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(id = topBarTitle) + ) + }, + navigationIcon = { + if (currentRoute != Destinations.HOME) { + IconButton(onClick = { + if (filesViewModel.currentDirectory.value != filesViewModel.homeDirectory && + currentRoute == Destinations.FILE_LIST) { + filesViewModel.navigateUp() + } else { + navController.popBackStack() + } + }) { + Icon( + imageVector = Icons.Default.ArrowBack, contentDescription = null + ) + } + } + }, + actions = { + if (currentRoute == Destinations.HOME) { + IconButton(onClick = { navController.navigate(Destinations.SETTINGS) }) { + Icon( + imageVector = Icons.Rounded.Settings, contentDescription = null + ) + } + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5F) + ), + ) + }) { + Surface( + modifier = Modifier + .fillMaxSize() + .padding(it), + color = MaterialTheme.colorScheme.background + ) { + Column { + if (currentRoute == Destinations.FILE_LIST) { + PathTabs( + filesViewModel.homeDirectory, filesViewModel.currentDirectory.value + ) { newPath -> + filesViewModel.getFiles(newPath) + } + } + NavGraph( + filesViewModel = filesViewModel, navController = navController + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/components/DeleteDialog.kt b/app/src/main/java/com/reysand/files/ui/components/DeleteDialog.kt new file mode 100644 index 0000000..21f981a --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/components/DeleteDialog.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.components + +import android.content.res.Configuration +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.reysand.files.R +import com.reysand.files.ui.theme.FilesTheme + +/** + * Composable function for displaying a delete dialog. + * + * @param fileName The name of the file to be deleted. + * @param onDelete Callback for when the delete button is clicked. + * @param onDismiss Callback for when the dialog is dismissed. + */ +@Composable +fun DeleteDialog( + fileName: String, + onDelete: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = { onDismiss() }, + confirmButton = { + DialogOutlinedButton( + text = stringResource(id = R.string.dialog_confirm_button), + icon = Icons.Default.Done, + onClick = { onDelete() } + ) + }, + dismissButton = { + DialogOutlinedButton( + text = stringResource(id = R.string.dialog_dismiss_button), + icon = Icons.Default.Close, + onClick = { onDismiss() } + ) + }, + title = { Text(text = stringResource(id = R.string.dialog_delete_title)) }, + text = { + Text(text = stringResource(id = R.string.dialog_delete_message, fileName)) + } + ) +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun DeleteDialogPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + color = MaterialTheme.colorScheme.background + ) { + DeleteDialog( + fileName = "test.txt", + onDelete = { }, + onDismiss = { } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/components/DialogOutlinedButton.kt b/app/src/main/java/com/reysand/files/ui/components/DialogOutlinedButton.kt new file mode 100644 index 0000000..c8dddb6 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/components/DialogOutlinedButton.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.reysand.files.R +import com.reysand.files.ui.theme.FilesTheme + +/** + * Composable function for displaying an outlined button in a dialog. + * + * @param icon The icon to display in the button. + * @param text The text to display in the button. + * @param modifier Modifier for customizing the layout. + * @param enabled Whether the button is enabled or not. + * @param onClick The callback to be invoked when the button is clicked. + */ +@Composable +fun DialogOutlinedButton( + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit +) { + OutlinedButton( + onClick = onClick, + modifier = modifier.fillMaxWidth(0.48F), + enabled = enabled + ) { + Icon(imageVector = icon, contentDescription = null) + Spacer(modifier = modifier.width(8.dp)) + Text(text = text) + } +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun DialogOutlinedButtonPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + DialogOutlinedButton( + icon = Icons.Rounded.Close, + text = stringResource(R.string.dialog_dismiss_button) + ) { } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/components/FileListItem.kt b/app/src/main/java/com/reysand/files/ui/components/FileListItem.kt new file mode 100644 index 0000000..9d2d219 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/components/FileListItem.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.reysand.files.R +import com.reysand.files.data.model.FileModel +import com.reysand.files.ui.theme.FilesTheme + +/** + * Composable function for displaying a single file item. + * + * @param file The [FileModel] representing the file. + * @param homeDirectory The path to the home directory. + * @param moveOperation Callback for when the move option is clicked. + * @param copyOperation Callback for when the copy option is clicked. + * @param renameOperation Callback for when the rename option is clicked. + * @param deleteOperation Callback for when the delete option is clicked. + * @param modifier Modifier for customizing the layout. + * @param onClick Callback for when the item is clicked. + */ +@Composable +fun FileListItem( + file: FileModel, + homeDirectory: String, + moveOperation: (FileModel, String) -> Unit, + copyOperation: (FileModel, String) -> Unit, + renameOperation: (FileModel, String) -> Unit, + deleteOperation: (String) -> Unit, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + + // Determine the icon based on the file type + val iconResId = when (file.fileType) { + FileModel.FileType.DIRECTORY -> R.drawable.ic_folder + FileModel.FileType.OTHER -> R.drawable.ic_file + } + + // Determine the information text based on the file type + val info = when (file.fileType) { + FileModel.FileType.DIRECTORY -> file.getLastModified() + else -> "${file.getFormattedSize()}, ${file.getLastModified()}" + } + + ListItem( + headlineContent = { + Text( + text = file.name, + modifier = modifier, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + }, + modifier = modifier.clickable(onClick = onClick), + supportingContent = { + Text( + text = info, + modifier = modifier + ) + }, + leadingContent = { + Icon( + painter = painterResource(id = iconResId), + contentDescription = null, + modifier = modifier + ) + }, + trailingContent = { + OptionsMenu( + file = file, + homeDirectory = homeDirectory, + moveOperation = moveOperation, + copyOperation = copyOperation, + renameOperation = renameOperation, + deleteOperation = deleteOperation + ) + } + ) +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun FileListItemPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + color = MaterialTheme.colorScheme.background + ) { + Column { + FileListItem( + file = FileModel( + name = "Android", + path = "/storage/emulated/0", + fileType = FileModel.FileType.DIRECTORY, + size = 1, + lastModified = 1_593_689_259_000 + ), + homeDirectory = "", + moveOperation = { _, _ -> }, + copyOperation = { _, _ -> }, + renameOperation = { _, _ -> }, + deleteOperation = {} + ) {} + FileListItem( + file = FileModel( + name = "text.txt", + path = "/storage/emulated/0", + fileType = FileModel.FileType.OTHER, + size = 1024, + lastModified = 1_693_689_259_000 + ), + homeDirectory = "", + moveOperation = { _, _ -> }, + copyOperation = { _, _ -> }, + renameOperation = { _, _ -> }, + deleteOperation = {} + ) {} + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/components/OptionsMenu.kt b/app/src/main/java/com/reysand/files/ui/components/OptionsMenu.kt new file mode 100644 index 0000000..aa51828 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/components/OptionsMenu.kt @@ -0,0 +1,181 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.components + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.reysand.files.R +import com.reysand.files.data.model.FileModel +import com.reysand.files.ui.theme.FilesTheme + +/** + * Composable function for displaying an options menu. + * + * @param file The [FileModel] representing the file. + * @param homeDirectory The path to the home directory. + * @param moveOperation Callback for when the move option is clicked. + * @param copyOperation Callback for when the copy option is clicked. + * @param renameOperation Callback for when the rename option is clicked. + * @param deleteOperation Callback for when the delete option is clicked. + */ +@Composable +fun OptionsMenu( + file: FileModel, + homeDirectory: String, + moveOperation: (FileModel, String) -> Unit, + copyOperation: (FileModel, String) -> Unit, + renameOperation: (FileModel, String) -> Unit, + deleteOperation: (String) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(0) } + + IconButton(onClick = { expanded = true }) { + Icon(imageVector = Icons.Default.MoreVert, contentDescription = null) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + OptionsMenuItem( + text = stringResource(id = R.string.options_move_to), + leadingIcon = R.drawable.ic_option_move + ) { + showDialog = 1 + expanded = false + } + OptionsMenuItem( + text = stringResource(id = R.string.options_copy_to), + leadingIcon = R.drawable.ic_option_copy + ) { + showDialog = 2 + expanded = false + } + OptionsMenuItem( + text = stringResource(id = R.string.options_rename), + leadingIcon = R.drawable.ic_option_rename + ) { + showDialog = 3 + expanded = false + } + OptionsMenuItem( + text = stringResource(id = R.string.options_delete), + leadingIcon = R.drawable.ic_option_delete + ) { + showDialog = 4 + expanded = false + } + } + } + + when (showDialog) { + 1 -> { + RenameDialog( + dialogTitle = stringResource(id = R.string.options_move_to), + fileName = file.path.removePrefix(homeDirectory), + onConfirm = { newName -> + moveOperation(file, newName) + showDialog = 0 + }, + onDismiss = { showDialog = 0 } + ) + } + + 2 -> { + RenameDialog( + dialogTitle = stringResource(id = R.string.options_copy_to), + fileName = file.path.removePrefix(homeDirectory), + onConfirm = { newName -> + copyOperation(file, newName) + showDialog = 0 + }, + onDismiss = { showDialog = 0 } + ) + } + + 3 -> { + RenameDialog( + dialogTitle = stringResource(id = R.string.options_rename), + fileName = file.name, + onConfirm = { newName -> + renameOperation(file, newName) + showDialog = 0 + }, + onDismiss = { showDialog = 0 } + ) + } + + 4 -> { + DeleteDialog( + fileName = file.name, + onDelete = { + deleteOperation(file.path) + showDialog = 0 + }, + onDismiss = { showDialog = 0 } + ) + } + } +} + +@Composable +fun OptionsMenuItem( + text: String, + @DrawableRes leadingIcon: Int, + onClick: () -> Unit +) { + DropdownMenuItem( + text = { Text(text = text) }, + onClick = onClick, + leadingIcon = { + Icon(painter = painterResource(id = leadingIcon), contentDescription = null) + } + ) +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun OptionsMenuItemPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + color = MaterialTheme.colorScheme.background + ) { + OptionsMenuItem( + stringResource(id = R.string.options_rename), + R.drawable.ic_option_rename + ) {} + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/components/PathTabs.kt b/app/src/main/java/com/reysand/files/ui/components/PathTabs.kt new file mode 100644 index 0000000..dc1c5e6 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/components/PathTabs.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.components + +import android.content.res.Configuration +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.reysand.files.ui.theme.FilesTheme +import java.io.File + +/** + * Composable function for displaying a row of tabs representing the current directory path. + * + * @param homeDirectory The path to the home directory. + * @param currentDirectory The path to the current directory. + * @param onNavigateToDirectory Callback function for navigating to a specific directory. + */ +@Composable +fun PathTabs( + homeDirectory: String, + currentDirectory: String, + onNavigateToDirectory: (String) -> Unit +) { + // Extract the relative path from the home directory + val path = currentDirectory.removePrefix(homeDirectory) + + // Split the path into individual components + val pathComponents = listOf(homeDirectory) + path.split(File.separator).filter { + it.isNotEmpty() + } + + ScrollableTabRow( + selectedTabIndex = pathComponents.size - 1, + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5F), + edgePadding = 0.dp + ) { + pathComponents.forEachIndexed { index, component -> + Tab(selected = index == pathComponents.size - 1, onClick = { + // Join the path components to form the new directory path + val newPath = pathComponents.subList(0, index + 1).joinToString(File.separator) + onNavigateToDirectory(newPath) + }, text = { + Text( + text = component, + overflow = TextOverflow.Ellipsis, + softWrap = true, + maxLines = 1 + ) + }) + } + } +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PathTabsPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + color = MaterialTheme.colorScheme.background + ) { + PathTabs( + homeDirectory = "/storage/emulated/0", + currentDirectory = "/storage/emulated/0${File.separator}Download" + ) {} + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/components/PermissionAlertDialog.kt b/app/src/main/java/com/reysand/files/ui/components/PermissionAlertDialog.kt new file mode 100644 index 0000000..5c75306 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/components/PermissionAlertDialog.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.components + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.provider.Settings +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +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.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.reysand.files.R +import com.reysand.files.ui.theme.FilesTheme + +/** + * Composable function to display a permission alert dialog. + * + * @param showPermissionDialog State that controls whether the dialog is shown. + * @param modifier Modifier for customizing the layout. + */ +@Composable +fun PermissionAlertDialog( + showPermissionDialog: MutableState, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + if (showPermissionDialog.value) { + AlertDialog( + onDismissRequest = { showPermissionDialog.value = false }, + confirmButton = { + TextButton(onClick = { + allowPermission(context) + showPermissionDialog.value = false + }) { + Text(text = stringResource(id = R.string.file_access_permission_confirm_button)) + } + }, + modifier = modifier, + dismissButton = { + TextButton(onClick = { showPermissionDialog.value = false }) { + Text(text = stringResource(id = R.string.dialog_dismiss_button)) + } + }, + icon = { Icon(imageVector = Icons.Default.Warning, contentDescription = null) }, + title = { Text(text = stringResource(id = R.string.file_access_permission_title)) }, + text = { Text(text = stringResource(id = R.string.file_access_permission_message)) }, + containerColor = MaterialTheme.colorScheme.tertiaryContainer + ) + } +} + +/** + * Utility function to open the system settings for app permission management. + * + * @param context The context of the current application. + */ +fun allowPermission(context: Context) { + val uri = Uri.parse("package:${context.packageName}") + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, uri) + context.startActivity(intent) +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun PermissionAlertDialogPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + color = MaterialTheme.colorScheme.background + ) { + PermissionAlertDialog(remember { mutableStateOf(true) }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/components/RenameDialog.kt b/app/src/main/java/com/reysand/files/ui/components/RenameDialog.kt new file mode 100644 index 0000000..a5bc427 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/components/RenameDialog.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.components + +import android.content.res.Configuration +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import com.reysand.files.R +import com.reysand.files.ui.theme.FilesTheme + +/** + * Composable function for displaying a rename dialog. + * + * @param dialogTitle The title of the dialog. + * @param fileName The name of the file to be renamed. + * @param onConfirm The callback to be invoked when the confirm button is clicked. + * @param onDismiss The callback to be invoked when the dialog is dismissed. + */ +@Composable +fun RenameDialog( + dialogTitle: String, + fileName: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var newName by remember { mutableStateOf(fileName) } + var confirmButtonEnabled by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + DialogOutlinedButton( + icon = Icons.Default.Done, + text = stringResource(id = R.string.dialog_confirm_button), + enabled = confirmButtonEnabled, + onClick = { onConfirm(newName) } + ) + }, + dismissButton = { + DialogOutlinedButton( + icon = Icons.Default.Close, + text = stringResource(id = R.string.dialog_dismiss_button), + onClick = onDismiss + ) + }, + title = { Text(text = dialogTitle) }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { + newName = it + confirmButtonEnabled = (newName != fileName) && newName.isNotBlank() + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onConfirm(newName) }), + singleLine = true, + maxLines = 1 + ) + } + ) +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun RenameDialogPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + color = MaterialTheme.colorScheme.background + ) { + RenameDialog("Test", "test.txt", {}, {}) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/components/StorageCard.kt b/app/src/main/java/com/reysand/files/ui/components/StorageCard.kt new file mode 100644 index 0000000..30c142b --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/components/StorageCard.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.components + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.reysand.files.R +import com.reysand.files.ui.theme.FilesTheme + +/** + * Composable function for displaying a storage card. + * + * @param title The title of the card. + * @param leadingIcon The resource ID of the leading icon. + * @param info Additional information to display. + * @param modifier Modifier for customizing the layout. + * @param enabled Whether the card is enabled or not. + * @param onClick The callback to be executed when the card is clicked. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StorageCard( + title: String, + @DrawableRes leadingIcon: Int, + info: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + enabled = enabled, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + elevation = CardDefaults.cardElevation(2.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = leadingIcon), + contentDescription = null, + modifier = Modifier.size(40.dp) + ) + Spacer(modifier = Modifier.size(16.dp)) + Column { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text(text = info, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun StorageCardPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + color = MaterialTheme.colorScheme.background + ) { + StorageCard( + title = "Internal Storage", + enabled = true, + leadingIcon = R.drawable.ic_internal_storage, + info = "O B" + ) { } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/navigation/Destinations.kt b/app/src/main/java/com/reysand/files/ui/navigation/Destinations.kt new file mode 100644 index 0000000..c6f5138 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/navigation/Destinations.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.navigation + +/** + * Object containing constant destination keys used for navigation. + */ +object Destinations { + const val HOME = "home" + const val FILE_LIST = "fileList" + const val SETTINGS = "settings" +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt b/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..626b226 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/navigation/NavGraph.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.reysand.files.ui.screens.FileListScreen +import com.reysand.files.ui.screens.HomeScreen +import com.reysand.files.ui.screens.SettingsScreen +import com.reysand.files.ui.viewmodel.FilesViewModel + +/** + * Composable function for setting up the navigation graph. + * + * @param filesViewModel The [FilesViewModel] providing data for the screen. + * @param navController NavHostController for managing navigation within the app. + * @param modifier Modifier for customizing the layout. + */ +@Composable +fun NavGraph( + filesViewModel: FilesViewModel, + navController: NavHostController, + modifier: Modifier = Modifier +) { + NavHost(navController = navController, startDestination = Destinations.HOME) { + composable(Destinations.HOME) { + HomeScreen(filesViewModel = filesViewModel, navController = navController, modifier = modifier) + } + composable(Destinations.FILE_LIST) { + FileListScreen(filesViewModel = filesViewModel) + } + composable(Destinations.SETTINGS) { + SettingsScreen() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/screens/FileListScreen.kt b/app/src/main/java/com/reysand/files/ui/screens/FileListScreen.kt new file mode 100644 index 0000000..0a58260 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/screens/FileListScreen.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.screens + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.reysand.files.data.model.FileModel +import com.reysand.files.ui.components.FileListItem +import com.reysand.files.ui.components.PermissionAlertDialog +import com.reysand.files.ui.viewmodel.FilesViewModel + +/** + * Composable function for displaying a list of files. + * + * @param filesViewModel The [FilesViewModel] providing data for the screen. + * @param modifier Modifier for customizing the layout. + */ +@Composable +fun FileListScreen( + filesViewModel: FilesViewModel, + modifier: Modifier = Modifier +) { + // Collect the list of files as state + val files by filesViewModel.files.collectAsState(initial = emptyList()) + + // Display permission alert dialog if needed + PermissionAlertDialog(showPermissionDialog = filesViewModel.showPermissionDialog) + + LazyColumn(modifier = modifier) { + items(files) { file -> + FileListItem( + file = file, + homeDirectory = filesViewModel.homeDirectory, + moveOperation = { fileModel, path -> filesViewModel.moveFile(fileModel, path) }, + copyOperation = { fileModel, path -> filesViewModel.copyFile(fileModel, path) }, + renameOperation = { fileModel, path -> filesViewModel.renameFile(fileModel, path) }, + deleteOperation = { path -> filesViewModel.deleteFile(path) } + ) { + if (file.fileType == FileModel.FileType.DIRECTORY) { + filesViewModel.getFiles(file.path) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/screens/HomeScreen.kt b/app/src/main/java/com/reysand/files/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..6434c8b --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/screens/HomeScreen.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavHostController +import com.reysand.files.R +import com.reysand.files.ui.components.StorageCard +import com.reysand.files.ui.navigation.Destinations +import com.reysand.files.ui.viewmodel.FilesViewModel + +/** + * Composable function for the Home screen. + * + * @param filesViewModel The [FilesViewModel] providing data for the screen. + * @param navController NavHostController for managing navigation within the app. + * @param modifier Modifier for customizing the layout. + */ +@Composable +fun HomeScreen( + filesViewModel: FilesViewModel, + navController: NavHostController, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center + ) { + StorageCard( + title = stringResource(id = R.string.internal_storage), + leadingIcon = R.drawable.ic_internal_storage, + info = filesViewModel.getStorageFreeSpace(), + ) { + navController.navigate(Destinations.FILE_LIST) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt b/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..efb556f --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/screens/SettingsScreen.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.screens + +import android.content.res.Configuration +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.reysand.files.R +import com.reysand.files.ui.components.allowPermission +import com.reysand.files.ui.theme.FilesTheme + +/** + * Composable function for displaying the settings screen. + */ +@Composable +fun SettingsScreen() { + val context = LocalContext.current + + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + SettingsSection(title = stringResource(id = R.string.settings_privacy_title)) { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.file_access_permission_title)) + }, + modifier = Modifier.clickable(onClick = { allowPermission(context) }), + trailingContent = { + Icon( + painter = painterResource(id = R.drawable.ic_launch), + contentDescription = null + ) + } + ) + } + } +} + +/** + * Composable function for a settings section with a title. + * + * @param title The title of the settings section. + * @param modifier [Modifier] for customizing the layout. + * @param content Content of the settings section. + */ +@Composable +fun SettingsSection( + title: String, + modifier: Modifier = Modifier, + content: @Composable (ColumnScope.() -> Unit) +) { + Text( + text = title, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + + Card( + modifier = modifier.fillMaxWidth() + ) { + Column(content = content) + } +} + +@Preview(name = "Light Mode") +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "Dark Mode" +) +@Composable +fun SettingsSectionPreview() { + FilesTheme { + // A surface container using the 'background' color from the theme + Surface( + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + SettingsSection(title = stringResource(id = R.string.settings_privacy_title)) { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.file_access_permission_title)) + }, + trailingContent = { + Icon( + painter = painterResource(id = R.drawable.ic_launch), + contentDescription = null + ) + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/util/ContextWrapper.kt b/app/src/main/java/com/reysand/files/ui/util/ContextWrapper.kt new file mode 100644 index 0000000..5a599a1 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/util/ContextWrapper.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.util + +import android.content.Context + +/** + * Wrapper class for [Context]. + * + * @param context The [Context] to be wrapped. + */ +class ContextWrapper(private val context: Context) { + + /** + * Returns the wrapped [Context]. + * + * @return The wrapped [Context]. + */ + fun getContext(): Context = context +} \ No newline at end of file diff --git a/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt new file mode 100644 index 0000000..688e125 --- /dev/null +++ b/app/src/main/java/com/reysand/files/ui/viewmodel/FilesViewModel.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2023 Andrey Slyusar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.reysand.files.ui.viewmodel + +import android.os.Environment +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.reysand.files.FilesApplication +import com.reysand.files.R +import com.reysand.files.data.model.FileModel +import com.reysand.files.data.repository.FileRepository +import com.reysand.files.ui.util.ContextWrapper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +/** + * ViewModel for managing file-related data and operations. + * + * @param fileRepository The repository for accessing file data. + * @param contextWrapper The wrapper for accessing the application context. + */ +class FilesViewModel( + private val fileRepository: FileRepository, + private val contextWrapper: ContextWrapper +) : ViewModel() { + + // MutableStateFlow holding the list of files + private var _files = MutableStateFlow>(emptyList()) + val files = _files.asStateFlow() + + // Paths for the home and current directories + val homeDirectory = Environment.getExternalStorageDirectory().path + val currentDirectory = mutableStateOf(homeDirectory) + + // State indicating whether to show the permission dialog + val showPermissionDialog = mutableStateOf(!Environment.isExternalStorageManager()) + + // Initialize the ViewModel by loading files from the home directory + init { + getFiles(homeDirectory) + } + + /** + * Get a list of files from the specified path. + * + * @param path The path to the directory containing the files. + */ + fun getFiles(path: String) { + viewModelScope.launch { + _files.value = fileRepository.getFiles(path) + currentDirectory.value = path + } + } + + /** + * Navigate up to the parent directory, + */ + fun navigateUp() { + val parentDirectory = File(currentDirectory.value).parent + + if (currentDirectory.value != homeDirectory && parentDirectory != null) { + getFiles(parentDirectory) + } + } + + /** + * Gets the free space of the storage. + * + * @return A string representing the free space of the storage. + */ + fun getStorageFreeSpace(): String { + return contextWrapper.getContext().getString( + R.string.storage_free_space, + fileRepository.getStorageFreeSpace() + ) + } + + /** + * Move a file from one path to another. + * + * @param file The file to move. + * @param destination The destination path. + */ + fun moveFile(file: FileModel, destination: String) { + viewModelScope.launch { + if (fileRepository.moveFile(file.path, homeDirectory.plus(destination))) { + getFiles(currentDirectory.value) + } + } + } + + /** + * Copy a file from one path to another. + * + * @param file The file to copy. + * @param destination The destination path. + */ + fun copyFile(file: FileModel, destination: String) { + viewModelScope.launch { + if (fileRepository.copyFile(file.path, homeDirectory.plus(destination))) { + getFiles(currentDirectory.value) + } + } + } + + /** + * Rename a file. + * + * @param file The file to rename. + * @param newName The new name of the file. + */ + fun renameFile(file: FileModel, newName: String) { + viewModelScope.launch { + if (fileRepository.renameFile( + file.path, + file.path.removeSuffix(file.name).plus(newName) + ) + ) { + getFiles(currentDirectory.value) + } + } + } + + /** + * Delete a file. + * + * @param path The path of the file to delete. + */ + fun deleteFile(path: String) { + viewModelScope.launch { + if (fileRepository.deleteFile(path)) { + getFiles(currentDirectory.value) + } + } + } + + companion object { + // Factory for creating FilesViewModel instances + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val application = (this[APPLICATION_KEY] as FilesApplication) + val fileRepository = application.container.fileRepository + val contextWrapper = ContextWrapper(application.applicationContext) + FilesViewModel(fileRepository = fileRepository, contextWrapper = contextWrapper) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_file.xml b/app/src/main/res/drawable/ic_file.xml new file mode 100644 index 0000000..d33224a --- /dev/null +++ b/app/src/main/res/drawable/ic_file.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 0000000..2c8a4ee --- /dev/null +++ b/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/drawable/ic_internal_storage.xml b/app/src/main/res/drawable/ic_internal_storage.xml new file mode 100644 index 0000000..583a811 --- /dev/null +++ b/app/src/main/res/drawable/ic_internal_storage.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launch.xml b/app/src/main/res/drawable/ic_launch.xml new file mode 100644 index 0000000..22d042a --- /dev/null +++ b/app/src/main/res/drawable/ic_launch.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/drawable/ic_option_copy.xml b/app/src/main/res/drawable/ic_option_copy.xml new file mode 100644 index 0000000..8af2d28 --- /dev/null +++ b/app/src/main/res/drawable/ic_option_copy.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/drawable/ic_option_delete.xml b/app/src/main/res/drawable/ic_option_delete.xml new file mode 100644 index 0000000..841abc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_option_delete.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/drawable/ic_option_move.xml b/app/src/main/res/drawable/ic_option_move.xml new file mode 100644 index 0000000..f4f2d81 --- /dev/null +++ b/app/src/main/res/drawable/ic_option_move.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/drawable/ic_option_rename.xml b/app/src/main/res/drawable/ic_option_rename.xml new file mode 100644 index 0000000..b669810 --- /dev/null +++ b/app/src/main/res/drawable/ic_option_rename.xml @@ -0,0 +1,31 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49f82b2..970c671 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,4 +15,29 @@ --> Files + + + OK + Cancel + Delete + Are you sure you want to delete \'%1$s\'? + + + File Access Permission + This app requires access to manage all files on your device for proper functionality. Please grant the permission. + Grant Permission + + + Move to + Copy to + Rename + Delete + + + Internal Storage + %1$s free + + + Settings + Privacy \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8b82b3f..87a11f3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.1.1' apply false - id 'org.jetbrains.kotlin.android' version '1.8.10' apply false + alias libs.plugins.android.application apply false + alias libs.plugins.kotlin.android apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..2574ce2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,34 @@ +[versions] +activity-compose = "1.7.2" +agp = "8.1.3" +androidx-test-ext-junit = "1.1.5" +compose-bom = "2023.06.01" +core-ktx = "1.10.1" +espresso-core = "3.5.1" +junit = "4.13.2" +kotlin = "1.9.20" +lifecycle-runtime-ktx = "2.6.2" +navigation-compose = "2.6.0" + +[libraries] +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } +espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime-ktx" } +material3 = { group = "androidx.compose.material3", name = "material3" } +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" } +ui = { group = "androidx.compose.ui", name = "ui" } +ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7fdf476..48b7bb1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Sep 06 17:00:14 MSK 2023 +#Fri Nov 10 19:07:23 MSK 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists