⎇ f01b7a1aa91e314295debf67c22ce5803495b2de
launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.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.Manifest
4import android.content.pm.PackageManager
5import androidx.activity.compose.BackHandler
6import androidx.activity.compose.rememberLauncherForActivityResult
7import androidx.activity.result.contract.ActivityResultContracts
8import androidx.compose.animation.core.Spring
9import androidx.compose.animation.core.animate
10import androidx.compose.animation.core.spring
11import androidx.compose.foundation.ExperimentalFoundationApi
12import androidx.compose.foundation.Image
13import androidx.compose.foundation.background
14import androidx.compose.foundation.clickable
15import androidx.compose.foundation.combinedClickable
16import androidx.compose.foundation.gestures.Orientation
17import androidx.compose.foundation.gestures.draggable
18import androidx.compose.foundation.gestures.rememberDraggableState
19import androidx.compose.foundation.interaction.MutableInteractionSource
20import androidx.compose.foundation.layout.Box
21import androidx.compose.foundation.layout.BoxWithConstraints
22import androidx.compose.foundation.layout.Column
23import androidx.compose.foundation.layout.Row
24import androidx.compose.foundation.layout.RowScope
25import androidx.compose.foundation.layout.Spacer
26import androidx.compose.foundation.layout.WindowInsets
27import androidx.compose.foundation.layout.fillMaxSize
28import androidx.compose.foundation.layout.fillMaxWidth
29import androidx.compose.foundation.layout.height
30import androidx.compose.foundation.layout.padding
31import androidx.compose.foundation.layout.size
32import androidx.compose.foundation.layout.systemBars
33import androidx.compose.foundation.layout.width
34import androidx.compose.foundation.layout.windowInsetsPadding
35import androidx.compose.foundation.lazy.LazyColumn
36import androidx.compose.foundation.lazy.items
37import androidx.compose.foundation.shape.CircleShape
38import androidx.compose.foundation.shape.RoundedCornerShape
39import androidx.compose.foundation.text.BasicTextField
40import androidx.compose.foundation.text.KeyboardOptions
41import androidx.compose.material3.Surface
42import androidx.compose.material3.Text
43import androidx.compose.runtime.Composable
44import androidx.compose.runtime.LaunchedEffect
45import androidx.compose.runtime.collectAsState
46import androidx.compose.runtime.derivedStateOf
47import androidx.compose.runtime.getValue
48import androidx.compose.runtime.mutableFloatStateOf
49import androidx.compose.runtime.mutableStateOf
50import androidx.compose.runtime.remember
51import androidx.compose.runtime.rememberCoroutineScope
52import androidx.compose.runtime.setValue
53import androidx.compose.ui.Alignment
54import androidx.compose.ui.Modifier
55import androidx.compose.ui.draw.clip
56import androidx.compose.ui.focus.FocusRequester
57import androidx.compose.ui.focus.focusRequester
58import androidx.compose.ui.graphics.Color
59import androidx.compose.ui.graphics.SolidColor
60import androidx.compose.ui.graphics.graphicsLayer
61import androidx.compose.ui.platform.LocalContext
62import androidx.compose.ui.platform.LocalDensity
63import androidx.compose.ui.res.stringResource
64import androidx.compose.ui.text.TextStyle
65import androidx.compose.ui.text.font.FontWeight
66import androidx.compose.ui.text.input.ImeAction
67import androidx.compose.ui.text.style.TextAlign
68import androidx.compose.ui.text.style.TextOverflow
69import androidx.compose.ui.unit.dp
70import androidx.compose.ui.unit.sp
71import androidx.compose.ui.window.Dialog
72import androidx.core.content.ContextCompat
73import androidx.lifecycle.viewmodel.compose.viewModel
74import com.rizoma.orbita.R
75import com.rizoma.orbita.model.AppNode
76import com.rizoma.orbita.model.SearchSort
77import com.rizoma.orbita.ui.theme.NeonCian
78import com.rizoma.orbita.ui.theme.NeonRosa
79import com.rizoma.orbita.ui.theme.NeonVerde
80import com.rizoma.orbita.ui.theme.Superficie
81import com.rizoma.orbita.ui.theme.Tenue
82import com.rizoma.orbita.ui.theme.Vacio
83import kotlin.math.abs
84import kotlin.math.roundToInt
85import kotlinx.coroutines.launch
86
87// El rizoma como cinta de cinco páginas (0..4). El centro del átomo es la 2.
88// 0 clima · 1 nuevas+pedazo · 2 centro del átomo · 3 pedazo+expulsadas · 4 desterradas
89private const val CENTER = 2f
90private const val FLING = 0.09f // cuánto pesa la velocidad del gesto al decidir página
91
92@Composable
93fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
94 val buckets by vm.buckets.collectAsState()
95 val allNodes by vm.allNodes.collectAsState()
96 val sky by vm.sky.collectAsState()
97 val weather by vm.weather.collectAsState()
98
99 var searchOpen by remember { mutableStateOf(false) }
100 val searchSort by vm.searchSort.collectAsState()
101 var menuNode by remember { mutableStateOf<AppNode?>(null) }
102
103 val context = LocalContext.current
104 val nimboInstalled = remember {
105 context.packageManager.getLaunchIntentForPackage("com.rizoma.nimbo") != null
106 }
107
108 // Posición continua entre páginas. Solo se lee de forma diferida (graphicsLayer,
109 // derivedStateOf, gestos): el cuerpo no se recompone en cada fotograma del arrastre.
110 var pos by remember { mutableFloatStateOf(CENTER) }
111 val scope = rememberCoroutineScope()
112
113 // Qué zonas existen en RAM: cada una se compone solo cerca de su página.
114 val showClima by remember { derivedStateOf { pos < 1.5f } }
115 val showNuevas by remember { derivedStateOf { pos > 0.3f && pos < 2.4f } }
116 val showExpulsadas by remember { derivedStateOf { pos > 1.6f && pos < 3.7f } }
117 val showDesterradas by remember { derivedStateOf { pos > 3.3f } }
118 val centered by remember { derivedStateOf { abs(pos - CENTER) < 0.02f } }
119
120 // Clima perezoso: solo se pide (permiso + red) al acercarse a su página.
121 val permLauncher = rememberLauncherForActivityResult(
122 ActivityResultContracts.RequestPermission()
123 ) { granted -> if (granted) vm.refreshWeather() }
124 var askedWeather by remember { mutableStateOf(false) }
125 LaunchedEffect(showClima) {
126 if (showClima && !askedWeather) {
127 askedWeather = true
128 val granted = ContextCompat.checkSelfPermission(
129 context, Manifest.permission.ACCESS_COARSE_LOCATION,
130 ) == PackageManager.PERMISSION_GRANTED
131 if (granted) vm.ensureWeather()
132 else permLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
133 }
134 }
135
136 // Como launcher: "atrás" cierra la búsqueda, o vuelve al centro del átomo.
137 BackHandler(enabled = searchOpen || !centered) {
138 if (searchOpen) searchOpen = false
139 else scope.launch { animate(pos, CENTER) { v, _ -> pos = v } }
140 }
141
142 BoxWithConstraints(Modifier.fillMaxSize().background(Vacio)) {
143 val wPx = with(LocalDensity.current) { maxWidth.toPx() }
144 val drag = rememberDraggableState { delta -> pos = (pos - delta / wPx).coerceIn(0f, 4f) }
145
146 Box(
147 Modifier
148 .fillMaxSize()
149 .draggable(
150 state = drag,
151 orientation = Orientation.Horizontal,
152 onDragStopped = { velocity ->
153 val target = (pos - velocity / wPx * FLING).roundToInt().coerceIn(0, 4).toFloat()
154 animate(
155 initialValue = pos,
156 targetValue = target,
157 animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow),
158 ) { v, _ -> pos = v }
159 },
160 )
161 ) {
162 // El cielo es el telón continuo: no se desplaza con las páginas.
163 DynamicBackground(palette = sky)
164
165 // El átomo: UNA sola pieza que se desliza tras las páginas (de ahí la
166 // sensación de continuidad). En las páginas 1 y 3 asoma su borde.
167 Box(Modifier.fillMaxSize().graphicsLayer { translationX = (CENTER - pos) * wPx }) {
168 OrbitLayout(
169 nodes = buckets.atom,
170 onLaunch = vm::launch,
171 onLongPress = { menuNode = it },
172 onCenterTap = { searchOpen = true },
173 )
174 }
175
176 if (showClima) {
177 Box(Modifier.fillMaxSize().graphicsLayer { translationX = (0f - pos) * wPx }) {
178 ClimaPage(weather, nimboInstalled) { vm.launchPackage("com.rizoma.nimbo") }
179 }
180 }
181 if (showNuevas) {
182 Box(Modifier.fillMaxSize().graphicsLayer { translationX = (1f - pos) * wPx }) {
183 NuevasZone(buckets.nuevas, vm::launch) { menuNode = it }
184 }
185 }
186 if (showExpulsadas) {
187 Box(Modifier.fillMaxSize().graphicsLayer { translationX = (3f - pos) * wPx }) {
188 ExpulsadasZone(buckets.expulsadas, vm::launch) { menuNode = it }
189 }
190 }
191 if (showDesterradas) {
192 Box(Modifier.fillMaxSize().graphicsLayer { translationX = (4f - pos) * wPx }) {
193 DesterradasZone(buckets.desterradas, vm::launch) { menuNode = it }
194 }
195 }
196
197 Column(
198 modifier = Modifier
199 .align(Alignment.BottomCenter)
200 .windowInsetsPadding(WindowInsets.systemBars)
201 .padding(bottom = 14.dp),
202 horizontalAlignment = Alignment.CenterHorizontally,
203 ) {
204 PageDots(progress = { pos })
205 Spacer(Modifier.height(10.dp))
206 Marca()
207 }
208 }
209
210 if (searchOpen) {
211 SearchOverlay(
212 nodes = allNodes,
213 sort = searchSort,
214 onSort = vm::setSearchSort,
215 onLaunch = {
216 vm.launch(it)
217 searchOpen = false
218 },
219 onLongPress = { menuNode = it },
220 onClose = { searchOpen = false },
221 )
222 }
223
224 menuNode?.let { node ->
225 NodeMenu(
226 node = node,
227 onPin = { vm.togglePin(node); menuNode = null },
228 onPush = { vm.pushAway(node); menuNode = null },
229 onHide = { vm.toggleHidden(node); menuNode = null },
230 onInfo = { vm.openInfo(node); menuNode = null },
231 onUninstall = { vm.uninstall(node); menuNode = null },
232 onDismiss = { menuNode = null },
233 )
234 }
235 }
236}
237
238@OptIn(ExperimentalFoundationApi::class)
239@Composable
240private fun SearchOverlay(
241 nodes: List<AppNode>,
242 sort: SearchSort,
243 onSort: (SearchSort) -> Unit,
244 onLaunch: (AppNode) -> Unit,
245 onLongPress: (AppNode) -> Unit,
246 onClose: () -> Unit,
247) {
248 var query by remember { mutableStateOf("") }
249 val filtered = remember(query, nodes, sort) {
250 val hits =
251 if (query.isBlank()) nodes
252 else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) }
253 when (sort) {
254 // `nodes` ya viene ordenado por calor (fijados primero): es el orden de casa.
255 SearchSort.FRECUENCIA -> hits
256 SearchSort.NOMBRE -> hits.sortedBy { it.label.lowercase() }
257 SearchSort.RECIENTE -> hits.sortedWith(
258 compareByDescending<AppNode> { it.firstInstallTime }.thenBy { it.label.lowercase() }
259 )
260 }
261 }
262 val focus = remember { FocusRequester() }
263 LaunchedEffect(Unit) { focus.requestFocus() }
264
265 Box(
266 Modifier
267 .fillMaxSize()
268 .background(Vacio.copy(alpha = 0.97f))
269 // Tocar el fondo cierra; el contenido absorbe sus propios toques.
270 .clickable(
271 interactionSource = remember { MutableInteractionSource() },
272 indication = null,
273 onClick = onClose,
274 )
275 ) {
276 Column(
277 Modifier
278 .fillMaxSize()
279 .windowInsetsPadding(WindowInsets.systemBars)
280 .padding(horizontal = 28.dp)
281 .padding(top = 64.dp)
282 ) {
283 BasicTextField(
284 value = query,
285 onValueChange = { query = it },
286 singleLine = true,
287 textStyle = TextStyle(color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light),
288 cursorBrush = SolidColor(NeonRosa),
289 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
290 modifier = Modifier.fillMaxWidth().focusRequester(focus),
291 decorationBox = { inner ->
292 if (query.isEmpty()) {
293 Text(
294 text = stringResource(R.string.search_hint),
295 color = Tenue,
296 fontSize = 26.sp,
297 fontWeight = FontWeight.Light,
298 )
299 }
300 inner()
301 },
302 )
303 Spacer(Modifier.height(4.dp))
304 Box(Modifier.fillMaxWidth().height(1.dp).background(NeonVerde.copy(alpha = 0.4f)))
305 Spacer(Modifier.height(14.dp))
306
307 // Criterio de orden: frecuencia (el del rizoma), nombre o recién instaladas.
308 Row(Modifier.fillMaxWidth()) {
309 OrdenChip(R.string.sort_frequency, sort == SearchSort.FRECUENCIA, NeonVerde) {
310 onSort(SearchSort.FRECUENCIA)
311 }
312 OrdenChip(R.string.sort_name, sort == SearchSort.NOMBRE, NeonCian) {
313 onSort(SearchSort.NOMBRE)
314 }
315 OrdenChip(R.string.sort_recent, sort == SearchSort.RECIENTE, NeonRosa) {
316 onSort(SearchSort.RECIENTE)
317 }
318 }
319 Spacer(Modifier.height(12.dp))
320
321 LazyColumn(Modifier.fillMaxWidth().weight(1f)) {
322 items(filtered, key = { it.key }) { node ->
323 // Los expulsados se ven atenuados; un toque largo abre su menú (para devolverlos).
324 val dim = if (node.hidden) 0.4f else 1f
325 Row(
326 Modifier
327 .fillMaxWidth()
328 .combinedClickable(
329 onClick = { onLaunch(node) },
330 onLongClick = { onLongPress(node) },
331 )
332 .padding(vertical = 8.dp),
333 verticalAlignment = Alignment.CenterVertically,
334 ) {
335 Image(
336 bitmap = node.icon,
337 contentDescription = node.label,
338 alpha = dim,
339 modifier = Modifier.size(40.dp).clip(CircleShape),
340 )
341 Spacer(Modifier.width(16.dp))
342 Text(
343 text = node.label,
344 color = Color.White.copy(alpha = dim),
345 fontSize = 16.sp,
346 maxLines = 1,
347 overflow = TextOverflow.Ellipsis,
348 )
349 if (node.hidden) {
350 Spacer(Modifier.width(10.dp))
351 Text(
352 text = stringResource(R.string.tag_hidden),
353 color = NeonCian.copy(alpha = 0.7f),
354 fontSize = 11.sp,
355 )
356 }
357 }
358 }
359 }
360 Marca(Modifier.align(Alignment.CenterHorizontally).padding(vertical = 14.dp))
361 }
362 }
363}
364
365/** Un criterio de orden: encendido lleva su color; apagado, apenas se insinúa. */
366@Composable
367private fun RowScope.OrdenChip(labelRes: Int, selected: Boolean, accent: Color, onClick: () -> Unit) {
368 Text(
369 text = stringResource(labelRes),
370 color = if (selected) accent else Tenue.copy(alpha = 0.45f),
371 fontSize = 12.sp,
372 fontWeight = if (selected) FontWeight.Medium else FontWeight.Light,
373 maxLines = 1,
374 textAlign = TextAlign.Center,
375 modifier = Modifier
376 .weight(1f)
377 .clip(RoundedCornerShape(20.dp))
378 .background(if (selected) accent.copy(alpha = 0.12f) else Color.Transparent)
379 .clickable(onClick = onClick)
380 .padding(vertical = 7.dp),
381 )
382}
383
384/** Marca de la casa: discreta, del tamaño de una firma. */
385@Composable
386internal fun Marca(modifier: Modifier = Modifier) {
387 Text(
388 text = stringResource(R.string.brand),
389 color = Color.White.copy(alpha = 0.22f),
390 fontSize = 9.sp,
391 letterSpacing = 3.sp,
392 fontWeight = FontWeight.Light,
393 modifier = modifier,
394 )
395}
396
397@Composable
398private fun NodeMenu(
399 node: AppNode,
400 onPin: () -> Unit,
401 onPush: () -> Unit,
402 onHide: () -> Unit,
403 onInfo: () -> Unit,
404 onUninstall: () -> Unit,
405 onDismiss: () -> Unit,
406) {
407 Dialog(onDismissRequest = onDismiss) {
408 Surface(
409 color = Superficie,
410 shape = RoundedCornerShape(20.dp),
411 ) {
412 Column(Modifier.width(240.dp).padding(vertical = 10.dp)) {
413 Text(
414 text = node.label,
415 color = Color.White,
416 fontSize = 15.sp,
417 fontWeight = FontWeight.Medium,
418 maxLines = 1,
419 overflow = TextOverflow.Ellipsis,
420 modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
421 )
422 // Un nodo expulsado solo se puede devolver, consultar o desinstalar:
423 // fijar/alejar no aplican mientras está fuera del átomo.
424 if (!node.hidden) {
425 MenuRow(
426 label = stringResource(if (node.pinned) R.string.menu_unpin else R.string.menu_pin),
427 accent = NeonRosa,
428 onClick = onPin,
429 )
430 MenuRow(label = stringResource(R.string.menu_push), accent = NeonVerde, onClick = onPush)
431 }
432 MenuRow(
433 label = stringResource(if (node.hidden) R.string.menu_unhide else R.string.menu_hide),
434 accent = NeonCian,
435 onClick = onHide,
436 )
437 MenuRow(label = stringResource(R.string.menu_info), accent = NeonCian, onClick = onInfo)
438 MenuRow(label = stringResource(R.string.menu_uninstall), accent = NeonRosa, onClick = onUninstall)
439 }
440 }
441 }
442}
443
444@Composable
445private fun MenuRow(label: String, accent: Color, onClick: () -> Unit) {
446 Row(
447 Modifier
448 .fillMaxWidth()
449 .clickable(onClick = onClick)
450 .padding(horizontal = 20.dp, vertical = 14.dp),
451 verticalAlignment = Alignment.CenterVertically,
452 ) {
453 Box(Modifier.size(6.dp).clip(CircleShape).background(accent))
454 Spacer(Modifier.width(14.dp))
455 Text(text = label, color = Color.White, fontSize = 15.sp)
456 }
457}