⎇ 3feee86e320e76ed9ddd32f09ed18d20260c4e2a
launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
History
raw
Órbita: launcher orbital anti-rejilla (v0.1)
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 OrbitLayout(
69 nodes = nodes,
70 onLaunch = vm::launch,
71 onLongPress = { menuNode = it },
72 onCenterTap = { searchOpen = true },
73 )
74
75 if (searchOpen) {
76 SearchOverlay(
77 nodes = nodes,
78 onLaunch = {
79 vm.launch(it)
80 searchOpen = false
81 },
82 onClose = { searchOpen = false },
83 )
84 }
85
86 menuNode?.let { node ->
87 NodeMenu(
88 node = node,
89 onPin = { vm.togglePin(node); menuNode = null },
90 onInfo = { vm.openInfo(node); menuNode = null },
91 onUninstall = { vm.uninstall(node); menuNode = null },
92 onDismiss = { menuNode = null },
93 )
94 }
95 }
96}
97
98@Composable
99private fun SearchOverlay(
100 nodes: List<AppNode>,
101 onLaunch: (AppNode) -> Unit,
102 onClose: () -> Unit,
103) {
104 var query by remember { mutableStateOf("") }
105 val filtered = remember(query, nodes) {
106 if (query.isBlank()) nodes
107 else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) }
108 }
109 val focus = remember { FocusRequester() }
110 LaunchedEffect(Unit) { focus.requestFocus() }
111
112 Box(
113 Modifier
114 .fillMaxSize()
115 .background(Vacio.copy(alpha = 0.97f))
116 // Tocar el fondo cierra; el contenido absorbe sus propios toques.
117 .clickable(
118 interactionSource = remember { MutableInteractionSource() },
119 indication = null,
120 onClick = onClose,
121 )
122 ) {
123 Column(
124 Modifier
125 .fillMaxSize()
126 .padding(horizontal = 28.dp)
127 .padding(top = 96.dp)
128 ) {
129 BasicTextField(
130 value = query,
131 onValueChange = { query = it },
132 singleLine = true,
133 textStyle = TextStyle(color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light),
134 cursorBrush = SolidColor(NeonRosa),
135 keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search),
136 modifier = Modifier.fillMaxWidth().focusRequester(focus),
137 decorationBox = { inner ->
138 if (query.isEmpty()) {
139 Text(
140 text = stringResource(R.string.search_hint),
141 color = Tenue,
142 fontSize = 26.sp,
143 fontWeight = FontWeight.Light,
144 )
145 }
146 inner()
147 },
148 )
149 Spacer(Modifier.height(4.dp))
150 Box(Modifier.fillMaxWidth().height(1.dp).background(NeonVerde.copy(alpha = 0.4f)))
151 Spacer(Modifier.height(16.dp))
152
153 LazyColumn(Modifier.fillMaxSize()) {
154 items(filtered, key = { it.key }) { node ->
155 Row(
156 Modifier
157 .fillMaxWidth()
158 .clickable { onLaunch(node) }
159 .padding(vertical = 8.dp),
160 verticalAlignment = Alignment.CenterVertically,
161 ) {
162 Image(
163 bitmap = node.icon,
164 contentDescription = node.label,
165 modifier = Modifier.size(40.dp).clip(CircleShape),
166 )
167 Spacer(Modifier.width(16.dp))
168 Text(
169 text = node.label,
170 color = androidx.compose.ui.graphics.Color.White,
171 fontSize = 16.sp,
172 maxLines = 1,
173 overflow = TextOverflow.Ellipsis,
174 )
175 }
176 }
177 }
178 }
179 }
180}
181
182@Composable
183private fun NodeMenu(
184 node: AppNode,
185 onPin: () -> Unit,
186 onInfo: () -> Unit,
187 onUninstall: () -> Unit,
188 onDismiss: () -> Unit,
189) {
190 Dialog(onDismissRequest = onDismiss) {
191 Surface(
192 color = Superficie,
193 shape = RoundedCornerShape(20.dp),
194 ) {
195 Column(Modifier.width(240.dp).padding(vertical = 10.dp)) {
196 Text(
197 text = node.label,
198 color = androidx.compose.ui.graphics.Color.White,
199 fontSize = 15.sp,
200 fontWeight = FontWeight.Medium,
201 maxLines = 1,
202 overflow = TextOverflow.Ellipsis,
203 modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
204 )
205 MenuRow(
206 label = stringResource(if (node.pinned) R.string.menu_unpin else R.string.menu_pin),
207 accent = NeonRosa,
208 onClick = onPin,
209 )
210 MenuRow(label = stringResource(R.string.menu_info), accent = NeonCian, onClick = onInfo)
211 MenuRow(label = stringResource(R.string.menu_uninstall), accent = NeonRosa, onClick = onUninstall)
212 }
213 }
214 }
215}
216
217@Composable
218private fun MenuRow(label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
219 Row(
220 Modifier
221 .fillMaxWidth()
222 .clickable(onClick = onClick)
223 .padding(horizontal = 20.dp, vertical = 14.dp),
224 verticalAlignment = Alignment.CenterVertically,
225 ) {
226 Box(Modifier.size(6.dp).clip(CircleShape).background(accent))
227 Spacer(Modifier.width(14.dp))
228 Text(text = label, color = androidx.compose.ui.graphics.Color.White, fontSize = 15.sp)
229 }
230}