tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Definiciones

⎇ f01b7a1aa91e314295debf67c22ce5803495b2de launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt 213 líneas · 8.1 KB Historia crudo

Órbita: núcleo vivo, ronda sin saltos, orden de búsqueda y marca gioser.net f01b7a1aa · Sergio ·

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}