⎇ 8ab12b6800f7b28dfd3aa1a0ee97dff7b1ca41b7
launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
Historia
crudo
Órbita: alejar + fondo dinámico · Trazo: aprendizaje con umbral y bilingüe
1package com.rizoma.orbita.ui
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.Image
5import androidx.compose.foundation.background
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.interaction.MutableInteractionSource
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Row
11import androidx.compose.foundation.layout.Spacer
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.height
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.size
17import androidx.compose.foundation.layout.width
18import androidx.compose.foundation.lazy.LazyColumn
19import androidx.compose.foundation.lazy.items
20import androidx.compose.foundation.shape.CircleShape
21import androidx.compose.foundation.shape.RoundedCornerShape
22import androidx.compose.foundation.text.BasicTextField
23import androidx.compose.material3.Surface
24import androidx.compose.material3.Text
25import androidx.compose.runtime.Composable
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
29import androidx.compose.runtime.setValue
30import androidx.compose.runtime.LaunchedEffect
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.draw.clip
34import androidx.compose.ui.focus.FocusRequester
35import androidx.compose.ui.focus.focusRequester
36import androidx.compose.ui.graphics.SolidColor
37import androidx.compose.ui.res.stringResource
38import androidx.compose.ui.text.TextStyle
39import androidx.compose.ui.text.font.FontWeight
40import androidx.compose.ui.text.input.ImeAction
41import androidx.compose.ui.text.style.TextOverflow
42import androidx.compose.ui.unit.dp
43import androidx.compose.ui.unit.sp
44import androidx.compose.ui.window.Dialog
45import androidx.lifecycle.viewmodel.compose.viewModel
46import androidx.compose.runtime.collectAsState
47import com.rizoma.orbita.R
48import com.rizoma.orbita.model.AppNode
49import com.rizoma.orbita.ui.theme.NeonCian
50import com.rizoma.orbita.ui.theme.NeonRosa
51import com.rizoma.orbita.ui.theme.NeonVerde
52import com.rizoma.orbita.ui.theme.Superficie
53import com.rizoma.orbita.ui.theme.Tenue
54import com.rizoma.orbita.ui.theme.Vacio
55
56@Composable
57fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
58 val nodes by vm.nodes.collectAsState()
59 var searchOpen by remember { mutableStateOf(false) }
60 var menuNode by remember { mutableStateOf<AppNode?>(null) }
61
62 // Como launcher: "atrás" cierra la búsqueda o no hace nada (nunca sale al sistema).
63 BackHandler(enabled = true) {
64 if (searchOpen) searchOpen = false
65 }
66
67 Box(Modifier.fillMaxSize().background(Vacio)) {
68 DynamicBackground()
69 OrbitLayout(
70 nodes = nodes,
71 onLaunch = vm::launch,
72 onLongPress = { menuNode = it },
73 onCenterTap = { searchOpen = true },
74 )
75
76 if (searchOpen) {
77 SearchOverlay(
78 nodes = nodes,
79 onLaunch = {
80 vm.launch(it)
81 searchOpen = false
82 },
83 onClose = { searchOpen = false },
84 )
85 }
86
87 menuNode?.let { node ->
88 NodeMenu(
89 node = node,
90 onPin = { vm.togglePin(node); menuNode = null },
91 onPush = { vm.pushAway(node); menuNode = null },
92 onInfo = { vm.openInfo(node); menuNode = null },
93 onUninstall = { vm.uninstall(node); menuNode = null },
94 onDismiss = { menuNode = null },
95 )
96 }
97 }
98}
99
100@Composable
101private fun SearchOverlay(
102 nodes: List<AppNode>,
103 onLaunch: (AppNode) -> Unit,
104 onClose: () -> Unit,
105) {
106 var query by remember { mutableStateOf("") }
107 val filtered = remember(query, nodes) {
108 if (query.isBlank()) nodes
109 else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) }
110 }
111 val focus = remember { FocusRequester() }
112 LaunchedEffect(Unit) { focus.requestFocus() }
113
114 Box(
115 Modifier
116 .fillMaxSize()
117 .background(Vacio.copy(alpha = 0.97f))
118 // Tocar el fondo cierra; el contenido absorbe sus propios toques.
119 .clickable(
120 interactionSource = remember { MutableInteractionSource() },
121 indication = null,
122 onClick = onClose,
123 )
124 ) {
125 Column(
126 Modifier
127 .fillMaxSize()
128 .padding(horizontal = 28.dp)
129 .padding(top = 96.dp)
130 ) {
131 BasicTextField(
132 value = query,
133 onValueChange = { query = it },
134 singleLine = true,
135 textStyle = TextStyle(color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light),
136 cursorBrush = SolidColor(NeonRosa),
137 keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search),
138 modifier = Modifier.fillMaxWidth().focusRequester(focus),
139 decorationBox = { inner ->
140 if (query.isEmpty()) {
141 Text(
142 text = stringResource(R.string.search_hint),
143 color = Tenue,
144 fontSize = 26.sp,
145 fontWeight = FontWeight.Light,
146 )
147 }
148 inner()
149 },
150 )
151 Spacer(Modifier.height(4.dp))
152 Box(Modifier.fillMaxWidth().height(1.dp).background(NeonVerde.copy(alpha = 0.4f)))
153 Spacer(Modifier.height(16.dp))
154
155 LazyColumn(Modifier.fillMaxSize()) {
156 items(filtered, key = { it.key }) { node ->
157 Row(
158 Modifier
159 .fillMaxWidth()
160 .clickable { onLaunch(node) }
161 .padding(vertical = 8.dp),
162 verticalAlignment = Alignment.CenterVertically,
163 ) {
164 Image(
165 bitmap = node.icon,
166 contentDescription = node.label,
167 modifier = Modifier.size(40.dp).clip(CircleShape),
168 )
169 Spacer(Modifier.width(16.dp))
170 Text(
171 text = node.label,
172 color = androidx.compose.ui.graphics.Color.White,
173 fontSize = 16.sp,
174 maxLines = 1,
175 overflow = TextOverflow.Ellipsis,
176 )
177 }
178 }
179 }
180 }
181 }
182}
183
184@Composable
185private fun NodeMenu(
186 node: AppNode,
187 onPin: () -> Unit,
188 onPush: () -> Unit,
189 onInfo: () -> Unit,
190 onUninstall: () -> Unit,
191 onDismiss: () -> Unit,
192) {
193 Dialog(onDismissRequest = onDismiss) {
194 Surface(
195 color = Superficie,
196 shape = RoundedCornerShape(20.dp),
197 ) {
198 Column(Modifier.width(240.dp).padding(vertical = 10.dp)) {
199 Text(
200 text = node.label,
201 color = androidx.compose.ui.graphics.Color.White,
202 fontSize = 15.sp,
203 fontWeight = FontWeight.Medium,
204 maxLines = 1,
205 overflow = TextOverflow.Ellipsis,
206 modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
207 )
208 MenuRow(
209 label = stringResource(if (node.pinned) R.string.menu_unpin else R.string.menu_pin),
210 accent = NeonRosa,
211 onClick = onPin,
212 )
213 MenuRow(label = stringResource(R.string.menu_push), accent = NeonVerde, onClick = onPush)
214 MenuRow(label = stringResource(R.string.menu_info), accent = NeonCian, onClick = onInfo)
215 MenuRow(label = stringResource(R.string.menu_uninstall), accent = NeonRosa, onClick = onUninstall)
216 }
217 }
218 }
219}
220
221@Composable
222private fun MenuRow(label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
223 Row(
224 Modifier
225 .fillMaxWidth()
226 .clickable(onClick = onClick)
227 .padding(horizontal = 20.dp, vertical = 14.dp),
228 verticalAlignment = Alignment.CenterVertically,
229 ) {
230 Box(Modifier.size(6.dp).clip(CircleShape).background(accent))
231 Spacer(Modifier.width(14.dp))
232 Text(text = label, color = androidx.compose.ui.graphics.Color.White, fontSize = 15.sp)
233 }
234}