diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt new file mode 100644 index 0000000..66099da --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt @@ -0,0 +1,160 @@ +package com.korexlabs.dhinspeccion.data + +import android.content.Context +import com.korexlabs.dhinspeccion.BuildConfig +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path + +data class FieldFindingAct( + val id: String, + val code: String, + val status: String, +) + +data class FieldFindingCatalogItem( + val id: String, + val categoryId: String, + val code: String, + val sourceNumber: Int, + val title: String, + val legalBasis: String? = null, + val glossary: String? = null, + val suggestedSeverity: Int? = null, + val revision: Int = 1, + val categoryName: String? = null, +) + +data class FieldFindingOther( + val enabled: Boolean = true, + val code: String = "OTHER", + val label: String = "OTROS", + val help: String? = null, +) + +data class FieldFindingCatalog( + val typeConfigured: Boolean = false, + val configurationReason: String? = null, + val items: List = emptyList(), + val other: FieldFindingOther = FieldFindingOther(), +) + +data class FieldFindingItem( + val id: String, + val actId: String, + val assetId: String, + val catalogItemId: String? = null, + val findingNumber: Int, + val code: String, + val status: String, + val title: String, + val description: String, + val severity: Int? = null, + val suggestedSeverity: Int? = null, + val correctionDueOn: String? = null, +) + +data class FieldFindingOptionsResponse( + val act: FieldFindingAct, + val capture: CaptureStatus = CaptureStatus(), + val catalog: FieldFindingCatalog, + val findings: List = emptyList(), + val canAddAnother: Boolean = true, +) + +data class CreateFieldFindingRequest( + val catalogItemId: String? = null, + val customTitle: String? = null, + val customLegalBasis: String? = null, + val description: String, + val severity: Int? = null, + val correctionDueOn: String? = null, +) + +data class FieldFindingCreateResponse( + val act: FieldFindingAct, + val capture: CaptureStatus = CaptureStatus(), + val finding: FieldFindingItem, + val canAddAnother: Boolean = true, +) + +private interface FieldFindingsApi { + @GET("inspection-visits/{visitId}/field-findings/{assetId}/options") + suspend fun options( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Path("assetId") assetId: String, + ): FieldFindingOptionsResponse + + @POST("inspection-visits/{visitId}/field-findings/{assetId}") + suspend fun create( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Path("assetId") assetId: String, + @Body request: CreateFieldFindingRequest, + ): FieldFindingCreateResponse + + @POST("auth/mobile/refresh") + suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse +} + +/** + * Cliente separado para F2.3. Comparte el almacén cifrado de sesión de la APK, + * pero mantiene el contrato de Hallazgos desacoplado del cliente F2.2. + */ +class FieldFindingsRepository(context: Context) { + private val store = SecureSessionStore(context.applicationContext) + private val refreshMutex = Mutex() + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val api: FieldFindingsApi = Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(FieldFindingsApi::class.java) + + suspend fun options(visitId: String, assetId: String): FieldFindingOptionsResponse = + authorized { session -> + api.options("Bearer ${session.accessToken}", visitId, assetId) + } + + suspend fun create( + visitId: String, + assetId: String, + request: CreateFieldFindingRequest, + ): FieldFindingCreateResponse = authorized { session -> + api.create("Bearer ${session.accessToken}", visitId, assetId, request) + } + + private suspend fun authorized(block: suspend (StoredSession) -> T): T { + var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") + try { + return block(session) + } catch (error: HttpException) { + if (error.code() != 401) throw error + } + session = refresh(session.refreshToken) + return block(session) + } + + private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { + val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") + if (latest.refreshToken != previousRefreshToken) return@withLock latest + try { + store.save(api.refresh(RefreshRequest(previousRefreshToken))) + } catch (error: Throwable) { + store.clear() + throw error + } + } +}