diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/models/NetworkSettingsLocal.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/models/NetworkSettingsLocal.kt index 632ba204e..81a6d6dbd 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/models/NetworkSettingsLocal.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/core/data/settings/models/NetworkSettingsLocal.kt @@ -1,5 +1,6 @@ package io.github.openflocon.flocondesktop.core.data.settings.models +import io.github.openflocon.domain.models.settings.NetworkDetailTab import io.github.openflocon.domain.models.settings.NetworkSettings import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -17,7 +18,10 @@ internal data class NetworkSettingsLocal( val autoScroll: Boolean = false, @SerialName("invert_list") - val invertList: Boolean = false + val invertList: Boolean = false, + + @SerialName("default_selected_tab") + val defaultSelectedTab: NetworkDetailTab = NetworkDetailTab.Request, ) @@ -25,12 +29,15 @@ internal fun NetworkSettingsLocal.toDomain() = NetworkSettings( pinnedDetails = pinnedDetails, displayOldSessions = displayOldSessions, autoScroll = autoScroll, - invertList = invertList + invertList = invertList, + defaultSelectedTab = defaultSelectedTab, ) internal fun NetworkSettings.toLocal() = NetworkSettingsLocal( pinnedDetails = pinnedDetails, displayOldSessions = displayOldSessions, autoScroll = autoScroll, - invertList = invertList + invertList = invertList, + defaultSelectedTab = defaultSelectedTab, ) + diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/deeplinks/mapper/Mapper.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/deeplinks/mapper/Mapper.kt index 27e76981c..93993aaa6 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/deeplinks/mapper/Mapper.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/deeplinks/mapper/Mapper.kt @@ -53,9 +53,15 @@ internal fun mapToUi( internal fun parseDeeplinkString( input: String, - deepLink: DeeplinkDomainModel, - variables: List, - variableValues: Map + deepLink: DeeplinkDomainModel = DeeplinkDomainModel( + id = 0, + label = null, + link = input, + description = null, + parameters = emptyList() + ), + variables: List = emptyList(), + variableValues: Map = emptyMap() ): List { val regex = "\\[([^\\[\\]]*)\\]".toRegex() // Regex pour trouver [quelquechose] val result = mutableListOf() @@ -91,12 +97,20 @@ internal fun parseDeeplinkString( ) } ) + } else { + result.add( + DeeplinkPart.TextField( + label = value, + autoComplete = null + ) + ) } lastIndex = range.last + 1 } // 3. Ajouter la dernière partie "Text" après le dernier [value] (s'il y en a une) + if (lastIndex < input.length) { val remainingText = input.substring(lastIndex) if (remainingText.isNotEmpty()) { diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/deeplinks/model/DeeplinkPart.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/deeplinks/model/DeeplinkPart.kt index 17355e639..fafa966dc 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/deeplinks/model/DeeplinkPart.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/deeplinks/model/DeeplinkPart.kt @@ -13,9 +13,10 @@ sealed interface DeeplinkPart { @Immutable data class TextField( val label: String, - val autoComplete: List?, + val autoComplete: List? = null, ) : DeeplinkPart + @Immutable data class Variable( val value: String diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt index 311f509f6..a32fa8115 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt @@ -2,6 +2,7 @@ package io.github.openflocon.flocondesktop.features.network import io.github.openflocon.domain.network.usecase.ImportNetworkCallsFromCsvUseCase import io.github.openflocon.flocondesktop.features.network.badquality.BadQualityNetworkViewModel +import io.github.openflocon.flocondesktop.features.network.body.NetworkJsonViewModel import io.github.openflocon.flocondesktop.features.network.detail.NetworkDetailDelegate import io.github.openflocon.flocondesktop.features.network.detail.NetworkDetailViewModel import io.github.openflocon.flocondesktop.features.network.list.NetworkViewModel @@ -20,6 +21,8 @@ import org.koin.dsl.module internal val networkModule = module { viewModelOf(::NetworkViewModel) viewModelOf(::NetworkDetailViewModel) + viewModelOf(::NetworkJsonViewModel) + factoryOf(::MessagesServerDelegate) factoryOf(::HeaderDelegate) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/Navigation.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/Navigation.kt index 2374beefe..20d194566 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/Navigation.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/Navigation.kt @@ -7,9 +7,10 @@ import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.EntryProviderScope import io.github.openflocon.domain.settings.repository.SettingsRepository import io.github.openflocon.flocondesktop.app.MenuSceneStrategy -import io.github.openflocon.flocondesktop.features.network.body.NetworkBodyWindow import io.github.openflocon.flocondesktop.features.network.body.NetworkDiffWindow +import io.github.openflocon.flocondesktop.features.network.body.NetworkJsonScreen import io.github.openflocon.flocondesktop.features.network.body.model.NetworkBodyDetailUi + import io.github.openflocon.flocondesktop.features.network.body.model.NetworkDiffUi import io.github.openflocon.flocondesktop.features.network.detail.view.NetworkDetailScreen import io.github.openflocon.flocondesktop.features.network.list.view.NetworkScreen @@ -100,12 +101,22 @@ fun EntryProviderScope.networkRoutes() { NetworkDetailScreen(requestId = it.requestId, key = it.windowKey) } entry( - metadata = WindowSceneStrategy.window() + metadata = WindowSceneStrategy.window( + WindowProperties( + title = "Body", + size = DpSize( + width = 900.0.dp, + height = 700.0.dp + ) + ) + ) ) { - NetworkBodyWindow( - body = NetworkBodyDetailUi(text = it.json) + NetworkJsonScreen( + json = it.json, + key = it.id, ) } + entry( metadata = WindowSceneStrategy.window(WindowProperties(title = "Diff")) ) { diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/body/NetworkJsonScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/body/NetworkJsonScreen.kt index b384d8aac..eba2e8a97 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/body/NetworkJsonScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/body/NetworkJsonScreen.kt @@ -2,6 +2,7 @@ package io.github.openflocon.flocondesktop.features.network.body import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,14 +12,16 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowDownward import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -30,26 +33,50 @@ import androidx.compose.runtime.rememberCoroutineScope 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +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.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sebastianneubauer.jsontree.search.rememberSearchState import flocondesktop.composeapp.generated.resources.Res import flocondesktop.composeapp.generated.resources.search import io.github.openflocon.flocondesktop.features.network.body.model.NetworkBodyDetailUi import io.github.openflocon.flocondesktop.features.network.body.model.previewNetworkBodyDetailUi 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.FloconJsonTree import io.github.openflocon.library.designsystem.components.FloconSmallIconButton import io.github.openflocon.library.designsystem.components.FloconSurface -import io.github.openflocon.library.designsystem.components.FloconTextField +import io.github.openflocon.library.designsystem.components.FloconTextFieldWithoutM3 import io.github.openflocon.library.designsystem.components.defaultPlaceHolder import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun NetworkJsonScreen( + json: String, + key: String = json, +) { + val viewModel = koinViewModel(key = key) { + parametersOf(json) + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + NetworkBodyContent( + body = uiState, + modifier = Modifier.fillMaxSize(), + ) +} @Composable fun NetworkBodyWindow( @@ -61,6 +88,7 @@ fun NetworkBodyWindow( ) } + @Composable private fun NetworkBodyContent( body: NetworkBodyDetailUi, @@ -139,42 +167,100 @@ private fun SearchBar( focusRequester.requestFocus() } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .background(FloconTheme.colorPalette.primary) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - FloconTextField( - value = query, - onValueChange = { queryChanged(it) }, - placeholder = defaultPlaceHolder(stringResource(Res.string.search)), + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp, horizontal = 12.dp) - .heightIn(min = 24.dp) - .focusRequester(focusRequester), - ) - - AnimatedVisibility(visible = totalResults > 0) { - Row( - verticalAlignment = Alignment.CenterVertically, + .fillMaxWidth() + .background(FloconTheme.colorPalette.primary) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + FloconTextFieldWithoutM3( + value = query, + onValueChange = { queryChanged(it) }, + placeholder = defaultPlaceHolder( + stringResource(Res.string.search), + color = FloconTheme.colorPalette.onSecondary.copy(alpha = 0.5f) + ), + leadingComponent = { + FloconIcon( + imageVector = Icons.Outlined.Search, + modifier = Modifier.size(16.dp), + tint = FloconTheme.colorPalette.onSecondary.copy(alpha = 0.7f), + ) + }, + trailingComponent = { + if (query.isNotEmpty()) { + FloconSmallIconButton( + imageVector = Icons.Outlined.Close, + onClick = { queryChanged("") }, + contentDescription = "Clear", + modifier = Modifier.size(16.dp) + ) + } + }, + containerColor = FloconTheme.colorPalette.secondary, + textStyle = FloconTheme.typography.bodySmall.copy(color = FloconTheme.colorPalette.onSecondary), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp), modifier = Modifier - .padding(start = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "${selectedResultIndex?.inc() ?: 0}/$totalResults", - modifier = Modifier.widthIn(min = 40.dp), - textAlign = TextAlign.End, - style = FloconTheme.typography.bodyMedium.copy(fontSize = 14.sp), - ) + .width(280.dp) + .height(30.dp) + .border( + width = 1.dp, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.2f), + shape = FloconTheme.shapes.medium + ) + .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Enter, Key.NumPadEnter -> { + if (event.isShiftPressed) { + previousClicked() + } else { + nextClicked() + } + true + } + Key.DirectionDown -> { + nextClicked() + true + } + Key.DirectionUp -> { + previousClicked() + true + } + else -> false + } + } else { + false + } + }, + ) + + AnimatedVisibility(visible = totalResults > 0) { Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .height(32.dp) - .clip(RoundedCornerShape(6.dp)) - .background(FloconTheme.colorPalette.primary), + .height(30.dp) + .border( + width = 1.dp, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.2f), + shape = RoundedCornerShape(6.dp) + ) + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(6.dp) + ) + .padding(start = 8.dp, end = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + Text( + text = "${selectedResultIndex?.inc() ?: 0}/$totalResults", + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onSecondary, + ) FloconSmallIconButton( imageVector = Icons.Outlined.ArrowUpward, onClick = previousClicked, @@ -182,7 +268,12 @@ private fun SearchBar( enabled = selectedResultIndex != null, modifier = Modifier.fillMaxHeight().aspectRatio(1f), ) - VerticalDivider(modifier = Modifier.fillMaxHeight()) + VerticalDivider( + modifier = Modifier + .fillMaxHeight(0.6f) + .width(1.dp), + color = FloconTheme.colorPalette.onSecondary.copy(alpha = 0.2f) + ) FloconSmallIconButton( imageVector = Icons.Outlined.ArrowDownward, onClick = nextClicked, @@ -193,9 +284,15 @@ private fun SearchBar( } } } + + FloconHorizontalDivider( + modifier = Modifier.fillMaxWidth(), + color = FloconTheme.colorPalette.secondary + ) } } + @Preview @Composable private fun SearchBarPreview() { diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/body/NetworkJsonViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/body/NetworkJsonViewModel.kt new file mode 100644 index 000000000..090c88fea --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/body/NetworkJsonViewModel.kt @@ -0,0 +1,16 @@ +package io.github.openflocon.flocondesktop.features.network.body + +import androidx.lifecycle.ViewModel +import io.github.openflocon.flocondesktop.features.network.body.model.NetworkBodyDetailUi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class NetworkJsonViewModel( + json: String, +) : ViewModel() { + + private val _uiState = MutableStateFlow(NetworkBodyDetailUi(text = json)) + val uiState: StateFlow = _uiState.asStateFlow() + +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailAction.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailAction.kt index 40f472ddc..8f276c095 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailAction.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailAction.kt @@ -1,5 +1,6 @@ package io.github.openflocon.flocondesktop.features.network.detail +import androidx.compose.ui.graphics.ImageBitmap import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailViewState sealed interface NetworkDetailAction { @@ -10,12 +11,21 @@ sealed interface NetworkDetailAction { data class CopyText(val text: String) : NetworkDetailAction + data class CopyImage(val bitmap: ImageBitmap) : NetworkDetailAction + + data class SaveImage(val bitmap: ImageBitmap) : NetworkDetailAction + data class DiffWithClipboard(val text: String) : NetworkDetailAction + + sealed interface OpenBodyExternally : NetworkDetailAction { data class Request(val item: NetworkDetailViewState) : OpenBodyExternally data class Response(val item: NetworkDetailViewState.Response.Success) : OpenBodyExternally } data object ShareAsMarkdown : NetworkDetailAction + + data object CopyCurl : NetworkDetailAction } + diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailDelegate.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailDelegate.kt index 4270e82f3..b02c08a0e 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailDelegate.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailDelegate.kt @@ -6,6 +6,7 @@ import io.github.openflocon.domain.common.DispatcherProvider import io.github.openflocon.domain.feedback.FeedbackDisplayer import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel import io.github.openflocon.domain.network.usecase.DecodeJwtTokenUseCase +import io.github.openflocon.domain.network.usecase.GenerateCurlCommandUseCase import io.github.openflocon.domain.network.usecase.GetNetworkCallAsMarkdownUseCase import io.github.openflocon.domain.network.usecase.ObserveNetworkRequestsByIdUseCase import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableDelegate @@ -19,11 +20,14 @@ import io.github.openflocon.flocondesktop.features.network.list.model.NetworkMet import io.github.openflocon.flocondesktop.features.network.list.model.NetworkStatusUi import io.github.openflocon.library.designsystem.common.copyToClipboard import io.github.openflocon.library.designsystem.common.readFromClipboard +import io.github.openflocon.library.designsystem.common.saveImageToFile import io.github.openflocon.navigation.MainFloconNavigationState + import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -38,22 +42,28 @@ class NetworkDetailDelegate( private val feedbackDisplayer: FeedbackDisplayer, private val decodeJwtTokenUseCase: DecodeJwtTokenUseCase, private val navigationState: MainFloconNavigationState, - observeNetworkRequestsByIdUseCase: ObserveNetworkRequestsByIdUseCase, + private val observeNetworkRequestsByIdUseCase: ObserveNetworkRequestsByIdUseCase, dispatcherProvider: DispatcherProvider ) : CloseableScoped by closeableDelegate, KoinComponent { private val openBodyDelegate: OpenBodyDelegate by inject() private val getNetworkCallAsMarkdownUseCase: GetNetworkCallAsMarkdownUseCase by inject() + private val generateCurlCommandUseCase: GenerateCurlCommandUseCase by inject() + private val observeNetworkSettingsUseCase: io.github.openflocon.flocondesktop.core.data.settings.usecase.ObserveNetworkSettingsUseCase by inject() private val requestId = MutableStateFlow("") - val uiState: StateFlow = requestId.flatMapLatest { - observeNetworkRequestsByIdUseCase(it) + val uiState: StateFlow = kotlinx.coroutines.flow.combine( + requestId.flatMapLatest { + observeNetworkRequestsByIdUseCase(it) + } + .distinctUntilChanged() + .filterNotNull(), + observeNetworkSettingsUseCase() + ) { call, settings -> + call.toDetailUi(settings.defaultSelectedTab) } - .distinctUntilChanged() - .filterNotNull() - .map(FloconNetworkCallDomainModel::toDetailUi) .flowOn(dispatcherProvider.viewModel) .stateInWhileSubscribed( NetworkDetailViewState( @@ -77,9 +87,11 @@ class NetworkDetailDelegate( canOpenRequestBody = false, imageUrl = null, imageHeaders = null, + defaultSelectedTab = io.github.openflocon.domain.models.settings.NetworkDetailTab.Request, ) ) + fun setRequestId(requestId: String) { this@NetworkDetailDelegate.requestId.update { requestId } } @@ -87,12 +99,15 @@ class NetworkDetailDelegate( fun onAction(action: NetworkDetailAction) { when (action) { is NetworkDetailAction.CopyText -> onCopyText(action) + is NetworkDetailAction.CopyImage -> onCopyImage(action) + is NetworkDetailAction.SaveImage -> onSaveImage(action) is NetworkDetailAction.DisplayBearerJwt -> displayBearerJwt(action.token) is NetworkDetailAction.JsonDetail -> onJsonDetail(action) is NetworkDetailAction.DiffWithClipboard -> onDiffWithClipboard(action) is NetworkDetailAction.OpenBodyExternally.Request -> openBodyDelegate.openBodyExternally(action.item) is NetworkDetailAction.OpenBodyExternally.Response -> openBodyDelegate.openBodyExternally(action.item) is NetworkDetailAction.ShareAsMarkdown -> copyAsMarkdown(requestId.value) + is NetworkDetailAction.CopyCurl -> onCopyCurl() } } @@ -103,6 +118,24 @@ class NetworkDetailDelegate( } } + private fun onCopyImage(action: NetworkDetailAction.CopyImage) { + copyToClipboard(action.bitmap) + coroutineScope.launch { + feedbackDisplayer.displayMessage(getString(Res.string.copied_to_clipboard)) + } + } + + private fun onSaveImage(action: NetworkDetailAction.SaveImage) { + val id = requestId.value.takeIf { it.isNotBlank() } ?: System.currentTimeMillis().toString() + val saved = saveImageToFile(action.bitmap, defaultFileName = "network_$id.png") + if (saved) { + coroutineScope.launch { + feedbackDisplayer.displayMessage("Image saved successfully") + } + } + } + + private fun onJsonDetail(action: NetworkDetailAction.JsonDetail) { navigationState.navigate(NetworkRoutes.JsonDetail(action.json)) } @@ -131,4 +164,16 @@ class NetworkDetailDelegate( } } } + + private fun onCopyCurl() { + coroutineScope.launch { + val id = requestId.value + observeNetworkRequestsByIdUseCase(id).firstOrNull()?.let { model -> + val curl = generateCurlCommandUseCase(model) + copyToClipboard(curl) + feedbackDisplayer.displayMessage("cURL copied to clipboard") + } + } + } } + diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/mapper/NetworkDetailUiMapper.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/mapper/NetworkDetailUiMapper.kt index 5dd40b6bb..b0445b334 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/mapper/NetworkDetailUiMapper.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/mapper/NetworkDetailUiMapper.kt @@ -1,5 +1,6 @@ package io.github.openflocon.flocondesktop.features.network.detail.mapper +import io.github.openflocon.domain.models.settings.NetworkDetailTab import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel import io.github.openflocon.domain.network.models.isImage import io.github.openflocon.flocondesktop.common.ui.JsonPrettyPrinter @@ -19,7 +20,9 @@ import io.github.openflocon.flocondesktop.features.network.list.model.NetworkSta import io.github.openflocon.library.designsystem.common.isImageUrl import kotlinx.collections.immutable.toPersistentMap -fun FloconNetworkCallDomainModel.toDetailUi(): NetworkDetailViewState { +fun FloconNetworkCallDomainModel.toDetailUi( + defaultSelectedTab: NetworkDetailTab = NetworkDetailTab.Request, +): NetworkDetailViewState { val imageUrl = request.url.takeIf { response?.isImage() == true || it.isImageUrl() } return NetworkDetailViewState( callId = callId, @@ -56,6 +59,7 @@ fun FloconNetworkCallDomainModel.toDetailUi(): NetworkDetailViewState { } }, graphQlSection = graphQlSection(this), + defaultSelectedTab = defaultSelectedTab, ) } @@ -70,9 +74,10 @@ private fun requestBodyTitle(request: FloconNetworkCallDomainModel): String = wh is FloconNetworkCallDomainModel.Request.SpecificInfos.WebSocket -> "Content" is FloconNetworkCallDomainModel.Request.SpecificInfos.GraphQl, FloconNetworkCallDomainModel.Request.SpecificInfos.Grpc, - FloconNetworkCallDomainModel.Request.SpecificInfos.Http -> "Request - Body" + FloconNetworkCallDomainModel.Request.SpecificInfos.Http -> "Body" } + private fun toDetailHttpStatusUi(networkCall: FloconNetworkCallDomainModel): NetworkStatusUi = networkCall.response?.let { response -> when (response) { is FloconNetworkCallDomainModel.Response.Failure -> NetworkStatusUi( diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/model/NetworkDetailViewState.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/model/NetworkDetailViewState.kt index 607821561..11ba4d219 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/model/NetworkDetailViewState.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/model/NetworkDetailViewState.kt @@ -30,7 +30,9 @@ data class NetworkDetailViewState( val imageHeaders: PersistentMap?, // response val response: Response?, + val defaultSelectedTab: io.github.openflocon.domain.models.settings.NetworkDetailTab = io.github.openflocon.domain.models.settings.NetworkDetailTab.Request, ) { + @Immutable sealed interface Response { @Immutable diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/NetworkDetailView.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/NetworkDetailView.kt index 4fc5876f9..6aef0c847 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/NetworkDetailView.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/NetworkDetailView.kt @@ -12,30 +12,45 @@ import androidx.compose.foundation.layout.fillMaxSize 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.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.CompareArrows import androidx.compose.material.icons.automirrored.outlined.OpenInNew -import androidx.compose.material.icons.outlined.Compare -import androidx.compose.material.icons.outlined.CompareArrows +import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material.icons.outlined.CopyAll import androidx.compose.material.icons.outlined.Difference import androidx.compose.material.icons.outlined.OpenInFull +import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material3.Text + import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer + import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp + + import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.PlatformContext import coil3.compose.AsyncImage @@ -43,6 +58,7 @@ import coil3.network.NetworkHeaders import coil3.network.httpHeaders import coil3.request.ImageRequest import com.sebastianneubauer.jsontree.TreeState +import io.github.openflocon.domain.models.settings.NetworkDetailTab import io.github.openflocon.flocondesktop.features.network.detail.NetworkDetailAction import io.github.openflocon.flocondesktop.features.network.detail.NetworkDetailViewModel import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailViewState @@ -52,17 +68,23 @@ import io.github.openflocon.flocondesktop.features.network.list.model.NetworkMet import io.github.openflocon.flocondesktop.features.network.list.model.NetworkStatusUi import io.github.openflocon.flocondesktop.features.network.list.view.components.MethodView import io.github.openflocon.flocondesktop.features.network.list.view.components.StatusView +import com.composeunstyled.DropdownPanelAnchor import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.library.designsystem.components.FloconButton + import io.github.openflocon.library.designsystem.components.FloconCodeBlock -import io.github.openflocon.library.designsystem.components.FloconHorizontalDivider +import io.github.openflocon.library.designsystem.components.FloconDropdownMenu +import io.github.openflocon.library.designsystem.components.FloconDropdownMenuItem import io.github.openflocon.library.designsystem.components.FloconIconButton import io.github.openflocon.library.designsystem.components.FloconJsonTree import io.github.openflocon.library.designsystem.components.FloconLineDescription import io.github.openflocon.library.designsystem.components.FloconSection +import io.github.openflocon.library.designsystem.components.FloconTab import io.github.openflocon.library.designsystem.components.FloconVerticalScrollbar +import io.github.openflocon.library.designsystem.components.TabType import io.github.openflocon.library.designsystem.components.rememberFloconScrollbarAdapter import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.launch import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -85,58 +107,243 @@ fun NetworkDetailScreen( ) } +@Composable +private fun ExportImageDropdown( + graphicsLayer: GraphicsLayer, + onAction: (NetworkDetailAction) -> Unit, + modifier: Modifier = Modifier, + tooltip: String = "Export as image", +) { + val coroutineScope = rememberCoroutineScope() + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier) { + FloconDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + onExpandRequest = { expanded = true }, + anchor = DropdownPanelAnchor.BottomEnd, + anchorContent = { + FloconIconButton( + tooltip = tooltip, + imageVector = Icons.Outlined.CameraAlt, + onClick = { expanded = true } + ) + } + ) { + FloconDropdownMenuItem( + text = "Copy as image", + leadingIcon = Icons.Outlined.CameraAlt, + onClick = { + expanded = false + coroutineScope.launch { + try { + val bitmap = graphicsLayer.toImageBitmap() + onAction(NetworkDetailAction.CopyImage(bitmap)) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + ) + FloconDropdownMenuItem( + text = "Save as image", + leadingIcon = Icons.Outlined.Save, + onClick = { + expanded = false + coroutineScope.launch { + try { + val bitmap = graphicsLayer.toImageBitmap() + onAction(NetworkDetailAction.SaveImage(bitmap)) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + ) + } + } +} + @Composable fun NetworkDetailContent( uiState: NetworkDetailViewState, onAction: (NetworkDetailAction) -> Unit, modifier: Modifier = Modifier ) { + var selectedTab by remember(uiState.callId) { mutableStateOf(uiState.defaultSelectedTab) } + val scrollState: ScrollState = rememberScrollState() val scrollAdapter = rememberFloconScrollbarAdapter(scrollState) val linesLabelWidth: Dp = 130.dp val headersLabelWidth: Dp = 150.dp + val fullLogGraphicsLayer = rememberGraphicsLayer() + val coroutineScope = rememberCoroutineScope() + var shareMenuExpanded by remember { mutableStateOf(false) } Box( modifier = modifier .background(FloconTheme.colorPalette.primary) ) { + // Hidden off-screen full log view for Save full log as Image + Box( + modifier = Modifier + .layout { measurable, _ -> + val widthPx = 700.dp.roundToPx() + val placeable = measurable.measure( + Constraints( + minWidth = widthPx, + maxWidth = widthPx, + minHeight = 0, + maxHeight = Constraints.Infinity + ) + ) + layout(0, 0) { + placeable.place(0, 0) + } + } + .drawWithContent { + fullLogGraphicsLayer.record { + this@drawWithContent.drawContent() + } + } + ) { + CompositionLocalProvider( + LocalDensity provides Density( + density = maxOf(2f, LocalDensity.current.density), + fontScale = 1f + ) + ) { + FullLogExportView( + state = uiState, + linesLabelWidth = linesLabelWidth, + headersLabelWidth = headersLabelWidth, + modifier = Modifier.width(700.dp) + ) + } + } + Column( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() .verticalScroll(scrollState) .padding(vertical = 8.dp, horizontal = 4.dp), ) { + // Top action bar Row( - modifier = Modifier.fillMaxSize() - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.End + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - FloconIconButton( - tooltip = "Share as Markdown", - imageVector = Icons.Outlined.Share, - onClick = { onAction(NetworkDetailAction.ShareAsMarkdown) } + Text( + text = "Network Details", + style = FloconTheme.typography.titleMedium, + color = FloconTheme.colorPalette.onPrimary ) + + FloconDropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + onExpandRequest = { shareMenuExpanded = true }, + anchor = DropdownPanelAnchor.BottomEnd, + anchorContent = { + FloconIconButton( + tooltip = "Share & Export", + imageVector = Icons.Outlined.Share, + onClick = { shareMenuExpanded = true } + ) + } + ) { + FloconDropdownMenuItem( + text = "Copy as Markdown", + leadingIcon = Icons.Outlined.Share, + onClick = { + shareMenuExpanded = false + onAction(NetworkDetailAction.ShareAsMarkdown) + } + ) + FloconDropdownMenuItem( + text = "Save full log as Image", + leadingIcon = Icons.Outlined.Save, + onClick = { + shareMenuExpanded = false + coroutineScope.launch { + try { + val bitmap = fullLogGraphicsLayer.toImageBitmap() + onAction(NetworkDetailAction.SaveImage(bitmap)) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + ) + FloconDropdownMenuItem( + text = "Copy cURL command", + leadingIcon = Icons.Outlined.Terminal, + onClick = { + shareMenuExpanded = false + onAction(NetworkDetailAction.CopyCurl) + } + ) + } } - Request( - modifier = Modifier - .fillMaxWidth(), - state = uiState, - onAction = onAction, - linesLabelWidth = linesLabelWidth, - headersLabelWidth = headersLabelWidth, - ) - FloconHorizontalDivider( + + + // Tab bar + Row( modifier = Modifier .fillMaxWidth() - .padding(start = 12.dp) - .padding(vertical = 8.dp), - ) - Response( - modifier = Modifier - .fillMaxWidth(), - state = uiState, - onAction = onAction, - headersLabelWidth = headersLabelWidth, - ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + FloconTab( + text = "Request (${uiState.requestSize})", + isSelected = selectedTab == NetworkDetailTab.Request, + tabType = TabType.Start, + onSelected = { selectedTab = NetworkDetailTab.Request }, + modifier = Modifier.weight(1f) + ) + val responseSizeText = when (val res = uiState.response) { + is NetworkDetailViewState.Response.Success -> " (${res.size})" + is NetworkDetailViewState.Response.Error -> " (Error)" + null -> "" + } + FloconTab( + text = "Response$responseSizeText", + isSelected = selectedTab == NetworkDetailTab.Response, + tabType = TabType.End, + onSelected = { selectedTab = NetworkDetailTab.Response }, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + when (selectedTab) { + NetworkDetailTab.Request -> { + Request( + modifier = Modifier.fillMaxWidth(), + state = uiState, + onAction = onAction, + linesLabelWidth = linesLabelWidth, + headersLabelWidth = headersLabelWidth, + ) + } + + NetworkDetailTab.Response -> { + Response( + modifier = Modifier.fillMaxWidth(), + state = uiState, + onAction = onAction, + headersLabelWidth = headersLabelWidth, + ) + } + } + } } FloconVerticalScrollbar( adapter = scrollAdapter, @@ -146,6 +353,90 @@ fun NetworkDetailContent( } } +@Composable +private fun SummaryCard( + state: NetworkDetailViewState, + linesLabelWidth: Dp, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + FloconLineDescription( + modifier = Modifier + .fillMaxWidth() + .padding(end = 36.dp), + label = "Full url", + value = state.fullUrl, + contentColor = FloconTheme.colorPalette.onPrimary, + labelWidth = linesLabelWidth, + ) + + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Method", + contentColor = FloconTheme.colorPalette.onPrimary, + labelWidth = linesLabelWidth, + ) { + when (val m = state.method) { + is NetworkDetailViewState.Method.Http -> MethodView(method = m.method) + is NetworkDetailViewState.Method.MethodName -> { + Text( + text = m.name, + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.weight(2f) + .background( + color = FloconTheme.colorPalette.primary.copy(alpha = 0.8f), + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } + } + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = state.statusLabel, + contentColor = FloconTheme.colorPalette.onPrimary, + labelWidth = linesLabelWidth, + ) { + StatusView( + status = state.status, + ) + } + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Request Time", + value = state.requestTimeFormatted, + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onPrimary + ) + state.durationFormatted?.let { + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Time", + value = it, + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onPrimary + ) + } + + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Request Size", + value = state.requestSize, + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onPrimary + ) + } +} + @Composable private fun Request( state: NetworkDetailViewState, @@ -154,190 +445,193 @@ private fun Request( headersLabelWidth: Dp, modifier: Modifier = Modifier, ) { - FloconSection( - title = "Request", - initialValue = true, - modifier = modifier.fillMaxWidth() + val summaryGraphicsLayer = rememberGraphicsLayer() + val graphQlGraphicsLayer = rememberGraphicsLayer() + val reqHeadersGraphicsLayer = rememberGraphicsLayer() + val reqBodyGraphicsLayer = rememberGraphicsLayer() + + Column( + modifier = modifier + .padding(horizontal = 12.dp, vertical = 4.dp) ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp) + Box( + modifier = Modifier.fillMaxWidth() ) { - Column( + Box( modifier = Modifier - .background( - color = FloconTheme.colorPalette.secondary, - shape = RoundedCornerShape(12.dp), - ) - .padding(horizontal = 8.dp, vertical = 4.dp), + .fillMaxWidth() + .drawWithContent { + summaryGraphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(summaryGraphicsLayer) + } ) { - FloconLineDescription( - modifier = Modifier.fillMaxWidth(), - label = "Full url", - value = state.fullUrl, - contentColor = FloconTheme.colorPalette.onPrimary, - labelWidth = linesLabelWidth, + SummaryCard( + state = state, + linesLabelWidth = linesLabelWidth, + modifier = Modifier.fillMaxWidth() ) - FloconLineDescription( - modifier = Modifier.fillMaxWidth(), - label = "Method", - contentColor = FloconTheme.colorPalette.onPrimary, - labelWidth = linesLabelWidth, - ) { - when (val m = state.method) { - is NetworkDetailViewState.Method.Http -> MethodView(method = m.method) - is NetworkDetailViewState.Method.MethodName -> { - Text( - text = m.name, - style = FloconTheme.typography.bodySmall, - color = FloconTheme.colorPalette.onSecondary, - modifier = Modifier.weight(2f) - .background( - color = FloconTheme.colorPalette.primary.copy(alpha = 0.8f), - shape = RoundedCornerShape(4.dp), - ) - .padding(horizontal = 8.dp, vertical = 6.dp), - ) + } + + ExportImageDropdown( + graphicsLayer = summaryGraphicsLayer, + onAction = onAction, + tooltip = "Export summary as image", + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 4.dp, end = 4.dp) + ) + } + + state.graphQlSection?.let { + Spacer(modifier = Modifier.height(12.dp)) + FloconSection( + title = "GraphQl", + initialValue = true, + actions = { + ExportImageDropdown( + graphicsLayer = graphQlGraphicsLayer, + onAction = onAction, + tooltip = "Export GraphQl as image" + ) + }, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .drawWithContent { + graphQlGraphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(graphQlGraphicsLayer) } - } - } - FloconLineDescription( - modifier = Modifier.fillMaxWidth(), - label = state.statusLabel, - contentColor = FloconTheme.colorPalette.onPrimary, - labelWidth = linesLabelWidth, + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) ) { - StatusView( - status = state.status, - ) - } - FloconLineDescription( - modifier = Modifier.fillMaxWidth(), - label = "Request Time", - value = state.requestTimeFormatted, - labelWidth = linesLabelWidth, - contentColor = FloconTheme.colorPalette.onPrimary - ) - state.durationFormatted?.let { FloconLineDescription( modifier = Modifier.fillMaxWidth(), - label = "Time", - value = it, + label = "Query name", + value = it.queryName, + contentColor = FloconTheme.colorPalette.onSecondary, labelWidth = linesLabelWidth, - contentColor = FloconTheme.colorPalette.onPrimary ) - } - - FloconLineDescription( - modifier = Modifier.fillMaxWidth(), - label = "Request Size", - value = state.requestSize, - labelWidth = linesLabelWidth, - contentColor = FloconTheme.colorPalette.onPrimary - ) - } - - state.graphQlSection?.let { - Spacer(modifier = Modifier.height(12.dp)) - FloconSection( - title = "GraphQl", - initialValue = true, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .background( - color = FloconTheme.colorPalette.secondary, - shape = RoundedCornerShape(12.dp), - ) - .padding(12.dp) + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Type", + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onSecondary ) { - FloconLineDescription( - modifier = Modifier.fillMaxWidth(), - label = "Query name", - value = it.queryName, - contentColor = FloconTheme.colorPalette.onSecondary, - labelWidth = linesLabelWidth, - ) - FloconLineDescription( - modifier = Modifier.fillMaxWidth(), - label = "Type", - labelWidth = linesLabelWidth, - contentColor = FloconTheme.colorPalette.onSecondary - ) { - MethodView(method = it.method) - } + MethodView(method = it.method) } } } + } - state.imageUrl?.let { imageUrl -> - // Coil image - AsyncImage( - model = remember(state) { - ImageRequest.Builder(PlatformContext.INSTANCE) - .data(imageUrl) - .httpHeaders( - NetworkHeaders.Builder().apply { - state.imageHeaders?.forEach { (key, value) -> - set(key, value) - } - }.build() - ) - .build() - }, - contentDescription = "Image preview", + state.imageUrl?.let { imageUrl -> + // Coil image + AsyncImage( + model = remember(state) { + ImageRequest.Builder(PlatformContext.INSTANCE) + .data(imageUrl) + .httpHeaders( + NetworkHeaders.Builder().apply { + state.imageHeaders?.forEach { (key, value) -> + set(key, value) + } + }.build() + ) + .build() + }, + contentDescription = "Image preview", + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + .padding(4.dp) + .height(300.dp), + contentScale = ContentScale.Fit, + ) + } + + state.requestHeaders?.let { + Spacer(modifier = Modifier.height(12.dp)) + FloconSection( + title = "Headers", + initialValue = false, + actions = { + ExportImageDropdown( + graphicsLayer = reqHeadersGraphicsLayer, + onAction = onAction, + tooltip = "Export headers as image" + ) + }, + modifier = Modifier.fillMaxWidth() + ) { + DetailHeadersView( + headers = state.requestHeaders, + labelWidth = headersLabelWidth, + onAuthorizationClicked = { token -> onAction(NetworkDetailAction.DisplayBearerJwt(token)) }, + onCopyValue = { value -> onAction(NetworkDetailAction.CopyText(value)) }, modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp) - .padding(4.dp) - .height(300.dp), - contentScale = ContentScale.Fit, + .drawWithContent { + reqHeadersGraphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(reqHeadersGraphicsLayer) + } + .padding(8.dp) ) } + } - state.requestHeaders?.let { - FloconSection( - title = "Request - Headers", - initialValue = true, - modifier = Modifier.fillMaxWidth() - ) { - DetailHeadersView( - headers = state.requestHeaders, - labelWidth = headersLabelWidth, - onAuthorizationClicked = { token -> onAction(NetworkDetailAction.DisplayBearerJwt(token)) }, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) + Spacer(modifier = Modifier.height(12.dp)) + FloconSection( + title = state.requestBodyTitle, + initialValue = true, + actions = { + ExportImageDropdown( + graphicsLayer = reqBodyGraphicsLayer, + onAction = onAction, + tooltip = "Export body as image" + ) + if (state.requestBodyIsNotBlank) { + FloconIconButton( + tooltip = "View in app", + imageVector = Icons.Outlined.OpenInFull, + onClick = { onAction(NetworkDetailAction.JsonDetail(json = state.requestBody)) } ) } - } - FloconSection( - title = state.requestBodyTitle, - initialValue = true, - actions = { - if (state.requestBodyIsNotBlank) { - FloconIconButton( - tooltip = "View in app", - imageVector = Icons.Outlined.OpenInFull, - onClick = { onAction(NetworkDetailAction.JsonDetail(json = state.requestBody,)) } - ) - } - if (state.canOpenRequestBody) { - FloconIconButton( - tooltip = "Open in external editor", - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - onClick = { onAction(NetworkDetailAction.OpenBodyExternally.Request(state)) } - ) - } - if (state.requestBodyIsNotBlank) { - FloconIconButton( - tooltip = "Copy", - imageVector = Icons.Outlined.CopyAll, - onClick = { onAction(NetworkDetailAction.CopyText(state.requestBody)) } - ) + if (state.canOpenRequestBody) { + FloconIconButton( + tooltip = "Open in external editor", + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + onClick = { onAction(NetworkDetailAction.OpenBodyExternally.Request(state)) } + ) + } + if (state.requestBodyIsNotBlank) { + FloconIconButton( + tooltip = "Copy", + imageVector = Icons.Outlined.CopyAll, + onClick = { onAction(NetworkDetailAction.CopyText(state.requestBody)) } + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .drawWithContent { + reqBodyGraphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(reqBodyGraphicsLayer) } - }, - modifier = Modifier.fillMaxWidth() ) { var displayBody by remember(state.requestBody) { val isLargeResponse = state.requestBody.length > LARGE_BODY_LENGTH @@ -391,38 +685,76 @@ private fun Response( headersLabelWidth: Dp, modifier: Modifier = Modifier, ) { - val response = state.response ?: return - - FloconSection( - title = "Response", - initialValue = true, - modifier = modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier + val response = state.response + if (response == null) { + Box( + modifier = modifier .fillMaxWidth() - .padding(12.dp) + .padding(24.dp), + contentAlignment = Alignment.Center ) { - when (response) { - is NetworkDetailViewState.Response.Error -> { - FloconSection( - title = "Response - Body", - initialValue = true - ) { - FloconCodeBlock( - code = response.issue, - containerColor = FloconTheme.colorPalette.secondary, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) + Text( + text = "Waiting for response...", + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onPrimary.copy(alpha = 0.5f) + ) + } + return + } + + val resSummaryGraphicsLayer = rememberGraphicsLayer() + val resHeadersGraphicsLayer = rememberGraphicsLayer() + val resBodyGraphicsLayer = rememberGraphicsLayer() + val resErrorGraphicsLayer = rememberGraphicsLayer() + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + when (response) { + is NetworkDetailViewState.Response.Error -> { + FloconSection( + title = "Body", + initialValue = true, + actions = { + ExportImageDropdown( + graphicsLayer = resErrorGraphicsLayer, + onAction = onAction, + tooltip = "Export error as image" ) } + ) { + FloconCodeBlock( + code = response.issue, + containerColor = FloconTheme.colorPalette.secondary, + modifier = Modifier + .fillMaxWidth() + .drawWithContent { + resErrorGraphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(resErrorGraphicsLayer) + } + .padding(12.dp) + ) } + } - is NetworkDetailViewState.Response.Success -> { + is NetworkDetailViewState.Response.Success -> { + Box( + modifier = Modifier.fillMaxWidth() + ) { Column( modifier = Modifier - .padding(bottom = 12.dp) + .fillMaxWidth() + .padding(bottom = 8.dp) + .drawWithContent { + resSummaryGraphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(resSummaryGraphicsLayer) + } .background( color = FloconTheme.colorPalette.secondary, shape = RoundedCornerShape(12.dp), @@ -430,7 +762,9 @@ private fun Response( .padding(horizontal = 8.dp, vertical = 4.dp), ) { FloconLineDescription( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(end = 36.dp), label = "Response Size", value = response.size, labelWidth = 130.dp, @@ -438,61 +772,102 @@ private fun Response( ) } - response.headers?.let { - FloconSection( - title = "Response - Headers", - initialValue = true, - modifier = Modifier.fillMaxWidth() - ) { - DetailHeadersView( - headers = response.headers, - labelWidth = headersLabelWidth, - onAuthorizationClicked = { token -> onAction(NetworkDetailAction.DisplayBearerJwt(token)) }, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) - } - } + ExportImageDropdown( + graphicsLayer = resSummaryGraphicsLayer, + onAction = onAction, + tooltip = "Export response summary as image", + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 4.dp, end = 4.dp) + ) + } + response.headers?.let { + Spacer(modifier = Modifier.height(12.dp)) FloconSection( - title = "Response - Body", - initialValue = true, + title = "Headers", + initialValue = false, actions = { - if (response.responseBodyIsNotBlank) { - FloconIconButton( - tooltip = "View body in app", - imageVector = Icons.Outlined.OpenInFull, - onClick = { onAction(NetworkDetailAction.JsonDetail(response.body),) } - ) - } - if (response.canOpenResponseBody) { - FloconIconButton( - tooltip = "Open in external editor", - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - onClick = { onAction(NetworkDetailAction.OpenBodyExternally.Response(response,)) } - ) - } - if (response.responseBodyIsNotBlank) { - FloconIconButton( - tooltip = "Compare with clipboard", - imageVector = Icons.Outlined.Difference, - onClick = { onAction(NetworkDetailAction.DiffWithClipboard(response.body)) } - ) - } - if (response.responseBodyIsNotBlank) { - FloconIconButton( - tooltip = "Copy", - imageVector = Icons.Outlined.CopyAll, - onClick = { onAction(NetworkDetailAction.CopyText(response.body)) } - ) - } + ExportImageDropdown( + graphicsLayer = resHeadersGraphicsLayer, + onAction = onAction, + tooltip = "Export headers as image" + ) }, modifier = Modifier.fillMaxWidth() + ) { + DetailHeadersView( + headers = response.headers, + labelWidth = headersLabelWidth, + onAuthorizationClicked = { token -> onAction(NetworkDetailAction.DisplayBearerJwt(token)) }, + onCopyValue = { value -> onAction(NetworkDetailAction.CopyText(value)) }, + modifier = Modifier + .fillMaxWidth() + .drawWithContent { + resHeadersGraphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(resHeadersGraphicsLayer) + } + .padding(8.dp) + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + FloconSection( + title = "Body", + initialValue = true, + actions = { + ExportImageDropdown( + graphicsLayer = resBodyGraphicsLayer, + onAction = onAction, + tooltip = "Export body as image" + ) + if (response.responseBodyIsNotBlank) { + FloconIconButton( + tooltip = "View body in app", + imageVector = Icons.Outlined.OpenInFull, + onClick = { onAction(NetworkDetailAction.JsonDetail(response.body)) } + ) + } + if (response.canOpenResponseBody) { + FloconIconButton( + tooltip = "Open in external editor", + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + onClick = { onAction(NetworkDetailAction.OpenBodyExternally.Response(response)) } + ) + } + if (response.responseBodyIsNotBlank) { + FloconIconButton( + tooltip = "Compare with clipboard", + imageVector = Icons.Outlined.Difference, + onClick = { onAction(NetworkDetailAction.DiffWithClipboard(response.body)) } + ) + } + if (response.responseBodyIsNotBlank) { + FloconIconButton( + tooltip = "Copy", + imageVector = Icons.Outlined.CopyAll, + onClick = { onAction(NetworkDetailAction.CopyText(response.body)) } + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .drawWithContent { + resBodyGraphicsLayer.record { + this@drawWithContent.drawContent() + } + drawLayer(resBodyGraphicsLayer) + } ) { var jsonError by remember(response.body) { mutableStateOf(false) } - if(!jsonError) { + if (!jsonError) { Box( modifier = Modifier .fillMaxWidth() @@ -560,9 +935,310 @@ private fun Response( } } +@Composable +private fun FullLogExportView( + state: NetworkDetailViewState, + linesLabelWidth: Dp = 130.dp, + headersLabelWidth: Dp = 150.dp, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(FloconTheme.colorPalette.primary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Summary Card + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Text( + text = "Network Log", + style = FloconTheme.typography.titleMedium, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Full url", + value = state.fullUrl, + contentColor = FloconTheme.colorPalette.onPrimary, + labelWidth = linesLabelWidth, + ) + + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Method", + contentColor = FloconTheme.colorPalette.onPrimary, + labelWidth = linesLabelWidth, + ) { + when (val m = state.method) { + is NetworkDetailViewState.Method.Http -> MethodView(method = m.method) + is NetworkDetailViewState.Method.MethodName -> { + Text( + text = m.name, + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier + .background( + color = FloconTheme.colorPalette.primary.copy(alpha = 0.8f), + shape = RoundedCornerShape(4.dp), + ) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } + } + + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = state.statusLabel, + contentColor = FloconTheme.colorPalette.onPrimary, + labelWidth = linesLabelWidth, + ) { + StatusView(status = state.status) + } + + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Request Time", + value = state.requestTimeFormatted, + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onPrimary + ) + + state.durationFormatted?.let { + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Time", + value = it, + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onPrimary + ) + } + + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Request Size", + value = state.requestSize, + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onPrimary + ) + } + + // GraphQL Section (if any) + state.graphQlSection?.let { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Text( + text = "GraphQL", + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Query name", + value = it.queryName, + contentColor = FloconTheme.colorPalette.onSecondary, + labelWidth = linesLabelWidth, + ) + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Type", + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onSecondary + ) { + MethodView(method = it.method) + } + } + } + + // Request Headers (if any) + state.requestHeaders?.takeIf { it.isNotEmpty() }?.let { headers -> + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Text( + text = "Request Headers", + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + DetailHeadersView( + headers = headers, + labelWidth = headersLabelWidth, + onAuthorizationClicked = {}, + modifier = Modifier.fillMaxWidth() + ) + } + } + + // Request Body (if any) + if (state.requestBodyIsNotBlank) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Text( + text = state.requestBodyTitle, + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + FloconCodeBlock( + code = formatBodyForExport(state.requestBody), + containerColor = FloconTheme.colorPalette.primary, + modifier = Modifier.fillMaxWidth() + ) + } + } + + // Response Section + when (val res = state.response) { + is NetworkDetailViewState.Response.Success -> { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Text( + text = "Response (${res.size})", + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + FloconLineDescription( + modifier = Modifier.fillMaxWidth(), + label = "Response Size", + value = res.size, + labelWidth = linesLabelWidth, + contentColor = FloconTheme.colorPalette.onPrimary + ) + } + + res.headers?.takeIf { it.isNotEmpty() }?.let { headers -> + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Text( + text = "Response Headers", + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + DetailHeadersView( + headers = headers, + labelWidth = headersLabelWidth, + onAuthorizationClicked = {}, + modifier = Modifier.fillMaxWidth() + ) + } + } + + if (res.responseBodyIsNotBlank) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Text( + text = "Response Body", + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onSecondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + FloconCodeBlock( + code = formatBodyForExport(res.body), + containerColor = FloconTheme.colorPalette.primary, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + is NetworkDetailViewState.Response.Error -> { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = FloconTheme.colorPalette.secondary, + shape = RoundedCornerShape(12.dp), + ) + .padding(12.dp) + ) { + Text( + text = "Response Error", + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.error, + modifier = Modifier.padding(bottom = 8.dp) + ) + FloconCodeBlock( + code = formatBodyForExport(res.issue), + containerColor = FloconTheme.colorPalette.primary, + modifier = Modifier.fillMaxWidth() + ) + } + } + + null -> Unit + } + } +} + +private fun formatBodyForExport(body: String, maxLines: Int = 30): String { + val lines = body.lines() + return if (lines.size > maxLines) { + lines.take(maxLines).joinToString("\n") + "\n\n... (truncated, ${lines.size - maxLines} more lines)" + } else { + body + } +} + @Preview + @Composable private fun NetworkDetailViewPreview() { + FloconTheme { NetworkDetailContent( uiState = NetworkDetailViewState( @@ -581,7 +1257,7 @@ private fun NetworkDetailViewPreview() { previewNetworkDetailHeaderUi(), previewNetworkDetailHeaderUi(), ), - requestBodyTitle = "Request - Body", + requestBodyTitle = "Body", requestBody = """ { @@ -628,6 +1304,7 @@ private fun NetworkDetailViewPreview() { canOpenRequestBody = true, requestBodyIsNotBlank = true, imageHeaders = persistentMapOf(), + defaultSelectedTab = NetworkDetailTab.Request, ), onAction = {} ) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/components/DetailHeadersView.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/components/DetailHeadersView.kt index af1e66c46..61ea8906f 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/components/DetailHeadersView.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/components/DetailHeadersView.kt @@ -1,17 +1,42 @@ +@file:OptIn(ExperimentalFoundationApi::class) + package io.github.openflocon.flocondesktop.features.network.detail.view.components +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.TooltipPlacement +import androidx.compose.foundation.background import androidx.compose.foundation.border + +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material3.Text import androidx.compose.runtime.Composable +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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent 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.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailHeaderUi @@ -19,7 +44,7 @@ import io.github.openflocon.flocondesktop.features.network.detail.model.previewN import io.github.openflocon.library.designsystem.FloconTheme import io.github.openflocon.library.designsystem.components.FloconButton import io.github.openflocon.library.designsystem.components.FloconHorizontalDivider -import io.github.openflocon.library.designsystem.components.FloconLineDescription +import io.github.openflocon.library.designsystem.components.FloconSmallIconButton import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @@ -28,6 +53,7 @@ fun DetailHeadersView( labelWidth: Dp, onAuthorizationClicked: (value: String) -> Unit, modifier: Modifier = Modifier, + onCopyValue: ((value: String) -> Unit)? = null, ) { Column( modifier = modifier @@ -39,36 +65,13 @@ fun DetailHeadersView( .padding(8.dp) ) { headers.fastForEachIndexed { index, item -> - val isAuthBearer = item.name.equals( - "authorization", - ignoreCase = true - ) && - item.value.startsWith("Bearer ") - if (isAuthBearer) { - Row(modifier = Modifier.fillMaxWidth()) { - FloconLineDescription( - label = item.name, - value = item.value, - labelWidth = labelWidth, - contentColor = FloconTheme.colorPalette.onPrimary, - modifier = Modifier.weight(1f) - ) - Spacer(modifier = Modifier.width(4.dp)) - FloconButton( - onClick = { onAuthorizationClicked(item.value) } - ) { - Text("Decode\nJWT", textAlign = TextAlign.Center, color = FloconTheme.colorPalette.onPrimary) - } - } - } else { - FloconLineDescription( - label = item.name, - value = item.value, - labelWidth = labelWidth, - contentColor = FloconTheme.colorPalette.onPrimary, - modifier = Modifier.fillMaxWidth() - ) - } + HeaderItemRow( + item = item, + labelWidth = labelWidth, + onAuthorizationClicked = onAuthorizationClicked, + onCopyValue = onCopyValue, + modifier = Modifier.fillMaxWidth() + ) if (index != headers.lastIndex) { FloconHorizontalDivider( modifier = Modifier.fillMaxWidth(), @@ -79,6 +82,133 @@ fun DetailHeadersView( } } +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun HeaderItemRow( + item: NetworkDetailHeaderUi, + labelWidth: Dp, + onAuthorizationClicked: (value: String) -> Unit, + onCopyValue: ((value: String) -> Unit)?, + modifier: Modifier = Modifier +) { + var isTitleHovered by remember { mutableStateOf(false) } + val isAuthBearer = remember(item.name, item.value) { + item.name.equals("authorization", ignoreCase = true) && item.value.startsWith("Bearer ") + } + + Row( + modifier = modifier + .padding(vertical = 4.dp) + .onPointerEvent(PointerEventType.Enter) { isTitleHovered = true } + .onPointerEvent(PointerEventType.Exit) { isTitleHovered = false }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .width(labelWidth) + .padding(end = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + SelectionContainer { + Text( + text = item.name, + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onPrimary, + ) + } + if (isTitleHovered && onCopyValue != null) { + FloconSmallIconButton( + imageVector = Icons.Outlined.ContentCopy, + onClick = { onCopyValue(item.value) }, + contentDescription = "Copy value", + modifier = Modifier.size(16.dp) + ) + } + } + } + + // Header Value with max 10 lines and hover tooltip + HeaderValueText( + value = item.value, + modifier = Modifier.weight(1f) + ) + + if (isAuthBearer) { + Spacer(modifier = Modifier.width(4.dp)) + FloconButton( + onClick = { onAuthorizationClicked(item.value) } + ) { + Text("Decode\nJWT", textAlign = TextAlign.Center, color = FloconTheme.colorPalette.onPrimary) + } + } + } +} + +@Composable +private fun HeaderValueText( + value: String, + modifier: Modifier = Modifier +) { + val isLarge = remember(value) { value.lines().size > 10 || value.length > 200 } + + if (isLarge) { + TooltipArea( + tooltip = { + Box( + modifier = Modifier + .widthIn(max = 600.dp) + .clip(FloconTheme.shapes.small) + .background(FloconTheme.colorPalette.secondary) + .border( + width = 1.dp, + color = FloconTheme.colorPalette.accent.copy(alpha = 0.5f), + shape = FloconTheme.shapes.small + ) + .padding(8.dp) + ) { + Text( + text = value, + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onSecondary, + ) + } + }, + delayMillis = 200, + tooltipPlacement = TooltipPlacement.ComponentRect( + offset = DpOffset(x = 0.dp, y = 4.dp) + ), + modifier = modifier + ) { + SelectionContainer { + Text( + text = value, + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onPrimary, + maxLines = 10, + overflow = TextOverflow.Ellipsis, + ) + } + } + } else { + SelectionContainer(modifier = modifier) { + Text( + text = value, + style = FloconTheme.typography.bodyMedium, + color = FloconTheme.colorPalette.onPrimary, + maxLines = 10, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + + @Preview @Composable private fun DetailHeadersViewPreview() { @@ -90,6 +220,8 @@ private fun DetailHeadersViewPreview() { labelWidth = 100.dp, modifier = Modifier.fillMaxWidth(), onAuthorizationClicked = {}, + onCopyValue = {}, ) } } + diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt index 0fa8afc93..e5b289bbf 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt @@ -271,6 +271,7 @@ class NetworkViewModel( } is NetworkAction.Pinned -> onPinned(action) + is NetworkAction.SetDefaultSelectedTab -> setDefaultSelectedTab(action) is NetworkAction.DetailAction -> detailDelegate.onAction(action.action) is NetworkAction.SelectLine -> onSelectLine(action) NetworkAction.ClearMultiSelect -> onClearMultiSelect() @@ -282,6 +283,7 @@ class NetworkViewModel( } } + private fun onShareAsMarkdown(action: NetworkAction.ShareAsMarkdown) { viewModelScope.launch(dispatcherProvider.viewModel) { getNetworkCallAsMarkdownUseCase(action.item.uuid)?.let { @@ -372,6 +374,17 @@ class NetworkViewModel( } } + private fun setDefaultSelectedTab(action: NetworkAction.SetDefaultSelectedTab) { + viewModelScope.launch(dispatcherProvider.viewModel) { + saveNetworkSettingsUseCase( + settings.value.copy( + defaultSelectedTab = action.tab + ) + ) + } + } + + private fun onSelectRequest(action: NetworkAction.SelectRequest) { selectRequest(action.id) } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/SettingsUiMapper.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/SettingsUiMapper.kt index cb3007fa9..8f83b013a 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/SettingsUiMapper.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/SettingsUiMapper.kt @@ -7,5 +7,7 @@ fun NetworkSettings.toUi() = NetworkSettingsUiModel( pinPanel = pinnedDetails, displayOldSessions = displayOldSessions, autoScroll = autoScroll, - invertList = invertList + invertList = invertList, + defaultSelectedTab = defaultSelectedTab, ) + diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt index 7c10b1add..02582c80c 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt @@ -1,9 +1,11 @@ package io.github.openflocon.flocondesktop.features.network.list.model +import io.github.openflocon.domain.models.settings.NetworkDetailTab import io.github.openflocon.flocondesktop.features.network.detail.NetworkDetailAction import io.github.openflocon.flocondesktop.features.network.list.model.header.OnFilterAction import io.github.openflocon.flocondesktop.features.network.list.model.header.columns.NetworkColumnsTypeUiModel + sealed interface NetworkAction { data class SelectRequest(val id: String) : NetworkAction @@ -44,10 +46,13 @@ sealed interface NetworkAction { data class ToggleAutoScroll(val value: Boolean) : NetworkAction + data class SetDefaultSelectedTab(val tab: NetworkDetailTab) : NetworkAction + data object ClearOldSession : NetworkAction data class DetailAction(val action: NetworkDetailAction) : NetworkAction + data class SelectLine(val id: String, val selected: Boolean) : NetworkAction data object MultiSelect : NetworkAction diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkSettingsUiModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkSettingsUiModel.kt index 47cf5a409..1e7796c78 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkSettingsUiModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkSettingsUiModel.kt @@ -1,18 +1,22 @@ package io.github.openflocon.flocondesktop.features.network.list.model import androidx.compose.runtime.Immutable +import io.github.openflocon.domain.models.settings.NetworkDetailTab @Immutable data class NetworkSettingsUiModel( val displayOldSessions: Boolean, val autoScroll: Boolean, val invertList: Boolean, - val pinPanel: Boolean + val pinPanel: Boolean, + val defaultSelectedTab: NetworkDetailTab = NetworkDetailTab.Request, ) fun previewNetworkSettingsUiModel() = NetworkSettingsUiModel( displayOldSessions = true, autoScroll = false, invertList = false, - pinPanel = false + pinPanel = false, + defaultSelectedTab = NetworkDetailTab.Request, ) + diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkScreen.kt index 387c0f7bb..2a05fcec8 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkScreen.kt @@ -19,14 +19,18 @@ 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.KeyboardArrowRight 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.Check 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.History import androidx.compose.material.icons.outlined.ImportExport +import androidx.compose.material.icons.outlined.Layers import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.SignalWifiStatusbarConnectedNoInternet4 @@ -47,6 +51,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.composeunstyled.DropdownPanelAnchor +import io.github.openflocon.domain.models.settings.NetworkDetailTab +import io.github.openflocon.library.designsystem.components.FloconDropdownMenu +import io.github.openflocon.library.designsystem.components.FloconMenuItem + + import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -313,6 +324,44 @@ fun NetworkScreen( it() } ) + FloconDropdownSeparator() + var defaultTabMenuExpanded by remember { mutableStateOf(false) } + + FloconDropdownMenu( + expanded = defaultTabMenuExpanded, + onDismissRequest = { defaultTabMenuExpanded = false }, + onExpandRequest = { defaultTabMenuExpanded = true }, + anchor = DropdownPanelAnchor.BottomStart, + anchorContent = { + FloconMenuItem( + text = "Default detail tab: ${uiState.settings.defaultSelectedTab.name}", + leadingIcon = Icons.Outlined.Layers, + trailingIcon = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + onClick = { defaultTabMenuExpanded = !defaultTabMenuExpanded } + ) + } + ) { + FloconDropdownMenuItem( + text = "Request", + leadingIcon = Icons.Outlined.Upload, + trailingIcon = if (uiState.settings.defaultSelectedTab == NetworkDetailTab.Request) Icons.Outlined.Check else null, + onClick = { + onAction(NetworkAction.SetDefaultSelectedTab(NetworkDetailTab.Request)) + defaultTabMenuExpanded = false + } + ) + FloconDropdownMenuItem( + text = "Response", + leadingIcon = Icons.Outlined.Download, + trailingIcon = if (uiState.settings.defaultSelectedTab == NetworkDetailTab.Response) Icons.Outlined.Check else null, + onClick = { + onAction(NetworkAction.SetDefaultSelectedTab(NetworkDetailTab.Response)) + defaultTabMenuExpanded = false + } + ) + } + + FloconDropdownMenuItem( text = "Select items", leadingIcon = Icons.Sharp.SelectAll, @@ -326,6 +375,8 @@ fun NetworkScreen( ) } }, + + ) Column( modifier = Modifier diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/models/settings/NetworkSettings.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/models/settings/NetworkSettings.kt index 156164cae..f78c15da1 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/models/settings/NetworkSettings.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/models/settings/NetworkSettings.kt @@ -1,8 +1,15 @@ package io.github.openflocon.domain.models.settings +enum class NetworkDetailTab { + Request, + Response +} + data class NetworkSettings( val pinnedDetails: Boolean, val displayOldSessions: Boolean, val autoScroll: Boolean, - val invertList: Boolean + val invertList: Boolean, + val defaultSelectedTab: NetworkDetailTab = NetworkDetailTab.Request, ) + diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/common/ClipboardManager.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/common/ClipboardManager.kt index d95abcd14..4f4d1aeae 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/common/ClipboardManager.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/common/ClipboardManager.kt @@ -1,5 +1,10 @@ package io.github.openflocon.library.designsystem.common +import androidx.compose.ui.graphics.ImageBitmap + expect fun copyToClipboard(text: String) +expect fun copyToClipboard(bitmap: ImageBitmap) + expect fun readFromClipboard(): String? + diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/common/ImageSaver.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/common/ImageSaver.kt new file mode 100644 index 000000000..cdb3b2b47 --- /dev/null +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/common/ImageSaver.kt @@ -0,0 +1,5 @@ +package io.github.openflocon.library.designsystem.common + +import androidx.compose.ui.graphics.ImageBitmap + +expect fun saveImageToFile(bitmap: ImageBitmap, defaultFileName: String = "image.png"): Boolean diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconDropdown.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconDropdown.kt index 6abc05760..4cbc32e13 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconDropdown.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconDropdown.kt @@ -69,17 +69,22 @@ fun FloconDropdownMenu( fun FloconDropdownMenuItem( text: String, onClick: () -> Unit, + modifier: Modifier = Modifier, secondaryAction: (@Composable () -> Unit)? = null, - leadingIcon: ImageVector? = null + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, ) { FloconMenuItem( text = text, onClick = onClick, + modifier = modifier, secondaryAction = secondaryAction, - leadingIcon = leadingIcon + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, ) } + @Composable fun FloconDropdownMenuItem( text: String, diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconLineDescription.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconLineDescription.kt index a9d7beb41..c211fc1e9 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconLineDescription.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconLineDescription.kt @@ -34,24 +34,28 @@ fun FloconLineDescription( horizontalArrangement = Arrangement.spacedBy(4.dp) ) { CompositionLocalProvider(LocalContentColor provides contentColor) { - Text( - text = label, - style = FloconTheme.typography.titleSmall, + SelectionContainer( modifier = if (labelWidth != null) { Modifier .width(labelWidth) .padding(end = 8.dp) } else { Modifier.weight(1f) - }, - ) - SelectionContainer( - modifier = Modifier - .weight(1f) - .wrapContentWidth(align = Alignment.Start) + } ) { - content() + Text( + text = label, + style = FloconTheme.typography.titleSmall, + ) } + Row( + modifier = Modifier + .weight(1f) + .wrapContentWidth(align = Alignment.Start), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + content = content + ) } } } @@ -70,13 +74,16 @@ fun FloconLineDescription( contentColor = contentColor, modifier = modifier, ) { - Text( - text = value, - style = FloconTheme.typography.bodyMedium, - ) + SelectionContainer { + Text( + text = value, + style = FloconTheme.typography.bodyMedium, + ) + } } } + @Preview @Composable private fun DetailLineView_closed_Preview() { diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconSection.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconSection.kt index e181c3713..75bce979b 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconSection.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconSection.kt @@ -6,11 +6,13 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +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.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material3.Text @@ -21,6 +23,7 @@ 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.graphicsLayer import androidx.compose.ui.unit.dp import io.github.openflocon.library.designsystem.FloconTheme @@ -49,22 +52,36 @@ fun FloconSection( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (expandable) { - FloconIconButton( - onClick = { expanded = !expanded }, - ) { + Row( + modifier = Modifier + .weight(1f) + .then( + if (expandable) { + Modifier + .clip(FloconTheme.shapes.small) + .clickable { expanded = !expanded } + .padding(vertical = 4.dp, horizontal = 2.dp) + } else { + Modifier + } + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (expandable) { FloconIcon( imageVector = Icons.Outlined.ExpandMore, - modifier = Modifier.graphicsLayer { rotationZ = rotate } + modifier = Modifier + .padding(4.dp) + .graphicsLayer { rotationZ = rotate } ) } + Text( + text = title, + style = FloconTheme.typography.titleMedium, + color = FloconTheme.colorPalette.onPrimary, + ) } - Text( - text = title, - style = FloconTheme.typography.titleMedium, - color = FloconTheme.colorPalette.onPrimary, - modifier = Modifier.weight(1f) - ) actions?.invoke(this) } AnimatedVisibility( @@ -76,3 +93,4 @@ fun FloconSection( } } } + diff --git a/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ClipboardManager.desktop.kt b/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ClipboardManager.desktop.kt index 7039ecd06..c555a3e6f 100644 --- a/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ClipboardManager.desktop.kt +++ b/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ClipboardManager.desktop.kt @@ -1,22 +1,87 @@ package io.github.openflocon.library.designsystem.common +import androidx.compose.ui.graphics.ImageBitmap +import java.awt.Image import java.awt.Toolkit import java.awt.datatransfer.DataFlavor import java.awt.datatransfer.StringSelection +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException + + +import java.io.File +import java.util.concurrent.TimeUnit actual fun copyToClipboard(text: String) { val clipboard = Toolkit.getDefaultToolkit().systemClipboard val stringSelection = StringSelection(text) - clipboard.setContents(stringSelection, null) } +actual fun copyToClipboard(bitmap: ImageBitmap) { + try { + val bufferedImage = ImageOptimizer.toOptimizedBufferedImage(bitmap) + + val osName = System.getProperty("os.name") + val isMac = osName != null && osName.contains("Mac", ignoreCase = true) + + if (isMac) { + var nativeCopied = false + try { + val tempFile = File.createTempFile("flocon_clipboard", ".png") + tempFile.deleteOnExit() + if (ImageOptimizer.saveToFile(bufferedImage, tempFile)) { + val script = "set the clipboard to (read (POSIX file \"${tempFile.absolutePath}\") as «class PNGf»)" + val process = ProcessBuilder("osascript", "-e", script).start() + val finished = process.waitFor(2, TimeUnit.SECONDS) + if (finished && process.exitValue() == 0) { + nativeCopied = true + } + } + tempFile.delete() + } catch (e: Exception) { + // Fallback to standard AWT clipboard below + } + if (nativeCopied) { + return + } + } + + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + val imageSelection = ImageSelection(bufferedImage) + clipboard.setContents(imageSelection, null) + + } catch (e: Exception) { + e.printStackTrace() + } +} + + +private class ImageSelection( + private val image: Image, +) : Transferable { + + override fun getTransferDataFlavors(): Array = arrayOf(DataFlavor.imageFlavor) + + override fun isDataFlavorSupported(flavor: DataFlavor): Boolean = flavor == DataFlavor.imageFlavor + + override fun getTransferData(flavor: DataFlavor): Any { + if (flavor == DataFlavor.imageFlavor) { + return image + } + throw UnsupportedFlavorException(flavor) + } +} + + actual fun readFromClipboard(): String? { val clipboard = Toolkit.getDefaultToolkit().systemClipboard - return try { clipboard.getData(DataFlavor.stringFlavor) as String } catch (e: Exception) { null } } + + + diff --git a/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ImageOptimizer.desktop.kt b/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ImageOptimizer.desktop.kt new file mode 100644 index 000000000..303047d05 --- /dev/null +++ b/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ImageOptimizer.desktop.kt @@ -0,0 +1,115 @@ +package io.github.openflocon.library.designsystem.common + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toAwtImage +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.File +import javax.imageio.IIOImage +import javax.imageio.ImageIO +import javax.imageio.ImageWriteParam + +internal object ImageOptimizer { + + private const val MAX_WIDTH = 1600 + private const val MAX_HEIGHT = 2400 + private const val MAX_PIXELS = 3_600_000 // ~14.4 MB uncompressed ARGB / TIFF limit + + fun toOptimizedBufferedImage( + bitmap: ImageBitmap, + maxWidth: Int = MAX_WIDTH, + maxHeight: Int = MAX_HEIGHT, + ): BufferedImage { + val rawImage = bitmap.toAwtImage() + val origW = rawImage.getWidth(null).coerceAtLeast(1) + val origH = rawImage.getHeight(null).coerceAtLeast(1) + val totalPixels = origW.toLong() * origH.toLong() + + val scaleByDimension = minOf( + 1.0, + maxWidth.toDouble() / origW, + maxHeight.toDouble() / origH, + ) + + val scaleByPixels = if (totalPixels > MAX_PIXELS) { + kotlin.math.sqrt(MAX_PIXELS.toDouble() / totalPixels) + } else { + 1.0 + } + + val scale = minOf(scaleByDimension, scaleByPixels) + + if (scale >= 1.0) { + if (rawImage is BufferedImage && rawImage.type == BufferedImage.TYPE_INT_ARGB) { + return rawImage + } + val bufferedImage = BufferedImage(origW, origH, BufferedImage.TYPE_INT_ARGB) + val g2d = bufferedImage.createGraphics() + g2d.drawImage(rawImage, 0, 0, null) + g2d.dispose() + return bufferedImage + } + + val targetW = (origW * scale).toInt().coerceAtLeast(1) + val targetH = (origH * scale).toInt().coerceAtLeast(1) + + val bufferedImage = BufferedImage(targetW, targetH, BufferedImage.TYPE_INT_ARGB) + val g2d = bufferedImage.createGraphics() + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g2d.drawImage(rawImage, 0, 0, targetW, targetH, null) + g2d.dispose() + + return bufferedImage + } + + + + fun toPngByteArray(image: BufferedImage): ByteArray { + val baos = ByteArrayOutputStream() + val writers = ImageIO.getImageWritersByFormatName("png") + if (writers.hasNext()) { + val writer = writers.next() + val param = writer.defaultWriteParam + if (param.canWriteCompressed()) { + param.compressionMode = ImageWriteParam.MODE_EXPLICIT + param.compressionQuality = 0.85f + } + val ios = ImageIO.createImageOutputStream(baos) + writer.output = ios + writer.write(null, IIOImage(image, null, null), param) + writer.dispose() + ios.close() + } else { + ImageIO.write(image, "png", baos) + } + return baos.toByteArray() + } + + fun saveToFile(image: BufferedImage, targetFile: File): Boolean { + return try { + val writers = ImageIO.getImageWritersByFormatName("png") + if (writers.hasNext()) { + val writer = writers.next() + val param = writer.defaultWriteParam + if (param.canWriteCompressed()) { + param.compressionMode = ImageWriteParam.MODE_EXPLICIT + param.compressionQuality = 0.85f + } + val ios = ImageIO.createImageOutputStream(targetFile) + writer.output = ios + writer.write(null, IIOImage(image, null, null), param) + writer.dispose() + ios.close() + } else { + ImageIO.write(image, "png", targetFile) + } + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } +} diff --git a/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ImageSaver.desktop.kt b/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ImageSaver.desktop.kt new file mode 100644 index 000000000..eea108ebe --- /dev/null +++ b/FloconDesktop/library/designsystem/src/desktopMain/kotlin/io/github/openflocon/library/designsystem/common/ImageSaver.desktop.kt @@ -0,0 +1,35 @@ +package io.github.openflocon.library.designsystem.common + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toAwtImage +import java.awt.FileDialog +import java.awt.Frame +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +actual fun saveImageToFile(bitmap: ImageBitmap, defaultFileName: String): Boolean { + return try { + val parentFrame = Frame() + val dialog = FileDialog(parentFrame, "Save Image", FileDialog.SAVE).apply { + file = defaultFileName + } + dialog.isVisible = true + val file = dialog.file + val directory = dialog.directory + parentFrame.dispose() + + if (file != null && directory != null) { + val targetFile = File(directory, if (file.endsWith(".png", ignoreCase = true)) file else "$file.png") + val bufferedImage = ImageOptimizer.toOptimizedBufferedImage(bitmap) + ImageOptimizer.saveToFile(bufferedImage, targetFile) + } else { + false + } + } catch (e: Exception) { + e.printStackTrace() + false + } +} + + diff --git a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/WindowScene.kt b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/WindowScene.kt index d4f8e5935..30e16cec4 100644 --- a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/WindowScene.kt +++ b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/WindowScene.kt @@ -4,12 +4,15 @@ package io.github.openflocon.navigation.scene import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.contains import androidx.navigation3.runtime.get import androidx.navigation3.runtime.metadata import androidx.navigation3.scene.OverlayScene @@ -33,10 +36,11 @@ data class WindowScene( override val content: @Composable (() -> Unit) = { val windowProperties = entry.metadata[WindowPropertiesKey] - val state = rememberWindowState( size = windowProperties?.size ?: DpSize(800.dp, 600.dp), + position = WindowPosition.Aligned(Alignment.Center) ) + Window( onCloseRequest = onBack, state = state, @@ -52,7 +56,7 @@ class WindowSceneStrategy : SceneStrategy { override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { val entry = entries.last() - if (entry.metadata[WindowPropertiesKey]?.isWindow == true) { + if (entry.metadata.contains(WindowPropertiesKey)) { return WindowScene( entry = entry, previousEntries = entries.dropLast(1), @@ -73,8 +77,6 @@ class WindowSceneStrategy : SceneStrategy { data class WindowProperties( val size: DpSize? = null, val title: String? = null -) { - val isWindow: Boolean = true -} +) private object WindowPropertiesKey: NavMetadataKey