feat: add Android client app (Kotlin + Jetpack Compose)
Kotlin Android app consuming the sync-service REST API. Screens: Dashboard (manual sync triggers), Logs, Log detail, Product/Order mappings, Settings (server URL + Basic Auth). Stack: Retrofit + OkHttp, Hilt DI, DataStore, Navigation Compose. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b55805665b
commit
8d6c69d47b
|
|
@ -0,0 +1,11 @@
|
||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
**/build/
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.luklpz.tfg.syncmanager"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.luklpz.tfg.syncmanager"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "21"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material.icons.extended)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
|
||||||
|
implementation(libs.retrofit)
|
||||||
|
implementation(libs.retrofit.converter.gson)
|
||||||
|
implementation(libs.okhttp.logging)
|
||||||
|
|
||||||
|
implementation(libs.coroutines.android)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
implementation(libs.material)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
-keep class com.luklpz.tfg.syncmanager.data.model.** { *; }
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keep class retrofit2.** { *; }
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
-dontwarn retrofit2.**
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".SyncManagerApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.SyncManager">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.SyncManager.Splash">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.luklpz.tfg.syncmanager
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.navigation.AppNavGraph
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.theme.SyncManagerTheme
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
installSplashScreen()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
SyncManagerTheme {
|
||||||
|
AppNavGraph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.luklpz.tfg.syncmanager
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class SyncManagerApp : Application()
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.api
|
||||||
|
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.OrderMappingResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.ProductMappingResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncLogResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncTriggerResponse
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
interface SyncApi {
|
||||||
|
|
||||||
|
@POST("api/sync/products")
|
||||||
|
suspend fun syncProducts(): SyncTriggerResponse
|
||||||
|
|
||||||
|
@POST("api/sync/stock")
|
||||||
|
suspend fun syncStock(): SyncTriggerResponse
|
||||||
|
|
||||||
|
@POST("api/sync/orders")
|
||||||
|
suspend fun syncOrders(): SyncTriggerResponse
|
||||||
|
|
||||||
|
@GET("api/mappings/products")
|
||||||
|
suspend fun getProductMappings(@Query("status") status: String? = null): List<ProductMappingResponse>
|
||||||
|
|
||||||
|
@GET("api/mappings/orders")
|
||||||
|
suspend fun getOrderMappings(): List<OrderMappingResponse>
|
||||||
|
|
||||||
|
@GET("api/logs")
|
||||||
|
suspend fun getLogs(@Query("type") type: String? = null): List<SyncLogResponse>
|
||||||
|
|
||||||
|
@GET("api/logs/{id}")
|
||||||
|
suspend fun getLogById(@Path("id") id: Long): SyncLogResponse
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.datastore
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
|
data class AppSettings(
|
||||||
|
val serverUrl: String,
|
||||||
|
val username: String,
|
||||||
|
val password: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SettingsDataStore @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val KEY_SERVER_URL = stringPreferencesKey("server_url")
|
||||||
|
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||||
|
private val KEY_PASSWORD = stringPreferencesKey("password")
|
||||||
|
|
||||||
|
const val DEFAULT_URL = "https://proyectointermodular-production-a9c3.up.railway.app/"
|
||||||
|
const val DEFAULT_USERNAME = "admin"
|
||||||
|
const val DEFAULT_PASSWORD = "admin123"
|
||||||
|
}
|
||||||
|
|
||||||
|
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||||
|
AppSettings(
|
||||||
|
serverUrl = prefs[KEY_SERVER_URL] ?: DEFAULT_URL,
|
||||||
|
username = prefs[KEY_USERNAME] ?: DEFAULT_USERNAME,
|
||||||
|
password = prefs[KEY_PASSWORD] ?: DEFAULT_PASSWORD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSettings(settings: AppSettings) {
|
||||||
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_SERVER_URL] = settings.serverUrl
|
||||||
|
prefs[KEY_USERNAME] = settings.username
|
||||||
|
prefs[KEY_PASSWORD] = settings.password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.model
|
||||||
|
|
||||||
|
data class OrderMappingResponse(
|
||||||
|
val id: Long,
|
||||||
|
val prestashopOrderId: Int,
|
||||||
|
val dolibarrOrderId: Int?,
|
||||||
|
val dolibarrInvoiceId: Int?,
|
||||||
|
val importedAt: String,
|
||||||
|
val status: String
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.model
|
||||||
|
|
||||||
|
data class ProductMappingResponse(
|
||||||
|
val id: Long,
|
||||||
|
val sku: String,
|
||||||
|
val dolibarrId: Int?,
|
||||||
|
val prestashopId: Int?,
|
||||||
|
val lastSyncedAt: String?,
|
||||||
|
val syncStatus: String,
|
||||||
|
val errorMessage: String?
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.model
|
||||||
|
|
||||||
|
data class SyncLogResponse(
|
||||||
|
val id: Long,
|
||||||
|
val syncType: String,
|
||||||
|
val startedAt: String,
|
||||||
|
val finishedAt: String?,
|
||||||
|
val itemsProcessed: Int?,
|
||||||
|
val itemsFailed: Int?,
|
||||||
|
val errorDetails: String?
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.model
|
||||||
|
|
||||||
|
data class SyncTriggerResponse(
|
||||||
|
val syncType: String,
|
||||||
|
val itemsProcessed: Int,
|
||||||
|
val itemsFailed: Int,
|
||||||
|
val hasErrors: Boolean,
|
||||||
|
val errors: List<String>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.remote
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AuthInterceptor @Inject constructor(
|
||||||
|
private val serverConfig: ServerConfig
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val credentials = "${serverConfig.username}:${serverConfig.password}"
|
||||||
|
val encoded = Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)
|
||||||
|
val request = chain.request().newBuilder()
|
||||||
|
.header("Authorization", "Basic $encoded")
|
||||||
|
.build()
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.remote
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Rewrites host/scheme/port to the runtime-configured server URL. */
|
||||||
|
@Singleton
|
||||||
|
class DynamicUrlInterceptor @Inject constructor(
|
||||||
|
private val serverConfig: ServerConfig
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val original = chain.request()
|
||||||
|
val base = serverConfig.baseUrl.trimEnd('/').toHttpUrl()
|
||||||
|
val newUrl = original.url.newBuilder()
|
||||||
|
.scheme(base.scheme)
|
||||||
|
.host(base.host)
|
||||||
|
.port(base.port)
|
||||||
|
.build()
|
||||||
|
return chain.proceed(original.newBuilder().url(newUrl).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.remote
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ServerConfig @Inject constructor() {
|
||||||
|
var baseUrl: String = "https://proyectointermodular-production-a9c3.up.railway.app/"
|
||||||
|
var username: String = "admin"
|
||||||
|
var password: String = "admin123"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.data.repository
|
||||||
|
|
||||||
|
import com.luklpz.tfg.syncmanager.data.api.SyncApi
|
||||||
|
import com.luklpz.tfg.syncmanager.data.datastore.AppSettings
|
||||||
|
import com.luklpz.tfg.syncmanager.data.datastore.SettingsDataStore
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.OrderMappingResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.ProductMappingResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncLogResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncTriggerResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.remote.ServerConfig
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SyncRepository @Inject constructor(
|
||||||
|
private val syncApi: SyncApi,
|
||||||
|
private val settingsDataStore: SettingsDataStore,
|
||||||
|
private val serverConfig: ServerConfig
|
||||||
|
) {
|
||||||
|
val settingsFlow: Flow<AppSettings> = settingsDataStore.settingsFlow
|
||||||
|
|
||||||
|
/** Applies latest saved settings to the live ServerConfig before each network call. */
|
||||||
|
private suspend fun refreshConfig() {
|
||||||
|
val s = settingsDataStore.settingsFlow.first()
|
||||||
|
serverConfig.baseUrl = s.serverUrl
|
||||||
|
serverConfig.username = s.username
|
||||||
|
serverConfig.password = s.password
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncProducts(): SyncTriggerResponse { refreshConfig(); return syncApi.syncProducts() }
|
||||||
|
suspend fun syncStock(): SyncTriggerResponse { refreshConfig(); return syncApi.syncStock() }
|
||||||
|
suspend fun syncOrders(): SyncTriggerResponse { refreshConfig(); return syncApi.syncOrders() }
|
||||||
|
|
||||||
|
suspend fun getProductMappings(status: String? = null): List<ProductMappingResponse> {
|
||||||
|
refreshConfig(); return syncApi.getProductMappings(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getOrderMappings(): List<OrderMappingResponse> {
|
||||||
|
refreshConfig(); return syncApi.getOrderMappings()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLogs(type: String? = null): List<SyncLogResponse> {
|
||||||
|
refreshConfig(); return syncApi.getLogs(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLogById(id: Long): SyncLogResponse {
|
||||||
|
refreshConfig(); return syncApi.getLogById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSettings(settings: AppSettings) {
|
||||||
|
settingsDataStore.saveSettings(settings)
|
||||||
|
serverConfig.baseUrl = settings.serverUrl
|
||||||
|
serverConfig.username = settings.username
|
||||||
|
serverConfig.password = settings.password
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test connection with given credentials without persisting them. */
|
||||||
|
suspend fun testConnection(settings: AppSettings) {
|
||||||
|
serverConfig.baseUrl = settings.serverUrl
|
||||||
|
serverConfig.username = settings.username
|
||||||
|
serverConfig.password = settings.password
|
||||||
|
syncApi.getLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.di
|
||||||
|
|
||||||
|
import com.luklpz.tfg.syncmanager.data.api.SyncApi
|
||||||
|
import com.luklpz.tfg.syncmanager.data.remote.AuthInterceptor
|
||||||
|
import com.luklpz.tfg.syncmanager.data.remote.DynamicUrlInterceptor
|
||||||
|
import com.luklpz.tfg.syncmanager.data.remote.ServerConfig
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides @Singleton
|
||||||
|
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
|
||||||
|
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
|
||||||
|
|
||||||
|
@Provides @Singleton
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
loggingInterceptor: HttpLoggingInterceptor
|
||||||
|
): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(dynamicUrlInterceptor)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(loggingInterceptor)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides @Singleton
|
||||||
|
fun provideRetrofit(client: OkHttpClient, serverConfig: ServerConfig): Retrofit =
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl(serverConfig.baseUrl)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides @Singleton
|
||||||
|
fun provideSyncApi(retrofit: Retrofit): SyncApi = retrofit.create(SyncApi::class.java)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.luklpz.tfg.syncmanager.data.repository.SyncRepository
|
||||||
|
import com.luklpz.tfg.syncmanager.util.ConnectivityObserver
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AppViewModel @Inject constructor(
|
||||||
|
connectivity: ConnectivityObserver,
|
||||||
|
private val repository: SyncRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val isOnline = connectivity.isOnline
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||||
|
|
||||||
|
private val _errorLogCount = MutableStateFlow(0)
|
||||||
|
val errorLogCount = _errorLogCount.asStateFlow()
|
||||||
|
|
||||||
|
init { refreshErrorCount() }
|
||||||
|
|
||||||
|
fun refreshErrorCount() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val logs = repository.getLogs()
|
||||||
|
_errorLogCount.value = logs.count { (it.itemsFailed ?: 0) > 0 }
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui
|
||||||
|
|
||||||
|
sealed class UiState<out T> {
|
||||||
|
object Idle : UiState<Nothing>()
|
||||||
|
object Loading : UiState<Nothing>()
|
||||||
|
data class Success<T>(val data: T) : UiState<T>()
|
||||||
|
data class Error(val message: String) : UiState<Nothing>()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,415 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.dashboard
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.Inventory2
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
|
import androidx.compose.material.icons.filled.Sync
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material.icons.filled.Warehouse
|
||||||
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncLogResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncTriggerResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import com.luklpz.tfg.syncmanager.util.formatRelativeDateTime
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DashboardScreen(
|
||||||
|
isOnline: Boolean,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val productsState by viewModel.productsState.collectAsState()
|
||||||
|
val stockState by viewModel.stockState.collectAsState()
|
||||||
|
val ordersState by viewModel.ordersState.collectAsState()
|
||||||
|
val lastLogs by viewModel.lastLogs.collectAsState()
|
||||||
|
val syncAllStep by viewModel.syncAllStep.collectAsState()
|
||||||
|
val pendingSync by viewModel.pendingSync.collectAsState()
|
||||||
|
val pendingSyncAll by viewModel.pendingSyncAll.collectAsState()
|
||||||
|
val serverConfigured by viewModel.serverConfigured.collectAsState()
|
||||||
|
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||||
|
val serverReachable by viewModel.serverReachable.collectAsState()
|
||||||
|
|
||||||
|
val isSyncingAll = syncAllStep != SyncAllStep.IDLE && syncAllStep != SyncAllStep.DONE
|
||||||
|
|
||||||
|
pendingSync?.let { type ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::dismissSync,
|
||||||
|
title = { Text("Confirmar sincronización") },
|
||||||
|
text = { Text("¿Ejecutar sincronización de ${syncTypeLabel(type)}?") },
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = viewModel::confirmSync) { Text("Sincronizar") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = viewModel::dismissSync) { Text("Cancelar") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSyncAll) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::dismissSyncAll,
|
||||||
|
title = { Text("Sincronizar todo") },
|
||||||
|
text = { Text("Ejecutará productos, stock y pedidos en secuencia. ¿Continuar?") },
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = viewModel::confirmSyncAll) { Text("Sincronizar todo") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = viewModel::dismissSyncAll) { Text("Cancelar") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Sync Manager", style = MaterialTheme.typography.titleLarge)
|
||||||
|
Text(
|
||||||
|
"Dolibarr · PrestaShop",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = viewModel::refresh,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
state = rememberPullToRefreshState()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = !isOnline || serverReachable == false) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.WifiOff, null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
"Sin conexión — las sincronizaciones fallarán.",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = !serverConfigured) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Settings, null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"Servidor no configurado",
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Configura la URL del servidor en Ajustes",
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onNavigateToSettings) { Text("Ajustes") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Sincronización manual",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = viewModel::requestSyncAll,
|
||||||
|
enabled = !isSyncingAll && isOnline,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isSyncingAll) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(syncAllStepLabel(syncAllStep))
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.Sync, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Sincronizar todo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncCard(
|
||||||
|
title = "Productos",
|
||||||
|
origin = "Dolibarr",
|
||||||
|
destination = "PrestaShop",
|
||||||
|
description = "Crea o actualiza productos en la tienda según el catálogo del ERP",
|
||||||
|
icon = Icons.Default.Inventory2,
|
||||||
|
state = productsState,
|
||||||
|
lastLog = lastLogs["PRODUCT_PUSH"],
|
||||||
|
isOnline = isOnline,
|
||||||
|
onSync = { viewModel.requestSync(SyncType.PRODUCTS) }
|
||||||
|
)
|
||||||
|
SyncCard(
|
||||||
|
title = "Stock",
|
||||||
|
origin = "Dolibarr",
|
||||||
|
destination = "PrestaShop",
|
||||||
|
description = "Actualiza las unidades disponibles de cada producto en la tienda",
|
||||||
|
icon = Icons.Default.Warehouse,
|
||||||
|
state = stockState,
|
||||||
|
lastLog = lastLogs["STOCK_PUSH"],
|
||||||
|
isOnline = isOnline,
|
||||||
|
onSync = { viewModel.requestSync(SyncType.STOCK) }
|
||||||
|
)
|
||||||
|
SyncCard(
|
||||||
|
title = "Pedidos",
|
||||||
|
origin = "PrestaShop",
|
||||||
|
destination = "Dolibarr",
|
||||||
|
description = "Importa los pedidos de la tienda como comandas en el ERP",
|
||||||
|
icon = Icons.Default.ShoppingCart,
|
||||||
|
state = ordersState,
|
||||||
|
lastLog = lastLogs["ORDER_PULL"],
|
||||||
|
isOnline = isOnline,
|
||||||
|
onSync = { viewModel.requestSync(SyncType.ORDERS) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncCard(
|
||||||
|
title: String,
|
||||||
|
origin: String,
|
||||||
|
destination: String,
|
||||||
|
description: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
state: UiState<SyncTriggerResponse>,
|
||||||
|
lastLog: SyncLogResponse?,
|
||||||
|
isOnline: Boolean,
|
||||||
|
onSync: () -> Unit
|
||||||
|
) {
|
||||||
|
val isError = state is UiState.Error || (state is UiState.Success && state.data.itemsFailed > 0)
|
||||||
|
val isLoading = state is UiState.Loading
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = when {
|
||||||
|
isError -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
|
state is UiState.Success -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
"$origin → $destination",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
if (lastLog != null) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
val hadErrors = (lastLog.itemsFailed ?: 0) > 0
|
||||||
|
Text(
|
||||||
|
buildString {
|
||||||
|
append("Última: ${lastLog.startedAt.formatRelativeDateTime()}")
|
||||||
|
when {
|
||||||
|
hadErrors -> append(" · ${lastLog.itemsFailed} errores")
|
||||||
|
lastLog.itemsProcessed != null -> append(" · ${lastLog.itemsProcessed} items")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (hadErrors) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
is UiState.Success -> SyncResultRow(state.data)
|
||||||
|
is UiState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Error, contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
state.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSync,
|
||||||
|
enabled = !isLoading && isOnline,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Sincronizando…")
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.Sync, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Sincronizar $title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncResultRow(result: SyncTriggerResponse) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Check, null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("${result.itemsProcessed} procesados",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
if (result.itemsFailed > 0) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Warning, null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("${result.itemsFailed} fallidos",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.errors.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
result.errors.take(2).forEach { err ->
|
||||||
|
Text("• $err", style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncTypeLabel(type: SyncType) = when (type) {
|
||||||
|
SyncType.PRODUCTS -> "Productos"
|
||||||
|
SyncType.STOCK -> "Stock"
|
||||||
|
SyncType.ORDERS -> "Pedidos"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncAllStepLabel(step: SyncAllStep) = when (step) {
|
||||||
|
SyncAllStep.PRODUCTS -> "Sincronizando productos…"
|
||||||
|
SyncAllStep.STOCK -> "Sincronizando stock…"
|
||||||
|
SyncAllStep.ORDERS -> "Sincronizando pedidos…"
|
||||||
|
else -> "Sincronizando…"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.dashboard
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncLogResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncTriggerResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.repository.SyncRepository
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
enum class SyncType { PRODUCTS, STOCK, ORDERS }
|
||||||
|
|
||||||
|
enum class SyncAllStep { IDLE, PRODUCTS, STOCK, ORDERS, DONE }
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DashboardViewModel @Inject constructor(
|
||||||
|
private val repository: SyncRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _productsState = MutableStateFlow<UiState<SyncTriggerResponse>>(UiState.Idle)
|
||||||
|
val productsState = _productsState.asStateFlow()
|
||||||
|
|
||||||
|
private val _stockState = MutableStateFlow<UiState<SyncTriggerResponse>>(UiState.Idle)
|
||||||
|
val stockState = _stockState.asStateFlow()
|
||||||
|
|
||||||
|
private val _ordersState = MutableStateFlow<UiState<SyncTriggerResponse>>(UiState.Idle)
|
||||||
|
val ordersState = _ordersState.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastLogs = MutableStateFlow<Map<String, SyncLogResponse>>(emptyMap())
|
||||||
|
val lastLogs = _lastLogs.asStateFlow()
|
||||||
|
|
||||||
|
private val _syncAllStep = MutableStateFlow(SyncAllStep.IDLE)
|
||||||
|
val syncAllStep = _syncAllStep.asStateFlow()
|
||||||
|
|
||||||
|
private val _pendingSync = MutableStateFlow<SyncType?>(null)
|
||||||
|
val pendingSync = _pendingSync.asStateFlow()
|
||||||
|
|
||||||
|
private val _pendingSyncAll = MutableStateFlow(false)
|
||||||
|
val pendingSyncAll = _pendingSyncAll.asStateFlow()
|
||||||
|
|
||||||
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
|
val isRefreshing = _isRefreshing.asStateFlow()
|
||||||
|
|
||||||
|
/** null = not checked yet, true = reachable, false = unreachable */
|
||||||
|
private val _serverReachable = MutableStateFlow<Boolean?>(null)
|
||||||
|
val serverReachable = _serverReachable.asStateFlow()
|
||||||
|
|
||||||
|
val serverConfigured = repository.settingsFlow
|
||||||
|
.map { it.serverUrl.isNotBlank() }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadLastLogs()
|
||||||
|
checkConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isRefreshing.value = true
|
||||||
|
checkConnectionInternal()
|
||||||
|
loadLastLogsInternal()
|
||||||
|
_isRefreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkConnection() {
|
||||||
|
viewModelScope.launch { checkConnectionInternal() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkConnectionInternal() {
|
||||||
|
try {
|
||||||
|
val settings = repository.settingsFlow.first()
|
||||||
|
repository.testConnection(settings)
|
||||||
|
_serverReachable.value = true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
_serverReachable.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLastLogs() {
|
||||||
|
viewModelScope.launch { loadLastLogsInternal() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadLastLogsInternal() {
|
||||||
|
try {
|
||||||
|
val logs = repository.getLogs()
|
||||||
|
_lastLogs.value = logs
|
||||||
|
.groupBy { it.syncType }
|
||||||
|
.mapValues { (_, list) -> list.maxBy { it.id } }
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestSync(type: SyncType) { _pendingSync.value = type }
|
||||||
|
fun dismissSync() { _pendingSync.value = null }
|
||||||
|
|
||||||
|
fun confirmSync() {
|
||||||
|
val type = _pendingSync.value ?: return
|
||||||
|
_pendingSync.value = null
|
||||||
|
when (type) {
|
||||||
|
SyncType.PRODUCTS -> launchSync(_productsState) { repository.syncProducts() }
|
||||||
|
SyncType.STOCK -> launchSync(_stockState) { repository.syncStock() }
|
||||||
|
SyncType.ORDERS -> launchSync(_ordersState) { repository.syncOrders() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestSyncAll() { _pendingSyncAll.value = true }
|
||||||
|
fun dismissSyncAll() { _pendingSyncAll.value = false }
|
||||||
|
|
||||||
|
fun confirmSyncAll() {
|
||||||
|
_pendingSyncAll.value = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
_syncAllStep.value = SyncAllStep.PRODUCTS
|
||||||
|
_productsState.value = UiState.Loading
|
||||||
|
_productsState.value = try { UiState.Success(repository.syncProducts()) }
|
||||||
|
catch (e: Exception) { UiState.Error(e.message ?: "Error") }
|
||||||
|
|
||||||
|
_syncAllStep.value = SyncAllStep.STOCK
|
||||||
|
_stockState.value = UiState.Loading
|
||||||
|
_stockState.value = try { UiState.Success(repository.syncStock()) }
|
||||||
|
catch (e: Exception) { UiState.Error(e.message ?: "Error") }
|
||||||
|
|
||||||
|
_syncAllStep.value = SyncAllStep.ORDERS
|
||||||
|
_ordersState.value = UiState.Loading
|
||||||
|
_ordersState.value = try { UiState.Success(repository.syncOrders()) }
|
||||||
|
catch (e: Exception) { UiState.Error(e.message ?: "Error") }
|
||||||
|
|
||||||
|
_syncAllStep.value = SyncAllStep.DONE
|
||||||
|
loadLastLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchSync(
|
||||||
|
state: MutableStateFlow<UiState<SyncTriggerResponse>>,
|
||||||
|
block: suspend () -> SyncTriggerResponse
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state.value = UiState.Loading
|
||||||
|
state.value = try {
|
||||||
|
val result = block()
|
||||||
|
loadLastLogs()
|
||||||
|
UiState.Success(result)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
UiState.Error(e.message ?: "Error desconocido")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,226 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.logs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.Timer
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import com.luklpz.tfg.syncmanager.util.durationBetween
|
||||||
|
import com.luklpz.tfg.syncmanager.util.formatDateTime
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LogDetailScreen(
|
||||||
|
logId: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: LogDetailViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
LaunchedEffect(logId) { viewModel.loadLog(logId) }
|
||||||
|
val state by viewModel.log.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Detalle del log #$logId") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Volver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Loading -> Box(
|
||||||
|
Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
|
||||||
|
is UiState.Error -> Box(
|
||||||
|
Modifier.fillMaxSize().padding(padding).padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(Icons.Default.Error, null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(s.message, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is UiState.Success -> {
|
||||||
|
val log = s.data
|
||||||
|
val hasErrors = (log.itemsFailed ?: 0) > 0
|
||||||
|
val isRunning = log.finishedAt == null
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
// Status banner
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = when {
|
||||||
|
isRunning -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
hasErrors -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
else -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when {
|
||||||
|
isRunning -> Icons.Default.Timer
|
||||||
|
hasErrors -> Icons.Default.Error
|
||||||
|
else -> Icons.Default.CheckCircle
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
tint = when {
|
||||||
|
isRunning -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
hasErrors -> MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
else -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
isRunning -> "En curso"
|
||||||
|
hasErrors -> "${log.itemsFailed} elemento(s) fallaron"
|
||||||
|
else -> "Completado sin errores"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleSmall
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
syncTypeDescription(log.syncType),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info section
|
||||||
|
SectionTitle("Información")
|
||||||
|
InfoRow("Tipo de sync", syncTypeDescription(log.syncType))
|
||||||
|
InfoRow("Inicio", log.startedAt.formatDateTime())
|
||||||
|
InfoRow("Fin", log.finishedAt.formatDateTime())
|
||||||
|
InfoRow("Duración", durationBetween(log.startedAt, log.finishedAt))
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
// Results section
|
||||||
|
SectionTitle("Resultado")
|
||||||
|
InfoRow("Procesados correctamente", "${log.itemsProcessed ?: 0}")
|
||||||
|
InfoRow("Fallidos", "${log.itemsFailed ?: 0}",
|
||||||
|
valueColor = if (hasErrors) MaterialTheme.colorScheme.error else null)
|
||||||
|
|
||||||
|
// Error details
|
||||||
|
if (!log.errorDetails.isNullOrBlank()) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionTitle("Detalle de errores")
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = log.errorDetails,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(title: String) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
valueColor: androidx.compose.ui.graphics.Color? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 3.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = valueColor ?: MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncTypeDescription(type: String) = when (type) {
|
||||||
|
"PRODUCT_PUSH" -> "Productos: Dolibarr → PrestaShop"
|
||||||
|
"STOCK_PUSH" -> "Stock: Dolibarr → PrestaShop"
|
||||||
|
"ORDER_PULL" -> "Pedidos: PrestaShop → Dolibarr"
|
||||||
|
else -> type
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.logs
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncLogResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.repository.SyncRepository
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class LogDetailViewModel @Inject constructor(
|
||||||
|
private val repository: SyncRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _log = MutableStateFlow<UiState<SyncLogResponse>>(UiState.Loading)
|
||||||
|
val log = _log.asStateFlow()
|
||||||
|
|
||||||
|
fun loadLog(id: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_log.value = UiState.Loading
|
||||||
|
_log.value = try {
|
||||||
|
UiState.Success(repository.getLogById(id))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
UiState.Error(e.message ?: "Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.logs
|
||||||
|
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
import androidx.compose.material.icons.filled.Inbox
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Timer
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncLogResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import com.luklpz.tfg.syncmanager.util.durationBetween
|
||||||
|
import com.luklpz.tfg.syncmanager.util.formatRelativeDateTime
|
||||||
|
|
||||||
|
private val TYPE_FILTERS = listOf(
|
||||||
|
null to "Todos",
|
||||||
|
"PRODUCT_PUSH" to "Productos",
|
||||||
|
"STOCK_PUSH" to "Stock",
|
||||||
|
"ORDER_PULL" to "Pedidos"
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LogsScreen(
|
||||||
|
onLogClick: (Long) -> Unit,
|
||||||
|
viewModel: LogsViewModel = hiltViewModel()
|
||||||
|
) {
|
||||||
|
val logs by viewModel.logs.collectAsState()
|
||||||
|
val activeFilter by viewModel.activeFilter.collectAsState()
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(logs) { if (logs !is UiState.Loading) isRefreshing = false }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Historial de sincronización")
|
||||||
|
Text("Registros de cada ejecución",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
isRefreshing = true
|
||||||
|
viewModel.loadLogs(activeFilter)
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Recargar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.padding(padding)) {
|
||||||
|
LazyRow(
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(TYPE_FILTERS) { (value, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = activeFilter == value,
|
||||||
|
onClick = { viewModel.loadLogs(if (activeFilter == value) null else value) },
|
||||||
|
label = { Text(label) },
|
||||||
|
leadingIcon = if (activeFilter == value) {
|
||||||
|
{ Icon(Icons.Default.FilterList, null, modifier = Modifier.size(16.dp)) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = { isRefreshing = true; viewModel.loadLogs(activeFilter) },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = rememberPullToRefreshState()
|
||||||
|
) {
|
||||||
|
when (val state = logs) {
|
||||||
|
is UiState.Loading -> if (!isRefreshing) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is UiState.Error -> Box(
|
||||||
|
Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(Icons.Default.Error, null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(state.message, color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
isRefreshing = true
|
||||||
|
viewModel.loadLogs(activeFilter)
|
||||||
|
}) { Text("Reintentar") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (state.data.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(Icons.Default.Inbox, null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text("No hay registros de sincronización",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(state.data) { log ->
|
||||||
|
LogItem(log = log, onClick = { onLogClick(log.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogItem(log: SyncLogResponse, onClick: () -> Unit) {
|
||||||
|
val hasErrors = (log.itemsFailed ?: 0) > 0
|
||||||
|
val isRunning = log.finishedAt == null
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = when {
|
||||||
|
hasErrors -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f)
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when {
|
||||||
|
isRunning -> Icons.Default.Timer
|
||||||
|
hasErrors -> Icons.Default.Error
|
||||||
|
else -> Icons.Default.CheckCircle
|
||||||
|
},
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = when {
|
||||||
|
isRunning -> MaterialTheme.colorScheme.secondary
|
||||||
|
hasErrors -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = syncTypeLabel(log.syncType),
|
||||||
|
style = MaterialTheme.typography.titleSmall
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
log.startedAt.formatRelativeDateTime(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
"✓ ${log.itemsProcessed ?: 0} procesados",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (hasErrors) {
|
||||||
|
Text(
|
||||||
|
"✗ ${log.itemsFailed} fallidos",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
durationBetween(log.startedAt, log.finishedAt),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ChevronRight,
|
||||||
|
contentDescription = "Ver detalle",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncTypeLabel(type: String) = when (type) {
|
||||||
|
"PRODUCT_PUSH" -> "Productos"
|
||||||
|
"STOCK_PUSH" -> "Stock"
|
||||||
|
"ORDER_PULL" -> "Pedidos"
|
||||||
|
else -> type
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.logs
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.SyncLogResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.repository.SyncRepository
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class LogsViewModel @Inject constructor(
|
||||||
|
private val repository: SyncRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _logs = MutableStateFlow<UiState<List<SyncLogResponse>>>(UiState.Loading)
|
||||||
|
val logs = _logs.asStateFlow()
|
||||||
|
|
||||||
|
private val _activeFilter = MutableStateFlow<String?>(null)
|
||||||
|
val activeFilter = _activeFilter.asStateFlow()
|
||||||
|
|
||||||
|
init { loadLogs() }
|
||||||
|
|
||||||
|
fun loadLogs(type: String? = _activeFilter.value) {
|
||||||
|
_activeFilter.value = type
|
||||||
|
viewModelScope.launch {
|
||||||
|
_logs.value = UiState.Loading
|
||||||
|
_logs.value = try {
|
||||||
|
UiState.Success(repository.getLogs(type))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
UiState.Error(e.message ?: "Error al cargar logs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.mappings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
import androidx.compose.material.icons.filled.Inbox
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SuggestionChip
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
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.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.OrderMappingResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.ProductMappingResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import com.luklpz.tfg.syncmanager.util.formatDateTime
|
||||||
|
|
||||||
|
private val PRODUCT_FILTERS = listOf(
|
||||||
|
null to "Todos",
|
||||||
|
"SYNCED" to "Sincronizados",
|
||||||
|
"PENDING" to "Pendientes",
|
||||||
|
"ERROR" to "Con error"
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MappingsScreen(viewModel: MappingsViewModel = hiltViewModel()) {
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
val products by viewModel.products.collectAsState()
|
||||||
|
val orders by viewModel.orders.collectAsState()
|
||||||
|
val productFilter by viewModel.productFilter.collectAsState()
|
||||||
|
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||||
|
|
||||||
|
val productCount = (products as? UiState.Success)?.data?.size
|
||||||
|
val orderCount = (orders as? UiState.Success)?.data?.size
|
||||||
|
|
||||||
|
var isRefreshingProducts by remember { mutableStateOf(false) }
|
||||||
|
var isRefreshingOrders by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(products) { if (products !is UiState.Loading) isRefreshingProducts = false }
|
||||||
|
LaunchedEffect(orders) { if (orders !is UiState.Loading) isRefreshingOrders = false }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Mappings") },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (selectedTab == 0) {
|
||||||
|
isRefreshingProducts = true
|
||||||
|
viewModel.loadProductMappings()
|
||||||
|
} else {
|
||||||
|
isRefreshingOrders = true
|
||||||
|
viewModel.loadOrderMappings()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Recargar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.padding(padding)) {
|
||||||
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == 0,
|
||||||
|
onClick = { selectedTab = 0 },
|
||||||
|
text = {
|
||||||
|
Text(if (productCount != null) "Productos ($productCount)" else "Productos")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == 1,
|
||||||
|
onClick = { selectedTab = 1 },
|
||||||
|
text = {
|
||||||
|
Text(if (orderCount != null) "Pedidos ($orderCount)" else "Pedidos")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTab == 0) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = viewModel::updateSearchQuery,
|
||||||
|
placeholder = { Text("Buscar por SKU…") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, null, modifier = Modifier.size(20.dp)) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
contentPadding =
|
||||||
|
PaddingValues(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(PRODUCT_FILTERS) { (value, label) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = productFilter == value,
|
||||||
|
onClick = { viewModel.setProductFilter(if (productFilter == value) null else value) },
|
||||||
|
label = { Text(label) },
|
||||||
|
leadingIcon = if (productFilter == value) {
|
||||||
|
{ Icon(Icons.Default.FilterList, null, modifier = Modifier.size(16.dp)) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshingProducts,
|
||||||
|
onRefresh = { isRefreshingProducts = true; viewModel.loadProductMappings() },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = rememberPullToRefreshState()
|
||||||
|
) {
|
||||||
|
when (val state = products) {
|
||||||
|
is UiState.Loading -> if (!isRefreshingProducts) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is UiState.Error -> EmptyOrErrorState(
|
||||||
|
isError = true,
|
||||||
|
message = state.message,
|
||||||
|
onRetry = { isRefreshingProducts = true; viewModel.loadProductMappings() }
|
||||||
|
)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (state.data.isEmpty()) {
|
||||||
|
EmptyOrErrorState(
|
||||||
|
isError = false,
|
||||||
|
message = "No hay productos mapeados.\nEjecuta la sync de productos desde el Dashboard.",
|
||||||
|
onRetry = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(state.data) { ProductMappingItem(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshingOrders,
|
||||||
|
onRefresh = { isRefreshingOrders = true; viewModel.loadOrderMappings() },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = rememberPullToRefreshState()
|
||||||
|
) {
|
||||||
|
when (val state = orders) {
|
||||||
|
is UiState.Loading -> if (!isRefreshingOrders) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is UiState.Error -> EmptyOrErrorState(
|
||||||
|
isError = true,
|
||||||
|
message = state.message,
|
||||||
|
onRetry = { isRefreshingOrders = true; viewModel.loadOrderMappings() }
|
||||||
|
)
|
||||||
|
is UiState.Success -> {
|
||||||
|
if (state.data.isEmpty()) {
|
||||||
|
EmptyOrErrorState(
|
||||||
|
isError = false,
|
||||||
|
message = "No hay pedidos importados.\nEjecuta la sync de pedidos desde el Dashboard.",
|
||||||
|
onRetry = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(state.data) { OrderMappingItem(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProductMappingItem(item: ProductMappingResponse) {
|
||||||
|
val isError = item.syncStatus == "ERROR"
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (isError)
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(item.sku, style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
"Dolibarr ID: ${item.dolibarrId ?: "sin asignar"} · PrestaShop ID: ${item.prestashopId ?: "sin asignar"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Última sync: ${item.lastSyncedAt.formatDateTime()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
StatusChip(item.syncStatus)
|
||||||
|
}
|
||||||
|
if (isError && !item.errorMessage.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Error, null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(item.errorMessage, style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OrderMappingItem(item: OrderMappingResponse) {
|
||||||
|
val isError = item.status == "ERROR"
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (isError)
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Pedido PS #${item.prestashopOrderId}",
|
||||||
|
style = MaterialTheme.typography.titleSmall)
|
||||||
|
Text(
|
||||||
|
"Comanda Dolibarr: ${item.dolibarrOrderId ?: "sin crear"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Factura: ${item.dolibarrInvoiceId ?: "sin generar"} · ${item.importedAt.formatDateTime()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
StatusChip(item.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusChip(status: String) {
|
||||||
|
val (color, label) = when (status) {
|
||||||
|
"SYNCED" -> MaterialTheme.colorScheme.primary to "Sincronizado"
|
||||||
|
"INVOICED" -> MaterialTheme.colorScheme.primary to "Facturado"
|
||||||
|
"IMPORTED" -> MaterialTheme.colorScheme.tertiary to "Importado"
|
||||||
|
"PENDING" -> MaterialTheme.colorScheme.secondary to "Pendiente"
|
||||||
|
"ERROR" -> MaterialTheme.colorScheme.error to "Error"
|
||||||
|
else -> MaterialTheme.colorScheme.outline to status
|
||||||
|
}
|
||||||
|
SuggestionChip(
|
||||||
|
onClick = {},
|
||||||
|
label = { Text(label, style = MaterialTheme.typography.labelSmall, color = color) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyOrErrorState(isError: Boolean, message: String, onRetry: (() -> Unit)?) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(
|
||||||
|
if (isError) Icons.Default.Error else Icons.Default.Inbox,
|
||||||
|
null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = if (isError) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isError) MaterialTheme.colorScheme.error
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
if (onRetry != null) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Button(onClick = onRetry) { Text("Reintentar") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.mappings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.OrderMappingResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.model.ProductMappingResponse
|
||||||
|
import com.luklpz.tfg.syncmanager.data.repository.SyncRepository
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class MappingsViewModel @Inject constructor(
|
||||||
|
private val repository: SyncRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _rawProducts = MutableStateFlow<UiState<List<ProductMappingResponse>>>(UiState.Loading)
|
||||||
|
|
||||||
|
private val _orders = MutableStateFlow<UiState<List<OrderMappingResponse>>>(UiState.Loading)
|
||||||
|
val orders = _orders.asStateFlow()
|
||||||
|
|
||||||
|
private val _productFilter = MutableStateFlow<String?>(null)
|
||||||
|
val productFilter = _productFilter.asStateFlow()
|
||||||
|
|
||||||
|
private val _searchQuery = MutableStateFlow("")
|
||||||
|
val searchQuery = _searchQuery.asStateFlow()
|
||||||
|
|
||||||
|
val products = combine(_rawProducts, _searchQuery) { state, query ->
|
||||||
|
when {
|
||||||
|
state !is UiState.Success -> state
|
||||||
|
query.isBlank() -> state
|
||||||
|
else -> UiState.Success(
|
||||||
|
state.data.filter { it.sku.contains(query, ignoreCase = true) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, UiState.Loading)
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadProductMappings()
|
||||||
|
loadOrderMappings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSearchQuery(query: String) { _searchQuery.value = query }
|
||||||
|
|
||||||
|
fun setProductFilter(status: String?) {
|
||||||
|
_productFilter.value = status
|
||||||
|
loadProductMappings(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadProductMappings(status: String? = _productFilter.value) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_rawProducts.value = UiState.Loading
|
||||||
|
_rawProducts.value = try {
|
||||||
|
UiState.Success(repository.getProductMappings(status))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
UiState.Error(e.message ?: "Error al cargar productos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadOrderMappings() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_orders.value = UiState.Loading
|
||||||
|
_orders.value = try {
|
||||||
|
UiState.Success(repository.getOrderMappings())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
UiState.Error(e.message ?: "Error al cargar pedidos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.AppViewModel
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.dashboard.DashboardScreen
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.logs.LogDetailScreen
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.logs.LogsScreen
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.mappings.MappingsScreen
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.settings.SettingsScreen
|
||||||
|
|
||||||
|
private sealed class Screen(val route: String, val label: String, val icon: ImageVector) {
|
||||||
|
object Dashboard : Screen("dashboard", "Dashboard", Icons.Default.Home)
|
||||||
|
object Mappings : Screen("mappings", "Mappings", Icons.AutoMirrored.Filled.List)
|
||||||
|
object Logs : Screen("logs", "Logs", Icons.Default.History)
|
||||||
|
object Settings : Screen("settings", "Ajustes", Icons.Default.Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bottomNavItems = listOf(
|
||||||
|
Screen.Dashboard, Screen.Mappings, Screen.Logs, Screen.Settings
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppNavGraph() {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
val currentRoute = currentDestination?.route
|
||||||
|
|
||||||
|
val appViewModel = hiltViewModel<AppViewModel>()
|
||||||
|
val isOnline by appViewModel.isOnline.collectAsState()
|
||||||
|
val errorLogCount by appViewModel.errorLogCount.collectAsState()
|
||||||
|
|
||||||
|
val showBottomBar = bottomNavItems.any { it.route == currentRoute }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomBar) {
|
||||||
|
NavigationBar {
|
||||||
|
bottomNavItems.forEach { screen ->
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = {
|
||||||
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
if (screen == Screen.Logs && errorLogCount > 0) {
|
||||||
|
Badge { Text("$errorLogCount") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(screen.icon, contentDescription = screen.label)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text(screen.label) },
|
||||||
|
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(screen.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Screen.Dashboard.route,
|
||||||
|
modifier = Modifier.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
composable(Screen.Dashboard.route) {
|
||||||
|
DashboardScreen(
|
||||||
|
isOnline = isOnline,
|
||||||
|
onNavigateToSettings = {
|
||||||
|
navController.navigate(Screen.Settings.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Mappings.route) { MappingsScreen() }
|
||||||
|
composable(Screen.Logs.route) {
|
||||||
|
LogsScreen(onLogClick = { logId ->
|
||||||
|
navController.navigate("logs/$logId")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "logs/{logId}",
|
||||||
|
arguments = listOf(navArgument("logId") { type = NavType.LongType })
|
||||||
|
) { backStackEntry ->
|
||||||
|
val logId = backStackEntry.arguments?.getLong("logId") ?: return@composable
|
||||||
|
LogDetailScreen(logId = logId, onBack = { navController.popBackStack() })
|
||||||
|
}
|
||||||
|
composable(Screen.Settings.route) { SettingsScreen() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.Key
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel()) {
|
||||||
|
val settings by viewModel.settings.collectAsState()
|
||||||
|
val saved by viewModel.saved.collectAsState()
|
||||||
|
val connectionTest by viewModel.connectionTest.collectAsState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(saved) {
|
||||||
|
if (saved) {
|
||||||
|
snackbarHostState.showSnackbar("Ajustes guardados correctamente")
|
||||||
|
viewModel.onSavedConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Ajustes de conexión")
|
||||||
|
Text("Configura el servidor sync-service",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text("Servidor", style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.serverUrl,
|
||||||
|
onValueChange = viewModel::updateServerUrl,
|
||||||
|
label = { Text("URL del servidor") },
|
||||||
|
placeholder = { Text("https://tu-servidor.up.railway.app/") },
|
||||||
|
supportingText = { Text("Incluye la barra final /") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Wifi, null) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text("Credenciales (Basic Auth)", style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.username,
|
||||||
|
onValueChange = viewModel::updateUsername,
|
||||||
|
label = { Text("Usuario") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
leadingIcon = { Icon(Icons.Default.Person, null) }
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.password,
|
||||||
|
onValueChange = viewModel::updatePassword,
|
||||||
|
label = { Text("Contraseña") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Key, null) },
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = if (passwordVisible) "Ocultar contraseña" else "Mostrar contraseña"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Test connection result
|
||||||
|
when (val ct = connectionTest) {
|
||||||
|
is UiState.Success -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.CheckCircle, null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Conexión exitosa", color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
is UiState.Error -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Error, null,
|
||||||
|
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(ct.message, color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.resetConnectionTest()
|
||||||
|
viewModel.testConnection()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = connectionTest !is UiState.Loading
|
||||||
|
) {
|
||||||
|
if (connectionTest is UiState.Loading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.Wifi, null, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Probar")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = viewModel::save,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Save, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text("Guardar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.settings
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.luklpz.tfg.syncmanager.data.datastore.AppSettings
|
||||||
|
import com.luklpz.tfg.syncmanager.data.datastore.SettingsDataStore
|
||||||
|
import com.luklpz.tfg.syncmanager.data.repository.SyncRepository
|
||||||
|
import com.luklpz.tfg.syncmanager.ui.UiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val repository: SyncRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _settings = MutableStateFlow(
|
||||||
|
AppSettings(SettingsDataStore.DEFAULT_URL, SettingsDataStore.DEFAULT_USERNAME, SettingsDataStore.DEFAULT_PASSWORD)
|
||||||
|
)
|
||||||
|
val settings = _settings.asStateFlow()
|
||||||
|
|
||||||
|
private val _saved = MutableStateFlow(false)
|
||||||
|
val saved = _saved.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectionTest = MutableStateFlow<UiState<Unit>>(UiState.Idle)
|
||||||
|
val connectionTest = _connectionTest.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.settingsFlow.collect { _settings.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateServerUrl(url: String) = _settings.update { it.copy(serverUrl = url) }
|
||||||
|
fun updateUsername(username: String) = _settings.update { it.copy(username = username) }
|
||||||
|
fun updatePassword(password: String) = _settings.update { it.copy(password = password) }
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.saveSettings(_settings.value)
|
||||||
|
_saved.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun testConnection() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_connectionTest.value = UiState.Loading
|
||||||
|
_connectionTest.value = try {
|
||||||
|
repository.testConnection(_settings.value)
|
||||||
|
UiState.Success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val msg = when {
|
||||||
|
e.message?.contains("401") == true || e.message?.contains("Unauthorized") == true ->
|
||||||
|
"Credenciales incorrectas (401)"
|
||||||
|
e.message?.contains("Unable to resolve host") == true ->
|
||||||
|
"URL no alcanzable. Verifica la dirección del servidor."
|
||||||
|
e.message?.contains("timeout") == true ->
|
||||||
|
"Tiempo de espera agotado. El servidor no responde."
|
||||||
|
else -> e.message ?: "Error de conexión"
|
||||||
|
}
|
||||||
|
UiState.Error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSavedConsumed() { _saved.value = false }
|
||||||
|
fun resetConnectionTest() { _connectionTest.value = UiState.Idle }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Blue80 = Color(0xFFBBDEFB)
|
||||||
|
val BlueGrey80 = Color(0xFFB0BEC5)
|
||||||
|
val Teal80 = Color(0xFFB2EBF2)
|
||||||
|
|
||||||
|
val Blue40 = Color(0xFF1565C0)
|
||||||
|
val BlueGrey40 = Color(0xFF546E7A)
|
||||||
|
val Teal40 = Color(0xFF00838F)
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Blue80,
|
||||||
|
secondary = BlueGrey80,
|
||||||
|
tertiary = Teal80
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Blue40,
|
||||||
|
secondary = BlueGrey40,
|
||||||
|
tertiary = Teal40
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SyncManagerTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ConnectivityObserver @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
val isOnline: Flow<Boolean> = callbackFlow {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) { trySend(true) }
|
||||||
|
override fun onLost(network: Network) { trySend(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
cm.registerNetworkCallback(
|
||||||
|
NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build(),
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
|
||||||
|
val current = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
|
||||||
|
trySend(current?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true)
|
||||||
|
|
||||||
|
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package com.luklpz.tfg.syncmanager.util
|
||||||
|
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
private val DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")
|
||||||
|
|
||||||
|
fun String?.formatDateTime(): String {
|
||||||
|
if (this == null) return "—"
|
||||||
|
return try {
|
||||||
|
DISPLAY_FORMAT.format(Instant.parse(this).atZone(ZoneId.systemDefault()))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String?.formatRelativeDateTime(): String {
|
||||||
|
if (this == null) return "—"
|
||||||
|
return try {
|
||||||
|
val seconds = Duration.between(Instant.parse(this), Instant.now()).seconds
|
||||||
|
when {
|
||||||
|
seconds < 60 -> "hace menos de 1 min"
|
||||||
|
seconds < 3600 -> "hace ${seconds / 60} min"
|
||||||
|
seconds < 86400 -> "hace ${seconds / 3600}h"
|
||||||
|
else -> formatDateTime()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun durationBetween(startedAt: String, finishedAt: String?): String {
|
||||||
|
if (finishedAt == null) return "En curso…"
|
||||||
|
return try {
|
||||||
|
val seconds = Duration.between(Instant.parse(startedAt), Instant.parse(finishedAt)).seconds
|
||||||
|
when {
|
||||||
|
seconds < 60 -> "${seconds}s"
|
||||||
|
else -> "${seconds / 60}m ${seconds % 60}s"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"—"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#1565C0" />
|
||||||
|
</shape>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,24 C38.536,24 26,36.536 26,52 C26,67.464 38.536,80 54,80 C69.464,80 82,67.464 82,52 C82,36.536 69.464,24 54,24 Z M54,32 C65.046,32 74,40.954 74,52 C74,63.046 65.046,72 54,72 C42.954,72 34,63.046 34,52 C34,40.954 42.954,32 54,32 Z M54,40 L54,52 L64,52 L64,56 L50,56 L50,40 Z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Sync Manager</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.SyncManager" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||||
|
|
||||||
|
<style name="Theme.SyncManager.Splash" parent="Theme.SplashScreen">
|
||||||
|
<item name="postSplashScreenTheme">@style/Theme.SyncManager</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.hilt.android) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
android.suppressUnsupportedCompileSdk=35
|
||||||
|
kotlin.code.style=official
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
[versions]
|
||||||
|
agp = "8.5.0"
|
||||||
|
kotlin = "2.0.0"
|
||||||
|
ksp = "2.0.0-1.0.21"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.2.1"
|
||||||
|
espressoCore = "3.6.1"
|
||||||
|
lifecycleRuntimeKtx = "2.8.3"
|
||||||
|
activityCompose = "1.9.0"
|
||||||
|
composeBom = "2024.09.00"
|
||||||
|
navigationCompose = "2.7.7"
|
||||||
|
hilt = "2.51.1"
|
||||||
|
hiltNavigationCompose = "1.2.0"
|
||||||
|
retrofit = "2.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
coroutines = "1.8.1"
|
||||||
|
datastore = "1.1.1"
|
||||||
|
splashscreen = "1.0.1"
|
||||||
|
material = "1.12.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||||||
|
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||||
|
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
|
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||||
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
|
||||||
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/sh
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
APP_HOME=`pwd -P`
|
||||||
|
exec "$JAVACMD" "$DEFAULT_JVM_OPTS" $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
setlocal
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
"%JAVA_HOME%\bin\java.exe" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "SyncManager"
|
||||||
|
include(":app")
|
||||||
Loading…
Reference in New Issue