KEYPASS-237 | Add sorting options (#475)

This commit is contained in:
Yogesh Choudhary Paliyal
2023-04-29 19:51:39 +05:30
committed by GitHub
parent 321e0d020b
commit 2e6df18677
14 changed files with 313 additions and 164 deletions

View File

@@ -15,7 +15,7 @@ android {
defaultConfig {
applicationId appPackageId
minSdk 22
minSdk 23
targetSdk 33
versionCode 1411
versionName "1.4.11"
@@ -41,8 +41,8 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '1.8'
@@ -171,7 +171,6 @@ dependencies {
// ...with Kotlin.
kaptTest("com.google.dagger:hilt-android-compiler:$hilt_version")
implementation "org.reduxkotlin:redux-kotlin-compose-jvm:0.6.0"
implementation "me.saket.cascade:cascade-compose:2.0.0-rc02"
}

View File

@@ -3,12 +3,10 @@ package com.yogeshpaliyal.keypass.ui.home
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.yogeshpaliyal.common.data.AccountModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -25,40 +23,16 @@ class DashboardViewModel @Inject constructor(
) :
AndroidViewModel(application) {
val keyword by lazy {
MutableLiveData<String>("")
}
val tag by lazy {
MutableLiveData<String?>()
}
private val appDao = appDb.getDao()
val mediator = MediatorLiveData<List<AccountModel>>()
init {
mediator.addSource(keyword) {
viewModelScope.launch(Dispatchers.IO) {
mediator.postValue(appDao.getAllAccounts(keyword.value, tag.value))
}
}
mediator.addSource(tag) {
viewModelScope.launch(Dispatchers.IO) {
mediator.postValue(appDao.getAllAccounts(keyword.value, tag.value))
}
}
fun queryUpdated(keyword: String?, tag: String?, sortField: String?, sortAscending: Boolean = true) {
viewModelScope.launch(Dispatchers.IO) {
mediator.postValue(appDao.getAllAccounts(keyword.value, tag.value))
}
reloadData()
}
private fun reloadData() {
viewModelScope.launch(Dispatchers.IO) {
while (true) {
delay(1000)
mediator.postValue(appDao.getAllAccounts(keyword.value, tag.value))
if (sortAscending) {
mediator.postValue(appDao.getAllAccountsAscending(keyword ?: "", tag, sortField))
} else {
mediator.postValue(appDao.getAllAccountsDescending(keyword ?: "", tag, sortField))
}
}
}

View File

@@ -0,0 +1,95 @@
package com.yogeshpaliyal.keypass.ui.home
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yogeshpaliyal.keypass.ui.home.components.AccountsList
import com.yogeshpaliyal.keypass.ui.home.components.SearchBar
import com.yogeshpaliyal.keypass.ui.redux.HomeState
import com.yogeshpaliyal.keypass.ui.redux.NavigationAction
import com.yogeshpaliyal.keypass.ui.redux.StateUpdateAction
import org.reduxkotlin.compose.rememberDispatcher
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 09:25
*/
@Composable()
fun Homepage(
mViewModel: DashboardViewModel = viewModel(),
homeState: HomeState
) {
val tag = homeState.tag
val keyword = homeState.keyword
val sortField = homeState.sortField
val sortAscendingOrder = homeState.sortAscending
val listOfAccountsLiveData by mViewModel.mediator.observeAsState()
val dispatchAction = rememberDispatcher()
LaunchedEffect(tag, keyword, sortField, sortAscendingOrder, block = {
mViewModel.queryUpdated(keyword, tag, sortField, sortAscendingOrder)
})
Column(modifier = Modifier.fillMaxSize()) {
SearchBar(keyword, {
dispatchAction(StateUpdateAction(homeState.copy(keyword = it)))
}) { field, order ->
dispatchAction(
StateUpdateAction(
homeState.copy(
sortField = field.value,
sortAscending = order == SortingOrder.Ascending
)
)
)
}
if (tag != null) {
LazyRow(
modifier = Modifier.padding(vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
content = {
item {
AssistChip(onClick = { }, label = {
Text(text = tag)
}, trailingIcon = {
IconButton(onClick = {
dispatchAction(NavigationAction(HomeState(), true))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Close),
contentDescription = ""
)
}
})
}
}
)
}
AccountsList(listOfAccountsLiveData)
}
}

View File

@@ -0,0 +1,20 @@
package com.yogeshpaliyal.keypass.ui.home
import androidx.annotation.StringRes
import com.yogeshpaliyal.keypass.R
sealed class SortingOrder(@StringRes val label: Int) {
object Ascending : SortingOrder(R.string.ascending)
object Descending : SortingOrder(R.string.descending)
}
fun getSortingOrderOptions() = mutableListOf(SortingOrder.Ascending, SortingOrder.Descending)
sealed class SortingField(
@StringRes val label: Int,
val value: String,
val sortingOrders: List<SortingOrder>
) {
object Title : SortingField(R.string.account_name, "title", getSortingOrderOptions())
object Username : SortingField(R.string.username_email_phone, "username", getSortingOrderOptions())
}

View File

@@ -1,11 +1,10 @@
package com.yogeshpaliyal.keypass.ui.home
package com.yogeshpaliyal.keypass.ui.home.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -14,24 +13,19 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.twotone.ContentCopy
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -41,100 +35,19 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yogeshpaliyal.common.constants.AccountType
import com.yogeshpaliyal.common.data.AccountModel
import com.yogeshpaliyal.keypass.R
import com.yogeshpaliyal.keypass.ui.redux.AccountDetailState
import com.yogeshpaliyal.keypass.ui.redux.CopyToClipboard
import com.yogeshpaliyal.keypass.ui.redux.HomeState
import com.yogeshpaliyal.keypass.ui.redux.IntentNavigation
import com.yogeshpaliyal.keypass.ui.redux.NavigationAction
import com.yogeshpaliyal.keypass.ui.style.KeyPassTheme
import kotlinx.coroutines.delay
import org.reduxkotlin.compose.rememberDispatcher
import kotlin.time.Duration.Companion.seconds
/*
* @author Yogesh Paliyal
* techpaliyal@gmail.com
* https://techpaliyal.com
* created on 31-01-2021 09:25
*/
private fun getPassword(model: AccountModel): String {
if (model.type == AccountType.TOTP) {
return model.getOtp()
}
return model.password.orEmpty()
}
@Composable()
fun Homepage(mViewModel: DashboardViewModel = viewModel(), selectedTag: String?) {
val listOfAccountsLiveData by mViewModel.mediator.observeAsState()
val keyword by mViewModel.keyword.observeAsState()
val dispatchAction = rememberDispatcher()
LaunchedEffect(key1 = selectedTag, block = {
if (selectedTag.isNullOrBlank()) {
mViewModel.tag.postValue(null)
} else {
mViewModel.tag.postValue(selectedTag)
}
})
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(1f)
.padding(16.dp),
value = keyword ?: "",
placeholder = {
Text(text = "Search Account")
},
onValueChange = { newValue -> mViewModel.keyword.value = newValue },
trailingIcon = {
if (keyword.isNullOrBlank().not()) {
IconButton(onClick = { mViewModel.keyword.value = "" }) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Close),
contentDescription = ""
)
}
}
}
)
if (selectedTag != null) {
LazyRow(
modifier = Modifier.padding(vertical = 8.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
content = {
item {
AssistChip(onClick = { }, label = {
Text(text = selectedTag)
}, trailingIcon = {
IconButton(onClick = {
dispatchAction(NavigationAction(HomeState(), true))
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Close),
contentDescription = ""
)
}
})
}
}
)
}
AccountsList(listOfAccountsLiveData)
}
}
@Composable
fun AccountsList(accounts: List<AccountModel>? = null) {
val dispatch = rememberDispatcher()
@@ -167,18 +80,6 @@ fun AccountsList(accounts: List<AccountModel>? = null) {
}
}
@Preview
@Composable
fun PreviewAccount() {
KeyPassTheme {
Account(
accountModel = AccountModel(),
onClick = {
}
)
}
}
@Composable
fun Account(
accountModel: AccountModel,
@@ -187,7 +88,7 @@ fun Account(
val dispatch = rememberDispatcher()
Card(
elevation = androidx.compose.material3.CardDefaults.cardElevation(defaultElevation = 1.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
onClick = { onClick(accountModel) }
) {
Row(modifier = Modifier.padding(12.dp)) {
@@ -242,26 +143,6 @@ fun Account(
}
}
@Composable
fun WrapWithProgress(accountModel: AccountModel) {
val (progress, setProgress) = remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
if (accountModel.type == AccountType.TOTP) {
while (true) {
val newProgress = accountModel.getTOtpProgress().toFloat() / 30
setProgress(newProgress)
delay(1.seconds)
}
}
}
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(),
progress = progress
)
}
private fun getUsernameOrOtp(accountModel: AccountModel): String? {
return if (accountModel.type == AccountType.TOTP) accountModel.getOtp() else accountModel.username
}
@@ -282,7 +163,7 @@ fun RenderUserName(accountModel: AccountModel) {
}
Text(
text = username ?: "",
text = username,
style = MaterialTheme.typography.bodyMedium.merge(
TextStyle(
fontSize = 14.sp
@@ -318,3 +199,30 @@ fun NoDataFound() {
)
}
}
private fun getPassword(model: AccountModel): String {
if (model.type == AccountType.TOTP) {
return model.getOtp()
}
return model.password.orEmpty()
}
@Composable
fun WrapWithProgress(accountModel: AccountModel) {
val (progress, setProgress) = remember { mutableStateOf(0f) }
LaunchedEffect(Unit) {
if (accountModel.type == AccountType.TOTP) {
while (true) {
val newProgress = accountModel.getTOtpProgress().toFloat() / 30
setProgress(newProgress)
delay(1.seconds)
}
}
}
CircularProgressIndicator(
modifier = Modifier.fillMaxSize(),
progress = progress
)
}

View File

@@ -0,0 +1,68 @@
package com.yogeshpaliyal.keypass.ui.home.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Sort
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.dp
import com.yogeshpaliyal.keypass.ui.home.SortingField
import com.yogeshpaliyal.keypass.ui.home.SortingOrder
@Composable
fun SearchBar(
keyword: String?,
updateKeyword: (keyword: String) -> Unit,
updateSorting: (SortingField, SortingOrder) -> Unit
) {
val (isMenuVisible, setMenuVisible) = rememberSaveable { mutableStateOf(false) }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(1f)
.padding(16.dp),
value = keyword ?: "",
placeholder = {
Text(text = "Search Account")
},
onValueChange = { newValue ->
updateKeyword(newValue)
},
trailingIcon = {
Row {
if (keyword.isNullOrBlank().not()) {
IconButton(onClick = { updateKeyword("") }) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Close),
contentDescription = ""
)
}
}
IconButton(onClick = {
setMenuVisible(!isMenuVisible)
}) {
Icon(
painter = rememberVectorPainter(image = Icons.Rounded.Sort),
contentDescription = ""
)
}
}
SortingMenu(isMenuVisible, setMenuVisible) { sortingField, order ->
updateSorting(sortingField, order)
setMenuVisible(false)
}
}
)
}

