From 3965a9ebb2653cc7cf4331849c4583a4fb6f6123 Mon Sep 17 00:00:00 2001 From: Aliaksei Ivanouski Date: Sat, 6 Jun 2026 12:44:00 +0200 Subject: [PATCH] Add dialog to customize search options --- app/build.gradle.kts | 23 +- .../data/entity/EncryptedDatabaseEntry.kt | 31 +- .../ivanovsky/passnotes/data/entity/Group.kt | 17 - .../ivanovsky/passnotes/data/entity/Note.kt | 18 - .../encdb/EncryptedDatabaseConfig.kt | 3 + .../encdb/MutableEncryptedDatabaseConfig.kt | 3 + .../file/fake/FakeDatabaseContentFactory.kt | 122 ++++++- .../repository/file/fake/FakeFileFactory.kt | 4 +- .../keepass/kotpass/KotpassDatabase.kt | 1 + .../data/repository/settings/Settings.kt | 4 +- .../data/repository/settings/SettingsImpl.kt | 23 +- .../serialization/SearchSettingsConverter.kt | 115 +++++++ .../passnotes/domain/entity/SearchOptions.kt | 28 ++ .../passnotes/domain/entity/SearchScope.kt | 6 + .../interactor/groups/GroupsInteractor.kt | 7 +- .../passnotes/domain/search/EntryMatcher.kt | 2 + .../domain/search/Fzf4jFuzzyEntryMatcher.kt | 77 +++-- .../domain/search/StrictEntryMatcher.kt | 73 +++- .../domain/usecases/SearchUseCases.kt | 181 ++++++---- .../passnotes/extensions/OperationErrorExt.kt | 10 + .../passnotes/injection/modules/UiModule.kt | 2 + .../injection/modules/UseCaseModule.kt | 2 +- .../core/dialog/AllFilesPermissionDialog.kt | 3 +- .../core/dialog/BaseComposeDialog.kt | 3 +- .../core/dialog/BaseDialogFragment.kt | 9 + .../core/dialog/ConfirmationDialog.kt | 3 +- .../presentation/core/dialog/MessageDialog.kt | 3 +- .../core/dialog/helpDialog/HelpDialog.kt | 44 --- .../core/dialog/helpDialog/HelpDialogArgs.kt | 12 - .../core/dialog/optionDialog/OptionDialog.kt | 4 +- .../propertyAction/PropertyActionDialog.kt | 4 +- .../resolveConflict/ResolveConflictDialog.kt | 4 +- .../searchOptions/SearchOptionsDialog.kt | 37 ++ .../SearchOptionsDialogScreen.kt | 315 ++++++++++++++++++ .../SearchOptionsDialogViewModel.kt | 120 +++++++ .../searchOptions/model/SearchOptionState.kt | 16 + .../dialog/sortAndView/SortAndViewDialog.kt | 4 +- .../sortAndView/SortAndViewDialogViewModel.kt | 4 +- .../core/extensions/FragmentExt.kt | 7 - .../presentation/groups/GroupsFragment.kt | 9 + .../presentation/groups/GroupsViewModel.kt | 33 +- .../groups/dialog/ChooseOptionDialog.kt | 4 +- .../settings/app/AppSettingsFragment.kt | 2 - .../changePassword/ChangePasswordDialog.kt | 4 +- .../com/ivanovsky/passnotes/util/EitherExt.kt | 12 + .../ivanovsky/passnotes/util/SearchOptions.kt | 16 + app/src/main/res/drawable/ic_dice_24dp.xml | 10 + .../res/drawable/ic_search_config_24dp.xml | 12 + .../main/res/layout/cell_secret_property.xml | 2 +- app/src/main/res/layout/groups_fragment.xml | 24 +- app/src/main/res/values/strings.xml | 8 + app/src/main/res/values/styles.xml | 4 +- .../res/values/untranslatable-strings.xml | 2 +- app/src/main/res/xml/application_settings.xml | 7 - .../SearchOptionsConverterTest.kt | 124 +++++++ .../search/Fzf4jFuzzyEntryMatcherTest.kt | 218 ++++++++++++ gradle.properties | 12 +- gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 9 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 60 files changed, 1606 insertions(+), 264 deletions(-) delete mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/Group.kt delete mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/Note.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/data/serialization/SearchSettingsConverter.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/domain/entity/SearchOptions.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/domain/entity/SearchScope.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/BaseDialogFragment.kt delete mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/helpDialog/HelpDialog.kt delete mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/helpDialog/HelpDialogArgs.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialog.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialogScreen.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialogViewModel.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/model/SearchOptionState.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/util/EitherExt.kt create mode 100644 app/src/main/kotlin/com/ivanovsky/passnotes/util/SearchOptions.kt create mode 100644 app/src/main/res/drawable/ic_dice_24dp.xml create mode 100644 app/src/main/res/drawable/ic_search_config_24dp.xml create mode 100644 app/src/test/kotlin/com/ivanovsky/passnotes/data/serialization/SearchOptionsConverterTest.kt create mode 100644 app/src/test/kotlin/com/ivanovsky/passnotes/domain/search/Fzf4jFuzzyEntryMatcherTest.kt create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 868f9f14..d4e97929 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -179,7 +179,6 @@ android { release { isDebuggable = false isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("release") } } @@ -259,9 +258,11 @@ dependencies { androidTestImplementation(libs.truth) androidTestImplementation(libs.kotlin.reflect) + // DI implementation(libs.koin.android) implementation(libs.koin.core) + // Room implementation(libs.room.runtime) ksp(libs.room.compiler) androidTestImplementation(libs.room.testing) @@ -289,29 +290,37 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) - implementation(libs.apache.commons.lang3) + implementation(libs.apache.commons.lang3) // TODO: Remove implementation(libs.kotlin.stdlib) implementation(libs.kotlin.reflect) implementation(libs.kotlinx.coroutines.android) + // Network implementation(libs.okhttp) implementation(libs.okhttp.logging.interceptor) - implementation(libs.sardine.android) + // Git + implementation(libs.jgit) + implementation(libs.jgit.ssh.jsch) + + // Routing implementation(libs.cicerone) + // Logging implementation(libs.timber) implementation(libs.treessence) - implementation(libs.jgit) - implementation(libs.jgit.ssh.jsch) - + // KeePass implementation(libs.kotpass) implementation(libs.keepass.tree.diff) implementation(libs.keepass.tree.builder) - implementation(libs.fzf4j) + // Arrow + implementation(libs.arrowCore) + implementation(libs.arrowCoroutines) + // Other + implementation(libs.fzf4j) implementation(libs.totp.kt) } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/EncryptedDatabaseEntry.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/EncryptedDatabaseEntry.kt index e8dcabc5..d19fac84 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/EncryptedDatabaseEntry.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/EncryptedDatabaseEntry.kt @@ -1,6 +1,35 @@ package com.ivanovsky.passnotes.data.entity +import android.os.Parcelable +import java.util.Date +import java.util.UUID +import kotlinx.parcelize.Parcelize + /** * Interface for [Group] and [Note] */ -interface EncryptedDatabaseEntry : EncryptedDatabaseElement \ No newline at end of file +sealed interface EncryptedDatabaseEntry : EncryptedDatabaseElement + +@Parcelize +data class Note( + val uid: UUID? = null, + val groupUid: UUID, + val created: Date, + val modified: Date, + val expiration: Date?, + val title: String, + val properties: List = emptyList(), + val attachments: List = emptyList() +) : EncryptedDatabaseEntry, Parcelable + +// TODO(Refactor): Remove groupCount and noteCount, it should be in standalone entity +@Parcelize +data class Group( + val uid: UUID, + val parentUid: UUID?, + val title: String, + val groupCount: Int, + val noteCount: Int, + val autotypeEnabled: InheritableBooleanOption, + val searchEnabled: InheritableBooleanOption +) : EncryptedDatabaseEntry, Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/Group.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/Group.kt deleted file mode 100644 index b5161c7f..00000000 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/Group.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.ivanovsky.passnotes.data.entity - -import android.os.Parcelable -import java.util.UUID -import kotlinx.parcelize.Parcelize - -// TODO(Refactor): Remove groupCount and noteCount, it should be in standalone entity -@Parcelize -data class Group( - val uid: UUID, - val parentUid: UUID?, - val title: String, - val groupCount: Int, - val noteCount: Int, - val autotypeEnabled: InheritableBooleanOption, - val searchEnabled: InheritableBooleanOption -) : EncryptedDatabaseEntry, Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/Note.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/Note.kt deleted file mode 100644 index 70a29838..00000000 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/entity/Note.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.ivanovsky.passnotes.data.entity - -import android.os.Parcelable -import java.util.Date -import java.util.UUID -import kotlinx.parcelize.Parcelize - -@Parcelize -data class Note( - val uid: UUID? = null, - val groupUid: UUID, - val created: Date, - val modified: Date, - val expiration: Date?, - val title: String, - val properties: List = emptyList(), - val attachments: List = emptyList() -) : EncryptedDatabaseEntry, Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/encdb/EncryptedDatabaseConfig.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/encdb/EncryptedDatabaseConfig.kt index ae3d962e..25b75b4a 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/encdb/EncryptedDatabaseConfig.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/encdb/EncryptedDatabaseConfig.kt @@ -1,7 +1,10 @@ package com.ivanovsky.passnotes.data.repository.encdb +import java.util.UUID + interface EncryptedDatabaseConfig { val isRecycleBinEnabled: Boolean + val recycleBinUid: UUID? val maxHistoryItems: Int fun toMutableConfig(): MutableEncryptedDatabaseConfig } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/encdb/MutableEncryptedDatabaseConfig.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/encdb/MutableEncryptedDatabaseConfig.kt index 4a4dbb50..da238488 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/encdb/MutableEncryptedDatabaseConfig.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/encdb/MutableEncryptedDatabaseConfig.kt @@ -1,7 +1,10 @@ package com.ivanovsky.passnotes.data.repository.encdb +import java.util.UUID + data class MutableEncryptedDatabaseConfig( override var isRecycleBinEnabled: Boolean, + override val recycleBinUid: UUID?, override var maxHistoryItems: Int ) : EncryptedDatabaseConfig { diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/file/fake/FakeDatabaseContentFactory.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/file/fake/FakeDatabaseContentFactory.kt index 7fe4f31e..54dea6e7 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/file/fake/FakeDatabaseContentFactory.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/file/fake/FakeDatabaseContentFactory.kt @@ -23,24 +23,36 @@ object FakeDatabaseContentFactory { return DatabaseBuilderDsl.newBuilder(KotpassDatabaseConverter()) .key(PASSWORD_KEY) .content(ROOT) { - group(GROUP_EMAIL) + group(GROUP_EMAIL) { + entry(ENTRY_PROTON_MAIL) + entry(ENTRY_OUTLOOK) + } group(GROUP_INTERNET) { group(GROUP_CODING) { entry(ENTRY_LEETCODE) entry(ENTRY_NEETCODE) entry(ENTRY_GITHUB) + entry(ENTRY_STACK_OVERFLOW) + entry(ENTRY_BITBUCKET) } group(GROUP_GAMING) { entry(ENTRY_STADIA) + entry(ENTRY_STEAM) } group(GROUP_SHOPPING) { entry(ENTRY_AMAZON) + entry(ENTRY_EBAY) + entry(ENTRY_ETSY) + } + group(GROUP_SOCIAL) { + entry(ENTRY_LINKEDIN) + entry(ENTRY_REDDIT) } - group(GROUP_SOCIAL) entry(ENTRY_GOOGLE) entry(ENTRY_APPLE) entry(ENTRY_MICROSOFT) + entry(ENTRY_DROPBOX) } entry(ENTRY_NAS_LOGIN) entry(ENTRY_LAPTOP_LOGIN) @@ -159,24 +171,36 @@ object FakeDatabaseContentFactory { return DatabaseBuilderDsl.newBuilder(KotpassDatabaseConverter()) .key(PASSWORD_KEY) .content(ROOT) { - group(GROUP_EMAIL) + group(GROUP_EMAIL) { + entry(ENTRY_PROTON_MAIL) + entry(ENTRY_OUTLOOK) + } group(GROUP_INTERNET) { group(GROUP_CODING) { entry(ENTRY_LEETCODE) entry(ENTRY_NEETCODE) entry(ENTRY_GITHUB) + entry(ENTRY_STACK_OVERFLOW) + entry(ENTRY_BITBUCKET) } group(GROUP_GAMING) { entry(ENTRY_STADIA) + entry(ENTRY_STEAM) } group(GROUP_SHOPPING) { entry(newDemoAmazonEntry()) + entry(ENTRY_EBAY) + entry(ENTRY_ETSY) + } + group(GROUP_SOCIAL) { + entry(ENTRY_LINKEDIN) + entry(ENTRY_REDDIT) } - group(GROUP_SOCIAL) entry(ENTRY_GOOGLE) entry(ENTRY_APPLE) entry(ENTRY_MICROSOFT) + entry(ENTRY_DROPBOX) } entry(ENTRY_NAS_LOGIN) entry(ENTRY_LAPTOP_LOGIN) @@ -504,6 +528,96 @@ object FakeDatabaseContentFactory { ) ) + private val ENTRY_PROTON_MAIL = newEntry( + title = "Proton Mail", + username = "john.doe@protonmail.com", + password = "abc123", + url = "https://mail.proton.me", + created = parseDate("2020-01-10"), + modified = parseDate("2020-01-10") + ) + + private val ENTRY_OUTLOOK = newEntry( + title = "Outlook", + username = "john.doe@outlook.com", + password = "abc123", + url = "https://outlook.live.com", + created = parseDate("2020-01-11"), + modified = parseDate("2020-01-11") + ) + + private val ENTRY_STACK_OVERFLOW = newEntry( + title = "Stack Overflow", + username = "john.doe@example.com", + password = "abc123", + url = "https://stackoverflow.com", + created = parseDate("2020-01-12"), + modified = parseDate("2020-01-12") + ) + + private val ENTRY_BITBUCKET = newEntry( + title = "Bitbucket", + username = "john.doe@example.com", + password = "abc123", + url = "https://bitbucket.org", + created = parseDate("2020-01-13"), + modified = parseDate("2020-01-13") + ) + + private val ENTRY_STEAM = newEntry( + title = "Steam", + username = "john.doe", + password = "abc123", + url = "https://store.steampowered.com", + created = parseDate("2020-01-14"), + modified = parseDate("2020-01-14") + ) + + private val ENTRY_EBAY = newEntry( + title = "eBay", + username = "john.doe@example.com", + password = "abc123", + url = "https://ebay.com", + created = parseDate("2020-01-15"), + modified = parseDate("2020-01-15") + ) + + private val ENTRY_ETSY = newEntry( + title = "Etsy", + username = "john.doe@example.com", + password = "abc123", + url = "https://etsy.com", + created = parseDate("2020-01-16"), + modified = parseDate("2020-01-16") + ) + + private val ENTRY_LINKEDIN = newEntry( + title = "LinkedIn", + username = "john.doe@example.com", + password = "abc123", + url = "https://linkedin.com", + created = parseDate("2020-01-17"), + modified = parseDate("2020-01-17") + ) + + private val ENTRY_REDDIT = newEntry( + title = "Reddit", + username = "john.doe", + password = "abc123", + url = "https://reddit.com", + created = parseDate("2020-01-18"), + modified = parseDate("2020-01-18") + ) + + private val ENTRY_DROPBOX = newEntry( + title = "Dropbox", + username = "john.doe@example.com", + password = "abc123", + url = "https://dropbox.com", + created = parseDate("2020-01-19"), + modified = parseDate("2020-01-19") + ) + private val ENTRY_TOTP = newEntry( title = "TOTP Entry", username = "john.doe@example.com", diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/file/fake/FakeFileFactory.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/file/fake/FakeFileFactory.kt index 22bdfc42..6b4feb63 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/file/fake/FakeFileFactory.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/file/fake/FakeFileFactory.kt @@ -87,8 +87,8 @@ class FakeFileFactory( newEntry( localFile = newFile(Path.DEMO, Time.NO_CHANGES), syncStatus = SyncStatus.NO_CHANGES, - localContentFactory = LOCAL_CONTENT_FACTORY, - remoteContentFactory = LOCAL_CONTENT_FACTORY + localContentFactory = { createDemoDatabase() }, + remoteContentFactory = { createDemoDatabase() } ), newEntry( diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/keepass/kotpass/KotpassDatabase.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/keepass/kotpass/KotpassDatabase.kt index ec6a03a7..1ec24159 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/keepass/kotpass/KotpassDatabase.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/keepass/kotpass/KotpassDatabase.kt @@ -88,6 +88,7 @@ class KotpassDatabase( val config = MutableEncryptedDatabaseConfig( isRecycleBinEnabled = rawDatabase.content.meta.recycleBinEnabled, + recycleBinUid = rawDatabase.content.meta.recycleBinUuid, maxHistoryItems = rawDatabase.content.meta.historyMaxItems ) diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/Settings.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/Settings.kt index bef045f0..4ac46a5b 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/Settings.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/Settings.kt @@ -4,7 +4,7 @@ import com.ivanovsky.passnotes.data.entity.PasswordGeneratorSettings import com.ivanovsky.passnotes.data.entity.TestAutofillData import com.ivanovsky.passnotes.data.entity.TestToggles import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref -import com.ivanovsky.passnotes.domain.entity.SearchType +import com.ivanovsky.passnotes.domain.entity.SearchOptions import com.ivanovsky.passnotes.domain.entity.SortDirection import com.ivanovsky.passnotes.domain.entity.SortType @@ -18,7 +18,7 @@ interface Settings { var isLockNotificationDialogEnabled: Boolean var isLockDatabaseOnBack: Boolean var isFileLogEnabled: Boolean - var searchType: SearchType + var searchOptions: SearchOptions var isActivateSearchOnStart: Boolean var sortType: SortType var sortDirection: SortDirection diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/SettingsImpl.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/SettingsImpl.kt index 2283d3f6..d3a111d1 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/SettingsImpl.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/repository/settings/SettingsImpl.kt @@ -25,7 +25,7 @@ import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_LOC import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_POSTPONED_SYNC_ENABLED import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_SSL_CERTIFICATE_VALIDATION_ENABLED import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.PASSWORD_GENERATOR_SETTINGS -import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.SEARCH_TYPE +import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.SEARCH_OPTIONS import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.SORT_DIRECTION import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.SORT_TYPE import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.TEST_AUTOFILL_DATA @@ -34,9 +34,10 @@ import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.PrefType.BO import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.PrefType.INT import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.PrefType.STRING import com.ivanovsky.passnotes.data.serialization.PasswordGeneratorSettingsConverter +import com.ivanovsky.passnotes.data.serialization.SearchSettingsConverter import com.ivanovsky.passnotes.data.serialization.TestAutofillDataConverter import com.ivanovsky.passnotes.data.serialization.TestTogglesConverter -import com.ivanovsky.passnotes.domain.entity.SearchType +import com.ivanovsky.passnotes.domain.entity.SearchOptions import com.ivanovsky.passnotes.domain.entity.SortDirection import com.ivanovsky.passnotes.domain.entity.SortType import java.util.concurrent.CopyOnWriteArrayList @@ -120,13 +121,13 @@ class SettingsImpl(private val context: Context) : Settings { putString(AUTO_CLEAR_CLIPBOARD_DELAY_IN_MS, value.toString()) } - override var searchType: SearchType - get() { - return getString(SEARCH_TYPE)?.let { SearchType.getByName(it) } - ?: SearchType.default() - } + override var searchOptions: SearchOptions + get() = getString(SEARCH_OPTIONS)?.let { + SearchSettingsConverter.fromString(it) + } ?: SearchOptions.DEFAULT set(value) { - putString(SEARCH_TYPE, value.name) + val strValue = value.let { SearchSettingsConverter.toString(it) } + putString(SEARCH_OPTIONS, strValue) } override var isActivateSearchOnStart: Boolean @@ -351,10 +352,10 @@ class SettingsImpl(private val context: Context) : Settings { ), // String prefs - SEARCH_TYPE( - keyId = R.string.pref_search_type, + SEARCH_OPTIONS( + keyId = R.string.pref_search_options, type = STRING, - defaultValue = SearchType.default().name + defaultValue = null ), SORT_TYPE( keyId = R.string.pref_sort_type, diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/data/serialization/SearchSettingsConverter.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/data/serialization/SearchSettingsConverter.kt new file mode 100644 index 00000000..715ba92b --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/data/serialization/SearchSettingsConverter.kt @@ -0,0 +1,115 @@ +package com.ivanovsky.passnotes.data.serialization + +import com.ivanovsky.passnotes.domain.entity.SearchOptions +import com.ivanovsky.passnotes.domain.entity.SearchScope +import com.ivanovsky.passnotes.domain.entity.SearchType +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import timber.log.Timber + +object SearchSettingsConverter { + + const val KEY_SEARCH_TYPE = "searchType" + const val KEY_IS_TITLE_ENABLED = "isTitleEnabled" + const val KEY_IS_USERNAME_ENABLED = "isUsernameEnabled" + const val KEY_IS_PASSWORD_ENABLED = "isPasswordEnabled" + const val KEY_IS_URL_ENABLED = "isUrlEnabled" + const val KEY_IS_NOTES_ENABLED = "isNotesEnabled" + const val KEY_IS_OTHER_FIELDS_ENABLED = "isOtherFieldsEnabled" + const val KEY_RESTRICTION_SCOPES = "restrictionScopes" + const val KEY_IS_CASE_SENSITIVE = "isCaseSensitive" + + fun toString(settings: SearchOptions): String? { + return try { + val obj = JSONObject() + + obj.put(KEY_SEARCH_TYPE, settings.searchType.name) + obj.put(KEY_IS_TITLE_ENABLED, settings.isTitleEnabled) + obj.put(KEY_IS_USERNAME_ENABLED, settings.isUsernameEnabled) + obj.put(KEY_IS_PASSWORD_ENABLED, settings.isPasswordEnabled) + obj.put(KEY_IS_URL_ENABLED, settings.isUrlEnabled) + obj.put(KEY_IS_NOTES_ENABLED, settings.isNotesEnabled) + obj.put(KEY_IS_OTHER_FIELDS_ENABLED, settings.isOtherFieldsEnabled) + obj.put( + KEY_RESTRICTION_SCOPES, + JSONArray(settings.restrictionScopes.map { it.name }) + ) + obj.put(KEY_IS_CASE_SENSITIVE, settings.isCaseSensitive) + + obj.toString() + } catch (e: JSONException) { + Timber.d(e) + null + } + } + + fun fromString(data: String): SearchOptions? { + return try { + val obj = JSONObject(data) + val defaultSettings = SearchOptions.DEFAULT + + val searchType = obj.optString(KEY_SEARCH_TYPE) + .takeIf { it.isNotEmpty() } + ?.let { SearchType.getByName(it) } + ?: defaultSettings.searchType + + val isTitleEnabled = obj.optBoolean( + KEY_IS_TITLE_ENABLED, + defaultSettings.isTitleEnabled + ) + val isUsernameEnabled = obj.optBoolean( + KEY_IS_USERNAME_ENABLED, + defaultSettings.isUsernameEnabled + ) + val isPasswordEnabled = obj.optBoolean( + KEY_IS_PASSWORD_ENABLED, + defaultSettings.isPasswordEnabled + ) + val isUrlEnabled = obj.optBoolean( + KEY_IS_URL_ENABLED, + defaultSettings.isUrlEnabled + ) + val isNotesEnabled = obj.optBoolean( + KEY_IS_NOTES_ENABLED, + defaultSettings.isNotesEnabled + ) + val isOtherFieldsEnabled = obj.optBoolean( + KEY_IS_OTHER_FIELDS_ENABLED, + defaultSettings.isOtherFieldsEnabled + ) + val isCaseSensitive = obj.optBoolean( + KEY_IS_CASE_SENSITIVE, + defaultSettings.isCaseSensitive + ) + val scopes = obj.optJSONArray(KEY_RESTRICTION_SCOPES) + ?.toSearchScopes() + ?: defaultSettings.restrictionScopes + + SearchOptions( + searchType = searchType, + isTitleEnabled = isTitleEnabled, + isUsernameEnabled = isUsernameEnabled, + isPasswordEnabled = isPasswordEnabled, + isUrlEnabled = isUrlEnabled, + isNotesEnabled = isNotesEnabled, + isOtherFieldsEnabled = isOtherFieldsEnabled, + restrictionScopes = scopes, + isCaseSensitive = isCaseSensitive + ) + } catch (e: JSONException) { + Timber.d(e) + null + } + } + + private fun JSONArray.toSearchScopes(): Set { + return (0 until length()) + .mapNotNull { idx -> + optString(idx) + .takeIf { it.isNotEmpty() } + ?.let { name -> SearchScope.entries.firstOrNull { it.name == name } } + } + .toSet() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/entity/SearchOptions.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/entity/SearchOptions.kt new file mode 100644 index 00000000..78f16c37 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/entity/SearchOptions.kt @@ -0,0 +1,28 @@ +package com.ivanovsky.passnotes.domain.entity + +data class SearchOptions( + val searchType: SearchType, + val isTitleEnabled: Boolean, + val isUsernameEnabled: Boolean, + val isPasswordEnabled: Boolean, + val isUrlEnabled: Boolean, + val isNotesEnabled: Boolean, + val isOtherFieldsEnabled: Boolean, + val isCaseSensitive: Boolean, + val restrictionScopes: Set +) { + + companion object { + val DEFAULT = SearchOptions( + searchType = SearchType.default(), + isTitleEnabled = true, + isUsernameEnabled = true, + isPasswordEnabled = false, + isUrlEnabled = true, + isNotesEnabled = true, + isOtherFieldsEnabled = true, + isCaseSensitive = false, + restrictionScopes = emptySet() + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/entity/SearchScope.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/entity/SearchScope.kt new file mode 100644 index 00000000..076a4f84 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/entity/SearchScope.kt @@ -0,0 +1,6 @@ +package com.ivanovsky.passnotes.domain.entity + +enum class SearchScope { + SEARCHABLE, + RECYCLE_BIN +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/interactor/groups/GroupsInteractor.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/interactor/groups/GroupsInteractor.kt index c22d9a8a..c044adc0 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/interactor/groups/GroupsInteractor.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/interactor/groups/GroupsInteractor.kt @@ -11,6 +11,7 @@ import com.ivanovsky.passnotes.data.entity.UsedFile import com.ivanovsky.passnotes.data.repository.encdb.EncryptedDatabaseKey import com.ivanovsky.passnotes.domain.DatabaseLockInteractor import com.ivanovsky.passnotes.domain.DispatcherProvider +import com.ivanovsky.passnotes.domain.entity.SearchOptions import com.ivanovsky.passnotes.domain.entity.SelectionItem import com.ivanovsky.passnotes.domain.entity.SelectionItemType import com.ivanovsky.passnotes.domain.interactor.SelectionHolder @@ -261,13 +262,15 @@ class GroupsInteractor( } suspend fun getAllSearchableEntries( + options: SearchOptions, isRespectAutotypeProperty: Boolean ): OperationResult> = - searchUseCases.getAllSearchableEntries(isRespectAutotypeProperty) + searchUseCases.getAllSearchableEntries(options, isRespectAutotypeProperty) suspend fun filterEntries( + options: SearchOptions, entries: List, query: String ): List = - searchUseCases.filterEntries(entries, query) + searchUseCases.filterEntries(options, entries, query) } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/EntryMatcher.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/EntryMatcher.kt index 8c4c130a..4ae50ff0 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/EntryMatcher.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/EntryMatcher.kt @@ -1,9 +1,11 @@ package com.ivanovsky.passnotes.domain.search import com.ivanovsky.passnotes.data.entity.EncryptedDatabaseEntry +import com.ivanovsky.passnotes.domain.entity.SearchOptions interface EntryMatcher { fun match( + options: SearchOptions, query: String, entries: List ): List diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/Fzf4jFuzzyEntryMatcher.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/Fzf4jFuzzyEntryMatcher.kt index b51d9d6b..d5e89d86 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/Fzf4jFuzzyEntryMatcher.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/Fzf4jFuzzyEntryMatcher.kt @@ -3,7 +3,12 @@ package com.ivanovsky.passnotes.domain.search import com.ivanovsky.passnotes.data.entity.EncryptedDatabaseEntry import com.ivanovsky.passnotes.data.entity.Group import com.ivanovsky.passnotes.data.entity.Note +import com.ivanovsky.passnotes.data.entity.Property +import com.ivanovsky.passnotes.data.entity.PropertyType import com.ivanovsky.passnotes.domain.entity.MatcherResult +import com.ivanovsky.passnotes.domain.entity.SearchOptions +import com.ivanovsky.passnotes.util.StringUtils +import com.ivanovsky.passnotes.util.isPropertySearchable import de.gesundkrank.fzf4j.matchers.FuzzyMatcherV1 import de.gesundkrank.fzf4j.models.OrderBy import java.util.UUID @@ -11,6 +16,7 @@ import java.util.UUID class Fzf4jFuzzyEntryMatcher : EntryMatcher { override fun match( + options: SearchOptions, query: String, entries: List ): List { @@ -18,9 +24,10 @@ class Fzf4jFuzzyEntryMatcher : EntryMatcher { val result = mutableListOf() val matchedEntriesByTitle = match( - titles = entries.map { it.formatTitle() }, + titles = entries.map { it.formatTitle(options) }, entries = entries, - query = query + query = query, + isCaseSensitive = options.isCaseSensitive ) .map { it.entry } @@ -31,9 +38,10 @@ class Fzf4jFuzzyEntryMatcher : EntryMatcher { } val matchedEntriesByAllContent = match( - titles = entries.map { it.formatAllContent() }, + titles = entries.map { it.formatAllContent(options) }, entries = entries, - query = query + query = query, + isCaseSensitive = options.isCaseSensitive ) .map { it.entry } @@ -51,9 +59,10 @@ class Fzf4jFuzzyEntryMatcher : EntryMatcher { private fun match( titles: List, entries: List, - query: String + query: String, + isCaseSensitive: Boolean ): List> { - return FuzzyMatcherV1(titles, OrderBy.SCORE, false, false) + return FuzzyMatcherV1(titles, OrderBy.SCORE, false, isCaseSensitive) .match(query) .map { result -> MatcherResult( @@ -68,36 +77,52 @@ class Fzf4jFuzzyEntryMatcher : EntryMatcher { return when (this) { is Note -> uid is Group -> uid - else -> null } } - private fun EncryptedDatabaseEntry.formatTitle(): String { + private fun EncryptedDatabaseEntry.formatTitle(options: SearchOptions): String { return when (this) { - is Note -> title + is Note -> if (options.isTitleEnabled) title else StringUtils.EMPTY is Group -> title - else -> "" } } - private fun EncryptedDatabaseEntry.formatAllContent(): String { + private fun EncryptedDatabaseEntry.formatAllContent(options: SearchOptions): String { return when (this) { - is Note -> { - val words = mutableListOf() - - for (property in properties) { - if (!property.name.isNullOrEmpty()) { - words.add(property.name) - } - if (!property.value.isNullOrEmpty()) { - words.add(property.value) - } - } - - words.joinToString(separator = " ") - } + is Note -> formatAllContent(options) is Group -> title - else -> "" + } + } + + private fun Note.formatAllContent(options: SearchOptions): String { + val words = mutableListOf() + + for (property in properties) { + if (options.isPropertySearchable(property)) { + property.appendSearchableContentTo(words) + } + } + + if (options.isOtherFieldsEnabled) { + for (attachment in attachments) { + words.add(attachment.name) + } + } + + return words.joinToString(separator = " ") + } + + private fun Property.appendSearchableContentTo(words: MutableList) { + val name = this.name + val value = this.value + val isDefaultProperty = PropertyType.DEFAULT_TYPES.contains(this.type) + + if (!isDefaultProperty && !name.isNullOrEmpty()) { + words.add(name) + } + + if (!value.isNullOrEmpty()) { + words.add(value) } } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/StrictEntryMatcher.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/StrictEntryMatcher.kt index 67fd9a8f..1924b452 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/StrictEntryMatcher.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/search/StrictEntryMatcher.kt @@ -1,22 +1,87 @@ package com.ivanovsky.passnotes.domain.search +import com.ivanovsky.passnotes.data.entity.Attachment import com.ivanovsky.passnotes.data.entity.EncryptedDatabaseEntry import com.ivanovsky.passnotes.data.entity.Group import com.ivanovsky.passnotes.data.entity.Note -import com.ivanovsky.passnotes.extensions.matches +import com.ivanovsky.passnotes.data.entity.Property +import com.ivanovsky.passnotes.data.entity.PropertyType +import com.ivanovsky.passnotes.domain.entity.SearchOptions +import com.ivanovsky.passnotes.util.StringUtils +import com.ivanovsky.passnotes.util.isPropertySearchable class StrictEntryMatcher : EntryMatcher { override fun match( + options: SearchOptions, query: String, entries: List ): List { return entries.filter { entry -> when (entry) { - is Note -> entry.matches(query) - is Group -> entry.matches(query) - else -> throw IllegalArgumentException() + is Note -> isNoteMatch(options, entry, query) + is Group -> isGroupMatch(options, entry, query) } } } + + private fun isNoteMatch( + options: SearchOptions, + note: Note, + query: String + ): Boolean { + for (property in note.properties) { + if (isPropertyMatch(options, property, query)) { + return true + } + } + + for (attachment in note.attachments) { + if (isAttachmentMatch(options, attachment, query)) { + return true + } + } + + return false + } + + private fun isAttachmentMatch( + options: SearchOptions, + attachment: Attachment, + query: String + ): Boolean { + return if (options.isOtherFieldsEnabled) { + attachment.name.contains(query, ignoreCase = !options.isCaseSensitive) + } else { + false + } + } + + private fun isPropertyMatch( + options: SearchOptions, + property: Property, + query: String + ): Boolean { + if (!options.isPropertySearchable(property)) { + return false + } + + val name = property.name ?: StringUtils.EMPTY + val value = property.value ?: StringUtils.EMPTY + val isDefaultProperty = PropertyType.DEFAULT_TYPES.contains(property.type) + + val isValueMatch = value.contains(query, ignoreCase = !options.isCaseSensitive) + val isNameMatch = + !isDefaultProperty && name.contains(query, ignoreCase = !options.isCaseSensitive) + + return isValueMatch || isNameMatch + } + + private fun isGroupMatch( + options: SearchOptions, + group: Group, + query: String + ): Boolean { + return group.title.contains(query, ignoreCase = !options.isCaseSensitive) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/usecases/SearchUseCases.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/usecases/SearchUseCases.kt index b54f8037..ca430c1d 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/domain/usecases/SearchUseCases.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/domain/usecases/SearchUseCases.kt @@ -1,85 +1,156 @@ package com.ivanovsky.passnotes.domain.usecases +import arrow.core.raise.either import com.ivanovsky.passnotes.data.entity.EncryptedDatabaseEntry +import com.ivanovsky.passnotes.data.entity.Group import com.ivanovsky.passnotes.data.entity.OperationResult -import com.ivanovsky.passnotes.data.repository.settings.Settings +import com.ivanovsky.passnotes.data.repository.encdb.EncryptedDatabaseConfig import com.ivanovsky.passnotes.domain.DispatcherProvider +import com.ivanovsky.passnotes.domain.entity.SearchOptions +import com.ivanovsky.passnotes.domain.entity.SearchScope import com.ivanovsky.passnotes.domain.entity.SearchType -import com.ivanovsky.passnotes.domain.search.EntryMatcher import com.ivanovsky.passnotes.domain.search.Fzf4jFuzzyEntryMatcher import com.ivanovsky.passnotes.domain.search.StrictEntryMatcher -import com.ivanovsky.passnotes.extensions.mapError +import com.ivanovsky.passnotes.extensions.toEither +import com.ivanovsky.passnotes.util.toOperationResult +import java.util.LinkedList +import java.util.UUID import kotlinx.coroutines.withContext class SearchUseCases( private val dispatchers: DispatcherProvider, private val getDbUseCase: GetDatabaseUseCase, - private val sortUseCase: SortGroupsAndNotesUseCase, - private val settings: Settings + private val sortUseCase: SortGroupsAndNotesUseCase ) { - private val strictMatcher: EntryMatcher = StrictEntryMatcher() - private val fuzzyMatcher: EntryMatcher = Fzf4jFuzzyEntryMatcher() + private val strictMatcher = StrictEntryMatcher() + private val fuzzyMatcher = Fzf4jFuzzyEntryMatcher() suspend fun getAllSearchableEntries( + options: SearchOptions, isRespectAutotypeProperty: Boolean - ): OperationResult> { - return withContext(dispatchers.IO) { - val dbResult = getDbUseCase.getDatabase() - if (dbResult.isFailed) { - return@withContext dbResult.mapError() - } + ): OperationResult> = + withContext(dispatchers.IO) { + either { + val db = getDbUseCase.getDatabase().toEither().bind() + val root = db.groupDao.rootGroup.toEither().bind() + val allNotes = db.noteDao.all.toEither().bind() + val allGroups = db.groupDao.all.toEither().bind() + val dbConfig = db.config.toEither().bind() + + val recycledGroupUids = filterRecycledGroups( + config = dbConfig, + allGroups = allGroups + ) + + val isOnlySearchable = options.restrictionScopes.contains(SearchScope.SEARCHABLE) + val isIncludeRecycled = options.restrictionScopes.contains(SearchScope.RECYCLE_BIN) + + val searchableGroupMap = allGroups + .filter { group -> + val isGroupRecycled = recycledGroupUids.contains(group.uid) + + when { + !isIncludeRecycled && isGroupRecycled -> false + isOnlySearchable -> group.searchEnabled.isEnabled + else -> true + } + } + .associateBy { group -> group.uid } + + val searchableNotes = allNotes + .filter { searchableGroupMap.containsKey(it.groupUid) } + + val entries = if (isRespectAutotypeProperty) { + val autotypeableGroups = searchableGroupMap.values + .filter { it.uid != root.uid && it.autotypeEnabled.isEnabled } + + val autotypeableGroupMap = searchableGroupMap.values + .filter { it.autotypeEnabled.isEnabled } + .associateBy { it.uid } + + val autotypeableNotes = searchableNotes + .filter { autotypeableGroupMap.containsKey(it.groupUid) } + + autotypeableNotes + autotypeableGroups + } else { + searchableNotes + + searchableGroupMap.values.filter { group -> group.uid != root.uid } + } - val db = dbResult.obj - val getRootResult = db.groupDao.rootGroup - if (getRootResult.isFailed) { - return@withContext getRootResult.mapError() - } + val sortedEntries = sortUseCase.sortGroupsAndNotesAccordingToSettings(entries) - val getAllNotesResult = db.noteDao.all - if (getAllNotesResult.isFailed) { - return@withContext getAllNotesResult.mapError() - } + sortedEntries + }.toOperationResult() + } - val getAllGroupsResult = db.groupDao.all - if (getAllGroupsResult.isFailed) { - return@withContext getAllGroupsResult.mapError() - } + private fun filterRecycledGroups( + config: EncryptedDatabaseConfig, + allGroups: List + ): Set { + val groupUidToChildGroupsMap = createGroupUidToChildGroupsMap(allGroups) + + val recycleBinUid = config.recycleBinUid + val recycledGroupUids = if (recycleBinUid != null) { + filterGroupTree( + targetGroup = recycleBinUid, + groupUidToChildGroupsMap = groupUidToChildGroupsMap + ) + .toMutableSet() + .apply { + add(recycleBinUid) + } + } else { + emptySet() + } - val root = getRootResult.obj - val allNotes = getAllNotesResult.obj - val allGroups = getAllGroupsResult.obj + return recycledGroupUids + } - val searchableGroupMap = allGroups - .filter { it.searchEnabled.isEnabled } - .associateBy { it.uid } + private fun createGroupUidToChildGroupsMap(groups: List): Map> { + val result = HashMap>() - val searchableNotes = allNotes - .filter { searchableGroupMap.containsKey(it.groupUid) } + for (group in groups) { + val parentUid = group.parentUid ?: continue - val entries = if (isRespectAutotypeProperty) { - val autotypeableGroups = searchableGroupMap.values - .filter { it.uid != root.uid && it.autotypeEnabled.isEnabled } + result[parentUid] = result.getOrDefault(parentUid, mutableListOf()) + .apply { + add(group.uid) + } + } - val autotypeableGroupMap = searchableGroupMap.values - .filter { it.autotypeEnabled.isEnabled } - .associateBy { it.uid } + return result + } - val autotypeableNotes = searchableNotes - .filter { autotypeableGroupMap.containsKey(it.groupUid) } + private fun filterGroupTree( + targetGroup: UUID, + groupUidToChildGroupsMap: Map> + ): List { + val result = mutableListOf() - autotypeableNotes + autotypeableGroups - } else { - searchableNotes + searchableGroupMap.values.filter { it.uid != root.uid } + val queue = LinkedList() + .apply { + add(targetGroup) } - val sortedEntries = sortUseCase.sortGroupsAndNotesAccordingToSettings(entries) + while (queue.isNotEmpty()) { + repeat(queue.size) { + val uid = queue.removeFirst() + val childUids = groupUidToChildGroupsMap[uid] ?: emptyList() - OperationResult.success(sortedEntries) + result.addAll(childUids) + + for (childUid in childUids) { + queue.add(childUid) + } + } } + + return result } suspend fun filterEntries( + options: SearchOptions, entries: List, query: String ): List { @@ -88,19 +159,9 @@ class SearchUseCases( } return withContext(dispatchers.IO) { - when (settings.searchType) { - SearchType.FUZZY -> { - fuzzyMatcher.match( - query = query, - entries = entries - ) - } - SearchType.STRICT -> { - strictMatcher.match( - query = query, - entries = entries - ) - } + when (options.searchType) { + SearchType.FUZZY -> fuzzyMatcher.match(options, query, entries) + SearchType.STRICT -> strictMatcher.match(options, query, entries) } } } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/OperationErrorExt.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/OperationErrorExt.kt index ae296503..10b1e2e1 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/OperationErrorExt.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/extensions/OperationErrorExt.kt @@ -1,7 +1,9 @@ package com.ivanovsky.passnotes.extensions +import arrow.core.Either import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.data.entity.OperationError +import com.ivanovsky.passnotes.data.entity.OperationResult import com.ivanovsky.passnotes.domain.ResourceProvider fun OperationError.formatReadableMessage( @@ -32,4 +34,12 @@ fun OperationError.formatReadableMessage( exceptionMessage != null -> exceptionMessage else -> resourceProvider.getString(R.string.error_has_been_occurred) } +} + +fun OperationResult.toEither(): Either { + return if (isSucceededOrDeferred) { + Either.Right(getOrThrow()) + } else { + Either.Left(error) + } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UiModule.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UiModule.kt index 860794eb..66a0fcac 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UiModule.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UiModule.kt @@ -36,6 +36,7 @@ import com.ivanovsky.passnotes.presentation.core.dialog.reportErrorDialog.Report import com.ivanovsky.passnotes.presentation.core.dialog.resolveConflict.ResolveConflictDialogArgs import com.ivanovsky.passnotes.presentation.core.dialog.resolveConflict.ResolveConflictDialogInteractor import com.ivanovsky.passnotes.presentation.core.dialog.resolveConflict.ResolveConflictDialogViewModel +import com.ivanovsky.passnotes.presentation.core.dialog.searchOptions.SearchOptionsDialogViewModel import com.ivanovsky.passnotes.presentation.core.dialog.sortAndView.SortAndViewDialogArgs import com.ivanovsky.passnotes.presentation.core.dialog.sortAndView.SortAndViewDialogViewModel import com.ivanovsky.passnotes.presentation.debugmenu.DebugMenuViewModel @@ -330,6 +331,7 @@ object UiModule { viewModel { AppSettingsViewModel(get(), get(), get(), get(), get(), get()) } viewModel { DatabaseSettingsViewModel(get(), get(), get(), get()) } viewModel { ChangePasswordDialogViewModel(get(), get()) } + viewModel { SearchOptionsDialogViewModel(get()) } viewModel { PasswordGeneratorViewModel( get(), diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UseCaseModule.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UseCaseModule.kt index 32497717..f18297e8 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UseCaseModule.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/injection/modules/UseCaseModule.kt @@ -59,7 +59,7 @@ object UseCaseModule { single { RemoveBiometricDataUseCase(get(), get(), get()) } single { DecodePasswordWithBiometricUseCase(get()) } single { EncodePasswordWithBiometricUseCase(get()) } - single { SearchUseCases(get(), get(), get(), get()) } + single { SearchUseCases(get(), get(), get()) } single { FindParentGroupsUseCase(get(), get()) } single { GetDiffUseCase(get()) } single { GetHistoryUseCase(get(), get()) } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/AllFilesPermissionDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/AllFilesPermissionDialog.kt index 039cb47f..46044d80 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/AllFilesPermissionDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/AllFilesPermissionDialog.kt @@ -4,13 +4,12 @@ import android.app.AlertDialog import android.app.Dialog import android.content.DialogInterface import android.os.Bundle -import androidx.fragment.app.DialogFragment import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.domain.PermissionHelper import com.ivanovsky.passnotes.domain.entity.SystemPermission import com.ivanovsky.passnotes.injection.GlobalInjector.inject -class AllFilesPermissionDialog : DialogFragment(), DialogInterface.OnClickListener { +class AllFilesPermissionDialog : BaseDialogFragment(), DialogInterface.OnClickListener { lateinit var onPositiveClicked: () -> Unit lateinit var onNegativeClicked: () -> Unit diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/BaseComposeDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/BaseComposeDialog.kt index f5102f95..ee454997 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/BaseComposeDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/BaseComposeDialog.kt @@ -6,13 +6,12 @@ import android.view.View import android.view.ViewGroup import androidx.compose.runtime.Composable import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModel import com.ivanovsky.passnotes.databinding.CoreComposeDialogBinding import com.ivanovsky.passnotes.presentation.core.compose.AppTheme import com.ivanovsky.passnotes.presentation.core.compose.getComposeTheme -abstract class BaseComposeDialog : DialogFragment() { +abstract class BaseComposeDialog : BaseDialogFragment() { abstract val viewModel: T diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/BaseDialogFragment.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/BaseDialogFragment.kt new file mode 100644 index 00000000..75ffbe99 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/BaseDialogFragment.kt @@ -0,0 +1,9 @@ +package com.ivanovsky.passnotes.presentation.core.dialog + +import androidx.fragment.app.DialogFragment +import com.ivanovsky.passnotes.R + +abstract class BaseDialogFragment : DialogFragment() { + + override fun getTheme(): Int = R.style.AppDialogTheme +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/ConfirmationDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/ConfirmationDialog.kt index 16dd8a3a..bf15cf39 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/ConfirmationDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/ConfirmationDialog.kt @@ -4,10 +4,9 @@ import android.app.AlertDialog import android.app.Dialog import android.content.DialogInterface import android.os.Bundle -import androidx.fragment.app.DialogFragment import com.ivanovsky.passnotes.R -class ConfirmationDialog : DialogFragment(), DialogInterface.OnClickListener { +class ConfirmationDialog : BaseDialogFragment(), DialogInterface.OnClickListener { var onConfirmed: (() -> Unit)? = null var onDenied: (() -> Unit)? = null diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/MessageDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/MessageDialog.kt index 03ec5529..873b7641 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/MessageDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/MessageDialog.kt @@ -3,12 +3,11 @@ package com.ivanovsky.passnotes.presentation.core.dialog import android.app.AlertDialog import android.app.Dialog import android.os.Bundle -import androidx.fragment.app.DialogFragment import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.presentation.core.extensions.getMandatoryArgument import com.ivanovsky.passnotes.presentation.core.extensions.withArguments -class MessageDialog : DialogFragment() { +class MessageDialog : BaseDialogFragment() { private val args: MessageDialogArgs by lazy { getMandatoryArgument(ARGUMENTS) diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/helpDialog/HelpDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/helpDialog/HelpDialog.kt deleted file mode 100644 index 3e21af10..00000000 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/helpDialog/HelpDialog.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.ivanovsky.passnotes.presentation.core.dialog.helpDialog - -import android.app.Dialog -import android.os.Bundle -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import com.ivanovsky.passnotes.R -import com.ivanovsky.passnotes.extensions.cloneInContext -import com.ivanovsky.passnotes.presentation.core.extensions.getMandatoryArgument -import com.ivanovsky.passnotes.presentation.core.extensions.withArguments - -class HelpDialog : DialogFragment() { - - private val args: HelpDialogArgs by lazy { getMandatoryArgument(ARGUMENTS) } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val themedInflater = layoutInflater.cloneInContext(R.style.AppDialogTheme) - val view = themedInflater.inflate(args.layoutId, null, false) - - return AlertDialog.Builder(requireContext(), R.style.AppDialogTheme) - .apply { - if (args.title != null) { - setTitle(args.title) - } - setView(view) - setPositiveButton(R.string.ok, null) - } - .create() - } - - companion object { - - val TAG = HelpDialog::class.java.simpleName - - private const val ARGUMENTS = "arguments" - - fun newInstance(arguments: HelpDialogArgs): HelpDialog { - return HelpDialog() - .withArguments { - putParcelable(ARGUMENTS, arguments) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/helpDialog/HelpDialogArgs.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/helpDialog/HelpDialogArgs.kt deleted file mode 100644 index 7b605bfd..00000000 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/helpDialog/HelpDialogArgs.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.ivanovsky.passnotes.presentation.core.dialog.helpDialog - -import android.os.Parcelable -import androidx.annotation.LayoutRes -import kotlinx.parcelize.Parcelize - -@Parcelize -data class HelpDialogArgs( - val title: String?, - @LayoutRes - val layoutId: Int -) : Parcelable \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/optionDialog/OptionDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/optionDialog/OptionDialog.kt index d311a3bc..62386f19 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/optionDialog/OptionDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/optionDialog/OptionDialog.kt @@ -4,13 +4,13 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.databinding.CoreBaseCellDialogBinding import com.ivanovsky.passnotes.extensions.cloneInContext import com.ivanovsky.passnotes.presentation.core.ViewModelTypes import com.ivanovsky.passnotes.presentation.core.adapter.ViewModelsAdapter +import com.ivanovsky.passnotes.presentation.core.dialog.BaseDialogFragment import com.ivanovsky.passnotes.presentation.core.extensions.getMandatoryArgument import com.ivanovsky.passnotes.presentation.core.extensions.setViewModels import com.ivanovsky.passnotes.presentation.core.extensions.withArguments @@ -18,7 +18,7 @@ import com.ivanovsky.passnotes.presentation.core.viewmodel.DividerCellViewModel import com.ivanovsky.passnotes.presentation.core.viewmodel.OneLineTextCellViewModel import com.ivanovsky.passnotes.presentation.core.viewmodel.TwoLineTextCellViewModel -class OptionDialog : DialogFragment() { +class OptionDialog : BaseDialogFragment() { private val args: OptionDialogArgs by lazy { getMandatoryArgument(ARGUMENTS) diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/propertyAction/PropertyActionDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/propertyAction/PropertyActionDialog.kt index 6bffed6d..21fc2840 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/propertyAction/PropertyActionDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/propertyAction/PropertyActionDialog.kt @@ -4,14 +4,14 @@ import android.app.AlertDialog import android.app.Dialog import android.os.Bundle import android.view.View -import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import com.ivanovsky.passnotes.R +import com.ivanovsky.passnotes.presentation.core.dialog.BaseDialogFragment import com.ivanovsky.passnotes.presentation.core.extensions.getMandatoryArgument import com.ivanovsky.passnotes.presentation.core.extensions.openUrl import com.ivanovsky.passnotes.presentation.core.extensions.withArguments -class PropertyActionDialog : DialogFragment() { +class PropertyActionDialog : BaseDialogFragment() { lateinit var onActionClicked: (action: PropertyAction) -> Unit diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/resolveConflict/ResolveConflictDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/resolveConflict/ResolveConflictDialog.kt index e9786e77..02281a51 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/resolveConflict/ResolveConflictDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/resolveConflict/ResolveConflictDialog.kt @@ -4,15 +4,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.databinding.DialogResolveConflictBinding import com.ivanovsky.passnotes.extensions.cloneInContext +import com.ivanovsky.passnotes.presentation.core.dialog.BaseDialogFragment import com.ivanovsky.passnotes.presentation.core.extensions.getMandatoryArgument import com.ivanovsky.passnotes.presentation.core.extensions.withArguments -class ResolveConflictDialog : DialogFragment() { +class ResolveConflictDialog : BaseDialogFragment() { private val viewModel: ResolveConflictDialogViewModel by lazy { ViewModelProvider( diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialog.kt new file mode 100644 index 00000000..b371a563 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialog.kt @@ -0,0 +1,37 @@ +package com.ivanovsky.passnotes.presentation.core.dialog.searchOptions + +import android.os.Bundle +import android.view.View +import androidx.compose.runtime.Composable +import androidx.lifecycle.ViewModelProvider +import com.ivanovsky.passnotes.presentation.core.ViewModelFactory +import com.ivanovsky.passnotes.presentation.core.dialog.BaseComposeDialog + +class SearchOptionsDialog : BaseComposeDialog() { + + override val viewModel: SearchOptionsDialogViewModel by lazy { + ViewModelProvider( + owner = this, + factory = ViewModelFactory() + )[SearchOptionsDialogViewModel::class.java] + } + + @Composable + override fun RenderDialog() { + SearchOptionsDialogScreen(viewModel) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.dismissEvent.observe(viewLifecycleOwner) { + dismiss() + } + } + + companion object Companion { + val TAG = SearchOptionsDialog::class.java.simpleName + + fun newInstance() = SearchOptionsDialog() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialogScreen.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialogScreen.kt new file mode 100644 index 00000000..134a1a95 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialogScreen.kt @@ -0,0 +1,315 @@ +package com.ivanovsky.passnotes.presentation.core.dialog.searchOptions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ivanovsky.passnotes.R +import com.ivanovsky.passnotes.domain.entity.SearchType +import com.ivanovsky.passnotes.presentation.core.compose.AppTheme +import com.ivanovsky.passnotes.presentation.core.compose.DarkTheme +import com.ivanovsky.passnotes.presentation.core.compose.ElementMargin +import com.ivanovsky.passnotes.presentation.core.compose.GroupMargin +import com.ivanovsky.passnotes.presentation.core.compose.HalfMargin +import com.ivanovsky.passnotes.presentation.core.compose.HeaderTextStyle +import com.ivanovsky.passnotes.presentation.core.compose.LightTheme +import com.ivanovsky.passnotes.presentation.core.compose.PrimaryTextStyle +import com.ivanovsky.passnotes.presentation.core.compose.SecondaryTextStyle +import com.ivanovsky.passnotes.presentation.core.compose.SmallIconSize +import com.ivanovsky.passnotes.presentation.core.compose.ThemedScreenPreview +import com.ivanovsky.passnotes.presentation.core.dialog.searchOptions.model.SearchOptionState + +@Composable +fun SearchOptionsDialogScreen( + viewModel: SearchOptionsDialogViewModel +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + SearchOptionsDialogScreen( + state = state, + onTitleChanged = viewModel::onTitleChanged, + onUsernameChanged = viewModel::onUsernameChanged, + onPasswordChanged = viewModel::onPasswordChanged, + onUrlChanged = viewModel::onUrlChanged, + onNotesChanged = viewModel::onNotesChanged, + onOtherChanged = viewModel::onOtherChanged, + onSearchableChanged = viewModel::onSearchableChanged, + onRecycleBinChanged = viewModel::onRecycleBinChanged, + onSearchTypeChanged = viewModel::onSearchTypeChanged, + onCaseSensitiveChanged = viewModel::onCaseSensitiveChanged, + onDoneClick = viewModel::onDoneClicked + ) +} + +@Composable +private fun SearchOptionsDialogScreen( + state: SearchOptionState, + onTitleChanged: (Boolean) -> Unit, + onUsernameChanged: (Boolean) -> Unit, + onPasswordChanged: (Boolean) -> Unit, + onUrlChanged: (Boolean) -> Unit, + onNotesChanged: (Boolean) -> Unit, + onOtherChanged: (Boolean) -> Unit, + onSearchableChanged: (Boolean) -> Unit, + onRecycleBinChanged: (Boolean) -> Unit, + onSearchTypeChanged: (SearchType) -> Unit, + onCaseSensitiveChanged: (Boolean) -> Unit, + onDoneClick: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding( + horizontal = ElementMargin, + vertical = ElementMargin + ) + ) { + Text( + text = stringResource(R.string.search_options), + style = HeaderTextStyle(), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + SectionTitle( + text = stringResource(R.string.search_fields), + modifier = Modifier.padding(top = GroupMargin) + ) + ChipGroup { + CheckableChip( + text = stringResource(R.string.title), + isChecked = state.isTitleEnabled, + onCheckedChange = onTitleChanged + ) + CheckableChip( + text = stringResource(R.string.username), + isChecked = state.isUsernameEnabled, + onCheckedChange = onUsernameChanged + ) + CheckableChip( + text = stringResource(R.string.password), + isChecked = state.isPasswordEnabled, + onCheckedChange = onPasswordChanged + ) + CheckableChip( + text = stringResource(R.string.url_cap), + isChecked = state.isUrlEnabled, + onCheckedChange = onUrlChanged + ) + CheckableChip( + text = stringResource(R.string.notes), + isChecked = state.isNotesEnabled, + onCheckedChange = onNotesChanged + ) + CheckableChip( + text = stringResource(R.string.other), + isChecked = state.isOtherEnabled, + onCheckedChange = onOtherChanged + ) + } + + SectionTitle( + text = stringResource(R.string.search_scope), + modifier = Modifier.padding(top = ElementMargin) + ) + ChipGroup { + CheckableChip( + text = stringResource(R.string.searchable), + isChecked = state.isSearchableEnabled, + onCheckedChange = onSearchableChanged + ) + CheckableChip( + text = stringResource(R.string.recycle_bin), + isChecked = state.isRecycleBinEnabled, + onCheckedChange = onRecycleBinChanged + ) + } + + SectionTitle( + text = stringResource(R.string.search_type), + modifier = Modifier.padding(top = ElementMargin) + ) + SearchTypeGroup( + selectedSearchType = state.searchType, + onSearchTypeChanged = onSearchTypeChanged + ) + + SectionTitle( + text = stringResource(R.string.additional_options), + modifier = Modifier.padding(top = ElementMargin) + ) + ChipGroup { + CheckableChip( + text = stringResource(R.string.case_sensitive), + isChecked = state.isCaseSensitiveEnabled, + onCheckedChange = onCaseSensitiveChanged + ) + } + } +} + +@Composable +private fun SectionTitle( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = SecondaryTextStyle(), + modifier = modifier + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChipGroup(content: @Composable () -> Unit) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(HalfMargin), + modifier = Modifier + .fillMaxWidth() + ) { + content() + } +} + +@Composable +private fun CheckableChip( + text: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + FilterChip( + selected = isChecked, + onClick = { + onCheckedChange(!isChecked) + }, + label = { + Text( + text = text, + style = PrimaryTextStyle(), + fontSize = AppTheme.theme.textMetrics.secondary + ) + }, + leadingIcon = if (isChecked) { + { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(SmallIconSize) + ) + } + } else { + null + } + ) +} + +@Composable +private fun SearchTypeGroup( + selectedSearchType: SearchType, + onSearchTypeChanged: (SearchType) -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(HalfMargin) + ) { + SearchTypeOption( + text = stringResource(R.string.strict), + isSelected = selectedSearchType == SearchType.STRICT, + onClick = { onSearchTypeChanged(SearchType.STRICT) } + ) + SearchTypeOption( + text = stringResource(R.string.fuzzy), + isSelected = selectedSearchType == SearchType.FUZZY, + onClick = { onSearchTypeChanged(SearchType.FUZZY) } + ) + } +} + +@Composable +private fun SearchTypeOption( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable(onClick = onClick) + ) { + RadioButton( + selected = isSelected, + onClick = onClick + ) + Text( + text = text, + style = PrimaryTextStyle(), + fontSize = AppTheme.theme.textMetrics.secondary, + modifier = Modifier.padding(start = HalfMargin) + ) + } +} + +@Preview +@Composable +fun SearchOptionDialogLightPreview() { + ThemedScreenPreview(theme = LightTheme) { + SearchOptionsDialogScreen( + state = SearchOptionState(), + onTitleChanged = {}, + onUsernameChanged = {}, + onPasswordChanged = {}, + onUrlChanged = {}, + onNotesChanged = {}, + onOtherChanged = {}, + onSearchableChanged = {}, + onRecycleBinChanged = {}, + onSearchTypeChanged = {}, + onCaseSensitiveChanged = {}, + onDoneClick = {} + ) + } +} + +@Preview +@Composable +fun SearchOptionDialogDarkPreview() { + ThemedScreenPreview(theme = DarkTheme) { + SearchOptionsDialogScreen( + state = SearchOptionState(), + onTitleChanged = {}, + onUsernameChanged = {}, + onPasswordChanged = {}, + onUrlChanged = {}, + onNotesChanged = {}, + onOtherChanged = {}, + onSearchableChanged = {}, + onRecycleBinChanged = {}, + onSearchTypeChanged = {}, + onCaseSensitiveChanged = {}, + onDoneClick = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialogViewModel.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialogViewModel.kt new file mode 100644 index 00000000..ab1ea5c0 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/SearchOptionsDialogViewModel.kt @@ -0,0 +1,120 @@ +package com.ivanovsky.passnotes.presentation.core.dialog.searchOptions + +import androidx.lifecycle.ViewModel +import com.ivanovsky.passnotes.data.repository.settings.Settings +import com.ivanovsky.passnotes.domain.entity.SearchOptions +import com.ivanovsky.passnotes.domain.entity.SearchScope +import com.ivanovsky.passnotes.domain.entity.SearchType +import com.ivanovsky.passnotes.presentation.core.dialog.searchOptions.model.SearchOptionState +import com.ivanovsky.passnotes.presentation.core.event.SingleLiveEvent +import kotlinx.coroutines.flow.MutableStateFlow + +class SearchOptionsDialogViewModel( + private val settings: Settings +) : ViewModel() { + + val state = MutableStateFlow(createInitialState()) + val dismissEvent = SingleLiveEvent() + + fun onTitleChanged(isEnabled: Boolean) { + state.value = state.value.copy(isTitleEnabled = isEnabled) + saveToSettings() + } + + fun onUsernameChanged(isEnabled: Boolean) { + state.value = state.value.copy(isUsernameEnabled = isEnabled) + saveToSettings() + } + + fun onPasswordChanged(isEnabled: Boolean) { + state.value = state.value.copy(isPasswordEnabled = isEnabled) + saveToSettings() + } + + fun onUrlChanged(isEnabled: Boolean) { + state.value = state.value.copy(isUrlEnabled = isEnabled) + saveToSettings() + } + + fun onNotesChanged(isEnabled: Boolean) { + state.value = state.value.copy(isNotesEnabled = isEnabled) + saveToSettings() + } + + fun onOtherChanged(isEnabled: Boolean) { + state.value = state.value.copy(isOtherEnabled = isEnabled) + saveToSettings() + } + + fun onSearchableChanged(isEnabled: Boolean) { + state.value = state.value.copy(isSearchableEnabled = isEnabled) + saveToSettings() + } + + fun onRecycleBinChanged(isEnabled: Boolean) { + state.value = state.value.copy(isRecycleBinEnabled = isEnabled) + saveToSettings() + } + + fun onSearchTypeChanged(searchType: SearchType) { + state.value = state.value.copy(searchType = searchType) + saveToSettings() + } + + fun onCaseSensitiveChanged(isEnabled: Boolean) { + state.value = state.value.copy(isCaseSensitiveEnabled = isEnabled) + saveToSettings() + } + + fun onDoneClicked() { + dismissEvent.call(Unit) + } + + private fun saveToSettings() { + settings.searchOptions = state.value.toSearchSettings() + } + + private fun createInitialState(): SearchOptionState { + val searchSettings = settings.searchOptions + + return SearchOptionState( + isTitleEnabled = searchSettings.isTitleEnabled, + isUsernameEnabled = searchSettings.isUsernameEnabled, + isPasswordEnabled = searchSettings.isPasswordEnabled, + isUrlEnabled = searchSettings.isUrlEnabled, + isNotesEnabled = searchSettings.isNotesEnabled, + isOtherEnabled = searchSettings.isOtherFieldsEnabled, + isSearchableEnabled = searchSettings.restrictionScopes.contains( + SearchScope.SEARCHABLE + ), + isRecycleBinEnabled = searchSettings.restrictionScopes.contains( + SearchScope.RECYCLE_BIN + ), + searchType = searchSettings.searchType, + isCaseSensitiveEnabled = searchSettings.isCaseSensitive + ) + } + + private fun SearchOptionState.toSearchSettings(): SearchOptions { + val restrictionScopes = buildSet { + if (isSearchableEnabled) { + add(SearchScope.SEARCHABLE) + } + if (isRecycleBinEnabled) { + add(SearchScope.RECYCLE_BIN) + } + } + + return SearchOptions( + searchType = searchType, + isTitleEnabled = isTitleEnabled, + isUsernameEnabled = isUsernameEnabled, + isPasswordEnabled = isPasswordEnabled, + isUrlEnabled = isUrlEnabled, + isNotesEnabled = isNotesEnabled, + isOtherFieldsEnabled = isOtherEnabled, + isCaseSensitive = isCaseSensitiveEnabled, + restrictionScopes = restrictionScopes + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/model/SearchOptionState.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/model/SearchOptionState.kt new file mode 100644 index 00000000..e9b49920 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/searchOptions/model/SearchOptionState.kt @@ -0,0 +1,16 @@ +package com.ivanovsky.passnotes.presentation.core.dialog.searchOptions.model + +import com.ivanovsky.passnotes.domain.entity.SearchType + +data class SearchOptionState( + val isTitleEnabled: Boolean = true, + val isUsernameEnabled: Boolean = true, + val isPasswordEnabled: Boolean = false, + val isUrlEnabled: Boolean = true, + val isNotesEnabled: Boolean = true, + val isOtherEnabled: Boolean = true, + val isSearchableEnabled: Boolean = true, + val isRecycleBinEnabled: Boolean = false, + val searchType: SearchType = SearchType.default(), + val isCaseSensitiveEnabled: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/sortAndView/SortAndViewDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/sortAndView/SortAndViewDialog.kt index 9849dcbe..e2a6ef17 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/sortAndView/SortAndViewDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/sortAndView/SortAndViewDialog.kt @@ -4,15 +4,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.databinding.DialogSortAndViewBinding import com.ivanovsky.passnotes.extensions.cloneInContext +import com.ivanovsky.passnotes.presentation.core.dialog.BaseDialogFragment import com.ivanovsky.passnotes.presentation.core.extensions.getMandatoryArgument import com.ivanovsky.passnotes.presentation.core.extensions.withArguments -class SortAndViewDialog : DialogFragment() { +class SortAndViewDialog : BaseDialogFragment() { private val viewModel: SortAndViewDialogViewModel by lazy { ViewModelProvider( diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/sortAndView/SortAndViewDialogViewModel.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/sortAndView/SortAndViewDialogViewModel.kt index 1064727b..bf56166f 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/sortAndView/SortAndViewDialogViewModel.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/dialog/sortAndView/SortAndViewDialogViewModel.kt @@ -15,7 +15,7 @@ class SortAndViewDialogViewModel( private val args: SortAndViewDialogArgs ) : ViewModel() { - private var selectedSearchType = settings.searchType + private var selectedSearchType = settings.searchOptions.searchType private var selectedSortType = settings.sortType private var selectedSortDirection = settings.sortDirection @@ -42,7 +42,7 @@ class SortAndViewDialogViewModel( if (isChecked && searchType != selectedSearchType) { selectedSearchType = searchType - settings.searchType = searchType + settings.searchOptions = settings.searchOptions.copy(searchType = searchType) isSortGroupsEnabled.value = isSortGroupsEnabledInternal() } } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/extensions/FragmentExt.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/extensions/FragmentExt.kt index e282d104..a22bb2a8 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/extensions/FragmentExt.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/core/extensions/FragmentExt.kt @@ -21,8 +21,6 @@ import com.ivanovsky.passnotes.injection.GlobalInjector import com.ivanovsky.passnotes.presentation.autofill.AutofillResponseFactory import com.ivanovsky.passnotes.presentation.autofill.model.AutofillStructure import com.ivanovsky.passnotes.presentation.core.dialog.MessageDialog -import com.ivanovsky.passnotes.presentation.core.dialog.helpDialog.HelpDialog -import com.ivanovsky.passnotes.presentation.core.dialog.helpDialog.HelpDialogArgs import com.ivanovsky.passnotes.presentation.core.menu.ScreenMenuItem import com.ivanovsky.passnotes.presentation.core.permission.PermissionRequestSender import com.ivanovsky.passnotes.util.InputMethodUtils @@ -113,11 +111,6 @@ fun Fragment.showMessageDialog(message: String) { dialog.show(childFragmentManager, MessageDialog.TAG) } -fun Fragment.showHelpDialog(arguments: HelpDialogArgs) { - val dialog = HelpDialog.newInstance(arguments) - dialog.show(childFragmentManager, HelpDialog.TAG) -} - fun Fragment.updateMenuItemVisibility( menu: Menu, visibleItems: List, diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/GroupsFragment.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/GroupsFragment.kt index fcd50840..f680274a 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/GroupsFragment.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/GroupsFragment.kt @@ -34,6 +34,7 @@ import com.ivanovsky.passnotes.presentation.core.animation.AnimationFactory import com.ivanovsky.passnotes.presentation.core.dialog.ConfirmationDialog import com.ivanovsky.passnotes.presentation.core.dialog.resolveConflict.ResolveConflictDialog import com.ivanovsky.passnotes.presentation.core.dialog.resolveConflict.ResolveConflictDialogArgs +import com.ivanovsky.passnotes.presentation.core.dialog.searchOptions.SearchOptionsDialog import com.ivanovsky.passnotes.presentation.core.dialog.sortAndView.SortAndViewDialog import com.ivanovsky.passnotes.presentation.core.dialog.sortAndView.SortAndViewDialogArgs import com.ivanovsky.passnotes.presentation.core.extensions.finishActivity @@ -229,6 +230,9 @@ class GroupsFragment : BaseFragment(), PermissionRequestResultReceiver { viewModel.showSortAndViewDialogEvent.observe(viewLifecycleOwner) { args -> showSortAndViewDialog(args) } + viewModel.showSearchOptionDialogEvent.observe(viewLifecycleOwner) { + showSearchOptionDialog() + } viewModel.showBiometricSetupDialog.observe(viewLifecycleOwner) { cipher -> biometricAuthenticator.authenticateForSetup( activity = requireActivity(), @@ -355,6 +359,11 @@ class GroupsFragment : BaseFragment(), PermissionRequestResultReceiver { dialog.show(childFragmentManager, SortAndViewDialog.TAG) } + private fun showSearchOptionDialog() { + val dialog = SearchOptionsDialog.newInstance() + dialog.show(childFragmentManager, SearchOptionsDialog.TAG) + } + private fun showResolveConflictDialog(file: FileDescriptor) { val dialog = ResolveConflictDialog.newInstance( args = ResolveConflictDialogArgs(file) diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/GroupsViewModel.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/GroupsViewModel.kt index b30d5861..03685298 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/GroupsViewModel.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/GroupsViewModel.kt @@ -26,11 +26,12 @@ import com.ivanovsky.passnotes.data.repository.keepass.FileKeepassKey import com.ivanovsky.passnotes.data.repository.keepass.PasswordKeepassKey import com.ivanovsky.passnotes.data.repository.settings.OnSettingsChangeListener import com.ivanovsky.passnotes.data.repository.settings.Settings -import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl +import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref import com.ivanovsky.passnotes.domain.DatabaseLockInteractor import com.ivanovsky.passnotes.domain.PermissionHelper import com.ivanovsky.passnotes.domain.ResourceProvider import com.ivanovsky.passnotes.domain.biometric.BiometricResolver +import com.ivanovsky.passnotes.domain.entity.SearchOptions import com.ivanovsky.passnotes.domain.entity.SelectionItem import com.ivanovsky.passnotes.domain.entity.SelectionItemType import com.ivanovsky.passnotes.domain.entity.SystemPermission @@ -97,6 +98,7 @@ import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.core.parameter.parametersOf @@ -172,6 +174,7 @@ class GroupsViewModel( val requestPermissionEvent = SingleLiveEvent() val finishActivityEvent = SingleLiveEvent() val showSortAndViewDialogEvent = SingleLiveEvent() + val showSearchOptionDialogEvent = SingleLiveEvent() val showBiometricSetupDialog = SingleLiveEvent() val lockScreenEvent = LockScreenLiveEvent(observerBus, lockInteractor) val backIcon = MutableLiveData(BackNavigationIcon.Arrow) @@ -192,6 +195,7 @@ class GroupsViewModel( private var searchableEntries: List? = null private var navigationPanelGroups: List = emptyList() private var loadDataJob: Job? = null + private var searchOptions = MutableStateFlow(null) init { observerBus.register(this) @@ -242,11 +246,14 @@ class GroupsViewModel( } } - override fun onSettingsChanged(pref: SettingsImpl.Pref) { - if (pref == SettingsImpl.Pref.SORT_TYPE || - pref == SettingsImpl.Pref.SORT_DIRECTION || - pref == SettingsImpl.Pref.IS_GROUPS_AT_START_ENABLED + override fun onSettingsChanged(pref: Pref) { + if (pref == Pref.SORT_TYPE || + pref == Pref.SORT_DIRECTION || + pref == Pref.IS_GROUPS_AT_START_ENABLED || + pref == Pref.SEARCH_OPTIONS ) { + searchOptions.value = null + searchableEntries = null loadData() } } @@ -571,6 +578,10 @@ class GroupsViewModel( enableSearchMode() } + fun onSearchConfigButtonClicked() { + showSearchOptionDialogEvent.call(Unit) + } + fun onSortAndViewButtonClicked() { val dialogArgs = if (isSearchModeEnabled) { SortAndViewDialogArgs(ScreenType.SEARCH) @@ -1071,6 +1082,7 @@ class GroupsViewModel( var allEntries = searchableEntries if (allEntries == null) { val getAllEntriesResult = interactor.getAllSearchableEntries( + options = getSearchOptions(), isRespectAutotypeProperty = (args.appMode == ApplicationLaunchMode.AUTOFILL_SELECTION) ) @@ -1089,6 +1101,7 @@ class GroupsViewModel( val searchEntries = if (query.isNotEmpty()) { interactor.filterEntries( + options = getSearchOptions(), entries = allEntries, query = query ) @@ -1305,6 +1318,16 @@ class GroupsViewModel( } } + private fun getSearchOptions(): SearchOptions { + val current = searchOptions.value ?: settings.searchOptions + + if (searchOptions.value == null) { + searchOptions.value = current + } + + return current + } + private fun isBiometricUnlockAllowedForDatabase(): Boolean { return biometricInteractor.isBiometricUnlockAvailable() && settings.isBiometricUnlockEnabled diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/dialog/ChooseOptionDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/dialog/ChooseOptionDialog.kt index 264c26ec..e5cb2470 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/dialog/ChooseOptionDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/groups/dialog/ChooseOptionDialog.kt @@ -4,11 +4,11 @@ import android.app.AlertDialog import android.app.Dialog import android.content.DialogInterface import android.os.Bundle -import androidx.fragment.app.DialogFragment import com.ivanovsky.passnotes.R +import com.ivanovsky.passnotes.presentation.core.dialog.BaseDialogFragment @Deprecated("Use OptionDialog instead") -class ChooseOptionDialog : DialogFragment(), DialogInterface.OnClickListener { +class ChooseOptionDialog : BaseDialogFragment(), DialogInterface.OnClickListener { lateinit var onItemClickListener: (itemIndex: Int) -> Unit diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsFragment.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsFragment.kt index 60ad8420..4afc5e09 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsFragment.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/app/AppSettingsFragment.kt @@ -20,7 +20,6 @@ import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_FIL import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_LOCK_DATABASE_ON_BACK import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_LOCK_NOTIFICATION_VISIBLE import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.IS_POSTPONED_SYNC_ENABLED -import com.ivanovsky.passnotes.data.repository.settings.SettingsImpl.Pref.SEARCH_TYPE import com.ivanovsky.passnotes.domain.PermissionHelper import com.ivanovsky.passnotes.domain.entity.SystemPermission import com.ivanovsky.passnotes.injection.GlobalInjector.inject @@ -86,7 +85,6 @@ class AppSettingsFragment : BasePreferenceFragment(), PermissionRequestResultRec IS_FILE_LOG_ENABLED, IS_POSTPONED_SYNC_ENABLED, IS_BIOMETRIC_UNLOCK_ENABLED, - SEARCH_TYPE, IS_ACTIVATE_SEARCH_ON_START ) .forEach { settings.initDefaultIfNeed(it) } diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/database/changePassword/ChangePasswordDialog.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/database/changePassword/ChangePasswordDialog.kt index 46f449b0..b5a78166 100644 --- a/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/database/changePassword/ChangePasswordDialog.kt +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/presentation/settings/database/changePassword/ChangePasswordDialog.kt @@ -4,13 +4,13 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.DialogFragment import com.ivanovsky.passnotes.R import com.ivanovsky.passnotes.databinding.DialogChangePasswordBinding import com.ivanovsky.passnotes.extensions.cloneInContext +import com.ivanovsky.passnotes.presentation.core.dialog.BaseDialogFragment import org.koin.androidx.viewmodel.ext.android.viewModel -class ChangePasswordDialog : DialogFragment() { +class ChangePasswordDialog : BaseDialogFragment() { private val viewModel: ChangePasswordDialogViewModel by viewModel() diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/util/EitherExt.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/util/EitherExt.kt new file mode 100644 index 00000000..9af34909 --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/util/EitherExt.kt @@ -0,0 +1,12 @@ +package com.ivanovsky.passnotes.util + +import arrow.core.Either +import com.ivanovsky.passnotes.data.entity.OperationError +import com.ivanovsky.passnotes.data.entity.OperationResult + +fun Either.toOperationResult(): OperationResult { + return fold( + ifLeft = { error -> OperationResult.error(error) }, + ifRight = { result -> OperationResult.success(result) } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/ivanovsky/passnotes/util/SearchOptions.kt b/app/src/main/kotlin/com/ivanovsky/passnotes/util/SearchOptions.kt new file mode 100644 index 00000000..714e305f --- /dev/null +++ b/app/src/main/kotlin/com/ivanovsky/passnotes/util/SearchOptions.kt @@ -0,0 +1,16 @@ +package com.ivanovsky.passnotes.util + +import com.ivanovsky.passnotes.data.entity.Property +import com.ivanovsky.passnotes.data.entity.PropertyType +import com.ivanovsky.passnotes.domain.entity.SearchOptions + +fun SearchOptions.isPropertySearchable(property: Property): Boolean { + return when (property.type) { + PropertyType.TITLE -> isTitleEnabled + PropertyType.USER_NAME -> isUsernameEnabled + PropertyType.PASSWORD -> isPasswordEnabled + PropertyType.URL -> isUrlEnabled + PropertyType.NOTES -> isNotesEnabled + else -> isOtherFieldsEnabled + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_dice_24dp.xml b/app/src/main/res/drawable/ic_dice_24dp.xml new file mode 100644 index 00000000..d42af973 --- /dev/null +++ b/app/src/main/res/drawable/ic_dice_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_config_24dp.xml b/app/src/main/res/drawable/ic_search_config_24dp.xml new file mode 100644 index 00000000..ba80e44b --- /dev/null +++ b/app/src/main/res/drawable/ic_search_config_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/cell_secret_property.xml b/app/src/main/res/layout/cell_secret_property.xml index ae769a26..2553d74f 100644 --- a/app/src/main/res/layout/cell_secret_property.xml +++ b/app/src/main/res/layout/cell_secret_property.xml @@ -53,7 +53,7 @@ android:onClick="@{() -> viewModel.onGenerateButtonClicked()}" android:padding="@dimen/half_margin" android:contentDescription="@string/generate_icon" - android:src="@drawable/ic_edit_24dp" + android:src="@drawable/ic_dice_24dp" app:layout_constraintBottom_toBottomOf="@id/passwordLayout" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/passwordLayout" diff --git a/app/src/main/res/layout/groups_fragment.xml b/app/src/main/res/layout/groups_fragment.xml index abaf7b51..2a9a976e 100644 --- a/app/src/main/res/layout/groups_fragment.xml +++ b/app/src/main/res/layout/groups_fragment.xml @@ -1,7 +1,8 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> @@ -36,7 +37,7 @@ + + Cut Search type + Search options + Search fields + Search scope + Searchable + Recycle Bin + Other + Additional options + Case sensitive Fuzzy Strict Sort by diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f01ce302..c582bfea 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -69,9 +69,11 @@ @color/diff_update -