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:
luklpz 2026-05-15 12:48:54 +02:00
parent b55805665b
commit 8d6c69d47b
49 changed files with 2759 additions and 0 deletions

11
android-app/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
**/build/
/captures
.externalNativeBuild
.cxx
local.properties

View File

@ -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)
}

6
android-app/app/proguard-rules.pro vendored Normal file
View File

@ -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.**

View File

@ -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>

View File

@ -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()
}
}
}
}

View File

@ -0,0 +1,7 @@
package com.luklpz.tfg.syncmanager
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class SyncManagerApp : Application()

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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
)

View File

@ -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?
)

View File

@ -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?
)

View File

@ -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>
)

View File

@ -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)
}
}

View File

@ -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())
}
}

View File

@ -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"
}

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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) {}
}
}
}

View File

@ -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>()
}

View File

@ -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…"
}

View File

@ -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")
}
}
}
}

View File

@ -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
}

View File

@ -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")
}
}
}
}

View File

@ -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
}

View File

@ -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")
}
}
}
}

View File

@ -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") }
}
}
}
}

View File

@ -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")
}
}
}
}

View File

@ -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() }
}
}
}

View File

@ -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")
}
}
}
}
}

View File

@ -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 }
}

View File

@ -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)

View File

@ -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
)
}

View File

@ -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
)
)

View File

@ -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()
}

View File

@ -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) {
"—"
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Sync Manager</string>
</resources>

View File

@ -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>

View File

@ -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
}

View File

@ -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

View File

@ -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.

View File

@ -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

7
android-app/gradlew vendored Normal file
View File

@ -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 "$@"

9
android-app/gradlew.bat vendored Normal file
View File

@ -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 %*

View File

@ -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")