⎇ 608145b4b469bb0f8b58179bb8338053430cbdb6
launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
Historia
crudo
Órbita: quitar apps del átomo (sin desinstalar) + fondo según hora y clima
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 kotlinx.coroutines.Dispatchers
19import kotlinx.coroutines.delay
20import kotlinx.coroutines.flow.MutableStateFlow
21import kotlinx.coroutines.flow.SharingStarted
22import kotlinx.coroutines.flow.StateFlow
23import kotlinx.coroutines.flow.combine
24import kotlinx.coroutines.flow.flow
25import kotlinx.coroutines.flow.stateIn
26import kotlinx.coroutines.launch
27import java.util.Calendar
28
29class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
30
31 private val repo = AppRepository(app)
32 private val usage = UsageStore(app)
33
34 private val rawApps = MutableStateFlow<List<AppNode>>(emptyList())
35
36 /** Nodos ya ponderados y ordenados: fijados primero, luego por calor, luego alfabético. */
37 val nodes: StateFlow<List<AppNode>> = combine(
38 rawApps,
39 usage.counts(),
40 usage.pinned(),
41 usage.penalties(),
42 usage.hidden(),
43 ) { apps, counts, pinned, penalties, hidden ->
44 apps.map { node ->
45 node.copy(
46 score = (counts[node.key] ?: 0).toFloat(),
47 pinned = node.key in pinned,
48 hidden = node.key in hidden,
49 )
50 }.sortedWith(
51 // El orden define la órbita: fijados al centro, luego por calor efectivo
52 // (calor menos penalización por "alejar"), luego alfabético.
53 compareByDescending<AppNode> { it.pinned }
54 .thenByDescending { it.score - (penalties[it.key] ?: 0).toFloat() }
55 .thenBy { it.label.lowercase() }
56 )
57 }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
58
59 // --- Cielo del fondo: hora del día + clima local. ---
60
61 private val weather = MutableStateFlow<Weather?>(null)
62
63 /** Marca la hora actual (con fracción) y se reemite cada minuto. */
64 private val clock = flow {
65 while (true) {
66 emit(currentHourFraction())
67 delay(60_000L)
68 }
69 }
70
71 val sky: StateFlow<SkyPalette> = combine(clock, weather) { hour, w ->
72 skyPaletteFor(hour, w)
73 }.stateIn(
74 viewModelScope,
75 SharingStarted.WhileSubscribed(5_000),
76 skyPaletteFor(currentHourFraction(), null),
77 )
78
79 /** Refresca el clima local (best-effort). Llamar tras conceder ubicación. */
80 fun refreshWeather() {
81 viewModelScope.launch(Dispatchers.IO) {
82 SkyRepository(getApplication()).fetch()?.let { weather.value = it }
83 }
84 }
85
86 private fun currentHourFraction(): Float {
87 val c = Calendar.getInstance()
88 return c.get(Calendar.HOUR_OF_DAY) + c.get(Calendar.MINUTE) / 60f
89 }
90
91 private val pkgReceiver = object : BroadcastReceiver() {
92 override fun onReceive(context: Context?, intent: Intent?) = refresh()
93 }
94
95 init {
96 refresh()
97 val filter = IntentFilter().apply {
98 addAction(Intent.ACTION_PACKAGE_ADDED)
99 addAction(Intent.ACTION_PACKAGE_REMOVED)
100 addAction(Intent.ACTION_PACKAGE_CHANGED)
101 addDataScheme("package")
102 }
103 getApplication<Application>().registerReceiver(pkgReceiver, filter)
104 }
105
106 fun refresh() {
107 viewModelScope.launch(Dispatchers.IO) {
108 rawApps.value = repo.loadApps()
109 }
110 }
111
112 fun launch(node: AppNode) {
113 val intent = Intent(Intent.ACTION_MAIN)
114 .addCategory(Intent.CATEGORY_LAUNCHER)
115 .setComponent(ComponentName(node.packageName, node.activityName))
116 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
117 runCatching { getApplication<Application>().startActivity(intent) }
118 viewModelScope.launch { usage.record(node.key) }
119 }
120
121 fun togglePin(node: AppNode) {
122 viewModelScope.launch { usage.setPinned(node.key, !node.pinned) }
123 }
124
125 /** Retira el nodo del átomo (o lo devuelve). No desinstala: la app sigue ahí. */
126 fun toggleHidden(node: AppNode) {
127 viewModelScope.launch { usage.setHidden(node.key, !node.hidden) }
128 }
129
130 /** Aleja el nodo a órbitas externas (usa su calor actual como referencia). */
131 fun pushAway(node: AppNode) {
132 viewModelScope.launch { usage.pushAway(node.key, node.score.toInt()) }
133 }
134
135 fun openInfo(node: AppNode) {
136 val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
137 .setData(Uri.fromParts("package", node.packageName, null))
138 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
139 runCatching { getApplication<Application>().startActivity(intent) }
140 }
141
142 fun uninstall(node: AppNode) {
143 @Suppress("DEPRECATION")
144 val intent = Intent(Intent.ACTION_DELETE)
145 .setData(Uri.fromParts("package", node.packageName, null))
146 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
147 runCatching { getApplication<Application>().startActivity(intent) }
148 }
149
150 override fun onCleared() {
151 runCatching { getApplication<Application>().unregisterReceiver(pkgReceiver) }
152 }
153}