feat: [NETWORK] persist settings (#348)

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-10-14 13:34:17 +02:00 committed by GitHub
parent dd4b3dd6b9
commit 15eae36341
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 490 additions and 77 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 67,
"identityHash": "2038d2adaa065952692bba0a38a7de5f",
"identityHash": "94a1a4f3eb4cf29757584bdaa3c2a455",
"entities": [
{
"tableName": "FloconNetworkCallEntity",
@ -1216,6 +1216,52 @@
}
]
},
{
"tableName": "NetworkSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `packageName` TEXT NOT NULL, `valueAsJson` TEXT NOT NULL, PRIMARY KEY(`deviceId`, `packageName`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "deviceId",
"columnName": "deviceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "valueAsJson",
"columnName": "valueAsJson",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"deviceId",
"packageName"
]
},
"foreignKeys": [
{
"table": "DeviceAppEntity",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"deviceId",
"packageName"
],
"referencedColumns": [
"deviceId",
"packageName"
]
}
]
},
{
"tableName": "MockNetworkEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mockId` TEXT NOT NULL, `deviceId` TEXT, `packageName` TEXT, `isEnabled` INTEGER NOT NULL, `response` TEXT NOT NULL, `expectation_urlPattern` TEXT NOT NULL, `expectation_method` TEXT NOT NULL, PRIMARY KEY(`mockId`), FOREIGN KEY(`deviceId`, `packageName`) REFERENCES `DeviceAppEntity`(`deviceId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )",
@ -1590,7 +1636,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2038d2adaa065952692bba0a38a7de5f')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '94a1a4f3eb4cf29757584bdaa3c2a455')"
]
}
}

View file

