tawasuyu

sergio/humanoid

HTML 50Markdown 1TOML 10 Sutichaykuna

⎇ 4e0b0288593fde94ea257fc57545b1154e4493ea launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt 200 siq'ikuna · 7.6 KB Ñawpa willay ch'uya

Órbita: cinta de 5 páginas — continuidad del átomo + zonas vivas 4e0b02885 · 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 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}