diff --git a/android-app/.gitignore b/android-app/.gitignore
new file mode 100644
index 0000000..3324b6a
--- /dev/null
+++ b/android-app/.gitignore
@@ -0,0 +1,11 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+**/build/
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts
new file mode 100644
index 0000000..7e3de83
--- /dev/null
+++ b/android-app/app/build.gradle.kts
@@ -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)
+}
diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro
new file mode 100644
index 0000000..91f9fcd
--- /dev/null
+++ b/android-app/app/proguard-rules.pro
@@ -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.**
diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..64a9d3c
--- /dev/null
+++ b/android-app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/MainActivity.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/MainActivity.kt
new file mode 100644
index 0000000..84c83ee
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/MainActivity.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/SyncManagerApp.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/SyncManagerApp.kt
new file mode 100644
index 0000000..b869172
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/SyncManagerApp.kt
@@ -0,0 +1,7 @@
+package com.luklpz.tfg.syncmanager
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class SyncManagerApp : Application()
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/api/SyncApi.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/api/SyncApi.kt
new file mode 100644
index 0000000..d1fc19c
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/api/SyncApi.kt
@@ -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
+
+ @GET("api/mappings/orders")
+ suspend fun getOrderMappings(): List
+
+ @GET("api/logs")
+ suspend fun getLogs(@Query("type") type: String? = null): List
+
+ @GET("api/logs/{id}")
+ suspend fun getLogById(@Path("id") id: Long): SyncLogResponse
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/datastore/SettingsDataStore.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/datastore/SettingsDataStore.kt
new file mode 100644
index 0000000..39dfe38
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/datastore/SettingsDataStore.kt
@@ -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 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 = 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
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/OrderMappingResponse.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/OrderMappingResponse.kt
new file mode 100644
index 0000000..d495f48
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/OrderMappingResponse.kt
@@ -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
+)
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/ProductMappingResponse.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/ProductMappingResponse.kt
new file mode 100644
index 0000000..4c8a6d5
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/ProductMappingResponse.kt
@@ -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?
+)
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/SyncLogResponse.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/SyncLogResponse.kt
new file mode 100644
index 0000000..c16da75
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/SyncLogResponse.kt
@@ -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?
+)
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/SyncTriggerResponse.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/SyncTriggerResponse.kt
new file mode 100644
index 0000000..b1ede5e
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/model/SyncTriggerResponse.kt
@@ -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
+)
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/AuthInterceptor.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/AuthInterceptor.kt
new file mode 100644
index 0000000..8f84b11
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/AuthInterceptor.kt
@@ -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)
+ }
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/DynamicUrlInterceptor.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/DynamicUrlInterceptor.kt
new file mode 100644
index 0000000..a8ae5c7
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/DynamicUrlInterceptor.kt
@@ -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())
+ }
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/ServerConfig.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/ServerConfig.kt
new file mode 100644
index 0000000..5ae73fe
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/remote/ServerConfig.kt
@@ -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"
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/repository/SyncRepository.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/repository/SyncRepository.kt
new file mode 100644
index 0000000..4f47543
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/data/repository/SyncRepository.kt
@@ -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 = 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 {
+ refreshConfig(); return syncApi.getProductMappings(status)
+ }
+
+ suspend fun getOrderMappings(): List {
+ refreshConfig(); return syncApi.getOrderMappings()
+ }
+
+ suspend fun getLogs(type: String? = null): List {
+ 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()
+ }
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/di/NetworkModule.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/di/NetworkModule.kt
new file mode 100644
index 0000000..a2a3840
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/di/NetworkModule.kt
@@ -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)
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/AppViewModel.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/AppViewModel.kt
new file mode 100644
index 0000000..698e7a9
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/AppViewModel.kt
@@ -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) {}
+ }
+ }
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/UiState.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/UiState.kt
new file mode 100644
index 0000000..386e4f7
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/UiState.kt
@@ -0,0 +1,8 @@
+package com.luklpz.tfg.syncmanager.ui
+
+sealed class UiState {
+ object Idle : UiState()
+ object Loading : UiState()
+ data class Success(val data: T) : UiState()
+ data class Error(val message: String) : UiState()
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/dashboard/DashboardScreen.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/dashboard/DashboardScreen.kt
new file mode 100644
index 0000000..064cf6d
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/dashboard/DashboardScreen.kt
@@ -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,
+ 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…"
+}
diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/dashboard/DashboardViewModel.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/dashboard/DashboardViewModel.kt
new file mode 100644
index 0000000..3ed1f36
--- /dev/null
+++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/dashboard/DashboardViewModel.kt
@@ -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.Idle)
+ val productsState = _productsState.asStateFlow()
+
+ private val _stockState = MutableStateFlow>(UiState.Idle)
+ val stockState = _stockState.asStateFlow()
+
+ private val _ordersState = MutableStateFlow>(UiState.Idle)
+ val ordersState = _ordersState.asStateFlow()
+
+ private val _lastLogs = MutableStateFlow