package com.rizoma.orbita.ui import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animate import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.rizoma.orbita.R import com.rizoma.orbita.model.AppNode import com.rizoma.orbita.model.SearchSort import com.rizoma.orbita.ui.theme.NeonCian import com.rizoma.orbita.ui.theme.NeonRosa import com.rizoma.orbita.ui.theme.NeonVerde import com.rizoma.orbita.ui.theme.Superficie import com.rizoma.orbita.ui.theme.Tenue import com.rizoma.orbita.ui.theme.Vacio import kotlin.math.abs import kotlin.math.roundToInt import kotlinx.coroutines.launch // El rizoma como cinta de cinco páginas (0..4). El centro del átomo es la 2. // 0 clima · 1 nuevas+pedazo · 2 centro del átomo · 3 pedazo+expulsadas · 4 desterradas private const val CENTER = 2f private const val FLING = 0.09f // cuánto pesa la velocidad del gesto al decidir página @Composable fun OrbitApp(vm: OrbitaViewModel = viewModel()) { val buckets by vm.buckets.collectAsState() val allNodes by vm.allNodes.collectAsState() val sky by vm.sky.collectAsState() val weather by vm.weather.collectAsState() var searchOpen by remember { mutableStateOf(false) } val searchSort by vm.searchSort.collectAsState() var menuNode by remember { mutableStateOf(null) } val context = LocalContext.current val nimboInstalled = remember { context.packageManager.getLaunchIntentForPackage("com.rizoma.nimbo") != null } // Posición continua entre páginas. Solo se lee de forma diferida (graphicsLayer, // derivedStateOf, gestos): el cuerpo no se recompone en cada fotograma del arrastre. var pos by remember { mutableFloatStateOf(CENTER) } val scope = rememberCoroutineScope() // Qué zonas existen en RAM: cada una se compone solo cerca de su página. val showClima by remember { derivedStateOf { pos < 1.5f } } val showNuevas by remember { derivedStateOf { pos > 0.3f && pos < 2.4f } } val showExpulsadas by remember { derivedStateOf { pos > 1.6f && pos < 3.7f } } val showDesterradas by remember { derivedStateOf { pos > 3.3f } } val centered by remember { derivedStateOf { abs(pos - CENTER) < 0.02f } } // Clima perezoso: solo se pide (permiso + red) al acercarse a su página. val permLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> if (granted) vm.refreshWeather() } var askedWeather by remember { mutableStateOf(false) } LaunchedEffect(showClima) { if (showClima && !askedWeather) { askedWeather = true val granted = ContextCompat.checkSelfPermission( context, Manifest.permission.ACCESS_COARSE_LOCATION, ) == PackageManager.PERMISSION_GRANTED if (granted) vm.ensureWeather() else permLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION) } } // Como launcher: "atrás" cierra la búsqueda, o vuelve al centro del átomo. BackHandler(enabled = searchOpen || !centered) { if (searchOpen) searchOpen = false else scope.launch { animate(pos, CENTER) { v, _ -> pos = v } } } BoxWithConstraints(Modifier.fillMaxSize().background(Vacio)) { val wPx = with(LocalDensity.current) { maxWidth.toPx() } val drag = rememberDraggableState { delta -> pos = (pos - delta / wPx).coerceIn(0f, 4f) } Box( Modifier .fillMaxSize() .draggable( state = drag, orientation = Orientation.Horizontal, onDragStopped = { velocity -> val target = (pos - velocity / wPx * FLING).roundToInt().coerceIn(0, 4).toFloat() animate( initialValue = pos, targetValue = target, animationSpec = spring(dampingRatio = 0.85f, stiffness = Spring.StiffnessMediumLow), ) { v, _ -> pos = v } }, ) ) { // El cielo es el telón continuo: no se desplaza con las páginas. DynamicBackground(palette = sky) // El átomo: UNA sola pieza que se desliza tras las páginas (de ahí la // sensación de continuidad). En las páginas 1 y 3 asoma su borde. Box(Modifier.fillMaxSize().graphicsLayer { translationX = (CENTER - pos) * wPx }) { OrbitLayout( nodes = buckets.atom, onLaunch = vm::launch, onLongPress = { menuNode = it }, onCenterTap = { searchOpen = true }, ) } if (showClima) { Box(Modifier.fillMaxSize().graphicsLayer { translationX = (0f - pos) * wPx }) { ClimaPage(weather, nimboInstalled) { vm.launchPackage("com.rizoma.nimbo") } } } if (showNuevas) { Box(Modifier.fillMaxSize().graphicsLayer { translationX = (1f - pos) * wPx }) { NuevasZone(buckets.nuevas, vm::launch) { menuNode = it } } } if (showExpulsadas) { Box(Modifier.fillMaxSize().graphicsLayer { translationX = (3f - pos) * wPx }) { ExpulsadasZone(buckets.expulsadas, vm::launch) { menuNode = it } } } if (showDesterradas) { Box(Modifier.fillMaxSize().graphicsLayer { translationX = (4f - pos) * wPx }) { DesterradasZone(buckets.desterradas, vm::launch) { menuNode = it } } } Column( modifier = Modifier .align(Alignment.BottomCenter) .windowInsetsPadding(WindowInsets.systemBars) .padding(bottom = 14.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { PageDots(progress = { pos }) Spacer(Modifier.height(10.dp)) Marca() } } if (searchOpen) { SearchOverlay( nodes = allNodes, sort = searchSort, onSort = vm::setSearchSort, onLaunch = { vm.launch(it) searchOpen = false }, onLongPress = { menuNode = it }, onClose = { searchOpen = false }, ) } menuNode?.let { node -> NodeMenu( node = node, onPin = { vm.togglePin(node); menuNode = null }, onPush = { vm.pushAway(node); menuNode = null }, onHide = { vm.toggleHidden(node); menuNode = null }, onInfo = { vm.openInfo(node); menuNode = null }, onUninstall = { vm.uninstall(node); menuNode = null }, onDismiss = { menuNode = null }, ) } } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun SearchOverlay( nodes: List, sort: SearchSort, onSort: (SearchSort) -> Unit, onLaunch: (AppNode) -> Unit, onLongPress: (AppNode) -> Unit, onClose: () -> Unit, ) { var query by remember { mutableStateOf("") } val filtered = remember(query, nodes, sort) { val hits = if (query.isBlank()) nodes else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) } when (sort) { // `nodes` ya viene ordenado por calor (fijados primero): es el orden de casa. SearchSort.FRECUENCIA -> hits SearchSort.NOMBRE -> hits.sortedBy { it.label.lowercase() } SearchSort.RECIENTE -> hits.sortedWith( compareByDescending { it.firstInstallTime }.thenBy { it.label.lowercase() } ) } } val focus = remember { FocusRequester() } LaunchedEffect(Unit) { focus.requestFocus() } Box( Modifier .fillMaxSize() .background(Vacio.copy(alpha = 0.97f)) // Tocar el fondo cierra; el contenido absorbe sus propios toques. .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClose, ) ) { Column( Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.systemBars) .padding(horizontal = 28.dp) .padding(top = 64.dp) ) { BasicTextField( value = query, onValueChange = { query = it }, singleLine = true, textStyle = TextStyle(color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light), cursorBrush = SolidColor(NeonRosa), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), modifier = Modifier.fillMaxWidth().focusRequester(focus), decorationBox = { inner -> if (query.isEmpty()) { Text( text = stringResource(R.string.search_hint), color = Tenue, fontSize = 26.sp, fontWeight = FontWeight.Light, ) } inner() }, ) Spacer(Modifier.height(4.dp)) Box(Modifier.fillMaxWidth().height(1.dp).background(NeonVerde.copy(alpha = 0.4f))) Spacer(Modifier.height(14.dp)) // Criterio de orden: frecuencia (el del rizoma), nombre o recién instaladas. Row(Modifier.fillMaxWidth()) { OrdenChip(R.string.sort_frequency, sort == SearchSort.FRECUENCIA, NeonVerde) { onSort(SearchSort.FRECUENCIA) } OrdenChip(R.string.sort_name, sort == SearchSort.NOMBRE, NeonCian) { onSort(SearchSort.NOMBRE) } OrdenChip(R.string.sort_recent, sort == SearchSort.RECIENTE, NeonRosa) { onSort(SearchSort.RECIENTE) } } Spacer(Modifier.height(12.dp)) LazyColumn(Modifier.fillMaxWidth().weight(1f)) { items(filtered, key = { it.key }) { node -> // Los expulsados se ven atenuados; un toque largo abre su menú (para devolverlos). val dim = if (node.hidden) 0.4f else 1f Row( Modifier .fillMaxWidth() .combinedClickable( onClick = { onLaunch(node) }, onLongClick = { onLongPress(node) }, ) .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Image( bitmap = node.icon, contentDescription = node.label, alpha = dim, modifier = Modifier.size(40.dp).clip(CircleShape), ) Spacer(Modifier.width(16.dp)) Text( text = node.label, color = Color.White.copy(alpha = dim), fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (node.hidden) { Spacer(Modifier.width(10.dp)) Text( text = stringResource(R.string.tag_hidden), color = NeonCian.copy(alpha = 0.7f), fontSize = 11.sp, ) } } } } Marca(Modifier.align(Alignment.CenterHorizontally).padding(vertical = 14.dp)) } } } /** Un criterio de orden: encendido lleva su color; apagado, apenas se insinúa. */ @Composable private fun RowScope.OrdenChip(labelRes: Int, selected: Boolean, accent: Color, onClick: () -> Unit) { Text( text = stringResource(labelRes), color = if (selected) accent else Tenue.copy(alpha = 0.45f), fontSize = 12.sp, fontWeight = if (selected) FontWeight.Medium else FontWeight.Light, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier .weight(1f) .clip(RoundedCornerShape(20.dp)) .background(if (selected) accent.copy(alpha = 0.12f) else Color.Transparent) .clickable(onClick = onClick) .padding(vertical = 7.dp), ) } /** Marca de la casa: discreta, del tamaño de una firma. */ @Composable internal fun Marca(modifier: Modifier = Modifier) { Text( text = stringResource(R.string.brand), color = Color.White.copy(alpha = 0.22f), fontSize = 9.sp, letterSpacing = 3.sp, fontWeight = FontWeight.Light, modifier = modifier, ) } @Composable private fun NodeMenu( node: AppNode, onPin: () -> Unit, onPush: () -> Unit, onHide: () -> Unit, onInfo: () -> Unit, onUninstall: () -> Unit, onDismiss: () -> Unit, ) { Dialog(onDismissRequest = onDismiss) { Surface( color = Superficie, shape = RoundedCornerShape(20.dp), ) { Column(Modifier.width(240.dp).padding(vertical = 10.dp)) { Text( text = node.label, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), ) // Un nodo expulsado solo se puede devolver, consultar o desinstalar: // fijar/alejar no aplican mientras está fuera del átomo. if (!node.hidden) { MenuRow( label = stringResource(if (node.pinned) R.string.menu_unpin else R.string.menu_pin), accent = NeonRosa, onClick = onPin, ) MenuRow(label = stringResource(R.string.menu_push), accent = NeonVerde, onClick = onPush) } MenuRow( label = stringResource(if (node.hidden) R.string.menu_unhide else R.string.menu_hide), accent = NeonCian, onClick = onHide, ) MenuRow(label = stringResource(R.string.menu_info), accent = NeonCian, onClick = onInfo) MenuRow(label = stringResource(R.string.menu_uninstall), accent = NeonRosa, onClick = onUninstall) } } } } @Composable private fun MenuRow(label: String, accent: Color, onClick: () -> Unit) { Row( Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 20.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { Box(Modifier.size(6.dp).clip(CircleShape).background(accent)) Spacer(Modifier.width(14.dp)) Text(text = label, color = Color.White, fontSize = 15.sp) } }