⎇ 608145b4b469bb0f8b58179bb8338053430cbdb6
launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt
Historia
crudo
Órbita: quitar apps del átomo (sin desinstalar) + fondo según hora y clima
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.foundation.ExperimentalFoundationApi
9import androidx.compose.foundation.Image
10import androidx.compose.foundation.background
11import androidx.compose.foundation.clickable
12import androidx.compose.foundation.combinedClickable
13import androidx.compose.foundation.interaction.MutableInteractionSource
14import androidx.compose.foundation.layout.Box
15import androidx.compose.foundation.layout.Column
16import androidx.compose.foundation.layout.Row
17import androidx.compose.foundation.layout.Spacer
18import androidx.compose.foundation.layout.fillMaxSize
19import androidx.compose.foundation.layout.fillMaxWidth
20import androidx.compose.foundation.layout.height
21import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.size
23import androidx.compose.foundation.layout.width
24import androidx.compose.foundation.lazy.LazyColumn
25import androidx.compose.foundation.lazy.items
26import androidx.compose.foundation.shape.CircleShape
27import androidx.compose.foundation.shape.RoundedCornerShape
28import androidx.compose.foundation.text.BasicTextField
29import androidx.compose.material3.Surface
30import androidx.compose.material3.Text
31import androidx.compose.runtime.Composable
32import androidx.compose.runtime.getValue
33import androidx.compose.runtime.mutableStateOf
34import androidx.compose.runtime.remember
35import androidx.compose.runtime.setValue
36import androidx.compose.runtime.LaunchedEffect
37import androidx.compose.ui.Alignment
38import androidx.compose.ui.Modifier
39import androidx.compose.ui.draw.clip
40import androidx.compose.ui.focus.FocusRequester
41import androidx.compose.ui.focus.focusRequester
42import androidx.compose.ui.graphics.SolidColor
43import androidx.compose.ui.platform.LocalContext
44import androidx.compose.ui.res.stringResource
45import androidx.core.content.ContextCompat
46import androidx.compose.ui.text.TextStyle
47import androidx.compose.ui.text.font.FontWeight
48import androidx.compose.ui.text.input.ImeAction
49import androidx.compose.ui.text.style.TextOverflow
50import androidx.compose.ui.unit.dp
51import androidx.compose.ui.unit.sp
52import androidx.compose.ui.window.Dialog
53import androidx.lifecycle.viewmodel.compose.viewModel
54import androidx.compose.runtime.collectAsState
55import com.rizoma.orbita.R
56import com.rizoma.orbita.model.AppNode
57import com.rizoma.orbita.ui.theme.NeonCian
58import com.rizoma.orbita.ui.theme.NeonRosa
59import com.rizoma.orbita.ui.theme.NeonVerde
60import com.rizoma.orbita.ui.theme.Superficie
61import com.rizoma.orbita.ui.theme.Tenue
62import com.rizoma.orbita.ui.theme.Vacio
63
64@Composable
65fun OrbitApp(vm: OrbitaViewModel = viewModel()) {
66 val nodes by vm.nodes.collectAsState()
67 val sky by vm.sky.collectAsState()
68 var searchOpen by remember { mutableStateOf(false) }
69 var menuNode by remember { mutableStateOf<AppNode?>(null) }
70
71 // El cielo del fondo se tiñe con el clima local: pedimos ubicación aproximada
72 // una vez y, con permiso, refrescamos el clima.
73 val context = LocalContext.current
74 val permLauncher = rememberLauncherForActivityResult(
75 ActivityResultContracts.RequestPermission()
76 ) { granted -> if (granted) vm.refreshWeather() }
77 LaunchedEffect(Unit) {
78 val granted = ContextCompat.checkSelfPermission(
79 context, Manifest.permission.ACCESS_COARSE_LOCATION
80 ) == PackageManager.PERMISSION_GRANTED
81 if (granted) vm.refreshWeather()
82 else permLauncher.launch(Manifest.permission.ACCESS_COARSE_LOCATION)
83 }
84
85 // Como launcher: "atrás" cierra la búsqueda o no hace nada (nunca sale al sistema).
86 BackHandler(enabled = true) {
87 if (searchOpen) searchOpen = false
88 }
89
90 // Lo que orbita: el átomo sin los nodos retirados. Los ocultos siguen siendo
91 // alcanzables desde la búsqueda, para poder devolverlos.
92 val orbitNodes = remember(nodes) { nodes.filterNot { it.hidden } }
93
94 Box(Modifier.fillMaxSize().background(Vacio)) {
95 DynamicBackground(palette = sky)
96 OrbitLayout(
97 nodes = orbitNodes,
98 onLaunch = vm::launch,
99 onLongPress = { menuNode = it },
100 onCenterTap = { searchOpen = true },
101 )
102
103 if (searchOpen) {
104 SearchOverlay(
105 nodes = nodes,
106 onLaunch = {
107 vm.launch(it)
108 searchOpen = false
109 },
110 onLongPress = { menuNode = it },
111 onClose = { searchOpen = false },
112 )
113 }
114
115 menuNode?.let { node ->
116 NodeMenu(
117 node = node,
118 onPin = { vm.togglePin(node); menuNode = null },
119 onPush = { vm.pushAway(node); menuNode = null },
120 onHide = { vm.toggleHidden(node); menuNode = null },
121 onInfo = { vm.openInfo(node); menuNode = null },
122 onUninstall = { vm.uninstall(node); menuNode = null },
123 onDismiss = { menuNode = null },
124 )
125 }
126 }
127}
128
129@OptIn(ExperimentalFoundationApi::class)
130@Composable
131private fun SearchOverlay(
132 nodes: List<AppNode>,
133 onLaunch: (AppNode) -> Unit,
134 onLongPress: (AppNode) -> Unit,
135 onClose: () -> Unit,
136) {
137 var query by remember { mutableStateOf("") }
138 val filtered = remember(query, nodes) {
139 if (query.isBlank()) nodes
140 else nodes.filter { it.label.contains(query.trim(), ignoreCase = true) }
141 }
142 val focus = remember { FocusRequester() }
143 LaunchedEffect(Unit) { focus.requestFocus() }
144
145 Box(
146 Modifier
147 .fillMaxSize()
148 .background(Vacio.copy(alpha = 0.97f))
149 // Tocar el fondo cierra; el contenido absorbe sus propios toques.
150 .clickable(
151 interactionSource = remember { MutableInteractionSource() },
152 indication = null,
153 onClick = onClose,
154 )
155 ) {
156 Column(
157 Modifier
158 .fillMaxSize()
159 .padding(horizontal = 28.dp)
160 .padding(top = 96.dp)
161 ) {
162 BasicTextField(
163 value = query,
164 onValueChange = { query = it },
165 singleLine = true,
166 textStyle = TextStyle(color = NeonVerde, fontSize = 26.sp, fontWeight = FontWeight.Light),
167 cursorBrush = SolidColor(NeonRosa),
168 keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Search),
169 modifier = Modifier.fillMaxWidth().focusRequester(focus),
170 decorationBox = { inner ->
171 if (query.isEmpty()) {
172 Text(
173 text = stringResource(R.string.search_hint),
174 color = Tenue,
175 fontSize = 26.sp,
176 fontWeight = FontWeight.Light,
177 )
178 }
179 inner()
180 },
181 )
182 Spacer(Modifier.height(4.dp))
183 Box(Modifier.fillMaxWidth().height(1.dp).background(NeonVerde.copy(alpha = 0.4f)))
184 Spacer(Modifier.height(16.dp))
185
186 LazyColumn(Modifier.fillMaxSize()) {
187 items(filtered, key = { it.key }) { node ->
188 // Los ocultos se ven atenuados; un toque largo abre su menú (para devolverlos).
189 val dim = if (node.hidden) 0.4f else 1f
190 Row(
191 Modifier
192 .fillMaxWidth()
193 .combinedClickable(
194 onClick = { onLaunch(node) },
195 onLongClick = { onLongPress(node) },
196 )
197 .padding(vertical = 8.dp),
198 verticalAlignment = Alignment.CenterVertically,
199 ) {
200 Image(
201 bitmap = node.icon,
202 contentDescription = node.label,
203 alpha = dim,
204 modifier = Modifier.size(40.dp).clip(CircleShape),
205 )
206 Spacer(Modifier.width(16.dp))
207 Text(
208 text = node.label,
209 color = androidx.compose.ui.graphics.Color.White.copy(alpha = dim),
210 fontSize = 16.sp,
211 maxLines = 1,
212 overflow = TextOverflow.Ellipsis,
213 )
214 if (node.hidden) {
215 Spacer(Modifier.width(10.dp))
216 Text(
217 text = stringResource(R.string.tag_hidden),
218 color = NeonCian.copy(alpha = 0.7f),
219 fontSize = 11.sp,
220 )
221 }
222 }
223 }
224 }
225 }
226 }
227}
228
229@Composable
230private fun NodeMenu(
231 node: AppNode,
232 onPin: () -> Unit,
233 onPush: () -> Unit,
234 onHide: () -> Unit,
235 onInfo: () -> Unit,
236 onUninstall: () -> Unit,
237 onDismiss: () -> Unit,
238) {
239 Dialog(onDismissRequest = onDismiss) {
240 Surface(
241 color = Superficie,
242 shape = RoundedCornerShape(20.dp),
243 ) {
244 Column(Modifier.width(240.dp).padding(vertical = 10.dp)) {
245 Text(
246 text = node.label,
247 color = androidx.compose.ui.graphics.Color.White,
248 fontSize = 15.sp,
249 fontWeight = FontWeight.Medium,
250 maxLines = 1,
251 overflow = TextOverflow.Ellipsis,
252 modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
253 )
254 // Un nodo retirado solo se puede devolver, consultar o desinstalar:
255 // fijar/alejar no aplican mientras está fuera del átomo.
256 if (!node.hidden) {
257 MenuRow(
258 label = stringResource(if (node.pinned) R.string.menu_unpin else R.string.menu_pin),
259 accent = NeonRosa,
260 onClick = onPin,
261 )
262 MenuRow(label = stringResource(R.string.menu_push), accent = NeonVerde, onClick = onPush)
263 }
264 MenuRow(
265 label = stringResource(if (node.hidden) R.string.menu_unhide else R.string.menu_hide),
266 accent = NeonCian,
267 onClick = onHide,
268 )
269 MenuRow(label = stringResource(R.string.menu_info), accent = NeonCian, onClick = onInfo)
270 MenuRow(label = stringResource(R.string.menu_uninstall), accent = NeonRosa, onClick = onUninstall)
271 }
272 }
273 }
274}
275
276@Composable
277private fun MenuRow(label: String, accent: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
278 Row(
279 Modifier
280 .fillMaxWidth()
281 .clickable(onClick = onClick)
282 .padding(horizontal = 20.dp, vertical = 14.dp),
283 verticalAlignment = Alignment.CenterVertically,
284 ) {
285 Box(Modifier.size(6.dp).clip(CircleShape).background(accent))
286 Spacer(Modifier.width(14.dp))
287 Text(text = label, color = androidx.compose.ui.graphics.Color.White, fontSize = 15.sp)
288 }
289}