⎇ f01b7a1aa91e314295debf67c22ce5803495b2de
launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
Ñawpa willay
ch'uya
Órbita: núcleo vivo, ronda sin saltos, orden de búsqueda y marca gioser.net
1package com.rizoma.orbita.ui
2
3import android.app.Application
4import android.content.BroadcastReceiver
5import android.content.ComponentName
6import android.content.Context
7import android.content.Intent
8import android.content.IntentFilter
9import android.net.Uri
10import android.provider.Settings
11import androidx.lifecycle.AndroidViewModel
12import androidx.lifecycle.viewModelScope
13import com.rizoma.orbita.data.AppRepository
14import com.rizoma.orbita.data.SkyRepository
15import com.rizoma.orbita.data.UsageStore
16import com.rizoma.orbita.data.Weather
17import com.rizoma.orbita.model.AppNode
18import com.rizoma.orbita.model.Buckets
19import com.rizoma.orbita.model.ExpelledNode
20import com.rizoma.orbita.model.SearchSort
21import kotlinx.coroutines.Dispatchers
22import kotlinx.coroutines.delay
23import kotlinx.coroutines.flow.MutableStateFlow
24import kotlinx.coroutines.flow.SharingStarted
25import kotlinx.coroutines.flow.StateFlow
26import kotlinx.coroutines.flow.asStateFlow
27import kotlinx.coroutines.flow.combine
28import kotlinx.coroutines.flow.flow
29import kotlinx.coroutines.flow.map
30import kotlinx.coroutines.flow.stateIn
31import kotlinx.coroutines.launch
32import java.util.Calendar
33
34class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
35
36 private val repo = AppRepository(app)
37 private val usage = UsageStore(app)
38
39 private val rawApps = MutableStateFlow<List<AppNode>>(emptyList())
40
41 /** Todas las apps ya ponderadas y ordenadas (fijados, calor, alfabético). Para la búsqueda. */
42 val allNodes: StateFlow<List<AppNode>> = combine(rawApps, usage.snapshot()) { apps, u ->
43 apps.map { node ->
44 node.copy(
45 score = (u.counts[node.key] ?: 0).toFloat(),
46 pinned = node.key in u.pinned,
47 penalty = u.penalties[node.key] ?: 0,
48 hidden = node.key in u.hidden,
49 hiddenAt = u.hiddenAt[node.key] ?: 0L,
50 )
51 }.sortedWith(
52 compareByDescending<AppNode> { it.pinned }
53 .thenByDescending { it.score - (u.penalties[it.key] ?: 0).toFloat() }
54 .thenBy { it.label.lowercase() }
55 )
56 }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
57
58 /** Las apps repartidas en las cuatro zonas (átomo, nuevas, expulsadas, desterradas). */
59 val buckets: StateFlow<Buckets> = allNodes
60 .map { classify(it) }
61 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Buckets(emptyList(), emptyList(), emptyList(), emptyList()))
62
63 private fun classify(nodes: List<AppNode>): Buckets {
64 val now = System.currentTimeMillis()
65 val nuevas = nodes.filter {
66 !it.hidden && it.score == 0f &&
67 it.firstInstallTime > 0L && now - it.firstInstallTime < NEW_WINDOW
68 }.sortedByDescending { it.firstInstallTime }
69 val nuevasKeys = nuevas.mapTo(HashSet()) { it.key }
70
71 val atom = nodes.filter { !it.hidden && it.key !in nuevasKeys }
72
73 val expulsadas = ArrayList<ExpelledNode>()
74 val desterradas = ArrayList<AppNode>()
75 for (n in nodes.filter { it.hidden }) {
76 val age = now - n.hiddenAt
77 if (n.hiddenAt > 0L && age < CALM_WINDOW) {
78 expulsadas.add(ExpelledNode(n, (1f - age.toFloat() / CALM_WINDOW).coerceIn(0f, 1f)))
79 } else {
80 desterradas.add(n)
81 }
82 }
83 return Buckets(atom, nuevas, expulsadas, desterradas.sortedBy { it.label.lowercase() })
84 }
85
86 // --- Búsqueda: criterio de orden (se recuerda entre sesiones). ---
87
88 /** Cómo se ordena la lista de la búsqueda. Por defecto, por frecuencia de uso. */
89 val searchSort: StateFlow<SearchSort> = usage.sortMode()
90 .map { SearchSort.from(it) }
91 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchSort.FRECUENCIA)
92
93 fun setSearchSort(sort: SearchSort) {
94 viewModelScope.launch { usage.setSortMode(sort.name) }
95 }
96
97 // --- Cielo del fondo: hora del día + clima local (perezoso). ---
98
99 private val _weather = MutableStateFlow<Weather?>(null)
100 /** Clima actual (null hasta que se visita la página de clima). */
101 val weather: StateFlow<Weather?> = _weather.asStateFlow()
102
103 /** Marca la hora actual (con fracción) y se reemite cada minuto. */
104 private val clock = flow {
105 while (true) {
106 emit(currentHourFraction())
107 delay(60_000L)
108 }
109 }
110
111 val sky: StateFlow<SkyPalette> = combine(clock, _weather) { hour, w ->
112 skyPaletteFor(hour, w)
113 }.stateIn(
114 viewModelScope,
115 SharingStarted.WhileSubscribed(5_000),
116 skyPaletteFor(currentHourFraction(), null),
117 )
118
119 /**
120 * Refresca el clima local (best-effort, una sola vez salvo que se fuerce).
121 * Se llama al acercarse a la página de clima: mientras no se abra, no se carga.
122 */
123 fun ensureWeather() {
124 if (_weather.value != null) return
125 refreshWeather()
126 }
127
128 fun refreshWeather() {
129 viewModelScope.launch(Dispatchers.IO) {
130 SkyRepository(getApplication()).fetch()?.let { _weather.value = it }
131 }
132 }
133
134 private fun currentHourFraction(): Float {
135 val c = Calendar.getInstance()
136 return c.get(Calendar.HOUR_OF_DAY) + c.get(Calendar.MINUTE) / 60f
137 }
138
139 private val pkgReceiver = object : BroadcastReceiver() {
140 override fun onReceive(context: Context?, intent: Intent?) = refresh()
141 }
142
143 init {
144 refresh()
145 val filter = IntentFilter().apply {
146 addAction(Intent.ACTION_PACKAGE_ADDED)
147 addAction(Intent.ACTION_PACKAGE_REMOVED)
148 addAction(Intent.ACTION_PACKAGE_CHANGED)
149 addDataScheme("package")
150 }
151 getApplication<Application>().registerReceiver(pkgReceiver, filter)
152 }
153
154 fun refresh() {
155 viewModelScope.launch(Dispatchers.IO) {
156 rawApps.value = repo.loadApps()
157 }
158 }
159
160 fun launch(node: AppNode) {
161 val intent = Intent(Intent.ACTION_MAIN)
162 .addCategory(Intent.CATEGORY_LAUNCHER)
163 .setComponent(ComponentName(node.packageName, node.activityName))
164 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
165 runCatching { getApplication<Application>().startActivity(intent) }
166 viewModelScope.launch { usage.record(node.key) }
167 }
168
169 /** Abre una app por nombre de paquete (p.ej. Nimbo desde la página de clima). */
170 fun launchPackage(pkg: String): Boolean {
171 val intent = getApplication<Application>().packageManager.getLaunchIntentForPackage(pkg)
172 ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ?: return false
173 return runCatching { getApplication<Application>().startActivity(intent); true }.getOrDefault(false)
174 }
175
176 fun togglePin(node: AppNode) {
177 viewModelScope.launch { usage.setPinned(node.key, !node.pinned) }
178 }
179
180 /** Expulsa el nodo del átomo (o lo devuelve). No desinstala: la app sigue ahí. */
181 fun toggleHidden(node: AppNode) {
182 viewModelScope.launch { usage.setHidden(node.key, !node.hidden, System.currentTimeMillis()) }
183 }
184
185 /** Aleja el nodo a órbitas externas (usa su calor actual como referencia). */
186 fun pushAway(node: AppNode) {
187 viewModelScope.launch { usage.pushAway(node.key, node.score.toInt()) }
188 }
189
190 fun openInfo(node: AppNode) {
191 val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
192 .setData(Uri.fromParts("package", node.packageName, null))
193 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
194 runCatching { getApplication<Application>().startActivity(intent) }
195 }
196
197 fun uninstall(node: AppNode) {
198 @Suppress("DEPRECATION")
199 val intent = Intent(Intent.ACTION_DELETE)
200 .setData(Uri.fromParts("package", node.packageName, null))
201 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
202 runCatching { getApplication<Application>().startActivity(intent) }
203 }
204
205 override fun onCleared() {
206 runCatching { getApplication<Application>().unregisterReceiver(pkgReceiver) }
207 }
208
209 private companion object {
210 const val NEW_WINDOW = 3L * 24 * 3600 * 1000 // "nueva" durante 3 días
211 const val CALM_WINDOW = 7L * 24 * 3600 * 1000 // se calma/destierra en 7 días
212 }
213}