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>(emptyMap()) + val lastLogs = _lastLogs.asStateFlow() + + private val _syncAllStep = MutableStateFlow(SyncAllStep.IDLE) + val syncAllStep = _syncAllStep.asStateFlow() + + private val _pendingSync = MutableStateFlow(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(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>, + 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") + } + } + } +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogDetailScreen.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogDetailScreen.kt new file mode 100644 index 0000000..1ee31e1 --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogDetailScreen.kt @@ -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 +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogDetailViewModel.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogDetailViewModel.kt new file mode 100644 index 0000000..71c2dac --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogDetailViewModel.kt @@ -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.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") + } + } + } +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogsScreen.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogsScreen.kt new file mode 100644 index 0000000..8b1140b --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogsScreen.kt @@ -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 +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogsViewModel.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogsViewModel.kt new file mode 100644 index 0000000..a4b7b2f --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/logs/LogsViewModel.kt @@ -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.Loading) + val logs = _logs.asStateFlow() + + private val _activeFilter = MutableStateFlow(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") + } + } + } +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/mappings/MappingsScreen.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/mappings/MappingsScreen.kt new file mode 100644 index 0000000..54544c2 --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/mappings/MappingsScreen.kt @@ -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") } + } + } + } +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/mappings/MappingsViewModel.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/mappings/MappingsViewModel.kt new file mode 100644 index 0000000..020cf6e --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/mappings/MappingsViewModel.kt @@ -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.Loading) + + private val _orders = MutableStateFlow>>(UiState.Loading) + val orders = _orders.asStateFlow() + + private val _productFilter = MutableStateFlow(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") + } + } + } +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/navigation/AppNavGraph.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/navigation/AppNavGraph.kt new file mode 100644 index 0000000..895a797 --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/navigation/AppNavGraph.kt @@ -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() + 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() } + } + } +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/settings/SettingsScreen.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..7f7ccf8 --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/settings/SettingsScreen.kt @@ -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") + } + } + } + } +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/settings/SettingsViewModel.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..2be5067 --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/settings/SettingsViewModel.kt @@ -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.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 } +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Color.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Color.kt new file mode 100644 index 0000000..9bc6f9f --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Color.kt @@ -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) diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Theme.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Theme.kt new file mode 100644 index 0000000..98eedb1 --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Type.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Type.kt new file mode 100644 index 0000000..f76da33 --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/ui/theme/Type.kt @@ -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 + ) +) diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/util/ConnectivityObserver.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/util/ConnectivityObserver.kt new file mode 100644 index 0000000..188f3aa --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/util/ConnectivityObserver.kt @@ -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 = 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() +} diff --git a/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/util/DateUtils.kt b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/util/DateUtils.kt new file mode 100644 index 0000000..e5cffd5 --- /dev/null +++ b/android-app/app/src/main/java/com/luklpz/tfg/syncmanager/util/DateUtils.kt @@ -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) { + "—" + } +} diff --git a/android-app/app/src/main/res/drawable/ic_launcher_background.xml b/android-app/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..a2e8bb5 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c8621c1 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c5a84e5 --- /dev/null +++ b/android-app/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Sync Manager + diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..751fa27 --- /dev/null +++ b/android-app/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts new file mode 100644 index 0000000..922365d --- /dev/null +++ b/android-app/build.gradle.kts @@ -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 +} diff --git a/android-app/gradle.properties b/android-app/gradle.properties new file mode 100644 index 0000000..d17a316 --- /dev/null +++ b/android-app/gradle.properties @@ -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 diff --git a/android-app/gradle/libs.versions.toml b/android-app/gradle/libs.versions.toml new file mode 100644 index 0000000..47343ec --- /dev/null +++ b/android-app/gradle/libs.versions.toml @@ -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" } diff --git a/android-app/gradle/wrapper/gradle-wrapper.jar b/android-app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/android-app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-app/gradle/wrapper/gradle-wrapper.properties b/android-app/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/android-app/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android-app/gradlew b/android-app/gradlew new file mode 100644 index 0000000..b56d5df --- /dev/null +++ b/android-app/gradlew @@ -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 "$@" \ No newline at end of file diff --git a/android-app/gradlew.bat b/android-app/gradlew.bat new file mode 100644 index 0000000..0f57941 --- /dev/null +++ b/android-app/gradlew.bat @@ -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 %* \ No newline at end of file diff --git a/android-app/settings.gradle.kts b/android-app/settings.gradle.kts new file mode 100644 index 0000000..c46eae5 --- /dev/null +++ b/android-app/settings.gradle.kts @@ -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")