feat: graphql (#14)

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-07-31 23:09:40 +02:00 committed by GitHub
parent 7b502a21d0
commit cf1f96bc3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 2539 additions and 155 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "f2ab24c856bd7abdb58d0a171905f648",
"identityHash": "450793ab50e712166d71e2599a386ca2",
"entities": [
{
"tableName": "FloconHttpRequestEntity",
@ -860,11 +860,124 @@
"createSql": "CREATE INDEX IF NOT EXISTS `index_AnalyticsItemEntity_deviceId_analyticsTableId` ON `${TABLE_NAME}` (`deviceId`, `analyticsTableId`)"
}
]
},
{
"tableName": "GraphQlRequestEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `deviceId` TEXT NOT NULL, `url` TEXT NOT NULL, `durationMs` REAL NOT NULL, `request_startTime` INTEGER NOT NULL, `request_method` TEXT NOT NULL, `request_body` TEXT, `request_byteSize` INTEGER NOT NULL, `request_headers` TEXT NOT NULL, `response_httpCode` INTEGER NOT NULL, `response_contentType` TEXT, `response_body` TEXT, `response_byteSize` INTEGER NOT NULL, `response_headers` TEXT NOT NULL, PRIMARY KEY(`uuid`))",
"fields": [
{
"fieldPath": "uuid",
"columnName": "uuid",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "deviceId",
"columnName": "deviceId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "request.startTime",
"columnName": "request_startTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "request.method",
"columnName": "request_method",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "request.body",
"columnName": "request_body",
"affinity": "TEXT"
},
{
"fieldPath": "request.byteSize",
"columnName": "request_byteSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "request.headers",
"columnName": "request_headers",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "response.httpCode",
"columnName": "response_httpCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "response.contentType",
"columnName": "response_contentType",
"affinity": "TEXT"
},
{
"fieldPath": "response.body",
"columnName": "response_body",
"affinity": "TEXT"
},
{
"fieldPath": "response.byteSize",
"columnName": "response_byteSize",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "response.headers",
"columnName": "response_headers",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uuid"
]
},
"indices": [
{
"name": "index_GraphQlRequestEntity_deviceId",
"unique": false,
"columnNames": [
"deviceId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_GraphQlRequestEntity_deviceId` ON `${TABLE_NAME}` (`deviceId`)"
},
{
"name": "index_GraphQlRequestEntity_uuid",
"unique": true,
"columnNames": [
"uuid"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_GraphQlRequestEntity_uuid` ON `${TABLE_NAME}` (`uuid`)"
}
]
}
],
"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, 'f2ab24c856bd7abdb58d0a171905f648')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '450793ab50e712166d71e2599a386ca2')"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -18,6 +18,8 @@ import com.florent37.flocondesktop.features.deeplinks.data.datasource.room.Floco
import com.florent37.flocondesktop.features.deeplinks.data.datasource.room.model.DeeplinkEntity
import com.florent37.flocondesktop.features.files.data.datasources.FloconFileDao
import com.florent37.flocondesktop.features.files.data.datasources.model.FileEntity
import com.florent37.flocondesktop.features.graphql.data.datasource.room.GraphQlDao
import com.florent37.flocondesktop.features.graphql.data.datasource.room.model.GraphQlRequestEntity
import com.florent37.flocondesktop.features.grpc.data.datasource.room.GrpcDao
import com.florent37.flocondesktop.features.grpc.data.datasource.room.model.GrpcCallEntity
import com.florent37.flocondesktop.features.grpc.data.datasource.room.model.GrpcResponseEntity
@ -31,7 +33,7 @@ import com.florent37.flocondesktop.features.table.data.datasource.local.model.Ta
import kotlinx.coroutines.Dispatchers
@Database(
version = 17,
version = 18,
entities = [
FloconHttpRequestEntity::class,
FileEntity::class,
@ -46,6 +48,7 @@ import kotlinx.coroutines.Dispatchers
GrpcCallEntity::class,
GrpcResponseEntity::class,
AnalyticsItemEntity::class,
GraphQlRequestEntity::class,
],
)
@TypeConverters(
@ -62,6 +65,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract val deeplinkDao: FloconDeeplinkDao
abstract val grpcDao: GrpcDao
abstract val analyticsDao: FloconAnalyticsDao
abstract val graphQlDao: GraphQlDao
}
fun getRoomDatabase(): AppDatabase = getDatabaseBuilder()

View file

@ -34,4 +34,7 @@ val roomModule =
single {
get<AppDatabase>().analyticsDao
}
single {
get<AppDatabase>().graphQlDao
}
}

View file

@ -5,6 +5,7 @@ import com.florent37.flocondesktop.features.dashboard.di.dashboardModule
import com.florent37.flocondesktop.features.database.di.databaseModule
import com.florent37.flocondesktop.features.deeplinks.di.deeplinkModule
import com.florent37.flocondesktop.features.files.di.filesModule
import com.florent37.flocondesktop.features.graphql.di.graphqlModule
import com.florent37.flocondesktop.features.grpc.di.grpcModule
import com.florent37.flocondesktop.features.images.di.imagesModule
import com.florent37.flocondesktop.features.network.di.networkModule
@ -27,5 +28,6 @@ val featuresModule =
dashboardModule,
tableModule,
deeplinkModule,
graphqlModule,
)
}

View file

@ -0,0 +1,110 @@
package com.florent37.flocondesktop.features.graphql.data
import com.florent37.flocondesktop.DeviceId
import com.florent37.flocondesktop.FloconIncomingMessageDataModel
import com.florent37.flocondesktop.Protocol
import com.florent37.flocondesktop.common.coroutines.dispatcherprovider.DispatcherProvider
import com.florent37.flocondesktop.features.graphql.data.datasource.LocalGraphQlDataSource
import com.florent37.flocondesktop.features.graphql.data.model.FloconGraphQlRequestDataModel
import com.florent37.flocondesktop.features.graphql.domain.model.FloconGraphQlRequestInfos
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestId
import com.florent37.flocondesktop.features.graphql.domain.repository.GraphQlRepository
import com.florent37.flocondesktop.messages.domain.repository.sub.MessagesReceiverRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class GraphQlRepositoryImpl(
private val dispatcherProvider: DispatcherProvider,
private val localGraphQlDataSource: LocalGraphQlDataSource,
) : GraphQlRepository,
MessagesReceiverRepository {
private val graphQlParser =
Json {
ignoreUnknownKeys = true
}
override val pluginName = listOf(Protocol.FromDevice.Graphql.Plugin)
override suspend fun onMessageReceived(
deviceId: String,
message: FloconIncomingMessageDataModel,
) {
withContext(dispatcherProvider.data) {
when (message.method) {
Protocol.FromDevice.Graphql.Method.LogNetworkCall -> decode(message)?.let {
toDomain(it)
}?.let {
localGraphQlDataSource.insert(
deviceId = deviceId,
request = it,
)
}
}
}
}
private fun decode(message: FloconIncomingMessageDataModel): FloconGraphQlRequestDataModel? = try {
graphQlParser.decodeFromString<FloconGraphQlRequestDataModel>(message.body)
} catch (t: Throwable) {
t.printStackTrace()
null
}
override fun observeRequests(deviceId: DeviceId): Flow<List<GraphQlRequestDomainModel>> = localGraphQlDataSource.observeRequests(
deviceId = deviceId,
).flowOn(dispatcherProvider.data)
override fun observeRequest(
currentDeviceId: DeviceId,
requestId: GraphQlRequestId,
): Flow<GraphQlRequestDomainModel?> = localGraphQlDataSource.observeRequest(
deviceId = currentDeviceId,
requestId = requestId,
).flowOn(dispatcherProvider.data)
override suspend fun deleteRequest(deviceId: DeviceId, requestId: GraphQlRequestId) = withContext(dispatcherProvider.data) {
localGraphQlDataSource.deleteRequest(deviceId = deviceId, requestId = requestId)
}
override suspend fun deleteRequestsBefore(deviceId: DeviceId, requestId: GraphQlRequestId) = withContext(dispatcherProvider.data) {
localGraphQlDataSource.deleteRequestsBefore(deviceId = deviceId, requestId = requestId)
}
override suspend fun deleteRequestsForDevice(deviceId: DeviceId) = withContext(dispatcherProvider.data) {
localGraphQlDataSource.clearDeviceCalls(deviceId = deviceId)
}
@OptIn(ExperimentalUuidApi::class)
fun toDomain(decoded: FloconGraphQlRequestDataModel): GraphQlRequestDomainModel? = try {
GraphQlRequestDomainModel(
uuid = Uuid.random().toString(),
infos = FloconGraphQlRequestInfos(
url = decoded.url!!,
durationMs = decoded.durationMs!!,
request = FloconGraphQlRequestInfos.Request(
method = decoded.method!!,
startTime = decoded.startTime!!,
headers = decoded.requestHeaders!!,
body = decoded.requestBody,
byteSize = decoded.requestSize ?: 0L,
),
response = FloconGraphQlRequestInfos.Response(
httpCode = decoded.responseHttpCode!!,
contentType = decoded.responseContentType,
body = decoded.responseBody,
headers = decoded.responseHeaders!!,
byteSize = decoded.responseSize ?: 0L,
),
),
)
} catch (t: Throwable) {
t.printStackTrace()
null
}
}

View file

@ -0,0 +1,22 @@
package com.florent37.flocondesktop.features.graphql.data.datasource
import com.florent37.flocondesktop.DeviceId
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestId
import kotlinx.coroutines.flow.Flow
interface LocalGraphQlDataSource {
suspend fun insert(
deviceId: DeviceId,
request: GraphQlRequestDomainModel,
)
fun observeRequests(deviceId: DeviceId): Flow<List<GraphQlRequestDomainModel>>
fun observeRequest(deviceId: DeviceId, requestId: GraphQlRequestId): Flow<GraphQlRequestDomainModel?>
suspend fun clearDeviceCalls(deviceId: DeviceId)
suspend fun deleteRequest(deviceId: DeviceId, requestId: GraphQlRequestId)
suspend fun deleteRequestsBefore(deviceId: DeviceId, requestId: GraphQlRequestId)
suspend fun clear()
}

View file

@ -0,0 +1,69 @@
package com.florent37.flocondesktop.features.graphql.data.datasource.room
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import com.florent37.flocondesktop.features.graphql.data.datasource.room.model.GraphQlRequestEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface GraphQlDao {
@Insert
suspend fun insertGraphQlCall(call: GraphQlRequestEntity)
@Transaction
@Query(
"""
SELECT * FROM GraphQlRequestEntity
WHERE deviceId = :deviceId
ORDER BY request_startTime ASC
""",
)
fun observeRequests(deviceId: String): Flow<List<GraphQlRequestEntity>>
@Transaction
@Query(
"""
SELECT * FROM GraphQlRequestEntity
WHERE deviceId = :deviceId
AND uuid = :requestId
LIMIT 1
""",
)
fun observeRequest(deviceId: String, requestId: String): Flow<GraphQlRequestEntity?>
@Query("DELETE FROM GraphQlRequestEntity WHERE deviceId = :deviceId")
suspend fun clearDeviceRequests(deviceId: String)
@Query("DELETE FROM GraphQlRequestEntity WHERE uuid = :requestId")
suspend fun deleteRequestById(requestId: String)
@Transaction
suspend fun deleteRequestsBeforeTimestamp(deviceId: String, timestamp: Long) {
// First, get IDs of requests to be deleted
val idsToDelete = getRequestsIdsBeforeTimestamp(deviceId, timestamp)
// Delete associated headers and responses
for (callId in idsToDelete) {
deleteRequestById(callId)
}
}
@Query("SELECT uuid FROM GraphQlRequestEntity WHERE deviceId = :deviceId AND request_startTime < :timestamp")
suspend fun getRequestsIdsBeforeTimestamp(deviceId: String, timestamp: Long): List<String>
@Query("DELETE FROM GraphQlRequestEntity")
suspend fun clearAllData()
@Query(
"""
SELECT request_startTime
FROM GraphQlRequestEntity
WHERE deviceId = :deviceId
AND uuid = :requestId
LIMIT 1
""",
)
suspend fun getRequestTimestamp(deviceId: String, requestId: String): Long?
}

View file

@ -0,0 +1,50 @@
package com.florent37.flocondesktop.features.graphql.data.datasource.room
import com.florent37.flocondesktop.DeviceId
import com.florent37.flocondesktop.features.graphql.data.datasource.LocalGraphQlDataSource
import com.florent37.flocondesktop.features.graphql.data.datasource.room.mapper.toDomainModel
import com.florent37.flocondesktop.features.graphql.data.datasource.room.mapper.toEntity
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class LocalGraphQlDataSourceImpl(
private val graphQlDao: GraphQlDao,
) : LocalGraphQlDataSource {
override suspend fun insert(
deviceId: String,
request: GraphQlRequestDomainModel,
) {
graphQlDao.insertGraphQlCall(request.toEntity(deviceId = deviceId))
}
override fun observeRequests(deviceId: String): Flow<List<GraphQlRequestDomainModel>> = graphQlDao.observeRequests(deviceId = deviceId).map { entities ->
entities.map { it.toDomainModel() }
}
override fun observeRequest(deviceId: DeviceId, requestId: GraphQlRequestId) = graphQlDao.observeRequest(deviceId = deviceId, requestId = requestId).map {
it?.toDomainModel()
}
override suspend fun clearDeviceCalls(deviceId: String) {
graphQlDao.clearDeviceRequests(deviceId)
}
override suspend fun deleteRequest(deviceId: String, requestId: GraphQlRequestId) {
graphQlDao.deleteRequestById(requestId)
}
override suspend fun deleteRequestsBefore(deviceId: String, requestId: GraphQlRequestId) {
val timestamp = graphQlDao.getRequestTimestamp(deviceId = deviceId, requestId = requestId) ?: return
graphQlDao.deleteRequestsBeforeTimestamp(
deviceId = deviceId,
timestamp = timestamp,
)
}
override suspend fun clear() {
graphQlDao.clearAllData()
}
}

View file

@ -0,0 +1,56 @@
package com.florent37.flocondesktop.features.graphql.data.datasource.room.mapper
import com.florent37.flocondesktop.features.graphql.data.datasource.room.model.GraphQlRequestEntity
import com.florent37.flocondesktop.features.graphql.domain.model.FloconGraphQlRequestInfos
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
fun GraphQlRequestDomainModel.toEntity(deviceId: String): GraphQlRequestEntity = GraphQlRequestEntity(
uuid = this.uuid,
request = with(this.infos.request) {
GraphQlRequestEntity.Request(
method = this.method,
startTime = this.startTime,
headers = this.headers,
body = this.body,
byteSize = this.byteSize,
)
},
response = with(this.infos.response) {
GraphQlRequestEntity.Response(
httpCode = this.httpCode,
contentType = this.contentType,
body = this.body,
headers = this.headers,
byteSize = this.byteSize,
)
},
deviceId = deviceId,
url = this.infos.url,
durationMs = this.infos.durationMs,
)
fun GraphQlRequestEntity.toDomainModel(): GraphQlRequestDomainModel = GraphQlRequestDomainModel(
uuid = this.uuid,
infos = FloconGraphQlRequestInfos(
url = this.url,
durationMs = this.durationMs,
request = with(this.request) {
FloconGraphQlRequestInfos.Request(
method = this.method,
startTime = this.startTime,
headers = headers,
body = body,
byteSize = byteSize,
)
},
response = with(this.response) {
FloconGraphQlRequestInfos.Response(
httpCode = httpCode,
contentType = contentType,
body = body,
headers = headers,
byteSize = byteSize,
)
},
),
)

View file

@ -0,0 +1,37 @@
package com.florent37.flocondesktop.features.graphql.data.datasource.room.model
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
indices = [
Index(value = ["deviceId"]),
Index(value = ["uuid"], unique = true),
],
)
data class GraphQlRequestEntity(
@PrimaryKey val uuid: String,
val deviceId: String,
val url: String,
val durationMs: Double,
@Embedded(prefix = "request_") val request: Request,
@Embedded(prefix = "response_") val response: Response,
) {
data class Request(
val startTime: Long,
val method: String,
val body: String?,
val byteSize: Long,
val headers: Map<String, String>,
)
data class Response(
val httpCode: Int,
val contentType: String?,
val body: String?,
val byteSize: Long,
val headers: Map<String, String>,
)
}

View file

@ -0,0 +1,22 @@
package com.florent37.flocondesktop.features.graphql.data.di
import com.florent37.flocondesktop.features.graphql.data.GraphQlRepositoryImpl
import com.florent37.flocondesktop.features.graphql.data.datasource.LocalGraphQlDataSource
import com.florent37.flocondesktop.features.graphql.data.datasource.room.LocalGraphQlDataSourceImpl
import com.florent37.flocondesktop.features.graphql.domain.repository.GraphQlRepository
import com.florent37.flocondesktop.messages.domain.repository.sub.MessagesReceiverRepository
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val graphQlDataModule =
module {
factoryOf(::GraphQlRepositoryImpl) {
bind<GraphQlRepository>()
bind<MessagesReceiverRepository>()
}
singleOf(::LocalGraphQlDataSourceImpl) {
bind<LocalGraphQlDataSource>()
}
}

View file

@ -0,0 +1,50 @@
package com.florent37.flocondesktop.features.graphql.data.mapper
import com.florent37.flocondesktop.features.grpc.data.model.GrpcRequestDomainModelWrapper
import com.florent37.flocondesktop.features.grpc.data.model.GrpcResponseDomainModelWrapper
import com.florent37.flocondesktop.features.grpc.data.model.fromdevice.GrpcHeaderDataModel
import com.florent37.flocondesktop.features.grpc.data.model.fromdevice.GrpcRequestDataModel
import com.florent37.flocondesktop.features.grpc.data.model.fromdevice.GrpcResponseDataModel
import com.florent37.flocondesktop.features.grpc.domain.model.GrpcHeaderDomainModel
import com.florent37.flocondesktop.features.grpc.domain.model.GrpcRequestDomainModel
import com.florent37.flocondesktop.features.grpc.domain.model.GrpcResponseDomainModel
fun toDomain(dataModel: GrpcRequestDataModel): GrpcRequestDomainModelWrapper? = GrpcRequestDomainModelWrapper(
callId = dataModel.id,
request = GrpcRequestDomainModel(
timestamp = dataModel.timestamp,
authority = dataModel.authority,
method = dataModel.method,
headers = dataModel.headers.map {
toDomain(it)
},
data = dataModel.data,
),
)
fun toDomain(dataModel: GrpcResponseDataModel): GrpcResponseDomainModelWrapper? {
return GrpcResponseDomainModelWrapper(
callId = dataModel.id,
response = GrpcResponseDomainModel(
timestamp = dataModel.timestamp,
status = dataModel.status,
headers = dataModel.headers.map {
toDomain(it)
},
result = when {
dataModel.data != null -> GrpcResponseDomainModel.CallResult.Success(
data = dataModel.data,
)
dataModel.cause != null -> GrpcResponseDomainModel.CallResult.Error(
cause = dataModel.cause,
)
else -> return null
},
),
)
}
private fun toDomain(model: GrpcHeaderDataModel): GrpcHeaderDomainModel = GrpcHeaderDomainModel(
key = model.key,
value = model.value,
)

View file

@ -0,0 +1,21 @@
package com.florent37.flocondesktop.features.graphql.data.model
import kotlinx.serialization.Serializable
@Serializable
data class FloconGraphQlRequestDataModel(
val url: String? = null,
val method: String? = null,
val startTime: Long? = null,
val durationMs: Double? = null,
// request
val requestHeaders: Map<String, String>? = null,
val requestBody: String? = null,
val requestSize: Long? = null,
// response
val responseHttpCode: Int? = null, // ex: 200
val responseContentType: String? = null,
val responseBody: String? = null,
val responseHeaders: Map<String, String>? = null,
val responseSize: Long? = null,
)

View file

@ -0,0 +1,15 @@
package com.florent37.flocondesktop.features.graphql.di
import com.florent37.flocondesktop.features.graphql.data.di.graphQlDataModule
import com.florent37.flocondesktop.features.graphql.domain.di.graphQlDomainModule
import com.florent37.flocondesktop.features.graphql.ui.di.graphQlUiModule
import org.koin.dsl.module
val graphqlModule =
module {
includes(
graphQlDataModule,
graphQlDomainModule,
graphQlUiModule,
)
}

View file

@ -0,0 +1,15 @@
package com.florent37.flocondesktop.features.graphql.domain
import com.florent37.flocondesktop.core.domain.device.GetCurrentDeviceIdUseCase
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestId
import com.florent37.flocondesktop.features.graphql.domain.repository.GraphQlRepository
class DeleteGraphQlRequestUseCase(
private val graphQlRepository: GraphQlRepository,
private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase,
) {
suspend operator fun invoke(requestId: GraphQlRequestId) {
val deviceId = getCurrentDeviceIdUseCase() ?: return
graphQlRepository.deleteRequest(deviceId, requestId)
}
}

View file

@ -0,0 +1,15 @@
package com.florent37.flocondesktop.features.graphql.domain
import com.florent37.flocondesktop.core.domain.device.GetCurrentDeviceIdUseCase
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestId
import com.florent37.flocondesktop.features.graphql.domain.repository.GraphQlRepository
class DeleteGraphQlRequestsBeforeUseCase(
private val graphQlRepository: GraphQlRepository,
private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase,
) {
suspend operator fun invoke(requestId: GraphQlRequestId) {
val deviceId = getCurrentDeviceIdUseCase() ?: return
graphQlRepository.deleteRequestsBefore(deviceId = deviceId, requestId = requestId)
}
}

View file

@ -0,0 +1,22 @@
package com.florent37.flocondesktop.features.graphql.domain
import com.florent37.flocondesktop.core.domain.device.ObserveCurrentDeviceIdUseCase
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestId
import com.florent37.flocondesktop.features.graphql.domain.repository.GraphQlRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
class ObserveGraphQlRequestsByIdUseCase(
private val graphQlRepository: GraphQlRepository,
private val observeCurrentDeviceIdUseCase: ObserveCurrentDeviceIdUseCase,
) {
operator fun invoke(callId: GraphQlRequestId): Flow<GraphQlRequestDomainModel?> = observeCurrentDeviceIdUseCase().flatMapLatest { currentDeviceId ->
if (currentDeviceId == null) {
flowOf(null)
} else {
graphQlRepository.observeRequest(currentDeviceId, requestId = callId)
}
}
}

View file

@ -0,0 +1,21 @@
package com.florent37.flocondesktop.features.graphql.domain
import com.florent37.flocondesktop.core.domain.device.ObserveCurrentDeviceIdUseCase
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
import com.florent37.flocondesktop.features.graphql.domain.repository.GraphQlRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
class ObserveGraphQlRequestsUseCase(
private val graphQlRepository: GraphQlRepository,
private val observeCurrentDeviceIdUseCase: ObserveCurrentDeviceIdUseCase,
) {
operator fun invoke(): Flow<List<GraphQlRequestDomainModel>> = observeCurrentDeviceIdUseCase().flatMapLatest { currentDeviceId ->
if (currentDeviceId == null) {
flowOf(emptyList())
} else {
graphQlRepository.observeRequests(currentDeviceId)
}
}
}

View file

@ -0,0 +1,14 @@
package com.florent37.flocondesktop.features.graphql.domain
import com.florent37.flocondesktop.core.domain.device.GetCurrentDeviceIdUseCase
import com.florent37.flocondesktop.features.graphql.domain.repository.GraphQlRepository
class ResetCurrentDeviceGraphQlRequestsUseCase(
private val graphQlRepository: GraphQlRepository,
private val getCurrentDeviceIdUseCase: GetCurrentDeviceIdUseCase,
) {
suspend operator fun invoke() {
val deviceId = getCurrentDeviceIdUseCase() ?: return
graphQlRepository.deleteRequestsForDevice(deviceId = deviceId)
}
}

View file

@ -0,0 +1,18 @@
package com.florent37.flocondesktop.features.graphql.domain.di
import com.florent37.flocondesktop.features.graphql.domain.DeleteGraphQlRequestUseCase
import com.florent37.flocondesktop.features.graphql.domain.DeleteGraphQlRequestsBeforeUseCase
import com.florent37.flocondesktop.features.graphql.domain.ObserveGraphQlRequestsByIdUseCase
import com.florent37.flocondesktop.features.graphql.domain.ObserveGraphQlRequestsUseCase
import com.florent37.flocondesktop.features.graphql.domain.ResetCurrentDeviceGraphQlRequestsUseCase
import org.koin.core.module.dsl.factoryOf
import org.koin.dsl.module
val graphQlDomainModule =
module {
factoryOf(::ObserveGraphQlRequestsUseCase)
factoryOf(::ObserveGraphQlRequestsByIdUseCase)
factoryOf(::DeleteGraphQlRequestUseCase)
factoryOf(::DeleteGraphQlRequestsBeforeUseCase)
factoryOf(::ResetCurrentDeviceGraphQlRequestsUseCase)
}

View file

@ -0,0 +1,24 @@
package com.florent37.flocondesktop.features.graphql.domain.model
data class FloconGraphQlRequestInfos(
val url: String,
val durationMs: Double,
val request: Request,
val response: Response,
) {
data class Request(
val method: String,
val startTime: Long,
val headers: Map<String, String>,
val body: String?,
val byteSize: Long,
)
data class Response(
val httpCode: Int, // ex: 200
val contentType: String? = null,
val body: String? = null,
val headers: Map<String, String>,
val byteSize: Long,
)
}

View file

@ -0,0 +1,6 @@
package com.florent37.flocondesktop.features.graphql.domain.model
data class GraphQlHeaderDomainModel(
val key: String,
val value: String,
)

View file

@ -0,0 +1,6 @@
package com.florent37.flocondesktop.features.graphql.domain.model
data class GraphQlRequestDomainModel(
val uuid: String,
val infos: FloconGraphQlRequestInfos,
)

View file

@ -0,0 +1,3 @@
package com.florent37.flocondesktop.features.graphql.domain.model
typealias GraphQlRequestId = String

View file

@ -0,0 +1,15 @@
package com.florent37.flocondesktop.features.graphql.domain.repository
import com.florent37.flocondesktop.DeviceId
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestId
import kotlinx.coroutines.flow.Flow
interface GraphQlRepository {
fun observeRequests(deviceId: DeviceId): Flow<List<GraphQlRequestDomainModel>>
fun observeRequest(currentDeviceId: DeviceId, requestId: GraphQlRequestId): Flow<GraphQlRequestDomainModel?>
suspend fun deleteRequest(deviceId: DeviceId, requestId: GraphQlRequestId)
suspend fun deleteRequestsBefore(deviceId: DeviceId, requestId: GraphQlRequestId)
suspend fun deleteRequestsForDevice(deviceId: DeviceId)
}

View file

@ -0,0 +1,116 @@
package com.florent37.flocondesktop.features.graphql.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.florent37.flocondesktop.common.coroutines.dispatcherprovider.DispatcherProvider
import com.florent37.flocondesktop.common.ui.feedback.FeedbackDisplayer
import com.florent37.flocondesktop.copyToClipboard
import com.florent37.flocondesktop.features.graphql.domain.DeleteGraphQlRequestUseCase
import com.florent37.flocondesktop.features.graphql.domain.DeleteGraphQlRequestsBeforeUseCase
import com.florent37.flocondesktop.features.graphql.domain.ObserveGraphQlRequestsByIdUseCase
import com.florent37.flocondesktop.features.graphql.domain.ObserveGraphQlRequestsUseCase
import com.florent37.flocondesktop.features.graphql.domain.ResetCurrentDeviceGraphQlRequestsUseCase
import com.florent37.flocondesktop.features.graphql.ui.mapper.toDetailUi
import com.florent37.flocondesktop.features.graphql.ui.mapper.toUi
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlDetailViewState
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlItemViewState
import com.florent37.flocondesktop.features.graphql.ui.model.OnGraphQlItemUserAction
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class GraphQlViewModel(
observeGraphQlRequestsUseCase: ObserveGraphQlRequestsUseCase,
private val observeGraphQlRequestsByIdUseCase: ObserveGraphQlRequestsByIdUseCase,
private val resetCurrentDeviceGraphQlRequestsUseCase: ResetCurrentDeviceGraphQlRequestsUseCase,
private val removeGraphQlRequestsBeforeUseCase: DeleteGraphQlRequestsBeforeUseCase,
private val removeGraphQlRequestUseCase: DeleteGraphQlRequestUseCase,
private val dispatcherProvider: DispatcherProvider,
private val feedbackDisplayer: FeedbackDisplayer,
) : ViewModel() {
val state: StateFlow<List<GraphQlItemViewState>> =
observeGraphQlRequestsUseCase()
.map { list -> list.map { toUi(it) } }
.flowOn(dispatcherProvider.viewModel)
.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(5_000), emptyList())
private val clickedRequestId = MutableStateFlow<String?>(null)
val detailState: StateFlow<GraphQlDetailViewState?> =
clickedRequestId
.flatMapLatest { id ->
if (id == null) {
flowOf(null)
} else {
observeGraphQlRequestsByIdUseCase(id)
.distinctUntilChanged()
.map {
it?.let {
toDetailUi(it)
}
}
}
}
.flowOn(dispatcherProvider.viewModel)
.stateIn(viewModelScope, started = SharingStarted.WhileSubscribed(5_000), null)
fun onGraphQlItemUserAction(action: OnGraphQlItemUserAction) {
viewModelScope.launch(dispatcherProvider.viewModel) {
when (action) {
is OnGraphQlItemUserAction.CopyUrl -> {
val domainModel =
observeGraphQlRequestsByIdUseCase(action.item.uuid).firstOrNull()
?: return@launch
copyToClipboard(domainModel.infos.url)
}
is OnGraphQlItemUserAction.OnClicked -> {
clickedRequestId.update {
if (it == action.item.uuid) {
null
} else {
action.item.uuid
}
}
}
is OnGraphQlItemUserAction.Remove -> {
removeGraphQlRequestUseCase(requestId = action.item.uuid)
}
is OnGraphQlItemUserAction.RemoveLinesAbove -> {
removeGraphQlRequestsBeforeUseCase(requestId = action.item.uuid)
}
}
}
}
fun onCopyText(text: String) {
viewModelScope.launch(dispatcherProvider.viewModel) {
copyToClipboard(text)
feedbackDisplayer.displayMessage("copied")
}
}
fun closeDetailPanel() {
viewModelScope.launch(dispatcherProvider.viewModel) {
clickedRequestId.update { null }
}
}
fun onReset() {
viewModelScope.launch(dispatcherProvider.viewModel) {
resetCurrentDeviceGraphQlRequestsUseCase()
}
}
}

View file

@ -0,0 +1,10 @@
package com.florent37.flocondesktop.features.graphql.ui.di
import com.florent37.flocondesktop.features.graphql.ui.GraphQlViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val graphQlUiModule =
module {
viewModelOf(::GraphQlViewModel)
}

View file

@ -0,0 +1,37 @@
package com.florent37.flocondesktop.features.graphql.ui.mapper
import com.florent37.flocondesktop.common.ui.ByteFormatter
import com.florent37.flocondesktop.common.ui.JsonPrettyPrinter
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlDetailHeaderUi
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlDetailViewState
import com.florent37.flocondesktop.features.network.ui.mapper.formatDuration
import com.florent37.flocondesktop.features.network.ui.mapper.formatTimestamp
fun toDetailUi(request: GraphQlRequestDomainModel): GraphQlDetailViewState = GraphQlDetailViewState(
fullUrl = request.infos.url,
method = toMethodUi(request.infos.request.method),
status = toGraphQlStatusUi(request.infos.response.httpCode),
requestTimeFormatted = request.infos.request.startTime.let { formatTimestamp(it) },
durationFormatted = formatDuration(request.infos.durationMs),
// request
requestBody = httpBodyToUi(request.infos.request.body),
requestHeaders = toGraphQlHeadersUi(request.infos.request.headers),
requestSize = ByteFormatter.formatBytes(request.infos.request.byteSize),
// response
responseBody = httpBodyToUi(request.infos.response.body),
responseHeaders = toGraphQlHeadersUi(request.infos.response.headers),
responseSize = ByteFormatter.formatBytes(request.infos.response.byteSize),
)
fun httpBodyToUi(body: String?): String = body?.let { JsonPrettyPrinter.prettyPrint(body) } ?: ""
fun toGraphQlHeadersUi(headers: Map<String, String>?): List<GraphQlDetailHeaderUi> = headers?.let {
it
.map { (key, value) ->
GraphQlDetailHeaderUi(
name = key,
value = value,
)
}.sortedBy { it.name }
} ?: emptyList()

View file

@ -0,0 +1,33 @@
package com.florent37.flocondesktop.features.graphql.ui.mapper
import com.florent37.flocondesktop.common.ui.ByteFormatter
import com.florent37.flocondesktop.features.graphql.domain.model.GraphQlRequestDomainModel
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlItemViewState
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlMethodUi
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlStatusUi
import com.florent37.flocondesktop.features.network.ui.mapper.formatDuration
import com.florent37.flocondesktop.features.network.ui.mapper.formatTimestamp
fun listToUi(httpRequests: List<GraphQlRequestDomainModel>): List<GraphQlItemViewState> = httpRequests.map { toUi(it) }
fun toUi(httpRequest: GraphQlRequestDomainModel): GraphQlItemViewState = GraphQlItemViewState(
uuid = httpRequest.uuid,
dateFormatted = formatTimestamp(httpRequest.infos.request.startTime),
method = toMethodUi(httpRequest.infos.request.method),
graphQlStatusUi = toGraphQlStatusUi(code = 200),
route = httpRequest.infos.url,
timeFormatted = formatDuration(httpRequest.infos.durationMs),
requestSize = ByteFormatter.formatBytes(httpRequest.infos.request.byteSize),
responseSize = ByteFormatter.formatBytes(httpRequest.infos.response.byteSize),
)
fun toGraphQlStatusUi(code: Int): GraphQlStatusUi = GraphQlStatusUi(
code = code,
isSuccess = code >= 200 && code < 300,
)
fun toMethodUi(httpMethod: String): GraphQlMethodUi = when (httpMethod.lowercase()) {
"get" -> GraphQlMethodUi.GET
"post" -> GraphQlMethodUi.POST
else -> GraphQlMethodUi.Other(httpMethod)
}

View file

@ -0,0 +1,14 @@
package com.florent37.flocondesktop.features.graphql.ui.model
import androidx.compose.runtime.Immutable
@Immutable
data class GraphQlDetailHeaderUi(
val name: String,
val value: String,
)
fun previewGraphQlDetailHeaderUi() = GraphQlDetailHeaderUi(
name = "name",
value = "value",
)

View file

@ -0,0 +1,20 @@
package com.florent37.flocondesktop.features.graphql.ui.model
import androidx.compose.runtime.Immutable
@Immutable
data class GraphQlDetailViewState(
val fullUrl: String,
val requestTimeFormatted: String,
val durationFormatted: String,
val method: GraphQlMethodUi,
val status: GraphQlStatusUi,
// request
val requestBody: String,
val requestSize: String,
val requestHeaders: List<GraphQlDetailHeaderUi>,
// response
val responseBody: String,
val responseSize: String,
val responseHeaders: List<GraphQlDetailHeaderUi>,
)

View file

@ -0,0 +1,26 @@
package com.florent37.flocondesktop.features.graphql.ui.model
import androidx.compose.runtime.Immutable
@Immutable
data class GraphQlItemViewState(
val uuid: String,
val dateFormatted: String,
val route: String,
val method: GraphQlMethodUi,
val graphQlStatusUi: GraphQlStatusUi,
val requestSize: String,
val responseSize: String,
val timeFormatted: String,
)
fun previewGraphQlItemViewState(): GraphQlItemViewState = GraphQlItemViewState(
uuid = "0",
dateFormatted = "00:00:00.0000",
route = "www.google.com.test",
method = GraphQlMethodUi.GET,
graphQlStatusUi = GraphQlStatusUi(200, true),
requestSize = "10.kb",
responseSize = "0.B",
timeFormatted = "333ms",
)

View file

@ -0,0 +1,17 @@
package com.florent37.flocondesktop.features.graphql.ui.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class GraphQlMethodUi(
open val text: String,
) {
@Immutable
data object GET : GraphQlMethodUi(text = "GET")
@Immutable
data object POST : GraphQlMethodUi(text = "POST")
@Immutable
data class Other(override val text: String) : GraphQlMethodUi(text = "POST")
}

View file

@ -0,0 +1,9 @@
package com.florent37.flocondesktop.features.graphql.ui.model
import androidx.compose.runtime.Immutable
@Immutable
data class GraphQlStatusUi(
val code: Int,
val isSuccess: Boolean,
)

View file

@ -0,0 +1,19 @@
package com.florent37.flocondesktop.features.graphql.ui.model
sealed interface OnGraphQlItemUserAction {
data class OnClicked(
val item: GraphQlItemViewState,
) : OnGraphQlItemUserAction
data class CopyUrl(
val item: GraphQlItemViewState,
) : OnGraphQlItemUserAction
data class Remove(
val item: GraphQlItemViewState,
) : OnGraphQlItemUserAction
data class RemoveLinesAbove(
val item: GraphQlItemViewState,
) : OnGraphQlItemUserAction
}

View file

@ -0,0 +1,284 @@
package com.florent37.flocondesktop.features.graphql.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.florent37.flocondesktop.common.ui.FloconColors
import com.florent37.flocondesktop.common.ui.FloconTheme
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlDetailViewState
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlMethodUi
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlStatusUi
import com.florent37.flocondesktop.features.graphql.ui.model.previewGraphQlDetailHeaderUi
import com.florent37.flocondesktop.features.graphql.ui.view.components.DetailHeadersView
import com.florent37.flocondesktop.features.graphql.ui.view.components.MethodView
import com.florent37.flocondesktop.features.graphql.ui.view.components.StatusView
import com.florent37.flocondesktop.features.network.ui.view.detail.CodeBlockView
import com.florent37.flocondesktop.features.network.ui.view.detail.DetailLineTextView
import com.florent37.flocondesktop.features.network.ui.view.detail.DetailLineView
import com.florent37.flocondesktop.features.network.ui.view.detail.DetailSectionTitleView
import com.florent37.flocondesktop.features.network.ui.view.detail.ExpandedSectionView
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun GraphQlDetailView(
state: GraphQlDetailViewState,
onCopy: (String) -> Unit, // Le lambda onCopy remplace ClipboardManager
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
var isRequestExpanded by remember { mutableStateOf(true) }
var isRequestBodyExpanded by remember { mutableStateOf(true) }
var isRequestHeadersExpanded by remember { mutableStateOf(true) }
var isResponseExpanded by remember { mutableStateOf(true) }
var isResponseHeadersExpanded by remember { mutableStateOf(true) }
var isResponseBodyExpanded by remember { mutableStateOf(true) }
val linesLabelWidth: Dp = 130.dp
val headersLabelWidth: Dp = 150.dp
Column(
modifier =
modifier
.background(FloconColors.background)
.verticalScroll(scrollState) // Rendre le contenu défilable
.padding(all = 12.dp),
) {
DetailSectionTitleView(
isExpanded = isRequestExpanded,
title = "Request",
onCopy = null,
onToggle = {
isRequestExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isRequestExpanded,
) {
Column(
modifier =
Modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
DetailLineTextView(
modifier = Modifier.fillMaxWidth(),
label = "Full url",
value = state.fullUrl,
labelWidth = linesLabelWidth,
)
DetailLineView(
modifier = Modifier.fillMaxWidth(),
label = "Method",
labelWidth = linesLabelWidth,
) {
MethodView(method = state.method)
}
DetailLineView(
modifier = Modifier.fillMaxWidth(),
label = "Status",
labelWidth = linesLabelWidth,
) {
StatusView(status = state.status)
}
DetailLineTextView(
modifier = Modifier.fillMaxWidth(),
label = "Request Time",
value = state.requestTimeFormatted,
labelWidth = linesLabelWidth,
)
DetailLineTextView(
modifier = Modifier.fillMaxWidth(),
label = "Time",
value = state.durationFormatted,
labelWidth = linesLabelWidth,
)
}
// headers
DetailSectionTitleView(
isExpanded = isRequestHeadersExpanded,
title = "Request Headers",
onCopy = null,
onToggle = {
isRequestHeadersExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isRequestHeadersExpanded,
) {
DetailHeadersView(
headers = state.requestHeaders,
modifier = Modifier.fillMaxWidth(),
labelWidth = headersLabelWidth,
)
}
// body
DetailSectionTitleView(
isExpanded = isRequestBodyExpanded,
title = "Request Body",
onCopy = {
onCopy(state.requestBody)
},
onToggle = {
isRequestBodyExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isRequestBodyExpanded,
) {
CodeBlockView(
code = state.requestBody,
modifier = Modifier.fillMaxWidth(),
)
}
}
HorizontalDivider(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 12.dp)
.padding(vertical = 12.dp),
)
DetailSectionTitleView(
isExpanded = isResponseExpanded,
title = "Response",
onCopy = null,
onToggle = {
isResponseExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isResponseExpanded,
) {
// headers
DetailSectionTitleView(
isExpanded = isResponseHeadersExpanded,
title = "Response Headers",
onCopy = null,
onToggle = {
isResponseHeadersExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isResponseHeadersExpanded,
) {
DetailHeadersView(
headers = state.responseHeaders,
modifier = Modifier.fillMaxWidth(),
labelWidth = headersLabelWidth,
)
}
// body
DetailSectionTitleView(
isExpanded = isResponseBodyExpanded,
title = "Response Body",
onCopy = {
onCopy(state.responseBody)
},
onToggle = {
isResponseBodyExpanded = it
},
)
ExpandedSectionView(
modifier = Modifier.fillMaxWidth(),
isExpanded = isResponseBodyExpanded,
) {
CodeBlockView(
code = state.responseBody,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@Preview
@Composable
private fun GraphQlDetailViewPreview() {
FloconTheme {
GraphQlDetailView(
state =
GraphQlDetailViewState(
fullUrl = "http://www.google.com",
method = GraphQlMethodUi.GET,
status =
GraphQlStatusUi(
code = 200,
isSuccess = true,
),
requestHeaders =
listOf(
previewGraphQlDetailHeaderUi(),
previewGraphQlDetailHeaderUi(),
previewGraphQlDetailHeaderUi(),
),
requestBody =
"""
{
"id": "123",
"name": "Flocon App",
"version": "1.0.0",
"data": {
"items": [
{"key": "value1"},
{"key": "value2"}
]
}
}
""".trimIndent(),
responseHeaders =
listOf(
previewGraphQlDetailHeaderUi(),
previewGraphQlDetailHeaderUi(),
previewGraphQlDetailHeaderUi(),
previewGraphQlDetailHeaderUi(),
previewGraphQlDetailHeaderUi(),
),
requestTimeFormatted = "00:00:00.000",
durationFormatted = "300ms",
responseBody =
"""
{
"graphQlStatusUi": "success",
"message": "Data received and processed.",
"result": {
"timestamp": "2025-07-05T23:59:00Z",
"processed_count": 2
}
}
""".trimIndent(),
requestSize = "0kb",
responseSize = "0kb",
),
modifier = Modifier.padding(16.dp), // Padding pour la preview
onCopy = { },
)
}
}

View file

@ -0,0 +1,179 @@
package com.florent37.flocondesktop.features.graphql.ui.view
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.florent37.flocondesktop.common.ui.ContextualItem
import com.florent37.flocondesktop.common.ui.ContextualView
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlItemViewState
import com.florent37.flocondesktop.features.graphql.ui.model.OnGraphQlItemUserAction
import com.florent37.flocondesktop.features.graphql.ui.model.previewGraphQlItemViewState
import com.florent37.flocondesktop.features.graphql.ui.view.components.MethodView
import com.florent37.flocondesktop.features.graphql.ui.view.components.StatusView
import org.jetbrains.compose.ui.tooling.preview.Preview
/**
* Data class to define the fixed widths for each column in GraphQlItemView.
* This allows for easy configuration and consistency across all items in a LazyColumn.
*/
data class GraphQlItemColumnWidths(
val dateWidth: Dp = 90.dp,
val methodWidth: Dp = 65.dp,
val statusCodeWidth: Dp = 50.dp,
val requestSizeWidth: Dp = 65.dp,
val responseSizeWidth: Dp = 65.dp,
val timeWidth: Dp = 60.dp,
// The 'route' column will use Modifier.weight(1f) to take remaining space
)
@Composable
fun GraphQlItemView(
state: GraphQlItemViewState,
columnWidths: GraphQlItemColumnWidths = GraphQlItemColumnWidths(), // Default widths provided
onUserAction: (OnGraphQlItemUserAction) -> Unit,
modifier: Modifier = Modifier,
) {
// Use MaterialTheme.typography for consistent text sizes
val bodySmall = MaterialTheme.typography.bodySmall // Typically 12.sp or similar
val labelSmall = MaterialTheme.typography.labelSmall // Even smaller, good for labels/tags
ContextualView(
listOf(
ContextualItem(
id = "copy_url",
text = "Copy url",
),
ContextualItem(
id = "remove",
text = "Remove",
),
ContextualItem(
id = "remove_lines_above",
text = "Remove lines above ",
),
),
onSelect = {
when (it.id) {
"copy_url" -> onUserAction(OnGraphQlItemUserAction.CopyUrl(state))
"remove" -> onUserAction(OnGraphQlItemUserAction.Remove(state))
"remove_lines_above" -> onUserAction(OnGraphQlItemUserAction.RemoveLinesAbove(state))
}
},
) {
Row(
modifier = modifier
.padding(horizontal = 8.dp, vertical = 4.dp) // Padding for the entire item
.clip(shape = RoundedCornerShape(8.dp))
.clickable(onClick = {
onUserAction(OnGraphQlItemUserAction.OnClicked(state))
})
.background(
color = MaterialTheme.colorScheme.surface, // Use surface color for the item background
)
.padding(horizontal = 8.dp, vertical = 6.dp),
// Inner padding for content
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Date - Fixed width from data class
Box(
modifier = Modifier.width(columnWidths.dateWidth),
contentAlignment = Alignment.Center,
) {
Text(
state.dateFormatted,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
// Method - Fixed width for the tag from data class
MethodView(
method = state.method,
modifier =
Modifier
.width(columnWidths.methodWidth),
)
// Route - Takes remaining space (weight)
Box(modifier = Modifier.weight(1f)) {
Text(
text = state.route,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
// GraphQlStatusUi - Fixed width for the tag from data class
StatusView(
state.graphQlStatusUi,
modifier = Modifier.width(columnWidths.statusCodeWidth), // Apply fixed width to the StatusView composable
)
// Request Size - Fixed width from data class
Box(
modifier = Modifier.width(columnWidths.requestSizeWidth),
contentAlignment = Alignment.Center,
) {
Text(
state.requestSize,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
// Response Size - Fixed width from data class
Box(
modifier = Modifier.width(columnWidths.responseSizeWidth),
contentAlignment = Alignment.Center,
) {
Text(
state.responseSize,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
// Time - Fixed width from data class
Box(
modifier = Modifier.width(columnWidths.timeWidth),
contentAlignment = Alignment.Center,
) {
Text(
state.timeFormatted,
style = bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
)
}
}
}
}
@Composable
@Preview
private fun ItemViewPreview() {
MaterialTheme {
GraphQlItemView(
modifier = Modifier.fillMaxWidth(),
state = previewGraphQlItemViewState(),
onUserAction = {},
)
}
}

View file

@ -0,0 +1,154 @@
package com.florent37.flocondesktop.features.graphql.ui.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.florent37.flocondesktop.common.ui.FloconColors
import com.florent37.flocondesktop.common.ui.FloconTheme
import com.florent37.flocondesktop.features.graphql.ui.GraphQlViewModel
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlDetailViewState
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlItemViewState
import com.florent37.flocondesktop.features.graphql.ui.model.OnGraphQlItemUserAction
import com.florent37.flocondesktop.features.graphql.ui.model.previewGraphQlItemViewState
import com.florent37.flocondesktop.features.graphql.ui.view.header.GraphQlFilterBar
import com.florent37.flocondesktop.features.graphql.ui.view.header.GraphQlItemHeaderView
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun GraphQlScreen(modifier: Modifier = Modifier) {
val viewModel: GraphQlViewModel = koinViewModel()
val items by viewModel.state.collectAsStateWithLifecycle()
val detailState by viewModel.detailState.collectAsStateWithLifecycle()
GraphQlScreen(
graphQlItems = items,
modifier = modifier,
detailState = detailState,
onGraphQlItemUserAction = viewModel::onGraphQlItemUserAction,
onCopyText = viewModel::onCopyText,
onReset = viewModel::onReset,
closeDetailPanel = viewModel::closeDetailPanel,
)
}
@Composable
fun GraphQlScreen(
graphQlItems: List<GraphQlItemViewState>,
detailState: GraphQlDetailViewState?,
onGraphQlItemUserAction: (OnGraphQlItemUserAction) -> Unit,
onCopyText: (String) -> Unit,
closeDetailPanel: () -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
val columnWidths: GraphQlItemColumnWidths =
remember { GraphQlItemColumnWidths() } // Default widths provided
var filteredItems by remember { mutableStateOf<List<GraphQlItemViewState>>(emptyList()) }
Surface(modifier = modifier) {
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
Text(
text = "GraphQl",
modifier = Modifier
.fillMaxWidth()
.background(FloconColors.pannel)
.padding(all = 12.dp),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
GraphQlFilterBar(
modifier =
Modifier
.fillMaxWidth()
.background(FloconColors.pannel)
.padding(horizontal = 12.dp),
graphQlItems = graphQlItems,
onResetClicked = onReset,
onItemsChange = {
filteredItems = it
},
)
GraphQlItemHeaderView(
columnWidths = columnWidths,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.clickable(
interactionSource = null,
indication = null,
enabled = detailState != null,
) {
closeDetailPanel()
},
) {
items(filteredItems) {
GraphQlItemView(
state = it,
columnWidths = columnWidths,
modifier = Modifier.fillMaxWidth(),
onUserAction = onGraphQlItemUserAction,
)
}
}
}
detailState?.let {
GraphQlDetailView(
modifier =
Modifier
.align(Alignment.TopEnd)
.fillMaxHeight()
.width(500.dp),
state = it,
onCopy = onCopyText,
)
}
}
}
}
@Composable
@Preview
private fun GraphQlScreenPreview() {
FloconTheme {
val graphQlItems =
remember {
listOf(
previewGraphQlItemViewState(),
previewGraphQlItemViewState(),
)
}
GraphQlScreen(
graphQlItems = graphQlItems,
detailState = null,
closeDetailPanel = {},
onGraphQlItemUserAction = {},
onCopyText = {},
onReset = {},
)
}
}

View file

@ -0,0 +1,80 @@
package com.florent37.flocondesktop.features.graphql.ui.view.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.florent37.flocondesktop.common.ui.FloconTheme
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlDetailHeaderUi
import com.florent37.flocondesktop.features.graphql.ui.model.previewGraphQlDetailHeaderUi
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun DetailHeadersView(
headers: List<GraphQlDetailHeaderUi>,
labelWidth: Dp,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
headers.fastForEachIndexed { index, item ->
DetailHeadersItemView(
state = item,
labelWidth = labelWidth,
modifier = Modifier.fillMaxWidth(),
)
if (index != headers.lastIndex) {
HorizontalDivider(modifier = Modifier.fillMaxWidth())
}
}
}
}
@Composable
fun DetailHeadersItemView(
state: GraphQlDetailHeaderUi,
labelWidth: Dp,
modifier: Modifier = Modifier,
) {
SelectionContainer {
Row(
modifier = modifier.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = state.name,
style = MaterialTheme.typography.bodySmall, // Slightly smaller title for details
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), // Muted label color
modifier = Modifier.width(labelWidth).padding(end = 8.dp),
)
Text(
text = state.value,
style = MaterialTheme.typography.bodySmall, // Body text for the URL
color = MaterialTheme.colorScheme.onBackground, // Primary text color
modifier = Modifier.weight(1f), // Takes remaining space
)
}
}
}
@Preview
@Composable
private fun DetailHeadersItemViewPreview() {
FloconTheme {
DetailHeadersItemView(
state = previewGraphQlDetailHeaderUi(),
labelWidth = 100.dp,
modifier = Modifier.fillMaxWidth(),
)
}
}

View file

@ -0,0 +1,65 @@
package com.florent37.flocondesktop.features.graphql.ui.view.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.florent37.flocondesktop.common.ui.FloconTheme
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlMethodUi
import org.jetbrains.compose.ui.tooling.preview.Preview
private val getMethodBackground = Color(0xFF007BFF).copy(alpha = 0.3f) // Muted blue for GET
private val getMethodText = Color(0xFF007BFF)
private val postMethodBackground = Color(0xFF28A745).copy(alpha = 0.3f) // Muted green for POST
private val postMethodText = Color(0xFF28A745)
private val otherMethodBackground = Color(0xFF6C757D).copy(alpha = 0.3f) // Muted gray for OTHER
private val otherMethodText = Color(0xFF6C757D)
@Composable
fun MethodView(
method: GraphQlMethodUi,
textSize: TextUnit = 12.sp,
modifier: Modifier = Modifier,
) {
val (backgroundColor, textColor) =
when (method) {
GraphQlMethodUi.GET -> getMethodBackground to getMethodText
is GraphQlMethodUi.Other -> otherMethodBackground to otherMethodText
GraphQlMethodUi.POST -> postMethodBackground to postMethodText
}
Box(
modifier =
modifier
.background(
color = backgroundColor,
shape = RoundedCornerShape(20.dp), // Pill shape
).padding(horizontal = 4.dp),
// Padding inside the tag
contentAlignment = Alignment.Center, // Center content if Box is larger than text
) {
Text(
method.text,
color = textColor,
fontSize = textSize,
)
}
}
@Composable
@Preview
private fun MethodView_Preview() {
FloconTheme {
MethodView(method = GraphQlMethodUi.GET)
}
}

View file

@ -0,0 +1,54 @@
package com.florent37.flocondesktop.features.graphql.ui.view.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.florent37.flocondesktop.common.ui.FloconTheme
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlStatusUi
import org.jetbrains.compose.ui.tooling.preview.Preview
// Custom colors for graphQlStatusUi/method views to integrate better with the theme
val successTagBackground = Color(0xFF28A745).copy(alpha = 0.3f) // Muted green for success
val successTagText = Color(0xFF28A745) // Brighter green for text
val errorTagBackground = Color(0xFFDC3545).copy(alpha = 0.3f) // Muted red for error
val errorTagText = Color(0xFFDC3545) // Brighter red for text
@Composable
fun StatusView(
status: GraphQlStatusUi,
textSize: TextUnit = 12.sp,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier,
text = status.code.toString(),
textAlign = TextAlign.Center,
fontSize = textSize,
color =
when (status.isSuccess) {
true -> successTagText
false -> errorTagText
},
style = MaterialTheme.typography.labelSmall, // Use typography for consistency
)
}
@Composable
@Preview
private fun StatusView_Preview() {
FloconTheme {
StatusView(
status =
GraphQlStatusUi(
code = 200,
isSuccess = true,
),
)
}
}

View file

@ -0,0 +1,79 @@
package com.florent37.flocondesktop.features.graphql.ui.view.header
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.unit.dp
import com.florent37.flocondesktop.features.graphql.ui.model.GraphQlItemViewState
import com.florent37.flocondesktop.features.network.ui.view.components.FilterBar
import flocondesktop.composeapp.generated.resources.Res
import flocondesktop.composeapp.generated.resources.bin
import org.jetbrains.compose.resources.painterResource
@Composable
fun GraphQlFilterBar(
graphQlItems: List<GraphQlItemViewState>,
onItemsChange: (List<GraphQlItemViewState>) -> Unit,
onResetClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
var filterText by remember {
mutableStateOf("")
}
val onItemsChangeCallback by rememberUpdatedState(onItemsChange)
val filteredGraphQlItems: List<GraphQlItemViewState> =
remember(graphQlItems, filterText) {
if (filterText.isBlank()) {
graphQlItems
} else {
graphQlItems.filter {
it.route.contains(filterText, ignoreCase = true)
}
}
}
LaunchedEffect(filteredGraphQlItems) {
onItemsChangeCallback(filteredGraphQlItems)
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterBar(
placeholderText = "Filter",
modifier = Modifier.weight(1f),
onTextChange = {
filterText = it
},
)
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onResetClicked)
.padding(all = 8.dp),
) {
Image(
painter = painterResource(Res.drawable.bin),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
}
}
}

View file

@ -0,0 +1,61 @@
package com.florent37.flocondesktop.features.graphql.ui.view.header
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.florent37.flocondesktop.common.ui.FloconColors
import com.florent37.flocondesktop.features.graphql.ui.view.GraphQlItemColumnWidths
import com.florent37.flocondesktop.features.network.ui.view.components.HeaderLabelItem
@Composable
fun GraphQlItemHeaderView(
columnWidths: GraphQlItemColumnWidths = GraphQlItemColumnWidths(), // Default widths provided
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.background(FloconColors.pannel)
.padding(horizontal = 8.dp, vertical = 4.dp) // Padding for the entire item
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Bottom,
) {
// Date - Fixed width from data class
HeaderLabelItem(
modifier = Modifier.width(columnWidths.dateWidth),
text = "Request Time",
)
HeaderLabelItem(
modifier = Modifier.width(columnWidths.methodWidth),
text = "Method",
)
HeaderLabelItem(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.TopStart,
text = "Route",
)
HeaderLabelItem(
modifier = Modifier.width(columnWidths.statusCodeWidth),
text = "Status",
)
HeaderLabelItem(
modifier = Modifier.width(columnWidths.requestSizeWidth),
text = "Request Size",
)
HeaderLabelItem(
modifier = Modifier.width(columnWidths.responseSizeWidth),
text = "Response Size",
)
HeaderLabelItem(
modifier = Modifier.width(columnWidths.timeWidth),
text = "Time",
)
}
}

View file

@ -4,6 +4,7 @@ import com.florent37.flocondesktop.DeviceId
import com.florent37.flocondesktop.FloconIncomingMessageDataModel
import com.florent37.flocondesktop.Protocol
import com.florent37.flocondesktop.common.coroutines.dispatcherprovider.DispatcherProvider
import com.florent37.flocondesktop.features.graphql.data.mapper.toDomain
import com.florent37.flocondesktop.features.grpc.data.datasource.LocalGrpcDataSource
import com.florent37.flocondesktop.features.grpc.data.model.fromdevice.GrpcRequestDataModel
import com.florent37.flocondesktop.features.grpc.data.model.fromdevice.GrpcResponseDataModel

View file

@ -39,8 +39,8 @@ class ImagesRepositoryImpl(
imagesLocalDataSource.addImage(
deviceId = deviceId,
image = DeviceImageDomainModel(
url = request.infos.url,
time = (request.infos.startTime + request.infos.durationMs).toLong(),
url = request.url,
time = (request.request.startTime + request.durationMs).toLong(),
),
)
}

View file

@ -1,7 +1,6 @@
package com.florent37.flocondesktop.features.network.data
import com.florent37.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import com.florent37.flocondesktop.features.network.domain.model.FloconHttpRequestInfos
import kotlin.time.Clock
object FloconHttpRequestGenerator {
@ -66,13 +65,14 @@ object FloconHttpRequestGenerator {
null
}
FloconHttpRequestInfos(
FloconHttpRequestDomainModel(
uuid = index.toString(),
url = "$urlScheme://$domain$path/${index + 1}",
method = method,
startTime = Clock.System.now()
.toEpochMilliseconds() - (index * 500L), // Temps de démarrage décroissant
durationMs = (100.0 + (index * 25.0)), // Durée croissante
request = FloconHttpRequestInfos.Request(
request = FloconHttpRequestDomainModel.Request(
method = method,
startTime = Clock.System.now()
.toEpochMilliseconds() - (index * 500L), // Temps de démarrage décroissant
headers =
mapOf(
"Content-Type" to contentType,
@ -82,7 +82,7 @@ object FloconHttpRequestGenerator {
body = requestBodyContent,
byteSize = 300,
),
response = FloconHttpRequestInfos.Response(
response = FloconHttpRequestDomainModel.Response(
body = responseBodyContent,
httpCode = 200,
byteSize = 1500,
@ -93,9 +93,7 @@ object FloconHttpRequestGenerator {
"X-Response-ID" to "res-$index",
),
),
).let {
FloconHttpRequestDomainModel(uuid = index.toString(), infos = it)
}
)
}
}
}

View file

@ -7,7 +7,6 @@ import com.florent37.flocondesktop.common.coroutines.dispatcherprovider.Dispatch
import com.florent37.flocondesktop.features.network.data.datasource.local.NetworkLocalDataSource
import com.florent37.flocondesktop.features.network.data.model.FloconHttpRequestDataModel
import com.florent37.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import com.florent37.flocondesktop.features.network.domain.model.FloconHttpRequestInfos
import com.florent37.flocondesktop.features.network.domain.repository.NetworkImageRepository
import com.florent37.flocondesktop.features.network.domain.repository.NetworkRepository
import com.florent37.flocondesktop.messages.domain.repository.sub.MessagesReceiverRepository
@ -50,8 +49,8 @@ class NetworkRepositoryImpl(
) {
withContext(dispatcherProvider.data) {
decode(message)?.let { toDomain(it) }?.let { request ->
val responseContentType = request.infos.response.contentType
if (request.infos.response.contentType != null && responseContentType.startsWith("image/")) {
val responseContentType = request.response.contentType
if (request.response.contentType != null && responseContentType.startsWith("image/")) {
networkImageRepository.onImageReceived(deviceId = deviceId, request = request)
}
networkLocalDataSource.save(deviceId = deviceId, request = request)
@ -99,24 +98,21 @@ class NetworkRepositoryImpl(
fun toDomain(decoded: FloconHttpRequestDataModel): FloconHttpRequestDomainModel? = try {
FloconHttpRequestDomainModel(
uuid = Uuid.random().toString(),
infos =
FloconHttpRequestInfos(
url = decoded.url!!,
url = decoded.url!!,
durationMs = decoded.durationMs!!,
request = FloconHttpRequestDomainModel.Request(
method = decoded.method!!,
startTime = decoded.startTime!!,
durationMs = decoded.durationMs!!,
request = FloconHttpRequestInfos.Request(
headers = decoded.requestHeaders!!,
body = decoded.requestBody,
byteSize = decoded.requestSize ?: 0L,
),
response = FloconHttpRequestInfos.Response(
httpCode = decoded.responseHttpCode!!,
contentType = decoded.responseContentType,
body = decoded.responseBody,
headers = decoded.responseHeaders!!,
byteSize = decoded.responseSize ?: 0L,
),
headers = decoded.requestHeaders!!,
body = decoded.requestBody,
byteSize = decoded.requestSize ?: 0L,
),
response = FloconHttpRequestDomainModel.Response(
httpCode = decoded.responseHttpCode!!,
contentType = decoded.responseContentType,
body = decoded.responseBody,
headers = decoded.responseHeaders!!,
byteSize = decoded.responseSize ?: 0L,
),
)
} catch (t: Throwable) {

View file

@ -3,18 +3,17 @@ package com.florent37.flocondesktop.features.network.data.datasource.local.mappe
import com.florent37.flocondesktop.features.network.data.datasource.local.FloconHttpRequestEntity
import com.florent37.flocondesktop.features.network.data.datasource.local.FloconHttpRequestInfosEntity
import com.florent37.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
import com.florent37.flocondesktop.features.network.domain.model.FloconHttpRequestInfos
fun FloconHttpRequestDomainModel.toEntity(deviceId: String): FloconHttpRequestEntity = FloconHttpRequestEntity(
uuid = this.uuid,
infos = this.infos.toInfosEntity(),
infos = this.toInfosEntity(),
deviceId = deviceId,
)
fun FloconHttpRequestInfos.toInfosEntity(): FloconHttpRequestInfosEntity = FloconHttpRequestInfosEntity(
private fun FloconHttpRequestDomainModel.toInfosEntity(): FloconHttpRequestInfosEntity = FloconHttpRequestInfosEntity(
url = this.url,
method = this.method,
startTime = this.startTime,
method = this.request.method,
startTime = this.request.startTime,
durationMs = this.durationMs,
requestHeaders = this.request.headers,
requestBody = this.request.body,
@ -28,24 +27,20 @@ fun FloconHttpRequestInfos.toInfosEntity(): FloconHttpRequestInfosEntity = Floco
fun FloconHttpRequestEntity.toDomainModel(): FloconHttpRequestDomainModel = FloconHttpRequestDomainModel(
uuid = this.uuid,
infos = this.infos.toInfosDomainModel(),
)
fun FloconHttpRequestInfosEntity.toInfosDomainModel(): FloconHttpRequestInfos = FloconHttpRequestInfos(
url = this.url,
method = this.method,
startTime = this.startTime,
durationMs = this.durationMs,
request = FloconHttpRequestInfos.Request(
headers = this.requestHeaders,
body = this.requestBody,
byteSize = this.requestByteSize,
url = this.infos.url,
durationMs = this.infos.durationMs,
request = FloconHttpRequestDomainModel.Request(
method = this.infos.method,
startTime = this.infos.startTime,
headers = this.infos.requestHeaders,
body = this.infos.requestBody,
byteSize = this.infos.requestByteSize,
),
response = FloconHttpRequestInfos.Response(
httpCode = this.responseHttpCode,
contentType = this.responseContentType,
body = this.responseBody,
headers = this.responseHeaders,
byteSize = this.responseByteSize,
response = FloconHttpRequestDomainModel.Response(
httpCode = this.infos.responseHttpCode,
contentType = this.infos.responseContentType,
body = this.infos.responseBody,
headers = this.infos.responseHeaders,
byteSize = this.infos.responseByteSize,
),
)

View file

@ -3,13 +3,11 @@ package com.florent37.flocondesktop.features.network.domain
import com.florent37.flocondesktop.features.network.domain.model.FloconHttpRequestDomainModel
class GenerateCurlCommandUseCase {
operator fun invoke(request: FloconHttpRequestDomainModel): String {
val infos = request.infos
operator fun invoke(infos: FloconHttpRequestDomainModel): String {
val commandBuilder = StringBuilder("curl")
// 1. Add HTTP Method
commandBuilder.append(" -X ${infos.method}")
commandBuilder.append(" -X ${infos.request.method}")
// 2. Add Request Headers
infos.request.headers.forEach { (key, value) ->

View file

@ -2,19 +2,15 @@ package com.florent37.flocondesktop.features.network.domain.model
data class FloconHttpRequestDomainModel(
val uuid: String,
val infos: FloconHttpRequestInfos,
)
data class FloconHttpRequestInfos(
val url: String,
val method: String,
val startTime: Long,
val durationMs: Double,
val request: Request,
val response: Response,
) {
data class Request(
val startTime: Long,
val method: String,
val headers: Map<String, String>,
val body: String?,
val byteSize: Long,

View file

@ -79,7 +79,7 @@ class NetworkViewModel(
is OnNetworkItemUserAction.CopyUrl -> {
val domainModel = observeHttpRequestsByIdUseCase(action.item.uuid).firstOrNull()
?: return@launch
copyToClipboard(domainModel.infos.url)
copyToClipboard(domainModel.url)
}
is OnNetworkItemUserAction.OnClicked -> {

View file

@ -7,19 +7,19 @@ import com.florent37.flocondesktop.features.network.ui.model.NetworkDetailHeader
import com.florent37.flocondesktop.features.network.ui.model.NetworkDetailViewState
fun toDetailUi(request: FloconHttpRequestDomainModel): NetworkDetailViewState = NetworkDetailViewState(
fullUrl = request.infos.url ?: "",
method = toMethodUi(request.infos.method),
status = toNetworkStatusUi(request.infos.response.httpCode ?: 0),
requestTimeFormatted = request.infos.startTime?.let { formatTimestamp(it) } ?: "",
durationFormatted = formatDuration(request.infos.durationMs),
fullUrl = request.url,
method = toMethodUi(request.request.method),
status = toNetworkStatusUi(request.response.httpCode),
requestTimeFormatted = request.request.startTime.let { formatTimestamp(it) },
durationFormatted = formatDuration(request.durationMs),
// request
requestBody = httpBodyToUi(request.infos.request.body),
requestHeaders = toNetworkHeadersUi(request.infos.request.headers),
requestSize = ByteFormatter.formatBytes(request.infos.request.byteSize), // TODO
requestBody = httpBodyToUi(request.request.body),
requestHeaders = toNetworkHeadersUi(request.request.headers),
requestSize = ByteFormatter.formatBytes(request.request.byteSize),
// response
responseBody = httpBodyToUi(request.infos.response.body),
responseHeaders = toNetworkHeadersUi(request.infos.response.headers),
responseSize = ByteFormatter.formatBytes(request.infos.response.byteSize), // TODO
responseBody = httpBodyToUi(request.response.body),
responseHeaders = toNetworkHeadersUi(request.response.headers),
responseSize = ByteFormatter.formatBytes(request.response.byteSize),
)
fun httpBodyToUi(body: String?): String = body?.let { JsonPrettyPrinter.prettyPrint(body) } ?: ""

View file

@ -15,19 +15,18 @@ fun listToUi(httpRequests: List<FloconHttpRequestDomainModel>): List<NetworkItem
fun toUi(httpRequest: FloconHttpRequestDomainModel): NetworkItemViewState = NetworkItemViewState(
uuid = httpRequest.uuid,
dateFormatted = formatTimestamp(httpRequest.infos.startTime),
method = toMethodUi(httpRequest.infos.method),
networkStatusUi = toNetworkStatusUi(code = 200),
route = httpRequest.infos.url,
timeFormatted = formatDuration(httpRequest.infos.durationMs),
requestSize = ByteFormatter.formatBytes(httpRequest.infos.request.byteSize),
responseSize = ByteFormatter.formatBytes(httpRequest.infos.response.byteSize),
dateFormatted = formatTimestamp(httpRequest.request.startTime),
method = toMethodUi(httpRequest.request.method),
networkStatusUi = toNetworkStatusUi(code = httpRequest.response.httpCode),
route = httpRequest.url,
timeFormatted = formatDuration(httpRequest.durationMs),
requestSize = ByteFormatter.formatBytes(httpRequest.request.byteSize),
responseSize = ByteFormatter.formatBytes(httpRequest.response.byteSize),
)
// TODO
fun toNetworkStatusUi(code: Int): NetworkStatusUi = NetworkStatusUi(
code = code,
isSuccess = true, // TODO
isSuccess = code >= 200 && code < 300,
)
fun toMethodUi(httpMethod: String): NetworkMethodUi = when (httpMethod.lowercase()) {

View file

@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.florent37.flocondesktop.common.ui.ContextualItem
@ -25,7 +26,6 @@ import com.florent37.flocondesktop.features.network.ui.model.previewNetworkItemV
import com.florent37.flocondesktop.features.network.ui.view.components.MethodView
import com.florent37.flocondesktop.features.network.ui.view.components.StatusView
import org.jetbrains.compose.ui.tooling.preview.Preview
import androidx.compose.ui.text.style.TextOverflow
/**
* Data class to define the fixed widths for each column in NetworkItemView.

View file

@ -3,6 +3,7 @@ package com.florent37.flocondesktop.main.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
@ -17,6 +18,7 @@ import com.florent37.flocondesktop.features.dashboard.ui.view.DashboardScreen
import com.florent37.flocondesktop.features.database.ui.view.DatabaseScreen
import com.florent37.flocondesktop.features.deeplinks.ui.view.DeeplinkScreen
import com.florent37.flocondesktop.features.files.ui.view.FilesScreen
import com.florent37.flocondesktop.features.graphql.ui.view.GraphQlScreen
import com.florent37.flocondesktop.features.grpc.ui.view.GRPCScreen
import com.florent37.flocondesktop.features.images.ui.view.ImagesScreen
import com.florent37.flocondesktop.features.network.ui.view.NetworkScreen
@ -63,9 +65,10 @@ private fun MainScreen(
) {
Column(modifier) {
// TODO navigation
Row(modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxSize()) {
LeftPanelView(
modifier = Modifier.width(300.dp),
modifier = Modifier.width(300.dp)
.fillMaxHeight(),
onClickItem = onClickLeftPanelItem,
state = leftPanelState,
devicesState = devicesState,
@ -145,6 +148,12 @@ private fun MainScreen(
.fillMaxSize(),
)
}
SubScreen.GraphQl ->
GraphQlScreen(
modifier = Modifier
.fillMaxSize(),
)
}
}
}

View file

@ -42,14 +42,14 @@ class MainViewModel(
}
val leftPanelState = subScreen.map { subScreen ->
buildLeftPannelState(
buildLeftPanelState(
selectedId = subScreen.id,
)
}.flowOn(dispatcherProvider.ui)
.stateIn(
scope = viewModelScope,
started = kotlinx.coroutines.flow.SharingStarted.Eagerly,
initialValue = buildLeftPannelState(subScreen.value.id),
initialValue = buildLeftPanelState(subScreen.value.id),
)
val devicesState: StateFlow<DevicesStateUiModel> = devicesDelegate.devicesState
@ -65,7 +65,7 @@ class MainViewModel(
}
}
fun buildLeftPannelState(selectedId: String?) = LeftPanelState(
fun buildLeftPanelState(selectedId: String?) = LeftPanelState(
bottomItems = listOf(
item(subScreen = SubScreen.Settings, selectedId = selectedId),
),
@ -75,6 +75,7 @@ fun buildLeftPannelState(selectedId: String?) = LeftPanelState(
items = listOf(
item(subScreen = SubScreen.Network, selectedId = selectedId),
item(subScreen = SubScreen.Images, selectedId = selectedId),
item(subScreen = SubScreen.GraphQl, selectedId = selectedId),
item(subScreen = SubScreen.GRPC, selectedId = selectedId),
),
),

View file

@ -7,7 +7,7 @@ enum class SubScreen {
Network,
Images, // network images
// GraphQl ?
GraphQl,
GRPC,
// storage

View file

@ -7,6 +7,7 @@ import flocondesktop.composeapp.generated.resources.dashboard
import flocondesktop.composeapp.generated.resources.database
import flocondesktop.composeapp.generated.resources.deeplinks
import flocondesktop.composeapp.generated.resources.files
import flocondesktop.composeapp.generated.resources.graphql
import flocondesktop.composeapp.generated.resources.grpc
import flocondesktop.composeapp.generated.resources.images
import flocondesktop.composeapp.generated.resources.network
@ -28,6 +29,7 @@ fun SubScreen.displayName(): String = when (this) {
SubScreen.Dashboard -> "Dashboard"
SubScreen.Settings -> "Settings"
SubScreen.Deeplinks -> "Deeplinks"
SubScreen.GraphQl -> "GraphQl"
}
// Extension function to get the icon for each SubScreen
@ -43,4 +45,5 @@ fun SubScreen.icon(): DrawableResource = when (this) {
SubScreen.Settings -> Res.drawable.settings
SubScreen.Dashboard -> Res.drawable.dashboard
SubScreen.Deeplinks -> Res.drawable.deeplinks
SubScreen.GraphQl -> Res.drawable.graphql
}

View file

@ -7,11 +7,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -73,12 +76,17 @@ fun LeftPanelView(
),
)
}
LeftPannelDivider(
Spacer(
modifier = Modifier.fillMaxWidth()
.padding(vertical = 12.dp),
.height(12.dp),
)
Column(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
) {
state.sections.fastForEachIndexed { index, section ->
PannelLabel(
text = section.title,

View file

@ -46,6 +46,14 @@ object Protocol {
}
}
object Graphql {
const val Plugin = "graphql"
object Method {
const val LogNetworkCall = "logNetworkCall"
}
}
object GRPC {
const val Plugin = "gRPC"