@ -30,8 +30,10 @@ import io.github.openflocon.data.local.network.dao.FloconNetworkDao
import io.github.openflocon.data.local.network.dao.NetworkBadQualityConfigDao
import io.github.openflocon.data.local.network.dao.NetworkFilterDao
import io.github.openflocon.data.local.network.dao.NetworkMocksDao
import io.github.openflocon.data.local.network.dao.NetworkSettingsDao
import io.github.openflocon.data.local.network.models.FloconNetworkCallEntity
import io.github.openflocon.data.local.network.models.NetworkFilterEntity
import io.github.openflocon.data.local.network.models.NetworkSettingsEntity
import io.github.openflocon.data.local.network.models.badquality.BadQualityConfigEntity
import io.github.openflocon.data.local.network.models.mock.MockNetworkEntity
import io.github.openflocon.data.local.table.dao.FloconTableDao
@ -43,7 +45,7 @@ import io.github.openflocon.flocondesktop.common.db.converters.MapStringsConvert
import kotlinx.coroutines.Dispatchers
@Database(
version = 67,
version = 68,
entities = [
FloconNetworkCallEntity::class,
FileEntity::class,
@ -58,6 +60,7 @@ import kotlinx.coroutines.Dispatchers
DeeplinkEntity::class,
AnalyticsItemEntity::class,
NetworkFilterEntity::class,
NetworkSettingsEntity::class,
MockNetworkEntity::class,
DeviceWithSerialEntity::class,
BadQualityConfigEntity::class,
@ -73,6 +76,7 @@ import kotlinx.coroutines.Dispatchers
)
abstract class AppDatabase : RoomDatabase() {
abstract val networkDao: FloconNetworkDao
abstract val networkSettingsDao: NetworkSettingsDao
abstract val fileDao: FloconFileDao
abstract val dashboardDao: FloconDashboardDao
abstract val tableDao: FloconTableDao

View file

@ -10,6 +10,9 @@ val roomModule =
single {
get<AppDatabase>().networkDao
}
single {
get<AppDatabase>().networkSettingsDao
}
single {
get<AppDatabase>().fileDao
}

View file

@ -10,8 +10,6 @@ data class ContentUiState(
val detailJsons: Set<NetworkBodyDetailUi>,
val mocksDisplayed: MockDisplayed?,
val badNetworkQualityDisplayed: Boolean,
val invertList: Boolean,
val autoScroll: Boolean
)
@Immutable
@ -25,6 +23,4 @@ fun previewContentUiState() = ContentUiState(
detailJsons = emptySet(),
mocksDisplayed = null,
badNetworkQualityDisplayed = false,
invertList = false,
autoScroll = false
)

View file

@ -16,6 +16,7 @@ import io.github.openflocon.domain.network.models.BadQualityConfigDomainModel
import io.github.openflocon.domain.network.models.MockNetworkDomainModel
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel
import io.github.openflocon.domain.network.models.NetworkFilterDomainModel.Filters
import io.github.openflocon.domain.network.models.NetworkSettingsDomainModel
import io.github.openflocon.domain.network.models.NetworkTextFilterColumns
import io.github.openflocon.domain.network.usecase.DecodeJwtTokenUseCase
import io.github.openflocon.domain.network.usecase.ExportNetworkCallsToCsvUseCase
@ -29,6 +30,8 @@ import io.github.openflocon.domain.network.usecase.RemoveOldSessionsNetworkReque
import io.github.openflocon.domain.network.usecase.ResetCurrentDeviceHttpRequestsUseCase
import io.github.openflocon.domain.network.usecase.badquality.ObserveAllNetworkBadQualitiesUseCase
import io.github.openflocon.domain.network.usecase.mocks.ObserveNetworkMocksUseCase
import io.github.openflocon.domain.network.usecase.settings.ObserveNetworkSettingsUseCase
import io.github.openflocon.domain.network.usecase.settings.UpdateNetworkSettingsUseCase
import io.github.openflocon.flocondesktop.features.network.body.model.ContentUiState
import io.github.openflocon.flocondesktop.features.network.body.model.MockDisplayed
import io.github.openflocon.flocondesktop.features.network.detail.mapper.toDetailUi
@ -55,7 +58,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -77,6 +79,8 @@ class NetworkViewModel(
private val exportNetworkCallsToCsv: ExportNetworkCallsToCsvUseCase,
private val decodeJwtTokenUseCase: DecodeJwtTokenUseCase,
private val removeOldSessionsNetworkRequestUseCase: RemoveOldSessionsNetworkRequestUseCase,
private val observeNetworkSettingsUseCase: ObserveNetworkSettingsUseCase,
private val updateNetworkSettingsUseCase: UpdateNetworkSettingsUseCase,
) : ViewModel(headerDelegate) {
private val contentState = MutableStateFlow(
@ -85,26 +89,37 @@ class NetworkViewModel(
detailJsons = emptySet(),
mocksDisplayed = null,
badNetworkQualityDisplayed = false,
invertList = false,
autoScroll = false
),
)
private val _filterText = mutableStateOf("")
val filterText: State<String> = _filterText
private val displayOldSessions = MutableStateFlow(true)
private val defaultNetworkSettings = NetworkSettingsDomainModel(
displayOldSessions = true,
autoScroll = false,
invertList = false,
)
private val settings: StateFlow<NetworkSettingsDomainModel> = observeNetworkSettingsUseCase()
.flowOn(dispatcherProvider.viewModel)
.map { it ?: defaultNetworkSettings }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
initialValue = defaultNetworkSettings
)
private val filterUiState = combine(
mocksUseCase().map { it.any(MockNetworkDomainModel::isEnabled) }.distinctUntilChanged(),
badNetworkUseCase().map { it.any(BadQualityConfigDomainModel::isEnabled) }
.distinctUntilChanged(),
displayOldSessions,
) { mockEnabled, badNetworkEnabled, displayOldSessions ->
settings,
) { mockEnabled, badNetworkEnabled, settings ->
TopBarUiState(
hasBadNetwork = badNetworkEnabled,
hasMocks = mockEnabled,
displayOldSessions = displayOldSessions,
displayOldSessions = settings.displayOldSessions,
)
}.stateIn(
viewModelScope, SharingStarted.WhileSubscribed(5_000),
@ -135,13 +150,13 @@ class NetworkViewModel(
headerDelegate.textFiltersState.map { it.toDomain() }.distinctUntilChanged(),
headerDelegate.allowedMethods().map { items -> methodsToDomain(items) }
.distinctUntilChanged(),
displayOldSessions,
).map { (textFilters, filterOnAllColumns, methods, displayOldSessions) ->
settings,
).map { (textFilters, filterOnAllColumns, methods, settings) ->
NetworkFilterDomainModel(
filterOnAllColumns = textFilters,
textsFilters = filterOnAllColumns,
methodFilter = methods,
displayOldSessions = displayOldSessions,
displayOldSessions = settings.displayOldSessions,
)
}
@ -150,36 +165,39 @@ class NetworkViewModel(
filter,
).distinctUntilChanged()
val items: Flow<PagingData<NetworkItemViewState>> = observeCurrentDeviceIdAndPackageNameUseCase()
.flatMapLatest { deviceIdAndPackageName ->
sortAndFilter.flatMapLatest { (sorted, filter) ->
observeNetworkRequestsUseCase(
sortedBy = sorted,
filter = filter,
deviceIdAndPackageName = deviceIdAndPackageName,
).map { networkCallPagingData ->
networkCallPagingData.map {
it.toUi(
deviceIdAndPackageName = deviceIdAndPackageName
)
val items: Flow<PagingData<NetworkItemViewState>> =
observeCurrentDeviceIdAndPackageNameUseCase()
.flatMapLatest { deviceIdAndPackageName ->
sortAndFilter.flatMapLatest { (sorted, filter) ->
observeNetworkRequestsUseCase(
sortedBy = sorted,
filter = filter,
deviceIdAndPackageName = deviceIdAndPackageName,
).map { networkCallPagingData ->
networkCallPagingData.map {
it.toUi(
deviceIdAndPackageName = deviceIdAndPackageName
)
}
}
}
}
}
.flowOn(dispatcherProvider.viewModel)
.cachedIn(viewModelScope)
.flowOn(dispatcherProvider.viewModel)
.cachedIn(viewModelScope)
val uiState = combine(
contentState,
detailState,
filterUiState,
headerDelegate.headerUiState,
) { content, detail, filter, header ->
settings.map { it.toUi() },
) { content, detail, filter, header, settings ->
NetworkUiState(
contentState = content,
detailState = detail,
filterState = filter,
headerState = header,
settings = settings,
)
}
.flowOn(dispatcherProvider.viewModel)
@ -191,6 +209,7 @@ class NetworkViewModel(
contentState = contentState.value,
filterState = filterUiState.value,
headerState = headerDelegate.headerUiState.value,
settings = settings.value.toUi(),
),
)
@ -226,12 +245,12 @@ class NetworkViewModel(
action = action.action,
)
is NetworkAction.InvertList -> onInvertList(action)
NetworkAction.ToggleAutoScroll -> onAutoScroll()
is NetworkAction.InvertList -> toggleInvertList(action)
is NetworkAction.ToggleAutoScroll -> toggleAutoScroll(action)
NetworkAction.ClearOldSession -> onClearSession()
is NetworkAction.Down -> contentState.update { it.copy(selectedRequestId = action.itemIdToSelect) }
is NetworkAction.Up -> contentState.update { it.copy(selectedRequestId = action.itemIdToSelect) }
is NetworkAction.UpdateDisplayOldSessions -> displayOldSessions.update { action.value }
is NetworkAction.UpdateDisplayOldSessions -> toggleDisplayOldSessions(action)
}
}
@ -241,12 +260,34 @@ class NetworkViewModel(
}
}
private fun onAutoScroll() {
contentState.update { it.copy(autoScroll = !it.autoScroll) }
private fun toggleAutoScroll(action: NetworkAction.ToggleAutoScroll) {
viewModelScope.launch(dispatcherProvider.viewModel) {
updateNetworkSettingsUseCase(
settings.value.copy(
autoScroll = action.value
)
)
}
}
private fun onInvertList(action: NetworkAction.InvertList) {
contentState.update { it.copy(invertList = action.value) }
private fun toggleDisplayOldSessions(action: NetworkAction.UpdateDisplayOldSessions) {
viewModelScope.launch(dispatcherProvider.viewModel) {
updateNetworkSettingsUseCase(
settings.value.copy(
displayOldSessions = action.value
)
)
}
}
private fun toggleInvertList(action: NetworkAction.InvertList) {
viewModelScope.launch(dispatcherProvider.viewModel) {
updateNetworkSettingsUseCase(
settings.value.copy(
invertList = action.value
)
)
}
}
private fun displayBearerJwt(token: String) {
@ -418,7 +459,7 @@ private fun Map<NetworkTextFilterColumns, TextFilterStateUiModel>.toDomain(): Li
}
}
private fun TextFilterStateUiModel.FilterItem.toDomain(): NetworkFilterDomainModel.Filters.FilterItem? {
private fun TextFilterStateUiModel.FilterItem.toDomain(): Filters.FilterItem? {
return if (isActive) {
Filters.FilterItem(
text = text,
@ -427,5 +468,6 @@ private fun TextFilterStateUiModel.FilterItem.toDomain(): NetworkFilterDomainMod
}
private fun methodsToDomain(items: List<NetworkMethodUi>): List<String>? {
return items.map { it.text }.takeIf { it.isNotEmpty() }?.takeIf { it.size != NetworkMethodUi.all().size } // returns null if we accept all
return items.map { it.text }.takeIf { it.isNotEmpty() }
?.takeIf { it.size != NetworkMethodUi.all().size } // returns null if we accept all
}

View file

@ -0,0 +1,10 @@
package io.github.openflocon.flocondesktop.features.network.list.mapper
import io.github.openflocon.domain.network.models.NetworkSettingsDomainModel
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkSettingsUiModel
fun NetworkSettingsDomainModel.toUi() = NetworkSettingsUiModel(
displayOldSessions = displayOldSessions,
autoScroll = autoScroll,
invertList = invertList,
)

View file

@ -42,7 +42,7 @@ sealed interface NetworkAction {
data class InvertList(val value: Boolean) : NetworkAction
data object ToggleAutoScroll : NetworkAction
data class ToggleAutoScroll(val value: Boolean) : NetworkAction
data object ClearOldSession : NetworkAction

View file

@ -0,0 +1,16 @@
package io.github.openflocon.flocondesktop.features.network.list.model
import androidx.compose.runtime.Immutable
@Immutable
data class NetworkSettingsUiModel(
val displayOldSessions: Boolean,
val autoScroll: Boolean,
val invertList: Boolean
)
fun previewNetworkSettingsUiModel() = NetworkSettingsUiModel(
displayOldSessions = true,
autoScroll = false,
invertList = false,
)

View file

@ -10,6 +10,7 @@ import io.github.openflocon.flocondesktop.features.network.body.model.previewCon
@Immutable
data class NetworkUiState(
val contentState: ContentUiState,
val settings: NetworkSettingsUiModel,
val detailState: NetworkDetailViewState?,
val filterState: TopBarUiState,
val headerState: NetworkHeaderUiState,
@ -20,4 +21,5 @@ fun previewNetworkUiState() = NetworkUiState(
contentState = previewContentUiState(),
filterState = previewTopBarUiState(),
headerState = previewNetworkHeaderUiState(),
settings = previewNetworkSettingsUiModel(),
)

View file

@ -3,7 +3,6 @@ package io.github.openflocon.flocondesktop.features.network.list.view
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -28,7 +27,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
@ -36,13 +34,11 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.composeunstyled.Text
import io.github.openflocon.flocondesktop.common.ui.window.FloconWindowState
import io.github.openflocon.flocondesktop.common.ui.window.createFloconWindowState
import io.github.openflocon.flocondesktop.features.network.badquality.list.view.BadNetworkQualityWindow
@ -61,7 +57,6 @@ import io.github.openflocon.flocondesktop.features.network.mock.list.view.Networ
import io.github.openflocon.flocondesktop.features.network.model.NetworkBodyDetailUi
import io.github.openflocon.flocondesktop.features.network.view.NetworkBodyWindow
import io.github.openflocon.library.designsystem.FloconTheme
import io.github.openflocon.library.designsystem.components.FloconCheckbox
import io.github.openflocon.library.designsystem.components.FloconDropdownMenuItem
import io.github.openflocon.library.designsystem.components.FloconDropdownSeparator
import io.github.openflocon.library.designsystem.components.FloconFeature
@ -109,8 +104,8 @@ fun NetworkScreen(
val columnWidths: NetworkItemColumnWidths =
remember { NetworkItemColumnWidths() } // Default widths provided
LaunchedEffect(uiState.contentState.autoScroll, rows.itemCount) {
if (uiState.contentState.autoScroll && rows.itemCount != -1) {
LaunchedEffect(uiState.settings.autoScroll, rows.itemCount) {
if (uiState.settings.autoScroll && rows.itemCount != -1) {
lazyListState.animateScrollToItem(rows.itemCount)
}
}
@ -242,13 +237,13 @@ fun NetworkScreen(
onClick = { onAction(NetworkAction.ExportCsv) }
)
FloconDropdownMenuItem(
checked = uiState.contentState.autoScroll,
checked = uiState.settings.autoScroll,
text = "Auto scroll",
leadingIcon = Icons.Outlined.PlayCircle,
onCheckedChange = { onAction(NetworkAction.ToggleAutoScroll) }
onCheckedChange = { onAction(NetworkAction.ToggleAutoScroll(it)) }
)
FloconDropdownMenuItem(
checked = uiState.contentState.invertList,
checked = uiState.settings.invertList,
text = "Invert list",
leadingIcon = Icons.AutoMirrored.Outlined.List,
onCheckedChange = { onAction(NetworkAction.InvertList(it)) }
@ -287,7 +282,7 @@ fun NetworkScreen(
) {
LazyColumn(
state = lazyListState,
reverseLayout = uiState.contentState.invertList,
reverseLayout = uiState.settings.invertList,
modifier = Modifier
.fillMaxHeight()
.weight(1f)
@ -388,7 +383,7 @@ private fun selectPreviousRow(
rows.itemSnapshotList.indexOfFirst { it?.uuid == uiState.contentState.selectedRequestId }
.takeIf { it != -1 }
?.let { selectedIndex ->
val newIndex = if (uiState.contentState.invertList)
val newIndex = if (uiState.settings.invertList)
selectedIndex + 1
else
selectedIndex - 1
@ -404,7 +399,7 @@ private fun selectNextRow(
rows.itemSnapshotList.indexOfFirst { it?.uuid == uiState.contentState.selectedRequestId }
.takeIf { it != -1 }
?.let { selectedIndex ->
val newIndex = if (uiState.contentState.invertList)
val newIndex = if (uiState.settings.invertList)
selectedIndex - 1
else
selectedIndex + 1
@ -441,24 +436,3 @@ private fun NetworkScreenPreview() {
)
}
}
@Composable
fun TopBarCheckbox(
checked: Boolean,
label: String,
onCheckedChange: (Boolean) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
val color = FloconTheme.colorPalette.surface
FloconCheckbox(
checked = checked,
onCheckedChange = {},
uncheckedColor = color
)
Text(
label,
style = FloconTheme.typography.bodySmall,
color = color,
)
}
}