diff --git a/app/src/main/kotlin/com/d4rk/cleaner/ui/home/HomeComposable.kt b/app/src/main/kotlin/com/d4rk/cleaner/ui/home/HomeComposable.kt index f2f77db..1bce379 100644 --- a/app/src/main/kotlin/com/d4rk/cleaner/ui/home/HomeComposable.kt +++ b/app/src/main/kotlin/com/d4rk/cleaner/ui/home/HomeComposable.kt @@ -60,7 +60,10 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.asFlow import androidx.lifecycle.viewmodel.compose.viewModel +import coil.ImageLoader import coil.compose.AsyncImage +import coil.disk.DiskCache +import coil.memory.MemoryCache import coil.request.ImageRequest import com.d4rk.cleaner.R import com.d4rk.cleaner.utils.CircularDeterminateIndicator @@ -73,7 +76,7 @@ import java.io.File @Composable fun HomeComposable() { val context = LocalContext.current - val viewModel : HomeViewModel = viewModel() + val viewModel: HomeViewModel = viewModel() val progress by viewModel.progress.observeAsState(0.3f) val storageUsed by viewModel.storageUsed.observeAsState("0") val storageTotal by viewModel.storageTotal.observeAsState("0") @@ -88,113 +91,112 @@ fun HomeComposable() { ) { Box( modifier = Modifier - .weight(4f) - .fillMaxWidth() + .weight(4f) + .fillMaxWidth() ) { - if (! showCleaningComposable) { + if (!showCleaningComposable) { CircularDeterminateIndicator( - progress = progress , - storageUsed = storageUsed , - storageTotal = storageTotal , + progress = progress, + storageUsed = storageUsed, + storageTotal = storageTotal, modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = 98.dp) + .align(Alignment.TopCenter) + .offset(y = 98.dp) ) Image( - painter = painterResource(R.drawable.ic_clean) , - contentDescription = null , + painter = painterResource(R.drawable.ic_clean), + contentDescription = null, modifier = Modifier - .align(Alignment.BottomCenter) - .padding(24.dp) - .size(128.dp , 66.dp) + .align(Alignment.BottomCenter) + .padding(24.dp) + .size(128.dp, 66.dp) ) - } - else { + } else { AnalyzeComposable(launchScanningKey) } } Row( modifier = Modifier - .fillMaxWidth() - .height(102.dp) - .padding(bottom = 16.dp) , + .fillMaxWidth() + .height(102.dp) + .padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly ) { AnimatedVisibility( - visible = showCleaningComposable , + visible = showCleaningComposable, enter = fadeIn(animationSpec = tween(durationMillis = 400)) + expandHorizontally( - animationSpec = tween(durationMillis = 400) , expandFrom = Alignment.Start - ) , + animationSpec = tween(durationMillis = 400), expandFrom = Alignment.Start + ), exit = fadeOut(animationSpec = tween(durationMillis = 400)) + shrinkHorizontally( - animationSpec = tween(durationMillis = 400) , shrinkTowards = Alignment.Start - ) , + animationSpec = tween(durationMillis = 400), shrinkTowards = Alignment.Start + ), modifier = Modifier.weight(1f) ) { - val enabled = ! isAnalyzing && selectedFileCount > 0 + val enabled = !isAnalyzing && selectedFileCount > 0 val animateStateButtonColor = animateColorAsState( - targetValue = if (enabled) MaterialTheme.colorScheme.secondaryContainer else Color.LightGray , - animationSpec = tween(400 , 0 , LinearEasing) , + targetValue = if (enabled) MaterialTheme.colorScheme.secondaryContainer else Color.LightGray, + animationSpec = tween(400, 0, LinearEasing), label = "" ) FilledTonalButton( modifier = Modifier - .weight(1f) - .fillMaxHeight() - .animateContentSize() - .padding(start = 16.dp , end = 8.dp) - .bounceClick() , + .weight(1f) + .fillMaxHeight() + .animateContentSize() + .padding(start = 16.dp, end = 8.dp) + .bounceClick(), onClick = { viewModel.clean(activity = context as Activity) - } , - shape = MaterialTheme.shapes.medium , - enabled = enabled , + }, + shape = MaterialTheme.shapes.medium, + enabled = enabled, colors = ButtonDefaults.filledTonalButtonColors( - containerColor = animateStateButtonColor.value , - ) , + containerColor = animateStateButtonColor.value, + ), ) { Column( - horizontalAlignment = Alignment.CenterHorizontally , - verticalArrangement = Arrangement.Center , + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, modifier = Modifier - .fillMaxSize() - .padding(ButtonDefaults.ContentPadding) + .fillMaxSize() + .padding(ButtonDefaults.ContentPadding) ) { Icon( - painterResource(R.drawable.ic_broom) , - contentDescription = null , + painterResource(R.drawable.ic_broom), + contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize) ) - Text(text = "Clean" , style = MaterialTheme.typography.bodyMedium) + Text(text = "Clean", style = MaterialTheme.typography.bodyMedium) } } } FilledTonalButton( modifier = Modifier - .weight(1f) - .fillMaxHeight() - .animateContentSize() - .padding(start = if (showCleaningComposable) 8.dp else 16.dp , end = 16.dp) - .bounceClick() , onClick = { - if (! showCleaningComposable) { + .weight(1f) + .fillMaxHeight() + .animateContentSize() + .padding(start = if (showCleaningComposable) 8.dp else 16.dp, end = 16.dp) + .bounceClick(), onClick = { + if (!showCleaningComposable) { viewModel.analyze(activity = context as Activity) } - } , shape = MaterialTheme.shapes.medium + }, shape = MaterialTheme.shapes.medium ) { Column( - horizontalAlignment = Alignment.CenterHorizontally , - verticalArrangement = Arrangement.Center , + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, modifier = Modifier - .fillMaxSize() - .padding(ButtonDefaults.ContentPadding) + .fillMaxSize() + .padding(ButtonDefaults.ContentPadding) ) { Icon( - painterResource(R.drawable.ic_search) , - contentDescription = null , + painterResource(R.drawable.ic_search), + contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize) ) - Text(text = "Analyze" , style = MaterialTheme.typography.bodyMedium) + Text(text = "Analyze", style = MaterialTheme.typography.bodyMedium) } } } @@ -211,7 +213,7 @@ fun HomeComposable() { */ @Composable fun AnalyzeComposable(launchScanningKey: MutableState) { - val viewModel : HomeViewModel = viewModel() + val viewModel: HomeViewModel = viewModel() val files by viewModel.scannedFiles.asFlow().collectAsState(initial = listOf()) val allFilesSelected by viewModel.allFilesSelected @@ -228,81 +230,112 @@ fun AnalyzeComposable(launchScanningKey: MutableState) { Column( modifier = Modifier - .animateContentSize() - .fillMaxWidth() - .padding(16.dp) , + .animateContentSize() + .fillMaxWidth() + .padding(16.dp), horizontalAlignment = Alignment.End ) { OutlinedCard( modifier = Modifier - .weight(1f) - .fillMaxWidth() , + .weight(1f) + .fillMaxWidth(), ) { LazyVerticalGrid( - columns = GridCells.Fixed(3) , - verticalArrangement = Arrangement.spacedBy(8.dp) , - horizontalArrangement = Arrangement.spacedBy(8.dp) , + columns = GridCells.Fixed(3), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(8.dp) ) { items(files) { file -> - FileCard(file = file , viewModel = viewModel) // FIXME: Type mismatch: inferred type is Int but File was expected + FileCard(file = file, viewModel = viewModel) } } } Row( - modifier = Modifier.fillMaxWidth() , - verticalAlignment = Alignment.CenterVertically , - horizontalArrangement = Arrangement.SpaceBetween , + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { val statusText = if (selectedFileCount > 0) { "Status: Selected $selectedFileCount files" - } - else { + } else { "Status: No files selected" } val statusColor by animateColorAsState( targetValue = if (selectedFileCount > 0) { MaterialTheme.colorScheme.primary - } - else { + } else { MaterialTheme.colorScheme.secondary - } , animationSpec = tween() , label = "" + }, animationSpec = tween(), label = "" ) Text( - text = statusText , color = statusColor , modifier = Modifier.animateContentSize() + text = statusText, color = statusColor, modifier = Modifier.animateContentSize() ) SelectAllComposable( - checked = allFilesSelected , - onCheckedChange = { viewModel.selectAllFiles(it) } , + checked = allFilesSelected, + onCheckedChange = { viewModel.selectAllFiles(it) }, ) } } } + @Composable fun FileCard(file: File, viewModel: HomeViewModel) { val context = LocalContext.current val fileExtension = getFileExtension(file.name) - val thumbnail = remember { + + val imageLoader = ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(0.24) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("image_cache")) + .maxSizePercent(0.02) + .build() + } + .build() + + val thumbnail = remember(file.absolutePath) { getVideoThumbnail(file.absolutePath, thumbnailWidth = 64, thumbnailHeight = 64) } + Card( modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .bounceClick() , + .fillMaxWidth() + .aspectRatio(1f) + .bounceClick(), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Box(modifier = Modifier.fillMaxSize()) { when (fileExtension) { in context.resources.getStringArray(R.array.image_extensions).toList() -> { AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(file) - .crossfade(true).build() , - contentDescription = file.name , - contentScale = ContentScale.Crop , - modifier = Modifier.fillMaxSize() + model = ImageRequest.Builder(LocalContext.current) + .data( + when (fileExtension) { + in context.resources.getStringArray(R.array.image_extensions) + .toList(), + in context.resources.getStringArray(R.array.video_extensions) + .toList() -> { + file + } + + else -> { + R.drawable.ic_image + } + } + ) + .crossfade(true) + .build(), + imageLoader = imageLoader, + contentDescription = file.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), ) } @@ -319,46 +352,42 @@ fun FileCard(file: File, viewModel: HomeViewModel) { painter = painterResource(R.drawable.ic_video_file), contentDescription = null, modifier = Modifier - .size(24.dp) - .align(Alignment.Center) + .size(24.dp) + .align(Alignment.Center) ) } } else -> { Icon( - painter = painterResource(getFileIcon(fileExtension , context)) , - contentDescription = null , + painter = painterResource(getFileIcon(fileExtension, context)), + contentDescription = null, modifier = Modifier - .size(24.dp) - .align(Alignment.Center) + .size(24.dp) + .align(Alignment.Center) ) } } Checkbox( - checked = viewModel.fileSelectionStates[file] ?: false , + checked = viewModel.fileSelectionStates[file] ?: false, onCheckedChange = { isChecked -> - viewModel.fileSelectionStates[file] = isChecked - viewModel._selectedFileCount.value = - viewModel.fileSelectionStates.values.count { it } - viewModel.allFilesSelected.value = - viewModel.fileSelectionStates.values.all { it } - } , + viewModel.onFileSelectionChange(file, isChecked) + }, modifier = Modifier.align(Alignment.TopEnd) ) Text( - text = file.name , - maxLines = 1 , - overflow = TextOverflow.Ellipsis , + text = file.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier - .fillMaxWidth() - .background( - color = Color.Black.copy(alpha = 0.5f) - ) - .padding(8.dp) - .align(Alignment.BottomCenter) + .fillMaxWidth() + .background( + color = Color.Black.copy(alpha = 0.4f) + ) + .padding(8.dp) + .align(Alignment.BottomCenter) ) } } @@ -375,34 +404,34 @@ fun FileCard(file: File, viewModel: HomeViewModel) { */ @Composable fun SelectAllComposable( - checked : Boolean , onCheckedChange : (Boolean) -> Unit + checked: Boolean, onCheckedChange: (Boolean) -> Unit ) { Row( modifier = Modifier - .fillMaxWidth() - .animateContentSize() , - verticalAlignment = Alignment.CenterVertically , + .fillMaxWidth() + .animateContentSize(), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End ) { val interactionSource = remember { MutableInteractionSource() } FilterChip( - modifier = Modifier.bounceClick() , - selected = checked , + modifier = Modifier.bounceClick(), + selected = checked, onClick = { - onCheckedChange(! checked) - } , - label = { Text("Select All") } , + onCheckedChange(!checked) + }, + label = { Text("Select All") }, leadingIcon = { - AnimatedContent(targetState = checked , label = "") { targetChecked -> + AnimatedContent(targetState = checked, label = "") { targetChecked -> if (targetChecked) { Icon( - imageVector = Icons.Filled.Check , - contentDescription = null , + imageVector = Icons.Filled.Check, + contentDescription = null, ) } } - } , - interactionSource = interactionSource , + }, + interactionSource = interactionSource, ) } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/d4rk/cleaner/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/d4rk/cleaner/ui/home/HomeViewModel.kt index 5e3a3d1..1ea63c1 100644 --- a/app/src/main/kotlin/com/d4rk/cleaner/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/d4rk/cleaner/ui/home/HomeViewModel.kt @@ -23,11 +23,11 @@ import androidx.lifecycle.viewModelScope import com.d4rk.cleaner.data.datastore.DataStore import com.d4rk.cleaner.utils.FileScanner import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File import java.util.UUID import kotlin.math.roundToInt @@ -37,7 +37,6 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { val storageUsed = MutableLiveData() val storageTotal = MutableLiveData() var fileScanner: FileScanner - val scannedFiles = MutableLiveData>() val allFilesSelected = mutableStateOf(false) val fileSelectionStates = mutableStateMapOf() @@ -49,8 +48,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { init { updateStorageInfo() - val resourcesInstance = application.resources - fileScanner = FileScanner(dataStoreInstance, resourcesInstance) + fileScanner = FileScanner(dataStoreInstance,application.resources) } /** @@ -88,30 +86,10 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { } } - /** - * Initiates the file analysis process by invoking the `FileScanner` to scan for files - * and filter them based on predefined preferences. - * - * This function first checks for necessary permissions. If granted, it triggers - * the file scanning process asynchronously using a coroutine. If not, it returns. - * - * @param activity The Activity instance required to request permissions. - * @see FileScanner - */ - fun analyze(activity: Activity) { - if (!hasRequiredPermissions()) { - requestPermissions(activity) - return - } - isAnalyzing.value = true - showCleaningComposable.value = true - viewModelScope.launch { - delay(100) - isAnalyzing.value = true - fileScanner.startScanning() - scannedFiles.postValue(fileScanner.getFilteredFiles()) - isAnalyzing.postValue(false) - } + fun onFileSelectionChange(file: File, isChecked: Boolean) { + fileSelectionStates[file] = isChecked + _selectedFileCount.value = fileSelectionStates.count { it.value } + allFilesSelected.value = fileSelectionStates.all { it.value } } /** @@ -134,6 +112,29 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) { _selectedFileCount.value = fileSelectionStates.values.count { it } } + /** + * Initiates the file analysis process. + * + * This function checks for necessary permissions and, if granted, triggers the file* scanning process asynchronously. + * + * @param activity The Activity instance required to request permissions. + */ + fun analyze(activity: Activity) { + if (!hasRequiredPermissions()) { + requestPermissions(activity) + return + } + isAnalyzing.value = true + showCleaningComposable.value = true + viewModelScope.launch { + fileScanner.startScanning() + withContext(Dispatchers.Main) { + scannedFiles.value = fileScanner.getFilteredFiles() + isAnalyzing.value =false + } + } + } + /** * Initiates the cleaning process if the required permissions are granted. * diff --git a/app/src/main/kotlin/com/d4rk/cleaner/utils/FileScanner.kt b/app/src/main/kotlin/com/d4rk/cleaner/utils/FileScanner.kt index fc5dc95..c1e00be 100644 --- a/app/src/main/kotlin/com/d4rk/cleaner/utils/FileScanner.kt +++ b/app/src/main/kotlin/com/d4rk/cleaner/utils/FileScanner.kt @@ -32,13 +32,9 @@ class FileScanner(private val dataStore: DataStore, private val resources: Resou * * @throws Exception If an error occurs during the scanning process. */ - suspend fun startScanning() { - withContext(Dispatchers.IO) { - loadPreferences() - val allFiles = getAllFiles() - filteredFiles = filterFiles(allFiles) - .toList() // Collect the flow into a list asynchronously - } + suspend fun startScanning() = withContext(Dispatchers.IO) { + loadPreferences() + filteredFiles = filterFiles(getAllFiles()).toList() } /** @@ -69,25 +65,19 @@ class FileScanner(private val dataStore: DataStore, private val resources: Resou stack.addFirst(root) while (stack.isNotEmpty()) { - val file = stack.removeFirst() - if (file.isDirectory) { - file.listFiles()?.let { stack.addAll(it) } + val currentFile = stack.removeFirst() + if (currentFile.isDirectory) { + currentFile.listFiles()?.forEach { stack.addLast(it) } } else { - files.add(file) + files.add(currentFile) } } return files } - private fun filterFiles(allFiles: List): Flow { - return flow { - for (file in allFiles) { - if (shouldFilterFile(file)) { - emit(file) - } - } - } + private fun filterFiles(allFiles: List): Flow = flow { + allFiles.filter(::shouldFilterFile).forEach { emit(it) } } private fun shouldFilterFile(file: File): Boolean { diff --git a/app/src/main/kotlin/com/d4rk/cleaner/utils/ImageUtils.kt b/app/src/main/kotlin/com/d4rk/cleaner/utils/ImageUtils.kt index e9002af..ce53b28 100644 --- a/app/src/main/kotlin/com/d4rk/cleaner/utils/ImageUtils.kt +++ b/app/src/main/kotlin/com/d4rk/cleaner/utils/ImageUtils.kt @@ -10,53 +10,54 @@ import android.graphics.drawable.Drawable import android.media.MediaMetadataRetriever import com.d4rk.cleaner.R -fun getVideoThumbnail(videoPath: String, thumbnailWidth: Int = 128, thumbnailHeight: Int = 128): Bitmap? { +fun getVideoThumbnail( + videoPath: String, + thumbnailWidth: Int = 128, + thumbnailHeight: Int = 128 +): Bitmap? { val mediaMetadataRetriever = MediaMetadataRetriever() - try { + return try { mediaMetadataRetriever.setDataSource(videoPath) - val bitmap = mediaMetadataRetriever.getFrameAtTime(1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) - if (bitmap != null) { - return Bitmap.createScaledBitmap(bitmap, thumbnailWidth, thumbnailHeight, false) + val bitmap = + mediaMetadataRetriever.getFrameAtTime(1000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC) + bitmap?.let { + Bitmap.createScaledBitmap( + it, + thumbnailWidth, + thumbnailHeight, + false + ) } - } catch (_ : Exception) { - + } catch (e: Exception) { + null } finally { mediaMetadataRetriever.release() } - return null } -fun getFileIcon(extension : String , context : Context) : Int { - return when (extension.lowercase()) { - in context.resources.getStringArray(R.array.apk_extensions) - .toList() -> R.drawable.ic_apk_document - - in context.resources.getStringArray(R.array.image_extensions) - .toList() -> R.drawable.ic_image - - in context.resources.getStringArray(R.array.video_extensions) - .toList() -> R.drawable.ic_video_file - - in context.resources.getStringArray(R.array.audio_extensions) - .toList() -> R.drawable.ic_audio_file - - in context.resources.getStringArray(R.array.archive_extensions) - .toList() -> R.drawable.ic_archive_filter - +fun getFileIcon(extension: String, context: Context): Int { + val lowercaseExtension = extension.lowercase() + val resources = context.resources + return when (lowercaseExtension) { + in resources.getStringArray(R.array.apk_extensions) -> R.drawable.ic_apk_document + in resources.getStringArray(R.array.image_extensions) -> R.drawable.ic_image + in resources.getStringArray(R.array.video_extensions) -> R.drawable.ic_video_file + in resources.getStringArray(R.array.audio_extensions) -> R.drawable.ic_audio_file + in resources.getStringArray(R.array.archive_extensions) -> R.drawable.ic_archive_filter else -> R.drawable.ic_file_present } } -fun Drawable.toBitmapDrawable() : BitmapDrawable { +fun Drawable.toBitmapDrawable(resources: Resources = Resources.getSystem()): BitmapDrawable { return when (this) { is BitmapDrawable -> this is AdaptiveIconDrawable -> { val bitmap = - Bitmap.createBitmap(intrinsicWidth , intrinsicHeight , Bitmap.Config.ARGB_8888) + Bitmap.createBitmap(intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) - setBounds(0 , 0 , canvas.width , canvas.height) + setBounds(0, 0, canvas.width, canvas.height) draw(canvas) - BitmapDrawable(Resources.getSystem() , bitmap) + BitmapDrawable(resources, bitmap) } else -> throw IllegalArgumentException("Unsupported drawable type: ${this::class.java.name}")