mirror of
https://github.com/openflocon/Flocon.git
synced 2026-04-28 11:19:34 +00:00
feat: [NETWORK] deep search (#448)
Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
parent
5e47202096
commit
e702440d52
24 changed files with 955 additions and 16 deletions
|
|
@ -1,10 +1,10 @@
|
|||
package io.github.openflocon.flocondesktop.features.crashreporter
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import flocondesktop.composeapp.generated.resources.Res
|
||||
import flocondesktop.composeapp.generated.resources.copied_to_clipboard
|
||||
import io.github.openflocon.domain.common.DispatcherProvider
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import io.github.openflocon.flocondesktop.features.network.list.delegate.OpenBod
|
|||
import io.github.openflocon.flocondesktop.features.network.mock.NetworkMocksViewModel
|
||||
import io.github.openflocon.flocondesktop.features.network.mock.processor.ExportMocksProcessor
|
||||
import io.github.openflocon.flocondesktop.features.network.mock.processor.ImportMocksProcessor
|
||||
import io.github.openflocon.flocondesktop.features.network.search.NetworkSearchViewModel
|
||||
import io.github.openflocon.flocondesktop.features.network.websocket.NetworkWebsocketMockViewModel
|
||||
import io.github.openflocon.flocondesktop.messages.ui.MessagesServerDelegate
|
||||
import org.koin.core.module.dsl.factoryOf
|
||||
|
|
@ -32,4 +33,5 @@ internal val networkModule = module {
|
|||
|
||||
viewModelOf(::BadQualityNetworkViewModel)
|
||||
viewModelOf(::NetworkWebsocketMockViewModel)
|
||||
viewModelOf(::NetworkSearchViewModel)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
package io.github.openflocon.flocondesktop.features.network
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.scene.DialogSceneStrategy
|
||||
import io.github.openflocon.domain.settings.repository.SettingsRepository
|
||||
|
|
@ -11,8 +13,10 @@ import io.github.openflocon.flocondesktop.features.network.body.model.NetworkBod
|
|||
import io.github.openflocon.flocondesktop.features.network.detail.view.NetworkDetailScreen
|
||||
import io.github.openflocon.flocondesktop.features.network.list.view.NetworkScreen
|
||||
import io.github.openflocon.flocondesktop.features.network.mock.list.view.NetworkMocksWindow
|
||||
import io.github.openflocon.flocondesktop.features.network.search.view.NetworkSearchScreen
|
||||
import io.github.openflocon.navigation.FloconRoute
|
||||
import io.github.openflocon.navigation.PanelRoute
|
||||
import io.github.openflocon.navigation.WindowRoute
|
||||
import io.github.openflocon.navigation.scene.PanelSceneStrategy
|
||||
import io.github.openflocon.navigation.scene.WindowSceneStrategy
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -39,6 +43,11 @@ internal sealed interface NetworkRoutes : FloconRoute {
|
|||
val windowKey: String,
|
||||
) : NetworkRoutes
|
||||
|
||||
@Serializable
|
||||
data object DeepSearch : NetworkRoutes, WindowRoute {
|
||||
override val singleTopKey = "deepsearch"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class JsonDetail(
|
||||
val json: String,
|
||||
|
|
@ -59,7 +68,8 @@ fun EntryProviderScope<FloconRoute>.networkRoutes() {
|
|||
onPin = {
|
||||
val repository = KoinPlatform.getKoin().get<SettingsRepository>()
|
||||
|
||||
repository.networkSettings = repository.networkSettings.copy(pinnedDetails = true)
|
||||
repository.networkSettings =
|
||||
repository.networkSettings.copy(pinnedDetails = true)
|
||||
}
|
||||
)
|
||||
) {
|
||||
|
|
@ -84,4 +94,14 @@ fun EntryProviderScope<FloconRoute>.networkRoutes() {
|
|||
body = NetworkBodyDetailUi(text = it.json)
|
||||
)
|
||||
}
|
||||
entry<NetworkRoutes.DeepSearch>(
|
||||
metadata = WindowSceneStrategy.window(
|
||||
size = DpSize(
|
||||
width = 1200.0.dp,
|
||||
height = 800.0.dp
|
||||
)
|
||||
)
|
||||
) {
|
||||
NetworkSearchScreen()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ class NetworkViewModel(
|
|||
NetworkAction.MultiSelect -> onMultiSelect()
|
||||
NetworkAction.DeleteSelection -> onDeleteSelection()
|
||||
is NetworkAction.DoubleClicked -> onDoubleClicked(action)
|
||||
NetworkAction.OpenDeepSearch -> navigationState.navigate(NetworkRoutes.DeepSearch)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -502,15 +503,16 @@ class NetworkViewModel(
|
|||
.also { onClearMultiSelect() }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
private fun onDoubleClicked(action: NetworkAction.DoubleClicked) {
|
||||
navigationState.navigate(NetworkRoutes.WindowDetail(
|
||||
requestId = action.item.uuid,
|
||||
windowKey = Uuid.random().toString(),
|
||||
))
|
||||
navigationState.navigate(
|
||||
NetworkRoutes.WindowDetail(
|
||||
requestId = action.item.uuid,
|
||||
windowKey = Uuid.random().toString(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private fun Map<NetworkTextFilterColumns, TextFilterStateUiModel>.toDomain(): List<Filters> = buildList {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import io.github.openflocon.domain.network.models.responseByteSizeFormatted
|
|||
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkItemViewState
|
||||
|
||||
fun FloconNetworkCallDomainModel.toUi(
|
||||
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel?
|
||||
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel?,
|
||||
): NetworkItemViewState = NetworkItemViewState(
|
||||
uuid = callId,
|
||||
dateFormatted = request.startTimeFormatted,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ sealed interface NetworkAction {
|
|||
val itemIdToSelect: String,
|
||||
) : NetworkAction
|
||||
|
||||
data object OpenDeepSearch : NetworkAction
|
||||
|
||||
data class DoubleClicked(val item: NetworkItemViewState) : NetworkAction
|
||||
|
||||
data object ClearMultiSelect : NetworkAction
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package io.github.openflocon.flocondesktop.features.network.list.view
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
|
|||
|
|
@ -18,12 +18,16 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ManageSearch
|
||||
import androidx.compose.material.icons.automirrored.outlined.List
|
||||
import androidx.compose.material.icons.filled.ManageSearch
|
||||
import androidx.compose.material.icons.filled.ScreenSearchDesktop
|
||||
import androidx.compose.material.icons.outlined.CleaningServices
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.ImportExport
|
||||
import androidx.compose.material.icons.outlined.PlayCircle
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.SignalWifiStatusbarConnectedNoInternet4
|
||||
import androidx.compose.material.icons.outlined.Upgrade
|
||||
import androidx.compose.material.icons.outlined.Upload
|
||||
|
|
@ -48,6 +52,7 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isMetaPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
|
|
@ -166,6 +171,15 @@ fun NetworkScreen(
|
|||
true
|
||||
}
|
||||
|
||||
Key.F -> {
|
||||
if (event.isMetaPressed) {
|
||||
onAction(NetworkAction.OpenDeepSearch)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
|
@ -181,6 +195,11 @@ fun NetworkScreen(
|
|||
)
|
||||
},
|
||||
actions = {
|
||||
FloconIconButton(
|
||||
imageVector = Icons.AutoMirrored.Filled.ManageSearch,
|
||||
onClick = { onAction(NetworkAction.OpenDeepSearch) },
|
||||
tooltip = "Deep Search"
|
||||
)
|
||||
FloconIconToggleButton(
|
||||
value = uiState.filterState.hasMocks,
|
||||
tooltip = "Mocks",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,230 @@
|
|||
package io.github.openflocon.flocondesktop.features.network.search
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.github.openflocon.domain.common.DispatcherProvider
|
||||
import io.github.openflocon.domain.common.combines
|
||||
import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase
|
||||
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
|
||||
import io.github.openflocon.domain.network.models.SearchScope
|
||||
import io.github.openflocon.domain.network.models.responseBody
|
||||
import io.github.openflocon.domain.network.models.responseHeaders
|
||||
import io.github.openflocon.domain.network.usecase.ObserveNetworkRequestsByIdUseCase
|
||||
import io.github.openflocon.domain.network.usecase.search.SearchNetworkCallsUseCase
|
||||
import io.github.openflocon.flocondesktop.features.network.NetworkRoutes
|
||||
import io.github.openflocon.flocondesktop.features.network.list.mapper.toUi
|
||||
import io.github.openflocon.flocondesktop.features.network.search.model.NetworkSearchUiState
|
||||
import io.github.openflocon.library.designsystem.common.asState
|
||||
import io.github.openflocon.navigation.MainFloconNavigationState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
class NetworkSearchViewModel(
|
||||
private val searchNetworkCallsUseCase: SearchNetworkCallsUseCase,
|
||||
private val observeNetworkRequestsByIdUseCase: ObserveNetworkRequestsByIdUseCase,
|
||||
private val observeCurrentDeviceIdAndPackageNameUseCase: ObserveCurrentDeviceIdAndPackageNameUseCase,
|
||||
private val dispatcherProvider: DispatcherProvider,
|
||||
private val navigationState: MainFloconNavigationState,
|
||||
) : ViewModel(),
|
||||
KoinComponent {
|
||||
|
||||
private val _query = mutableStateOf("")
|
||||
val query = _query.asState()
|
||||
private val selectedScopes = MutableStateFlow(SearchScope.entries.toSet())
|
||||
private val loading = MutableStateFlow(false)
|
||||
|
||||
val uiState: StateFlow<NetworkSearchUiState> = combines(
|
||||
snapshotFlow { _query.value },
|
||||
selectedScopes,
|
||||
observeCurrentDeviceIdAndPackageNameUseCase(),
|
||||
loading
|
||||
).flatMapLatest { (query, scopes, deviceInfo, loading) ->
|
||||
flow {
|
||||
val results = searchNetworkCallsUseCase(query, scopes)
|
||||
emit(
|
||||
NetworkSearchUiState(
|
||||
selectedScopes = scopes,
|
||||
results = results.map { it.toUi(null /* don't check old instance here*/) },
|
||||
loading = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.flowOn(dispatcherProvider.viewModel)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = NetworkSearchUiState()
|
||||
)
|
||||
|
||||
fun onQueryChanged(query: String) {
|
||||
_query.value = query
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
private val _selectedRequestId = MutableStateFlow<String?>(null)
|
||||
val selectedRequestId = _selectedRequestId.asStateFlow()
|
||||
|
||||
val selectedRequest: StateFlow<FloconNetworkCallDomainModel?> = _selectedRequestId
|
||||
.flatMapLatest { id ->
|
||||
if (id == null) flowOf(null)
|
||||
else observeNetworkRequestsByIdUseCase(id)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
private val _matches = MutableStateFlow<List<Match>>(emptyList())
|
||||
val matches = _matches.asStateFlow()
|
||||
|
||||
private val _currentMatchIndex = MutableStateFlow(0)
|
||||
val currentMatchIndex = _currentMatchIndex.asStateFlow()
|
||||
|
||||
init {
|
||||
// Calculate matches when query or selected request changes
|
||||
viewModelScope.launch {
|
||||
combine(snapshotFlow { _query.value }, selectedRequest) { query, request ->
|
||||
if (query.isBlank() || request == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
val regex = query.toRegex(RegexOption.IGNORE_CASE)
|
||||
val matches = mutableListOf<Match>()
|
||||
|
||||
// URL
|
||||
val url = request.request.url
|
||||
regex.findAll(url).forEach {
|
||||
matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.Url, url))
|
||||
}
|
||||
|
||||
// Method
|
||||
val method = request.request.method
|
||||
regex.findAll(method).forEach {
|
||||
matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.Method, method))
|
||||
}
|
||||
|
||||
// Status
|
||||
val status = request.response?.statusFormatted
|
||||
if (status != null) {
|
||||
regex.findAll(status).forEach {
|
||||
matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.Status, status))
|
||||
}
|
||||
}
|
||||
|
||||
// Request Headers
|
||||
val reqHeaders = request.request.headers.entries.joinToString("\n") { "${it.key}: ${it.value}" }
|
||||
regex.findAll(reqHeaders).forEach {
|
||||
matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.RequestHeaders, reqHeaders))
|
||||
}
|
||||
|
||||
// Request Body
|
||||
val reqBody = request.request.body
|
||||
if (reqBody != null) {
|
||||
regex.findAll(reqBody).forEach {
|
||||
matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.RequestBody, reqBody))
|
||||
}
|
||||
}
|
||||
|
||||
// Response Headers
|
||||
val resHeaders = request.responseHeaders()?.entries?.joinToString("\n") { "${it.key}: ${it.value}" }
|
||||
if (resHeaders != null) {
|
||||
regex.findAll(resHeaders).forEach {
|
||||
matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.ResponseHeaders, resHeaders))
|
||||
}
|
||||
}
|
||||
|
||||
// Response Body
|
||||
val resBody = request.responseBody()
|
||||
if (resBody != null) {
|
||||
regex.findAll(resBody).forEach {
|
||||
matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.ResponseBody, resBody))
|
||||
}
|
||||
}
|
||||
|
||||
matches
|
||||
}
|
||||
}.collect {
|
||||
_matches.value = it
|
||||
_currentMatchIndex.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSelectRequest(requestId: String) {
|
||||
_selectedRequestId.value = requestId
|
||||
}
|
||||
|
||||
fun onNextMatch() {
|
||||
if (_matches.value.isNotEmpty()) {
|
||||
_currentMatchIndex.update { (it + 1) % _matches.value.size }
|
||||
}
|
||||
}
|
||||
|
||||
fun onPrevMatch() {
|
||||
if (_matches.value.isNotEmpty()) {
|
||||
_currentMatchIndex.update {
|
||||
if (it - 1 < 0) _matches.value.lastIndex else it - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onClosePreview() {
|
||||
_selectedRequestId.value = null
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun onNavigateToDetail(requestId: String) {
|
||||
navigationState.navigate(
|
||||
NetworkRoutes.WindowDetail(
|
||||
requestId,
|
||||
Uuid.random().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun onScopeToggled(scope: SearchScope) {
|
||||
selectedScopes.update { current ->
|
||||
if (current.contains(scope)) {
|
||||
current - scope
|
||||
} else {
|
||||
current + scope
|
||||
}
|
||||
}
|
||||
loading.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Match(
|
||||
val start: Int,
|
||||
val length: Int,
|
||||
val location: MatchLocation,
|
||||
val content: String
|
||||
)
|
||||
|
||||
enum class MatchLocation(val label: String) {
|
||||
Url("URL"),
|
||||
Method("Method"),
|
||||
Status("Status"),
|
||||
RequestHeaders("Request Headers"),
|
||||
RequestBody("Request Body"),
|
||||
ResponseHeaders("Response Headers"),
|
||||
ResponseBody("Response Body")
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package io.github.openflocon.flocondesktop.features.network.search.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.github.openflocon.domain.network.models.SearchScope
|
||||
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkItemViewState
|
||||
|
||||
@Immutable
|
||||
data class NetworkSearchUiState(
|
||||
val selectedScopes: Set<SearchScope> = SearchScope.entries.toSet(),
|
||||
val results: List<NetworkItemViewState> = emptyList(),
|
||||
val loading: Boolean = false
|
||||
)
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
package io.github.openflocon.flocondesktop.features.network.search.view
|
||||
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
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.FocusManager
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
|
||||
import io.github.openflocon.domain.network.models.SearchScope
|
||||
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkAction
|
||||
import io.github.openflocon.flocondesktop.features.network.list.view.NetworkItemView
|
||||
import io.github.openflocon.flocondesktop.features.network.search.Match
|
||||
import io.github.openflocon.flocondesktop.features.network.search.NetworkSearchViewModel
|
||||
import io.github.openflocon.flocondesktop.features.network.search.model.NetworkSearchUiState
|
||||
import io.github.openflocon.flocondesktop.features.network.search.view.components.NetworkSearchPreviewView
|
||||
import io.github.openflocon.flocondesktop.features.network.search.view.components.ScopeChipsView
|
||||
import io.github.openflocon.library.designsystem.FloconTheme
|
||||
import io.github.openflocon.library.designsystem.components.FloconIcon
|
||||
import io.github.openflocon.library.designsystem.components.FloconSurface
|
||||
import io.github.openflocon.library.designsystem.components.FloconTextFieldWithoutM3
|
||||
import io.github.openflocon.library.designsystem.components.FloconVerticalScrollbar
|
||||
import io.github.openflocon.library.designsystem.components.defaultPlaceHolder
|
||||
import io.github.openflocon.library.designsystem.components.rememberFloconScrollbarAdapter
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun NetworkSearchScreen() {
|
||||
val viewModel = koinViewModel<NetworkSearchViewModel>()
|
||||
val query by viewModel.query
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val selectedRequestId by viewModel.selectedRequestId.collectAsStateWithLifecycle()
|
||||
val selectedRequest by viewModel.selectedRequest.collectAsStateWithLifecycle()
|
||||
val matches by viewModel.matches.collectAsStateWithLifecycle()
|
||||
val currentMatchIndex by viewModel.currentMatchIndex.collectAsStateWithLifecycle()
|
||||
|
||||
NetworkSearchScreen(
|
||||
query = query,
|
||||
uiState = uiState,
|
||||
selectedRequestId = selectedRequestId,
|
||||
selectedRequest = selectedRequest,
|
||||
matches = matches,
|
||||
currentMatchIndex = currentMatchIndex,
|
||||
onQueryChanged = viewModel::onQueryChanged,
|
||||
onNavigateToDetail = viewModel::onNavigateToDetail,
|
||||
onScopeToggled = viewModel::onScopeToggled,
|
||||
onSelectRequest = viewModel::onSelectRequest,
|
||||
onClosePreview = viewModel::onClosePreview,
|
||||
onNextMatch = viewModel::onNextMatch,
|
||||
onPrevMatch = viewModel::onPrevMatch,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkSearchScreen(
|
||||
query: String,
|
||||
onQueryChanged: (String) -> Unit,
|
||||
onScopeToggled: (SearchScope) -> Unit,
|
||||
uiState: NetworkSearchUiState,
|
||||
selectedRequestId: String?,
|
||||
selectedRequest: FloconNetworkCallDomainModel?,
|
||||
matches: List<Match>,
|
||||
currentMatchIndex: Int,
|
||||
onNextMatch: () -> Unit,
|
||||
onPrevMatch: () -> Unit,
|
||||
onSelectRequest: (String) -> Unit,
|
||||
onClosePreview: () -> Unit,
|
||||
onNavigateToDetail: (String) -> Unit,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
focusRequester.requestFocus()
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
FloconSurface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> {
|
||||
val index = uiState.results.indexOfFirst { it.uuid == selectedRequestId }
|
||||
if (index > 0) {
|
||||
onSelectRequest(uiState.results[index - 1].uuid)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionDown -> {
|
||||
val index = uiState.results.indexOfFirst { it.uuid == selectedRequestId }
|
||||
if (index != -1 && index < uiState.results.lastIndex) {
|
||||
onSelectRequest(uiState.results[index + 1].uuid)
|
||||
} else if (index == -1 && uiState.results.isNotEmpty()) {
|
||||
onSelectRequest(uiState.results[0].uuid)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
onNextMatch()
|
||||
true
|
||||
}
|
||||
|
||||
Key.DirectionLeft -> {
|
||||
onPrevMatch()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(FloconTheme.colorPalette.primary)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
FloconTextFieldWithoutM3(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
onQueryChanged(it)
|
||||
},
|
||||
placeholder = defaultPlaceHolder("Search..."),
|
||||
leadingComponent = {
|
||||
FloconIcon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
},
|
||||
containerColor = FloconTheme.colorPalette.secondary,
|
||||
textStyle = FloconTheme.typography.bodySmall.copy(color = FloconTheme.colorPalette.onSurface),
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
SearchScope.entries.forEach { scope ->
|
||||
ScopeChipsView(
|
||||
uiState = uiState,
|
||||
scope = scope,
|
||||
onScopeToggled = onScopeToggled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth()
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollAdapter = rememberFloconScrollbarAdapter(listState)
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
) {
|
||||
items(uiState.results) { item ->
|
||||
NetworkItemView(
|
||||
state = item,
|
||||
selected = item.uuid == selectedRequestId,
|
||||
multiSelect = false,
|
||||
multiSelected = false,
|
||||
onAction = { action ->
|
||||
if (action is NetworkAction.SelectRequest) {
|
||||
onSelectRequest(action.id)
|
||||
} else if (action is NetworkAction.DoubleClicked) {
|
||||
onNavigateToDetail(action.item.uuid)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
FloconVerticalScrollbar(
|
||||
adapter = scrollAdapter,
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedRequestId != null && selectedRequest != null) {
|
||||
NetworkSearchPreviewView(
|
||||
request = selectedRequest,
|
||||
matches = matches,
|
||||
currentMatchIndex = currentMatchIndex,
|
||||
onNextMatch = onNextMatch,
|
||||
onPrevMatch = onPrevMatch,
|
||||
onClose = onClosePreview,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
package io.github.openflocon.flocondesktop.features.network.search.view.components
|
||||
|
||||
import androidx.compose.foundation.LocalScrollbarStyle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
|
||||
import io.github.openflocon.domain.network.models.responseBody
|
||||
import io.github.openflocon.flocondesktop.features.network.search.Match
|
||||
import io.github.openflocon.library.designsystem.FloconTheme
|
||||
import io.github.openflocon.library.designsystem.components.FloconHorizontalDivider
|
||||
import io.github.openflocon.library.designsystem.components.FloconIcon
|
||||
import io.github.openflocon.library.designsystem.components.FloconVerticalScrollbar
|
||||
import io.github.openflocon.library.designsystem.components.rememberFloconScrollbarAdapter
|
||||
|
||||
@Composable
|
||||
internal fun NetworkSearchPreviewView(
|
||||
request: FloconNetworkCallDomainModel,
|
||||
matches: List<Match>,
|
||||
currentMatchIndex: Int,
|
||||
onNextMatch: () -> Unit,
|
||||
onPrevMatch: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locationLabel = remember(matches, currentMatchIndex) {
|
||||
if (matches.isNotEmpty() && matches.indices.contains(currentMatchIndex)) {
|
||||
matches[currentMatchIndex].location.label
|
||||
} else {
|
||||
"Preview"
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.background(FloconTheme.colorPalette.primary)
|
||||
) {
|
||||
// Divider
|
||||
FloconHorizontalDivider(Modifier.fillMaxWidth())
|
||||
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "${matches.size} matches",
|
||||
style = FloconTheme.typography.bodySmall,
|
||||
color = FloconTheme.colorPalette.onPrimary
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
if (matches.isNotEmpty()) {
|
||||
Text(
|
||||
text = "${currentMatchIndex + 1} / ${matches.size}",
|
||||
style = FloconTheme.typography.bodySmall,
|
||||
color = FloconTheme.colorPalette.onPrimary
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FloconIcon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowBack, // Left Arrow
|
||||
// contentDescription = "Previous Match",
|
||||
modifier = Modifier.clickable { onPrevMatch() }
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FloconIcon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.ArrowForward, // Right Arrow
|
||||
// contentDescription = "Next Match",
|
||||
modifier = Modifier.clickable { onNextMatch() }
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = locationLabel,
|
||||
style = FloconTheme.typography.titleSmall,
|
||||
color = FloconTheme.colorPalette.onPrimary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
FloconIcon(
|
||||
imageVector = Icons.Outlined.Close,
|
||||
// contentDescription = "Close Preview",
|
||||
modifier = Modifier.clickable { onClose() }
|
||||
)
|
||||
}
|
||||
|
||||
// Content
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
val content = remember(matches, currentMatchIndex, request) {
|
||||
if (matches.isNotEmpty() && matches.indices.contains(currentMatchIndex)) {
|
||||
matches[currentMatchIndex].content
|
||||
} else {
|
||||
request.responseBody() ?: "No content"
|
||||
}
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val scrollAdapter = rememberFloconScrollbarAdapter(scrollState)
|
||||
|
||||
val annotatedString = remember(content, matches, currentMatchIndex) {
|
||||
buildAnnotatedString {
|
||||
append(content)
|
||||
// Highlighting
|
||||
if (matches.isNotEmpty()) {
|
||||
val currentMatch = matches.getOrNull(currentMatchIndex)
|
||||
// Only highlight matches that belong to the SAME location/content
|
||||
matches.filter { it.location == currentMatch?.location }.forEach { match ->
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
background = if (match == currentMatch) Color.Red.copy(alpha = 0.5f) else Color.Yellow.copy(
|
||||
alpha = 0.3f
|
||||
),
|
||||
color = if (match == currentMatch) Color.White else Color.Black
|
||||
),
|
||||
start = match.start,
|
||||
end = match.start + match.length
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
|
||||
// Scroll to top when switching content or match
|
||||
LaunchedEffect(currentMatchIndex) {
|
||||
if (matches.isNotEmpty()) {
|
||||
val match = matches.getOrNull(currentMatchIndex)
|
||||
if (match != null) {
|
||||
layoutResult?.let { layout ->
|
||||
// Ensure the offset is valid for the current content
|
||||
if (match.start < layout.layoutInput.text.length) {
|
||||
val line = layout.getLineForOffset(match.start)
|
||||
val y = layout.getLineTop(line)
|
||||
scrollState.animateScrollTo(y.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
.background(FloconTheme.colorPalette.secondary)
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = annotatedString,
|
||||
style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = FloconTheme.colorPalette.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
onTextLayout = {
|
||||
layoutResult = it
|
||||
}
|
||||
)
|
||||
}
|
||||
FloconVerticalScrollbar(
|
||||
adapter = scrollAdapter,
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
unhoverColor = FloconTheme.colorPalette.onSurface.copy(alpha = 0.2f),
|
||||
hoverColor = FloconTheme.colorPalette.onSurface.copy(alpha = 0.8f),
|
||||
),
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package io.github.openflocon.flocondesktop.features.network.search.view.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.openflocon.domain.network.models.SearchScope
|
||||
import io.github.openflocon.domain.network.models.text
|
||||
import io.github.openflocon.flocondesktop.features.network.search.model.NetworkSearchUiState
|
||||
import io.github.openflocon.library.designsystem.FloconTheme
|
||||
|
||||
@Composable
|
||||
internal fun ScopeChipsView(
|
||||
uiState: NetworkSearchUiState,
|
||||
scope: SearchScope,
|
||||
onScopeToggled: (SearchScope) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isSelected = remember(uiState, scope) { uiState.selectedScopes.contains(scope) }
|
||||
val color by animateColorAsState(if (isSelected) FloconTheme.colorPalette.tertiary else Color.Transparent)
|
||||
val textColor by animateColorAsState(
|
||||
if (isSelected) FloconTheme.colorPalette.onTertiary else FloconTheme.colorPalette.onSurface.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(FloconTheme.shapes.large)
|
||||
.clickable { onScopeToggled(scope) }
|
||||
.background(color = color)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = scope.text(),
|
||||
style = FloconTheme.typography.bodySmall,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,6 @@ data class HtmlConfigDataModel(
|
|||
val value: String,
|
||||
)
|
||||
|
||||
|
||||
@Serializable
|
||||
data class MarkdownConfigDataModel(
|
||||
val label: String,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import io.github.openflocon.domain.network.usecase.mocks.SendNetworkWebsocketMoc
|
|||
import io.github.openflocon.domain.network.usecase.mocks.SetupNetworkMocksUseCase
|
||||
import io.github.openflocon.domain.network.usecase.mocks.UpdateNetworkMockIsEnabledUseCase
|
||||
import io.github.openflocon.domain.network.usecase.mocks.UpdateNetworkMocksDeviceUseCase
|
||||
import io.github.openflocon.domain.network.usecase.search.GetAllNetworkRequestsUseCase
|
||||
import io.github.openflocon.domain.network.usecase.search.SearchNetworkCallsUseCase
|
||||
import org.koin.core.module.dsl.factoryOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
|
|
@ -67,4 +69,6 @@ internal val networkModule = module {
|
|||
factoryOf(::SetupNetworkBadQualityUseCase)
|
||||
factoryOf(::SetNetworkBadQualityEnabledConfigUseCase)
|
||||
factoryOf(::ObserveAllNetworkBadQualitiesUseCase)
|
||||
factoryOf(::SearchNetworkCallsUseCase)
|
||||
factoryOf(::GetAllNetworkRequestsUseCase)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,18 @@ fun FloconNetworkCallDomainModel.responseByteSizeFormatted(): String? = when (th
|
|||
null -> null
|
||||
}
|
||||
|
||||
fun FloconNetworkCallDomainModel.responseHeaders(): Map<String, String>? = when (this.response) {
|
||||
is FloconNetworkCallDomainModel.Response.Failure -> null
|
||||
is FloconNetworkCallDomainModel.Response.Success -> this.response.headers
|
||||
null -> null
|
||||
}
|
||||
|
||||
fun FloconNetworkCallDomainModel.responseBody(): String? = when (this.response) {
|
||||
is FloconNetworkCallDomainModel.Response.Failure -> null
|
||||
is FloconNetworkCallDomainModel.Response.Success -> this.response.body
|
||||
null -> null
|
||||
}
|
||||
|
||||
fun FloconNetworkCallDomainModel.Response.getContentType(): String? = when (this) {
|
||||
is FloconNetworkCallDomainModel.Response.Failure -> null
|
||||
is FloconNetworkCallDomainModel.Response.Success -> this.contentType
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package io.github.openflocon.domain.network.models
|
||||
|
||||
enum class SearchScope {
|
||||
RequestHeader,
|
||||
RequestBody,
|
||||
ResponseHeader,
|
||||
ResponseBody,
|
||||
Url,
|
||||
Method,
|
||||
Status
|
||||
}
|
||||
|
||||
fun SearchScope.text(): String = when (this) {
|
||||
SearchScope.RequestHeader -> "Request Header"
|
||||
SearchScope.RequestBody -> "Request Body"
|
||||
SearchScope.ResponseHeader -> "Response Header"
|
||||
SearchScope.ResponseBody -> "Response Body"
|
||||
SearchScope.Url -> "Url"
|
||||
SearchScope.Method -> "Method"
|
||||
SearchScope.Status -> "Status"
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package io.github.openflocon.domain.network.usecase.search
|
||||
|
||||
import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdAndPackageNameUseCase
|
||||
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
|
||||
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel
|
||||
import io.github.openflocon.domain.network.repository.NetworkRepository
|
||||
|
||||
class GetAllNetworkRequestsUseCase(
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val getCurrentDeviceIdAndPackageNameUseCase: GetCurrentDeviceIdAndPackageNameUseCase,
|
||||
) {
|
||||
suspend operator fun invoke(): List<FloconNetworkCallDomainModel> {
|
||||
val current = getCurrentDeviceIdAndPackageNameUseCase() ?: return emptyList()
|
||||
return networkRepository.getRequests(
|
||||
deviceIdAndPackageName = current,
|
||||
sortedBy = null,
|
||||
filter = NetworkFilterDomainModel(
|
||||
filterOnAllColumns = null,
|
||||
textsFilters = null,
|
||||
methodFilter = null,
|
||||
displayOldSessions = true // Include everything for global search
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package io.github.openflocon.domain.network.usecase.search
|
||||
|
||||
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
|
||||
import io.github.openflocon.domain.network.models.SearchScope
|
||||
import io.github.openflocon.domain.network.models.responseBody
|
||||
import io.github.openflocon.domain.network.models.responseHeaders
|
||||
|
||||
class SearchNetworkCallsUseCase(
|
||||
private val getAllNetworkRequestsUseCase: GetAllNetworkRequestsUseCase
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
query: String,
|
||||
scope: Set<SearchScope>
|
||||
): List<FloconNetworkCallDomainModel> {
|
||||
if (query.isBlank() || scope.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val calls = getAllNetworkRequestsUseCase()
|
||||
|
||||
return calls.filter { call ->
|
||||
scope.any { s ->
|
||||
when (s) {
|
||||
SearchScope.RequestHeader -> call.request.headers.any {
|
||||
it.key.contains(query, ignoreCase = true) || it.value.contains(
|
||||
query,
|
||||
ignoreCase = true
|
||||
)
|
||||
}
|
||||
|
||||
SearchScope.RequestBody -> call.request.body?.contains(query, ignoreCase = true)
|
||||
?: false
|
||||
|
||||
SearchScope.ResponseHeader -> call.responseHeaders()?.any {
|
||||
it.key.contains(query, ignoreCase = true) || it.value.contains(
|
||||
query,
|
||||
ignoreCase = true
|
||||
)
|
||||
} == true
|
||||
|
||||
SearchScope.ResponseBody -> call.responseBody()
|
||||
?.contains(query, ignoreCase = true) == true
|
||||
|
||||
SearchScope.Url -> call.request.url.contains(query, ignoreCase = true)
|
||||
SearchScope.Method -> call.request.method.contains(query, ignoreCase = true)
|
||||
SearchScope.Status -> call.response?.statusFormatted?.contains(
|
||||
query,
|
||||
ignoreCase = true
|
||||
) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package io.github.openflocon.library.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.LocalScrollbarStyle
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.ScrollbarStyle
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -20,5 +22,6 @@ expect fun rememberFloconScrollbarAdapter(scrollState: LazyGridState): FloconScr
|
|||
@Composable
|
||||
expect fun FloconVerticalScrollbar(
|
||||
adapter: FloconScrollAdapter,
|
||||
modifier: Modifier = Modifier
|
||||
style: ScrollbarStyle = LocalScrollbarStyle.current,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package io.github.openflocon.library.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.LocalScrollbarStyle
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.ScrollbarStyle
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
|
|
@ -17,11 +19,13 @@ data class FloconScrollAdapterDesktop(
|
|||
@Composable
|
||||
actual fun FloconVerticalScrollbar(
|
||||
adapter: FloconScrollAdapter,
|
||||
style: ScrollbarStyle,
|
||||
modifier: Modifier
|
||||
) {
|
||||
VerticalScrollbar(
|
||||
adapter = (adapter as FloconScrollAdapterDesktop).scrollbarAdapter,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ class MainFloconNavigationState(initialScreen: FloconRoute = LoadingRoute) : Flo
|
|||
} else {
|
||||
_stack.add(route)
|
||||
}
|
||||
} else if (route is WindowRoute && route.singleTopKey != null) {
|
||||
_stack.find { it is WindowRoute && it.singleTopKey == route.singleTopKey }?.let {
|
||||
_stack.remove(it)
|
||||
}
|
||||
_stack.add(route)
|
||||
} else {
|
||||
_stack.add(route)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,7 @@ import androidx.navigation3.runtime.NavKey
|
|||
interface FloconRoute : NavKey
|
||||
|
||||
interface PanelRoute : NavKey
|
||||
|
||||
interface WindowRoute : NavKey {
|
||||
val singleTopKey: String?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ package io.github.openflocon.navigation.scene
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.scene.OverlayScene
|
||||
import androidx.navigation3.scene.Scene
|
||||
|
|
@ -26,8 +29,18 @@ data class WindowScene(
|
|||
override val entries: List<NavEntry<FloconRoute>> = listOf(entry)
|
||||
|
||||
override val content: @Composable (() -> Unit) = {
|
||||
val width = entry.metadata[WindowSceneStrategy.SIZE_WIDTH] as? Number
|
||||
val height = entry.metadata[WindowSceneStrategy.SIZE_HEIGHT] as? Number
|
||||
val size = if (width != null && height != null) {
|
||||
DpSize(width.toInt().dp, height.toInt().dp)
|
||||
} else null
|
||||
|
||||
val state = rememberWindowState(
|
||||
size = size ?: DpSize(800.dp, 600.dp),
|
||||
)
|
||||
Window(
|
||||
onCloseRequest = onBack
|
||||
onCloseRequest = onBack,
|
||||
state = state,
|
||||
) {
|
||||
entry.Content()
|
||||
}
|
||||
|
|
@ -52,7 +65,15 @@ class WindowSceneStrategy : SceneStrategy<FloconRoute> {
|
|||
|
||||
companion object {
|
||||
private const val IS_WINDOW = "is_window"
|
||||
internal const val SIZE_WIDTH = "SIZE_WIDTH"
|
||||
internal const val SIZE_HEIGHT = "SIZE_HEIGHT"
|
||||
|
||||
fun window() = mapOf(IS_WINDOW to true)
|
||||
fun window(size: DpSize? = null) = buildMap {
|
||||
put(IS_WINDOW, true)
|
||||
size?.let {
|
||||
put(SIZE_WIDTH, it.width.value)
|
||||
put(SIZE_HEIGHT, it.height.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue