⎇ 4e0b0288593fde94ea257fc57545b1154e4493ea
launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
History
raw
Órbita: cinta de 5 páginas — continuidad del átomo + zonas vivas
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}