tawasuyu

sergio/humanoid

HTML 38Markdown 1TOML 10 Definiciones

⎇ 608145b4b469bb0f8b58179bb8338053430cbdb6 launcher/src/main/java/com/rizoma/orbita/ui/OrbitApp.kt 289 líneas · 11.5 KB Historia crudo

Órbita: quitar apps del átomo (sin desinstalar) + fondo según hora y clima 608145b4b · sergio ·

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}