package com.rizoma.orbita.ui import android.app.Application import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter import android.net.Uri import android.provider.Settings import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.rizoma.orbita.data.AppRepository import com.rizoma.orbita.data.SkyRepository import com.rizoma.orbita.data.UsageStore import com.rizoma.orbita.data.Weather import com.rizoma.orbita.model.AppNode import com.rizoma.orbita.model.Buckets import com.rizoma.orbita.model.ExpelledNode import com.rizoma.orbita.model.SearchSort import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.util.Calendar class OrbitaViewModel(app: Application) : AndroidViewModel(app) { private val repo = AppRepository(app) private val usage = UsageStore(app) private val rawApps = MutableStateFlow>(emptyList()) /** Todas las apps ya ponderadas y ordenadas (fijados, calor, alfabético). Para la búsqueda. */ val allNodes: StateFlow> = combine(rawApps, usage.snapshot()) { apps, u -> apps.map { node -> node.copy( score = (u.counts[node.key] ?: 0).toFloat(), pinned = node.key in u.pinned, penalty = u.penalties[node.key] ?: 0, hidden = node.key in u.hidden, hiddenAt = u.hiddenAt[node.key] ?: 0L, ) }.sortedWith( compareByDescending { it.pinned } .thenByDescending { it.score - (u.penalties[it.key] ?: 0).toFloat() } .thenBy { it.label.lowercase() } ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) /** Las apps repartidas en las cuatro zonas (átomo, nuevas, expulsadas, desterradas). */ val buckets: StateFlow = allNodes .map { classify(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Buckets(emptyList(), emptyList(), emptyList(), emptyList())) private fun classify(nodes: List): Buckets { val now = System.currentTimeMillis() val nuevas = nodes.filter { !it.hidden && it.score == 0f && it.firstInstallTime > 0L && now - it.firstInstallTime < NEW_WINDOW }.sortedByDescending { it.firstInstallTime } val nuevasKeys = nuevas.mapTo(HashSet()) { it.key } val atom = nodes.filter { !it.hidden && it.key !in nuevasKeys } val expulsadas = ArrayList() val desterradas = ArrayList() for (n in nodes.filter { it.hidden }) { val age = now - n.hiddenAt if (n.hiddenAt > 0L && age < CALM_WINDOW) { expulsadas.add(ExpelledNode(n, (1f - age.toFloat() / CALM_WINDOW).coerceIn(0f, 1f))) } else { desterradas.add(n) } } return Buckets(atom, nuevas, expulsadas, desterradas.sortedBy { it.label.lowercase() }) } // --- Búsqueda: criterio de orden (se recuerda entre sesiones). --- /** Cómo se ordena la lista de la búsqueda. Por defecto, por frecuencia de uso. */ val searchSort: StateFlow = usage.sortMode() .map { SearchSort.from(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchSort.FRECUENCIA) fun setSearchSort(sort: SearchSort) { viewModelScope.launch { usage.setSortMode(sort.name) } } // --- Cielo del fondo: hora del día + clima local (perezoso). --- private val _weather = MutableStateFlow(null) /** Clima actual (null hasta que se visita la página de clima). */ val weather: StateFlow = _weather.asStateFlow() /** Marca la hora actual (con fracción) y se reemite cada minuto. */ private val clock = flow { while (true) { emit(currentHourFraction()) delay(60_000L) } } val sky: StateFlow = combine(clock, _weather) { hour, w -> skyPaletteFor(hour, w) }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5_000), skyPaletteFor(currentHourFraction(), null), ) /** * Refresca el clima local (best-effort, una sola vez salvo que se fuerce). * Se llama al acercarse a la página de clima: mientras no se abra, no se carga. */ fun ensureWeather() { if (_weather.value != null) return refreshWeather() } fun refreshWeather() { viewModelScope.launch(Dispatchers.IO) { SkyRepository(getApplication()).fetch()?.let { _weather.value = it } } } private fun currentHourFraction(): Float { val c = Calendar.getInstance() return c.get(Calendar.HOUR_OF_DAY) + c.get(Calendar.MINUTE) / 60f } private var refreshJob: Job? = null private val pkgReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) = refresh(PKG_DEBOUNCE) } init { refresh() val filter = IntentFilter().apply { addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_CHANGED) addDataScheme("package") } getApplication().registerReceiver(pkgReceiver, filter) } /** * Rehace la cosecha. Primero pinta lo que quedó en la caché —el átomo aparece * ya formado en vez de en blanco mientras se rasterizan ciento y pico iconos— * y luego deja que el repositorio lo vaya corrigiendo encima por lotes. * * [esperaMs] amortigua las ráfagas del receptor de paquetes: instalar una app * dispara varios avisos seguidos y no hace falta recorrer el sistema por cada uno. */ fun refresh(esperaMs: Long = 0L) { refreshJob?.cancel() refreshJob = viewModelScope.launch(Dispatchers.IO) { if (esperaMs > 0L) delay(esperaMs) if (rawApps.value.isEmpty()) { repo.cached().takeIf { it.isNotEmpty() }?.let { rawApps.value = it } } repo.load { rawApps.value = it } } } fun launch(node: AppNode) { val intent = Intent(Intent.ACTION_MAIN) .addCategory(Intent.CATEGORY_LAUNCHER) .setComponent(ComponentName(node.packageName, node.activityName)) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) runCatching { getApplication().startActivity(intent) } viewModelScope.launch { usage.record(node.key) } } /** Abre una app por nombre de paquete (p.ej. Nimbo desde la página de clima). */ fun launchPackage(pkg: String): Boolean { val intent = getApplication().packageManager.getLaunchIntentForPackage(pkg) ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ?: return false return runCatching { getApplication().startActivity(intent); true }.getOrDefault(false) } fun togglePin(node: AppNode) { viewModelScope.launch { usage.setPinned(node.key, !node.pinned) } } /** Expulsa el nodo del átomo (o lo devuelve). No desinstala: la app sigue ahí. */ fun toggleHidden(node: AppNode) { viewModelScope.launch { usage.setHidden(node.key, !node.hidden, System.currentTimeMillis()) } } /** Aleja el nodo a órbitas externas (usa su calor actual como referencia). */ fun pushAway(node: AppNode) { viewModelScope.launch { usage.pushAway(node.key, node.score.toInt()) } } fun openInfo(node: AppNode) { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) .setData(Uri.fromParts("package", node.packageName, null)) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) runCatching { getApplication().startActivity(intent) } } fun uninstall(node: AppNode) { @Suppress("DEPRECATION") val intent = Intent(Intent.ACTION_DELETE) .setData(Uri.fromParts("package", node.packageName, null)) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) runCatching { getApplication().startActivity(intent) } } override fun onCleared() { runCatching { getApplication().unregisterReceiver(pkgReceiver) } } private companion object { const val PKG_DEBOUNCE = 400L // ráfaga de avisos de paquete const val NEW_WINDOW = 3L * 24 * 3600 * 1000 // "nueva" durante 3 días const val CALM_WINDOW = 7L * 24 * 3600 * 1000 // se calma/destierra en 7 días } }