Add search to browser extension request screen

This commit is contained in:
Rafał Kobyłko 2023-11-19 13:55:41 +01:00
parent 7dee6658a7
commit ec590bd579
5 changed files with 268 additions and 163 deletions

View File

@ -0,0 +1,174 @@
package com.twofasapp.designsystem.common
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.twofasapp.designsystem.TwIcons
import com.twofasapp.designsystem.TwTheme
@Composable
fun TopAppBarWithSearch(
title: String,
searchHint: String = "",
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {},
onSearchValueChanged: (String) -> Unit = {},
navigationClick: () -> Unit
) {
val showSearch = remember { mutableStateOf(false) }
TwTopAppBar(title = {
if (showSearch.value) {
SearchBar(hint = searchHint, showSearch = showSearch, onValueChanged = onSearchValueChanged)
} else {
Text(text = title, color = TwTheme.color.onSurfacePrimary)
}
}, navigationIcon = {
IconButton(onClick = {
if (showSearch.value) {
showSearch.value = false
onSearchValueChanged("")
} else {
navigationClick.invoke()
}
}) {
Icon(TwIcons.ArrowBack, null, tint = TwTheme.color.onSurfacePrimary)
}
}, actions = {
if (showSearch.value.not()) {
IconButton(onClick = { showSearch.value = true }) {
Icon(TwIcons.Search, null, tint = TwTheme.color.primary)
}
}
actions()
}, modifier = modifier
)
}
@Composable
fun SearchBar(
value: String = "",
hint: String = "",
showSearch: MutableState<Boolean>,
onValueChanged: (String) -> Unit = {},
onClearClick: () -> Unit = {},
) {
val textValue = remember { mutableStateOf(value) }
val showClearButton = remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
BackHandler(
enabled = showSearch.value
) {
if (textValue.value.isNotEmpty()) {
textValue.value = ""
onValueChanged("")
} else {
showClearButton.value = false
showSearch.value = false
focusManager.clearFocus()
keyboardController?.hide()
}
onClearClick()
}
if (showSearch.value) {
OutlinedTextField(
modifier = Modifier
.height(56.dp)
.fillMaxWidth()
.onFocusChanged { focusState ->
showClearButton.value = focusState.isFocused
}
.focusRequester(focusRequester),
value = textValue.value,
onValueChange = {
textValue.value = it
onValueChanged(it)
},
placeholder = {
Text(text = hint, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp))
},
textStyle = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = TwTheme.color.onSurfacePrimary,
disabledTextColor = Color.Black,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
focusedLabelColor = TwTheme.color.onSurfaceSecondary,
unfocusedLabelColor = TwTheme.color.onSurfaceSecondary,
errorLabelColor = TwTheme.color.error,
),
trailingIcon = {
AnimatedVisibility(
visible = showClearButton.value,
enter = fadeIn(),
exit = fadeOut()
) {
IconButton(onClick = {
if (textValue.value.isNotEmpty()) {
textValue.value = ""
onValueChanged("")
} else {
showClearButton.value = false
showSearch.value = false
focusManager.clearFocus()
keyboardController?.hide()
}
onClearClick()
}) {
Icon(
painter = TwIcons.Close,
tint = TwTheme.color.onSurfacePrimary,
contentDescription = null
)
}
}
},
maxLines = 1,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
} else {
textValue.value = ""
onValueChanged("")
}
}

View File

