tawasuyu

sergio/humanoid

HTML 37Markdown 1TOML 10 Definitions

⎇ 4e0b0288593fde94ea257fc57545b1154e4493ea launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt 388 lines · 16.0 KB History raw

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