Kotlin Multiplatform

NutriTrack — App Móvil KMP

Arquitectura shared-first: lógica de negocio común para Android e iOS

~65%
código compartido
commonMain — dominio + red + DB
androidMain — OkHttp + Room Driver
iosMain — Darwin + Native Driver

Estructura del módulo shared

Android
Jetpack Compose
  • MainActivity
  • NutriTrackNavGraph
  • FoodLogScreen
  • MacroDashboard
  • EncryptedSharedPrefs
  • FCM Push Notifications
shared / commonMain
Domain: FoodEntry · MacroSummary · UserGoal
Repository: FoodRepository
Network: NutriTrackApi (Ktor)
Database: SQLDelight (FoodEntry.sq)
ViewModel: FoodLogViewModel (StateFlow)
expect: SecureStorage · PushToken
iOS
SwiftUI
  • NutriTrackApp.swift
  • ContentView
  • FoodLogView
  • MacroDashboardView
  • Keychain (SecureStorage)
  • APNs Push Notifications
📦
Modelos de dominio y Repositorio
commonMain — se compila igual para Android e iOS
domain/FoodEntry.kt commonMain
@Serializable
data class FoodEntry(
    val id: String,
    val userId: String,
    val name: String,
    val calories: Int,
    val protein: Double,
    val carbs: Double,
    val fat: Double,
    val mealType: MealType,
    val loggedAt: Instant,
    val pendingSync: Boolean = false,
)

@Serializable
enum class MealType {
    BREAKFAST, LUNCH, DINNER, SNACK
}

@Serializable
data class MacroSummary(
    val date: LocalDate,
    val totalCalories: Int,
    val totalProtein: Double,
    val totalCarbs: Double,
    val totalFat: Double,
    val goal: UserGoal,
) {
    val caloriesProgress: Float
        get() = totalCalories.toFloat() / goal.dailyCalories
}
data/FoodRepository.kt commonMain
class FoodRepository(
    private val api: NutriTrackApi,
    private val db: FoodDatabase,
) {
    /** Observa entradas del día actual (offline-first) */
    fun observeTodayEntries(): Flow<List<FoodEntry>> =
        db.foodEntryQueries
            .selectToday(Clock.System.now())
            .asFlow().mapToList(Dispatchers.IO)
            .map { it.map(FoodEntityMapper::toDomain) }

    suspend fun logFood(entry: FoodEntry) {
        // Guarda local primero (respuesta instantánea)
        db.foodEntryQueries.insert(entry.toEntity())
        try {
            api.logFood(entry.toRequest())
            db.foodEntryQueries
                .clearPendingSync(entry.id)
        } catch (e: Exception) {
            // Sin señal → queda pending_sync = 1
            db.foodEntryQueries
                .markPendingSync(entry.id)
        }
    }

    suspend fun syncPending() {
        db.foodEntryQueries.getPendingSync()
            .executeAsList()
            .forEach { entity ->
                try {
                    api.logFood(entity.toRequest())
                    db.foodEntryQueries
                        .clearPendingSync(entity.id)
                } catch (_: Exception) { }
            }
    }
}
🌐
Cliente HTTP con Ktor
Paginación cursor-based + autenticación JWT con refresh automático
network/NutriTrackApi.kt commonMain
class NutriTrackApi(
    private val baseUrl: String,
    private val secureStorage: SecureStorage,  // expect/actual
) {
    private val client = HttpClient {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true; isLenient = true })
        }
        install(Auth) {
            bearer {
                loadTokens {
                    BearerTokens(secureStorage.getAccessToken()!!, "")
                }
                refreshTokens {
                    val refresh = secureStorage.getRefreshToken()
                        ?: throw UnauthorizedException()
                    val resp: TokenResponse = client.post("$baseUrl/auth/refresh") {
                        setBody(RefreshRequest(refresh))
                        markAsRefreshTokenRequest()
                    }.body()
                    secureStorage.saveTokens(resp.accessToken, resp.refreshToken)
                    BearerTokens(resp.accessToken, resp.refreshToken)
                }
            }
        }
    }

    suspend fun getFoodEntries(
        cursor: String? = null,
        limit: Int = 20,
    ): Page<FoodEntryResponse> =
        client.get("$baseUrl/api/food") {
            cursor?.let { parameter("cursor", it) }
            parameter("limit", limit)
        }.body()

    suspend fun logFood(request: LogFoodRequest): FoodEntryResponse =
        client.post("$baseUrl/api/food") { setBody(request) }.body()

    suspend fun getDailySummary(date: LocalDate): MacroSummaryResponse =
        client.get("$baseUrl/api/macros/$date").body()
}
🗄️
SQLDelight + expect/actual
Base de datos type-safe y código específico de plataforma
FoodEntry.sq SQLDelight
CREATE TABLE FoodEntryEntity (
    id TEXT NOT NULL PRIMARY KEY,
    user_id TEXT NOT NULL,
    name TEXT NOT NULL,
    calories INTEGER NOT NULL,
    protein REAL NOT NULL,
    carbs REAL NOT NULL,
    fat REAL NOT NULL,
    meal_type TEXT NOT NULL,
    logged_at INTEGER NOT NULL,
    pending_sync INTEGER NOT NULL DEFAULT 0
);