@ -189,6 +189,7 @@ class Strings(c: Context) {
val browserRequestSuggested = c.getString(R.string.extension__services_suggested_header)
val browserRequestAll = c.getString(R.string.extension__services_all_header)
val browserRequestOther = c.getString(R.string.extension__services_other_header)
val browserRequestEmpty = c.getString(R.string.tokens__service_not_found_search)
val settingsTheme = c.getString(R.string.settings__option_theme)
val settingsShowNextCode = c.getString(R.string.settings__show_next_token)

View File

@ -3,10 +3,12 @@ package com.twofasapp.feature.browserext.ui.request
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@ -17,9 +19,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
@ -27,13 +33,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.twofasapp.common.domain.Service
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.common.TopAppBarWithSearch
import com.twofasapp.designsystem.common.TwDivider
import com.twofasapp.designsystem.common.TwSwitch
import com.twofasapp.designsystem.common.TwTopAppBar
import com.twofasapp.designsystem.ktx.LocalBackDispatcher
import com.twofasapp.designsystem.ktx.currentActivity
import com.twofasapp.designsystem.service.DsServiceSimple
import com.twofasapp.designsystem.service.asState
import com.twofasapp.feature.browserext.notification.BrowserExtRequestPayload
import com.twofasapp.feature.browserext.notification.BrowserExtRequestReceiver
import com.twofasapp.locale.R
import com.twofasapp.locale.TwLocale
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@ -53,6 +62,7 @@ internal fun BrowserExtRequestScreen(
ScreenContent(
uiState = uiState,
onSaveMyChoiceToggle = { viewModel.toggleSaveMyChoice() },
onSearchChanged = { viewModel.updateSearchQuery(it) },
onServiceClick = { service ->
activity.lifecycleScope.launch {
viewModel.assignDomain(service)
@ -81,11 +91,26 @@ private fun ScreenContent(
uiState: BrowserExtRequestUiState,
onSaveMyChoiceToggle: () -> Unit = {},
onServiceClick: (Service) -> Unit = {},
onSearchChanged: (String) -> Unit = {},
) {
val strings = TwLocale.strings
val backDispatcher = LocalBackDispatcher
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
Scaffold(
topBar = { TwTopAppBar(titleText = strings.browserRequestTitle) },
topBar = {
TopAppBarWithSearch(
title = strings.browserRequestTitle,
searchHint = stringResource(id = R.string.commons__search),
onSearchValueChanged = {
onSearchChanged(it)
},
) {
backDispatcher.onBackPressed()
}
}
) { padding ->
LazyColumn(modifier = Modifier.padding(padding)) {
item {
@ -146,6 +171,25 @@ private fun ScreenContent(
)
}
}
if (uiState.suggestedServices.isEmpty() && uiState.otherServices.isEmpty()) {
item {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TwDivider()
Spacer(modifier = Modifier.height(24.dp))
Text(
text = strings.browserRequestEmpty,
color = TwTheme.color.onSurfaceSecondary,
style = TwTheme.typo.body2,
modifier = Modifier,
)
}
}
}
}
}
}
@ -192,3 +236,16 @@ private fun Preview() {
),
)
}
@Preview
@Composable
private fun Empty() {
ScreenContent(
uiState = BrowserExtRequestUiState(
browserName = "{browser}",
domain = "{domain}",
suggestedServices = emptyList(),
otherServices = emptyList(),
),
)
}

View File

@ -1,12 +1,12 @@
package com.twofasapp.feature.browserext.ui.request
import androidx.lifecycle.ViewModel
import com.twofasapp.feature.browserext.notification.BrowserExtRequestPayload
import com.twofasapp.feature.browserext.notification.DomainMatcher
import com.twofasapp.common.domain.Service
import com.twofasapp.common.ktx.launchScoped
import com.twofasapp.data.browserext.BrowserExtRepository
import com.twofasapp.data.services.ServicesRepository
import com.twofasapp.feature.browserext.notification.BrowserExtRequestPayload
import com.twofasapp.feature.browserext.notification.DomainMatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@ -14,28 +14,45 @@ internal class BrowserExtRequestViewModel(
private val browserExtRepository: BrowserExtRepository,
private val servicesRepository: ServicesRepository,
) : ViewModel() {
val uiState = MutableStateFlow(BrowserExtRequestUiState())
private val searchQuery = MutableStateFlow("")
fun init(payload: BrowserExtRequestPayload) {
launchScoped {
val domain = payload.domain
val services = servicesRepository.getServices()
val matched = domain?.let { DomainMatcher.findMatchingDomain(services, domain) } ?: emptyList()
val suggested = domain?.let { DomainMatcher.findSuggestedForDomain(services, domain) }?.minus(matched.toSet()) ?: emptyList()
val recommended = matched.plus(suggested)
searchQuery.collect { query ->
val domain = payload.domain
val services = servicesRepository.getServices()
.filter {
if (query.isNotEmpty()) {
it.name.contains(query.trim(), ignoreCase = true) ||
it.tags.map { tag -> tag.lowercase() }.contains(query.lowercase())
} else {
true
}
}
val matched = domain?.let { DomainMatcher.findMatchingDomain(services, domain) } ?: emptyList()
val suggested =
domain?.let { DomainMatcher.findSuggestedForDomain(services, domain) }?.minus(matched.toSet()) ?: emptyList()
val recommended = matched.plus(suggested)
uiState.update { state ->
state.copy(
browserName = browserExtRepository.getPairedBrowser(payload.extensionId).name,
domain = payload.domain.orEmpty(),
suggestedServices = recommended,
otherServices = services.minus(recommended.toSet()),
payload = payload,
)
uiState.update { state ->
state.copy(
browserName = browserExtRepository.getPairedBrowser(payload.extensionId).name,
domain = payload.domain.orEmpty(),
suggestedServices = recommended,
otherServices = services.minus(recommended.toSet()),
payload = payload,
)
}
}
}
}
fun updateSearchQuery(query: String) {
searchQuery.update { query }
}
fun assignDomain(service: Service) {
launchScoped {
if (uiState.value.saveMyChoice) {

View File

@ -1,8 +1,5 @@
package com.twofasapp.feature.home.ui.editservice.changebrand
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -10,7 +7,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -22,18 +18,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -42,25 +31,16 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.twofasapp.designsystem.TwIcons
import com.twofasapp.designsystem.TwTheme
import com.twofasapp.designsystem.common.TwTopAppBar
import com.twofasapp.designsystem.common.TopAppBarWithSearch
import com.twofasapp.designsystem.dialog.ListDialog
import com.twofasapp.designsystem.ktx.LocalBackDispatcher
import com.twofasapp.feature.home.ui.editservice.EditServiceViewModel
@ -86,7 +66,7 @@ internal fun ChangeBrandScreen(
Scaffold(
topBar = {
ToolbarWithSearch(
TopAppBarWithSearch(
title = stringResource(id = R.string.customization_change_brand),
searchHint = stringResource(id = R.string.commons__search),
onSearchValueChanged = { brandViewModel.applySearchFilter(it) },
@ -269,127 +249,3 @@ fun SectionHeader(header: String) {
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
@Composable
fun ToolbarWithSearch(
title: String,
searchHint: String = "",
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {},
onSearchValueChanged: (String) -> Unit = {},
navigationClick: () -> Unit
) {
val showSearch = remember { mutableStateOf(false) }
TwTopAppBar(title = {
if (showSearch.value) {
SearchBar(hint = searchHint, showSearch = showSearch, onValueChanged = onSearchValueChanged)
} else {
Text(text = title, color = TwTheme.color.onSurfacePrimary)
}
}, navigationIcon = {
IconButton(onClick = {
if (showSearch.value) {
showSearch.value = false
onSearchValueChanged("")
} else {
navigationClick.invoke()
}
}) {
Icon(TwIcons.ArrowBack, null, tint = TwTheme.color.onSurfacePrimary)
}
}, actions = {
if (showSearch.value.not()) {
IconButton(onClick = { showSearch.value = true }) {
Icon(TwIcons.Search, null, tint = TwTheme.color.primary)
}
}
actions()
}, modifier = modifier
)
}
@Composable
fun SearchBar(
value: String = "",
hint: String = "",
showSearch: MutableState<Boolean>,
onValueChanged: (String) -> Unit = {},
onClearClick: () -> Unit = {},
) {
val textValue = remember { mutableStateOf(value) }
val showClearButton = remember { mutableStateOf(false) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
if (showSearch.value) {
OutlinedTextField(
modifier = Modifier
.height(56.dp)
.fillMaxWidth()
.onFocusChanged { focusState ->
showClearButton.value = focusState.isFocused
}
.focusRequester(focusRequester),
value = textValue.value,
onValueChange = {
textValue.value = it
onValueChanged(it)
},
placeholder = {
Text(text = hint, style = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp))
},
textStyle = MaterialTheme.typography.bodyMedium.copy(fontSize = 18.sp),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = TwTheme.color.onSurfacePrimary,
disabledTextColor = Color.Black,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
focusedLabelColor = TwTheme.color.onSurfaceSecondary,
unfocusedLabelColor = TwTheme.color.onSurfaceSecondary,
errorLabelColor = TwTheme.color.error,
),
trailingIcon = {
AnimatedVisibility(
visible = showClearButton.value,
enter = fadeIn(),
exit = fadeOut()
) {
IconButton(onClick = {
if (textValue.value.isNotEmpty()) {
textValue.value = ""
onValueChanged("")
} else {
showClearButton.value = false
showSearch.value = false
focusManager.clearFocus()
keyboardController?.hide()
}
onClearClick()
}) {
Icon(
painter = TwIcons.Close,
tint = TwTheme.color.onSurfacePrimary,
contentDescription = null
)
}
}
},
maxLines = 1,
singleLine = true,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
}),
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
} else {
textValue.value = ""
onValueChanged("")
}
}