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