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.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 val pkgReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) = refresh() } 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) } fun refresh() { viewModelScope.launch(Dispatchers.IO) { rawApps.value = repo.loadApps() } } 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 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 } }