diff --git a/README.md b/README.md index 36ace7a..5372d12 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,6 @@ FileFlow scans your files periodically and organizes them according to your rule - **Rules** - Use [regex](https://github.com/BURG3R5/FileFlow/wiki/Examples) to precisely target files and template strings to rename them 🎯 -- **Actions** - Choose what to do with the files ⚙ - 1. Copy or move 📁 - **History** - Recent executions are stored (locally) ⏳ - **Free, open-source & private** - No ads, subscriptions, or in-app purchases 🆓 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8393446..715f19d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,6 +31,7 @@ android { debug { applicationIdSuffix = ".debug" resValue("string", "app_name_launcher", "FileFlow Debug") + manifestPlaceholders["allowBackup"] = false } create("nightly") { isDebuggable = false @@ -42,6 +43,7 @@ android { ) applicationIdSuffix = ".nightly" resValue("string", "app_name_launcher", "FileFlow Nightly") + manifestPlaceholders["allowBackup"] = true } release { isDebuggable = false @@ -51,6 +53,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard/release.pro", ) + manifestPlaceholders["allowBackup"] = true } } buildFeatures { diff --git a/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/2.json b/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/2.json new file mode 100644 index 0000000..d403474 --- /dev/null +++ b/app/schemas/co.adityarajput.fileflow.data.FileFlowDatabase/2.json @@ -0,0 +1,85 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b5ed230e3566b0c5dc93a3990ae7aace", + "entities": [ + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`action` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `executions` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "executions", + "columnName": "executions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "executions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileName` TEXT NOT NULL, `actionVerb` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionVerb", + "columnName": "actionVerb", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b5ed230e3566b0c5dc93a3990ae7aace')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7d73135..d5ba3e7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ (value) + } catch (_: Exception) { + Action.MOVE("", "", "", "") + } } diff --git a/app/src/main/java/co/adityarajput/fileflow/data/FileFlowDatabase.kt b/app/src/main/java/co/adityarajput/fileflow/data/FileFlowDatabase.kt index 41c9ba0..51d8fc9 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/FileFlowDatabase.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/FileFlowDatabase.kt @@ -1,14 +1,11 @@ package co.adityarajput.fileflow.data import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverters +import androidx.room.* import co.adityarajput.fileflow.data.models.Execution import co.adityarajput.fileflow.data.models.Rule -@Database([Rule::class, Execution::class], version = 1) +@Database([Rule::class, Execution::class], version = 2, autoMigrations = [AutoMigration(1, 2)]) @TypeConverters(Converters::class) abstract class FileFlowDatabase : RoomDatabase() { abstract fun ruleDao(): RuleDao diff --git a/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt b/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt index 8c9f89e..adec208 100644 --- a/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt +++ b/app/src/main/java/co/adityarajput/fileflow/data/models/Action.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import co.adityarajput.fileflow.R -import co.adityarajput.fileflow.utils.Logger +import co.adityarajput.fileflow.utils.FileSuperlative import co.adityarajput.fileflow.utils.getGetDirectoryFromUri import kotlinx.serialization.Serializable @@ -20,11 +20,8 @@ sealed class Action(val title: String) { val destFileNameTemplate: String, val keepOriginal: Boolean = true, val overwriteExisting: Boolean = false, - ) : Action(srcFileNamePattern) { - override fun toString() = when (this) { - is MOVE -> "MOVE\n$src\n$srcFileNamePattern\n$dest\n$destFileNameTemplate\n$keepOriginal\n$overwriteExisting" - } - } + val superlative: FileSuperlative = FileSuperlative.LATEST, + ) : Action(srcFileNamePattern) val verb get() = when (this) { @@ -44,19 +41,4 @@ sealed class Action(val title: String) { append(destFileNameTemplate) } } - - companion object { - fun fromString(value: String) = when { - value.startsWith("MOVE") -> { - val args = value.split("\n") - - MOVE(args[1], args[2], args[3], args[4], args[5].toBoolean(), args[6].toBoolean()) - } - - else -> { - Logger.e("Action", value) - throw IllegalArgumentException("Can't convert value to Action, unknown value: $value") - } - } - } } diff --git a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt b/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt index 7c5cdda..d15eb59 100644 --- a/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt +++ b/app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt @@ -1,13 +1,12 @@ package co.adityarajput.fileflow.services import android.content.Context -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile import co.adityarajput.fileflow.data.AppContainer import co.adityarajput.fileflow.data.models.Action import co.adityarajput.fileflow.data.models.Execution import co.adityarajput.fileflow.data.models.Rule import co.adityarajput.fileflow.utils.Logger +import co.adityarajput.fileflow.utils.pathToFile import kotlinx.coroutines.flow.first class FlowExecutor(private val context: Context) { @@ -22,52 +21,78 @@ class FlowExecutor(private val context: Context) { if (!rule.enabled || rule.action !is Action.MOVE) continue val regex = Regex(rule.action.srcFileNamePattern) - val destDir = context.pathToFile(rule.action.dest) ?: continue + val destDir = context.pathToFile(rule.action.dest) - for (srcFile in context.pathToFile(rule.action.src)?.listFiles() ?: arrayOf()) { - if (!srcFile.isFile || srcFile.name == null || !regex.matches(srcFile.name!!)) continue + if (destDir == null) { + Logger.e("FlowExecutor", "${rule.action.dest} is invalid") + continue + } - val destFileName = regex.replace( - srcFile.name!!, - rule.action.destFileNameTemplate, - ) - val destFiles = destDir.listFiles().filter { it.isFile } - var destFile = destFiles.firstOrNull { it.name == destFileName } + val srcFile = context.pathToFile(rule.action.src)?.listFiles() + ?.filter { it.isFile && it.name != null && regex.matches(it.name!!) } + ?.maxByOrNull(rule.action.superlative.selector) + ?: continue - if (destFile != null) { - if (!rule.action.overwriteExisting) { - Logger.e("FlowExecutor", "$destFileName already exists") - continue - } + val destFileName = regex.replace(srcFile.name!!, rule.action.destFileNameTemplate) + var destFile = destDir.listFiles().firstOrNull { it.isFile && it.name == destFileName } - Logger.i("FlowExecutor", "Deleting existing $destFileName") - destFile.delete() + if (destFile != null) { + if (!rule.action.overwriteExisting) { + Logger.e("FlowExecutor", "${destFile.name} already exists") + continue } - destFile = destDir.createFile( - srcFile.type ?: "application/octet-stream", - destFileName, - ) ?: continue - resolver.openInputStream(srcFile.uri).use { src -> - resolver.openOutputStream(destFile.uri).use { dest -> - if (src == null || dest == null) continue - - src.copyTo(dest) - Logger.i("FlowExecutor", "Copied ${srcFile.name} to ${destFile.name}") - repository.registerExecution( - rule, - Execution(srcFile.name!!, rule.action.verb), - ) - if (!rule.action.keepOriginal) { - Logger.i("FlowExecutor", "Deleting original ${srcFile.name}") - srcFile.delete() + resolver.openInputStream(destFile.uri).use { dest -> + if (src == null || dest == null) { + Logger.e("FlowExecutor", "Failed to open file(s)") + continue + } + + if (src.readBytes().contentEquals(dest.readBytes())) { + Logger.i( + "FlowExecutor", + "Source and destination files are identical", + ) + continue } } } + + Logger.i("FlowExecutor", "Deleting existing ${destFile.name}") + destFile.delete() + } + + destFile = destDir.createFile( + srcFile.type ?: "application/octet-stream", + destFileName, + ) + + if (destFile == null) { + Logger.e("FlowExecutor", "Failed to create $destFileName") + continue + } + + resolver.openInputStream(srcFile.uri).use { src -> + resolver.openOutputStream(destFile.uri).use { dest -> + if (src == null || dest == null) { + Logger.e("FlowExecutor", "Failed to open file(s)") + continue + } + + Logger.i("FlowExecutor", "Copying ${srcFile.name} to ${destFile.name}") + src.copyTo(dest) + repository.registerExecution( + rule, + Execution(srcFile.name!!, rule.action.verb), + ) + + if (!rule.action.keepOriginal) { + Logger.i("FlowExecutor", "Deleting original ${srcFile.name}") + srcFile.delete() + } + } } } } } - -fun Context.pathToFile(path: String): DocumentFile? = DocumentFile.fromTreeUri(this, path.toUri()) diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt new file mode 100644 index 0000000..20d72fd --- /dev/null +++ b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt @@ -0,0 +1,15 @@ +package co.adityarajput.fileflow.utils + +import android.content.Context +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import co.adityarajput.fileflow.R + +fun Context.pathToFile(path: String) = DocumentFile.fromTreeUri(this, path.toUri()) + +enum class FileSuperlative(val displayName: Int, val selector: (DocumentFile) -> Long) { + EARLIEST(R.string.earliest, { -it.lastModified() }), + LATEST(R.string.latest, { it.lastModified() }), + SMALLEST(R.string.smallest, { -it.length() }), + LARGEST(R.string.largest, { it.length() }), +} diff --git a/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt b/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt index 8fe6cf1..abc4e1a 100644 --- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt +++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/RulesViewModel.kt @@ -27,9 +27,19 @@ class RulesViewModel(private val repository: Repository) : ViewModel() { var selectedRule by mutableStateOf(null) - fun executeRule(context: Context) { + fun executeRule(context: Context, showToast: (String) -> Unit) { viewModelScope.launch { + val latestLogBeforeExecution = Logger.logs.lastOrNull() + FlowExecutor(context).run(listOf(selectedRule!!)) + + val recentErrorLog = Logger.logs + .dropWhile { it != latestLogBeforeExecution }.drop(1) + .firstOrNull { it.contains("[ERROR]") } + ?: Logger.logs.lastOrNull { it.contains("[ERROR]") } + if (recentErrorLog != null) { + showToast("Error:" + recentErrorLog.substringAfter("[ERROR]")) + } } } diff --git a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt index c079925..72b7f6d 100644 --- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt +++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt @@ -8,8 +8,9 @@ import androidx.lifecycle.ViewModel import co.adityarajput.fileflow.data.Repository import co.adityarajput.fileflow.data.models.Action import co.adityarajput.fileflow.data.models.Rule -import co.adityarajput.fileflow.services.pathToFile +import co.adityarajput.fileflow.utils.FileSuperlative import co.adityarajput.fileflow.utils.Logger +import co.adityarajput.fileflow.utils.pathToFile class UpsertRuleViewModel( rule: Rule?, @@ -18,6 +19,7 @@ class UpsertRuleViewModel( data class State( val values: Values = Values(), val error: FormError? = null, + val warning: FormWarning? = null, ) data class Values( @@ -26,19 +28,22 @@ class UpsertRuleViewModel( val srcFileNamePattern: String = "", val dest: String = "", val destFileNameTemplate: String = "", + val superlative: FileSuperlative = FileSuperlative.LATEST, val keepOriginal: Boolean = true, val overwriteExisting: Boolean = false, + val currentSrcFileNames: List? = null, + val predictedDestFileNames: List? = null, ) { constructor(rule: Rule) : this( rule.id, (rule.action as Action.MOVE).src, rule.action.srcFileNamePattern, - rule.action.dest, rule.action.destFileNameTemplate, rule.action.keepOriginal, - rule.action.overwriteExisting, + rule.action.dest, rule.action.destFileNameTemplate, rule.action.superlative, + rule.action.keepOriginal, rule.action.overwriteExisting, ) fun toRule() = Rule( Action.MOVE( - src, srcFileNamePattern, dest, destFileNameTemplate, keepOriginal, - overwriteExisting, + src, srcFileNamePattern, dest, destFileNameTemplate, + keepOriginal, overwriteExisting, superlative, ), id = ruleId, ) @@ -49,25 +54,38 @@ class UpsertRuleViewModel( else State(Values(rule), null), ) - fun getFilesInSrc(context: Context): List? { + fun updateForm(context: Context, values: Values) { + var currentSrcFileNames: List? = null try { - if (state.values.src.isBlank()) return null - - return context.pathToFile(state.values.src)!! - .listFiles() - .filter { it.isFile && it.name != null } - .map { it.name!! } + if (values.src.isNotBlank()) + currentSrcFileNames = context.pathToFile(values.src)!!.listFiles() + .filter { it.isFile && it.name != null }.map { it.name!! } } catch (e: Exception) { - Logger.e("UpsertRuleViewModel", "Couldn't fetch files in ${state.values.src}", e) - return null + Logger.e("UpsertRuleViewModel", "Couldn't fetch files in ${values.src}", e) } - } - fun updateForm(values: Values) { - state = State(values, getError(values)) + var predictedDestFileNames: List? = null + var warning: FormWarning? = null + try { + val regex = Regex(values.srcFileNamePattern) + + if (values.destFileNameTemplate.isNotBlank()) + predictedDestFileNames = currentSrcFileNames + ?.filter { regex.matches(it) } + ?.also { if (it.isEmpty()) warning = FormWarning.NO_MATCHES_IN_SRC } + ?.map { regex.replace(it, values.destFileNameTemplate) } + ?.distinct() + } catch (_: Exception) { + } + + val values = values.copy( + currentSrcFileNames = currentSrcFileNames, + predictedDestFileNames = predictedDestFileNames, + ) + state = State(values, getError(values), warning) } - private fun getError(values: Values = state.values): FormError? { + private fun getError(values: Values): FormError? { try { if ( values.src.isBlank() || @@ -77,18 +95,20 @@ class UpsertRuleViewModel( ) return FormError.BLANK_FIELDS Regex(values.srcFileNamePattern).pattern == values.srcFileNamePattern + + if (values.predictedDestFileNames == null) return FormError.INVALID_TEMPLATE } catch (_: Exception) { - Logger.d("RulesViewModel", "Invalid regex") + Logger.d("UpsertRuleViewModel", "Invalid regex") return FormError.INVALID_REGEX } return null } suspend fun submitForm() { - if (getError() == null) { + if (getError(state.values) == null) { val rule = state.values.toRule() Logger.d( - "RulesViewModel", + "UpsertRuleViewModel", "${if (state.values.ruleId == 0) "Adding" else "Updating"} $rule", ) repository.upsert(rule) @@ -96,4 +116,5 @@ class UpsertRuleViewModel( } } -enum class FormError { BLANK_FIELDS, INVALID_REGEX } +enum class FormError { BLANK_FIELDS, INVALID_REGEX, INVALID_TEMPLATE } +enum class FormWarning { NO_MATCHES_IN_SRC } diff --git a/app/src/main/java/co/adityarajput/fileflow/views/components/ManageRuleDialog.kt b/app/src/main/java/co/adityarajput/fileflow/views/components/ManageRuleDialog.kt index 3c7a4f3..3012c0e 100644 --- a/app/src/main/java/co/adityarajput/fileflow/views/components/ManageRuleDialog.kt +++ b/app/src/main/java/co/adityarajput/fileflow/views/components/ManageRuleDialog.kt @@ -1,5 +1,6 @@ package co.adityarajput.fileflow.views.components +import android.widget.Toast import androidx.compose.foundation.layout.Row import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -57,9 +58,11 @@ fun ManageRuleDialog(viewModel: RulesViewModel) { TextButton( { when (dialogState) { - DialogState.EXECUTE -> viewModel.executeRule(context) DialogState.TOGGLE_RULE -> viewModel.toggleRule() DialogState.DELETE -> viewModel.deleteRule() + DialogState.EXECUTE -> viewModel.executeRule(context) { + Toast.makeText(context, it, Toast.LENGTH_LONG).show() + } } hideDialog() }, diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt index f2035d5..cf2665b 100644 --- a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt +++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt @@ -6,9 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -20,9 +18,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.lifecycle.viewmodel.compose.viewModel import co.adityarajput.fileflow.R +import co.adityarajput.fileflow.utils.FileSuperlative import co.adityarajput.fileflow.utils.getGetDirectoryFromUri import co.adityarajput.fileflow.utils.takePersistablePermission import co.adityarajput.fileflow.viewmodels.FormError +import co.adityarajput.fileflow.viewmodels.FormWarning import co.adityarajput.fileflow.viewmodels.Provider import co.adityarajput.fileflow.viewmodels.UpsertRuleViewModel import co.adityarajput.fileflow.views.components.AppBar @@ -115,11 +115,7 @@ fun UpsertRuleScreen( private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { val context = LocalContext.current - val filesInSrc = remember(viewModel.state.values.src) { viewModel.getFilesInSrc(context) } - val showWarning = remember(viewModel.state.values) { - if (viewModel.state.error != null || filesInSrc == null || filesInSrc.isEmpty()) false - else filesInSrc.none { Regex(viewModel.state.values.srcFileNamePattern).matches(it) } - } + var superlativeDropdownExpanded by remember { mutableStateOf(false) } val srcPicker = rememberLauncherForActivityResult( @@ -127,9 +123,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { ) { uri -> uri ?: return@rememberLauncherForActivityResult context.takePersistablePermission(uri) - viewModel.updateForm( - values = viewModel.state.values.copy(src = uri.toString()), - ) + viewModel.updateForm(context, viewModel.state.values.copy(src = uri.toString())) } val destPicker = rememberLauncherForActivityResult( @@ -137,9 +131,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { ) { uri -> uri ?: return@rememberLauncherForActivityResult context.takePersistablePermission(uri) - viewModel.updateForm( - values = viewModel.state.values.copy(dest = uri.toString()), - ) + viewModel.updateForm(context, viewModel.state.values.copy(dest = uri.toString())) } Text( @@ -155,15 +147,13 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { Modifier .fillMaxWidth() .clickable { srcPicker.launch(null) }, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Normal, ) OutlinedTextField( viewModel.state.values.srcFileNamePattern, { - viewModel.updateForm( - viewModel.state.values.copy(srcFileNamePattern = it), - ) + viewModel.updateForm(context, viewModel.state.values.copy(srcFileNamePattern = it)) }, Modifier.fillMaxWidth(), label = { @@ -171,13 +161,14 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { }, placeholder = { Text(stringResource(R.string.pattern_placeholder)) }, supportingText = { - if (filesInSrc == null || filesInSrc.isEmpty()) + if (viewModel.state.values.currentSrcFileNames?.isEmpty() ?: true) Text(stringResource(R.string.match_entire_filename)) else Text( stringResource( R.string.pattern_should_match, - filesInSrc.joinToString(stringResource(R.string.or), limit = 3), + viewModel.state.values.currentSrcFileNames!! + .joinToString(stringResource(R.string.or), limit = 3), ), ) }, @@ -190,7 +181,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { ) Row( Modifier.toggleable(!viewModel.state.values.keepOriginal) { - viewModel.updateForm(viewModel.state.values.copy(keepOriginal = !it)) + viewModel.updateForm(context, viewModel.state.values.copy(keepOriginal = !it)) }, Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), Alignment.Top, @@ -202,6 +193,30 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { fontWeight = FontWeight.Normal, ) } + Box { + Text( + buildAnnotatedString { + append(stringResource(R.string.choose_superlative)) + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + append(stringResource(viewModel.state.values.superlative.displayName)) + } + }, + Modifier.clickable { superlativeDropdownExpanded = true }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + ) + DropdownMenu(superlativeDropdownExpanded, { superlativeDropdownExpanded = false }) { + FileSuperlative.entries.forEach { + DropdownMenuItem( + { Text(stringResource(it.displayName)) }, + { + viewModel.updateForm(context, viewModel.state.values.copy(superlative = it)) + superlativeDropdownExpanded = false + }, + ) + } + } + } Icon( painterResource(R.drawable.arrow_down), stringResource(R.string.alttext_arrow_down), @@ -220,21 +235,29 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { Modifier .fillMaxWidth() .clickable { destPicker.launch(null) }, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Normal, ) OutlinedTextField( viewModel.state.values.destFileNameTemplate, { - viewModel.updateForm( - viewModel.state.values.copy(destFileNameTemplate = it), - ) + viewModel.updateForm(context, viewModel.state.values.copy(destFileNameTemplate = it)) }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.file_name_template)) }, placeholder = { Text(stringResource(R.string.template_placeholder)) }, + supportingText = { + if (viewModel.state.values.predictedDestFileNames?.isNotEmpty() ?: false) + Text( + stringResource( + R.string.template_will_yield, + viewModel.state.values.predictedDestFileNames!! + .joinToString(stringResource(R.string.or), limit = 3), + ), + ) + }, colors = OutlinedTextFieldDefaults.colors( focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, @@ -244,7 +267,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { ) Row( Modifier.toggleable(viewModel.state.values.overwriteExisting) { - viewModel.updateForm(viewModel.state.values.copy(overwriteExisting = it)) + viewModel.updateForm(context, viewModel.state.values.copy(overwriteExisting = it)) }, Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)), Alignment.Top, @@ -270,5 +293,6 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) { fontWeight = FontWeight.Normal, ) if (viewModel.state.error == FormError.INVALID_REGEX) ErrorText(R.string.invalid_regex) - if (showWarning) WarningText(R.string.pattern_doesnt_match_src_files) + else if (viewModel.state.error == FormError.INVALID_TEMPLATE) ErrorText(R.string.invalid_template) + else if (viewModel.state.warning == FormWarning.NO_MATCHES_IN_SRC) WarningText(R.string.pattern_doesnt_match_src_files) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b3f5247..5061ece 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,12 +31,19 @@ Pattern should match %1$s " or " Delete original files from the source after execution + "In case of multiple matches, choose: " + earliest + latest + smallest + largest "Destination: " File name template Enter a regex template + Template will yield %1$s Overwrite existing files in the destination, in case of conflict here↗ for example patterns and templates.]]> Regex pattern contains errors + Regex template contains errors Regex pattern doesn\'t match any file in the source folder Cancel Add diff --git a/metadata/en-US/changelogs/1.txt b/metadata/en-US/changelogs/1.txt index f8a032f..03081f0 100644 --- a/metadata/en-US/changelogs/1.txt +++ b/metadata/en-US/changelogs/1.txt @@ -1 +1 @@ -Initial release +Initial release \ No newline at end of file diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt new file mode 100644 index 0000000..8d20d85 --- /dev/null +++ b/metadata/en-US/full_description.txt @@ -0,0 +1 @@ +

FileFlow scans your files periodically and organizes them according to your rules.

Features:

  • Rules: Use regex to precisely target files and template strings to rename them.
  • History: Recent executions are stored (locally).
  • Private: Fully offline; your data never leaves your device.
  • Lightweight: Runs in the background with minimal battery and memory usage.

\ No newline at end of file diff --git a/metadata/en-US/images/featureGraphic.png b/metadata/en-US/images/featureGraphic.png new file mode 100644 index 0000000..4701226 Binary files /dev/null and b/metadata/en-US/images/featureGraphic.png differ diff --git a/metadata/en-US/short_description.txt b/metadata/en-US/short_description.txt new file mode 100644 index 0000000..4a3c865 --- /dev/null +++ b/metadata/en-US/short_description.txt @@ -0,0 +1 @@ +Organize files automatically \ No newline at end of file diff --git a/metadata/en-US/title.txt b/metadata/en-US/title.txt new file mode 100644 index 0000000..188fac1 --- /dev/null +++ b/metadata/en-US/title.txt @@ -0,0 +1 @@ +FileFlow \ No newline at end of file diff --git a/metadata/hi-IN/short_description.txt b/metadata/hi-IN/short_description.txt new file mode 100644 index 0000000..95aca94 --- /dev/null +++ b/metadata/hi-IN/short_description.txt @@ -0,0 +1 @@ +फ़ाइलों को अपने-आप व्यवस्थित करें \ No newline at end of file