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