Arquitectura shared-first: lógica de negocio común para Android e iOS
shared@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 }
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) { } } } }
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() }
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;
// 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) } }
// 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") } }
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") } } }