selectToday:
SELECT * FROM FoodEntryEntity
WHERE logged_at >= :startOfDay
ORDER BY logged_at DESC;

insert:
INSERT OR REPLACE INTO FoodEntryEntity
VALUES (?,?,?,?,?,?,?,?,?,?);

markPendingSync:
UPDATE FoodEntryEntity
SET pending_sync = 1
WHERE id = ?;

clearPendingSync:
UPDATE FoodEntryEntity
SET pending_sync = 0
WHERE id = ?;

getPendingSync:
SELECT * FROM FoodEntryEntity
WHERE pending_sync = 1;
platform/SecureStorage.kt expect
// commonMain — declara la interfaz
expect class SecureStorage {
    fun getAccessToken(): String?
    fun getRefreshToken(): String?
    fun saveTokens(
        access: String,
        refresh: String,
    )
    fun clear()
}

// commonMain — también expect para UUID
expect fun generateId(): String
expect fun getPlatformName(): String


// commonMain — ViewModel compartido
class FoodLogViewModel(
    private val repo: FoodRepository,
) : KMMViewModel() {

    private val _state = MutableStateFlow(
        FoodLogState()
    )
    val state: StateFlow<FoodLogState> =
        _state.asStateFlow()

    init {
        repo.observeTodayEntries()
            .onEach { entries ->
                _state.update {
                    it.copy(entries = entries)
                }
            }
            .launchIn(viewModelScope)
    }
}
platform/SecureStorage.kt (actual) androidMain
// androidMain — EncryptedSharedPreferences
actual class SecureStorage(
    private val context: Context,
) {
    private val prefs by lazy {
        EncryptedSharedPreferences.create(
            context, "nutritrack_secure",
            MasterKey.Builder(context)
                .setKeyScheme(AES256_GCM).build(),
            AES256_SIV, AES256_GCM,
        )
    }

    actual fun getAccessToken() =
        prefs.getString("access", null)

    actual fun getRefreshToken() =
        prefs.getString("refresh", null)

    actual fun saveTokens(a: String, r: String) =
        prefs.edit {
            putString("access", a)
            putString("refresh", r)
        }

    actual fun clear() = prefs.edit { clear() }
}

// iosMain — Keychain
actual class SecureStorage {
    actual fun getAccessToken() =
        KeychainWrapper.get("nt_access")
    actual fun saveTokens(a: String, r: String) {
        KeychainWrapper.set("nt_access", a)
        KeychainWrapper.set("nt_refresh", r)
    }
    actual fun clear() {
        KeychainWrapper.delete("nt_access")
        KeychainWrapper.delete("nt_refresh")
    }
}
📡
Estrategia offline-first
Usuarios en gimnasios sin señal — soporte obligatorio
1. Log Food Usuario registra un alimento
2. SQLDelight Guarda local inmediato (pending_sync=1)
3. Ktor POST Intenta enviar al servidor
✓ Online pending_sync = 0
— Si hay error de red —
⚡ Offline pending_sync = 1 en DB local
ConnectivityObserver Detecta reconexión
syncPending() Reintenta todos los pendientes
✓ Sincronizado UI actualizada via Flow
📋
Dependencias — build.gradle.kts
Módulo shared con targets Android + iOS
shared/build.gradle.kts Gradle KTS
kotlin {
    androidTarget()
    iosX64(); iosArm64(); iosSimulatorArm64()

    sourceSets {
        commonMain.dependencies {
            // HTTP
            implementation("io.ktor:ktor-client-core:2.3.7")
            implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
            implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
            implementation("io.ktor:ktor-client-auth:2.3.7")
            // Async
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
            // DB
            implementation("app.cash.sqldelight:coroutines-extensions:2.0.1")
            // DateTime
            implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.5.0")
        }
        androidMain.dependencies {
            implementation("io.ktor:ktor-client-okhttp:2.3.7")
            implementation("app.cash.sqldelight:android-driver:2.0.1")
            implementation("androidx.security:security-crypto:1.1.0-alpha06")
        }
        iosMain.dependencies {
            implementation("io.ktor:ktor-client-darwin:2.3.7")
            implementation("app.cash.sqldelight:native-driver:2.0.1")
        }
    }
}
~65%
código compartido
test suite para toda la lógica
100%
UI nativa por plataforma
0
CDNs ni WebViews