tawasuyu

sergio/humanoid

HTML 13TOML 10 Definiciones

⎇ b7b92b8360b37bcb7fa6c023ff5d614b5e7635ff launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt 457 líneas · 18.8 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.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}