View File

@@ -0,0 +1,45 @@
package com.yogeshpaliyal.keypass.ui.home.components
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import com.yogeshpaliyal.keypass.ui.home.SortingField
import com.yogeshpaliyal.keypass.ui.home.SortingOrder
import me.saket.cascade.CascadeDropdownMenu
import me.saket.cascade.rememberCascadeState
@Composable
fun SortingMenu(
isMenuVisible: Boolean,
setMenuVisible: (Boolean) -> Unit,
onOptionSelected: (SortingField, SortingOrder) -> Unit
) {
val state = rememberCascadeState()
val sortingOptions =
remember { mutableListOf(SortingField.Title, SortingField.Username) }
CascadeDropdownMenu(
state = state,
expanded = isMenuVisible,
onDismissRequest = { setMenuVisible(false) }
) {
sortingOptions.forEach { sortingField ->
DropdownMenuItem(
text = { Text(stringResource(id = sortingField.label)) },
children = {
sortingField.sortingOrders.forEach {
DropdownMenuItem(
text = { Text(stringResource(id = it.label)) },
onClick = {
onOptionSelected(sortingField, it)
}
)
}
}
)
}
}
}

View File

@@ -127,7 +127,7 @@ fun CurrentPage() {
when (currentScreen) {
is HomeState -> {
Homepage(selectedTag = (currentScreen as HomeState).type)
Homepage(homeState = (currentScreen as HomeState))
}
is SettingsState -> {
@@ -179,7 +179,7 @@ fun OptionBottomBar(
is NavigationModelItem.NavTagItem -> {
NavMenuFolder(folder = it) {
dispatchAction(NavigationAction(HomeState(it.tag), false))
dispatchAction(NavigationAction(HomeState(tag = it.tag), false))
dispatchAction(BottomSheetAction.HomeNavigationMenu(false))
}
}

View File

@@ -12,7 +12,7 @@ data class KeyPassState(
sealed class ScreenState(val showMainBottomAppBar: Boolean = false)
data class HomeState(val type: String? = null) : ScreenState(true)
data class HomeState(val keyword: String? = null, val tag: String? = null, val sortField: String? = null, val sortAscending: Boolean = true) : ScreenState(true)
data class AccountDetailState(val accountId: Long? = null) : ScreenState()
data class TotpDetailState(val accountId: String? = null) : ScreenState()
object SettingsState : ScreenState(true)

View File

@@ -82,5 +82,7 @@
<string name="restore">पुनर्स्थापित करना</string>
<string name="keyphrase_restore_info">कृपया वह कीफ़्रेज़ दर्ज करें जो आपके द्वारा बैक अप लेने पर प्राप्त होता है</string>
<string name="custom_generated_keyphrase_info">क्या आप बैकअप के लिए अपना खुद का कीफ्रेज बनाना चाहते हैं या मुझे आपके लिए जनरेट करना चाहिए?</string>
<string name="ascending">आरोही</string>
<string name="descending">अवरोही</string>
</resources>

View File

@@ -80,5 +80,7 @@
<string name="restore">Restaurar</string>
<string name="keyphrase_restore_info">Insira a frase-chave que você obteve ao fazer backup</string>
<string name="custom_generated_keyphrase_info">Deseja criar sua própria frase-chave para backups ou devo gerar para você?</string>
<string name="ascending">Ascendente</string>
<string name="descending">descendente</string>
</resources>

View File

@@ -81,5 +81,7 @@
<string name="restore">恢复</string>
<string name="keyphrase_restore_info">请输入您备份时获得的关键词</string>
<string name="custom_generated_keyphrase_info">您想创建自己的备份关键字还是我应该为您生成?</string>
<string name="ascending">上升</string>
<string name="descending">降序</string>
</resources>

View File

@@ -80,4 +80,7 @@
<string name="keyphrase_restore_info">Please enter keyphrase you get when you backed up</string>
<string name="custom_generated_keyphrase_info">Do you want to create your own keyphrase for backups or should I generate for you?</string>
<string name="ascending">Ascending</string>
<string name="descending">Descending</string>
</resources>

View File

@@ -40,9 +40,40 @@ interface DbDao {
"AND ((username LIKE '%'||:query||'%' ) " +
"OR (title LIKE '%'||:query||'%' ) " +
"OR (notes LIKE '%'||:query||'%' )) " +
"ORDER BY title ASC"
"ORDER BY" +
" CASE" +
" WHEN :sortingField = 'username' THEN username" +
" WHEN :sortingField = 'title' THEN title" +
" WHEN :sortingField = 'notes' THEN notes" +
" END ASC"
)
fun getAllAccounts(query: String?, tag: String?): List<AccountModel>
fun getAllAccountsAscending(
query: String?,
tag: String?,
sortingField: String?
): List<AccountModel>
@Query(
"SELECT * FROM account " +
"WHERE " +
"CASE WHEN :tag IS NOT NULL " +
"THEN tags = :tag " +
"ELSE 1 END " +
"AND ((username LIKE '%'||:query||'%' ) " +
"OR (title LIKE '%'||:query||'%' ) " +
"OR (notes LIKE '%'||:query||'%' )) " +
"ORDER BY" +
" CASE" +
" WHEN :sortingField = 'username' THEN username" +
" WHEN :sortingField = 'title' THEN title" +
" WHEN :sortingField = 'notes' THEN notes" +
" END DESC"
)
fun getAllAccountsDescending(
query: String?,
tag: String?,
sortingField: String?
): List<AccountModel>
@Query("SELECT * FROM account WHERE id = :id")
suspend fun getAccount(id: Long?): AccountModel?