Krolyxon
Commit 9dea828

Branches

Refs →

budctlandroidappsrcmainkotliniogithubkrolyxonbudctlScreen.kt

288 lines · 12.6 KB · Kotlin

1package io.github.krolyxon.budctl
2
3import androidx.compose.animation.animateColorAsState
4import androidx.compose.animation.core.animateFloatAsState
5import androidx.compose.animation.core.tween
6import androidx.compose.foundation.background
7import androidx.compose.foundation.border
8import androidx.compose.foundation.clickable
9import androidx.compose.foundation.horizontalScroll
10import androidx.compose.foundation.interaction.MutableInteractionSource
11import androidx.compose.foundation.layout.Arrangement
12import androidx.compose.foundation.layout.Box
13import androidx.compose.foundation.layout.Column
14import androidx.compose.foundation.layout.Row
15import androidx.compose.foundation.layout.Spacer
16import androidx.compose.foundation.layout.fillMaxSize
17import androidx.compose.foundation.layout.fillMaxWidth
18import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.safeDrawingPadding
20import androidx.compose.foundation.layout.size
21import androidx.compose.foundation.pager.HorizontalPager
22import androidx.compose.foundation.pager.rememberPagerState
23import androidx.compose.foundation.relocation.BringIntoViewRequester
24import androidx.compose.foundation.relocation.bringIntoViewRequester
25import androidx.compose.foundation.rememberScrollState
26import androidx.compose.foundation.shape.RoundedCornerShape
27import androidx.compose.foundation.text.BasicText
28import androidx.compose.foundation.text.BasicTextField
29import androidx.compose.foundation.text.KeyboardActions
30import androidx.compose.foundation.text.KeyboardOptions
31import androidx.compose.foundation.verticalScroll
32import androidx.compose.runtime.Composable
33import androidx.compose.runtime.CompositionLocalProvider
34import androidx.compose.runtime.LaunchedEffect
35import androidx.compose.runtime.getValue
36import androidx.compose.runtime.remember
37import androidx.compose.runtime.rememberCoroutineScope
38import androidx.compose.ui.Alignment
39import androidx.compose.ui.Modifier
40import androidx.compose.ui.draw.clip
41import androidx.compose.ui.focus.FocusRequester
42import androidx.compose.ui.focus.focusRequester
43import androidx.compose.ui.graphics.SolidColor
44import androidx.compose.ui.graphics.graphicsLayer
45import androidx.compose.ui.platform.LocalContext
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.input.KeyboardCapitalization
50import androidx.compose.ui.unit.dp
51import androidx.compose.ui.unit.sp
52import androidx.compose.ui.window.Dialog
53import kotlin.math.abs
54import kotlinx.coroutines.delay
55import kotlinx.coroutines.launch
56
57/**
58 * The whole screen: the buds and how the app stands with them, the pages as dot-matrix words, and
59 * the open page under them. Swiping changes the page; the pages fade into each other rather than
60 * slide, as Nothing's motion asks.
61 */
62@Composable
63fun Screen(app: App, askPermission: () -> Unit) {
64 val context = LocalContext.current
65 val fonts = remember { Fonts(context.assets) }
66 val palette = remember(app.materialYou) { if (app.materialYou) materialYou(context) else BUILT_IN }
67 val look = Look(palette, fonts, app.motion)
68 CompositionLocalProvider(LocalLook provides look) {
69 val pages = Page.entries
70 val pager = rememberPagerState { pages.size }
71 val scope = rememberCoroutineScope()
72 Column(
73 Modifier
74 .fillMaxSize()
75 .background(palette.bg)
76 .safeDrawingPadding(),
77 ) {
78 Header(app)
79 val open = { page: Int ->
80 scope.launch {
81 if (look.motion) pager.animateScrollToPage(page, animationSpec = tween(Motion.PAGE, easing = Motion.EASE))
82 else pager.scrollToPage(page)
83 }
84 Unit
85 }
86 PageWords(pages, pager.currentPage, open)
87 HorizontalPager(pager, Modifier.weight(1f)) { index ->
88 Box(
89 Modifier
90 .fillMaxSize()
91 .graphicsLayer {
92 // The page stays where it is and fades, however far the swipe took it.
93 val offset = (pager.currentPage - index) + pager.currentPageOffsetFraction
94 translationX = offset * size.width
95 alpha = 1f - abs(offset).coerceIn(0f, 1f)
96 },
97 ) {
98 Column(
99 Modifier
100 .fillMaxSize()
101 .verticalScroll(rememberScrollState())
102 .padding(horizontal = 14.dp, vertical = 10.dp),
103 verticalArrangement = Arrangement.spacedBy(18.dp),
104 ) {
105 Caption(pages[index].caption)
106 app.problem?.let { Problem(it) { app.read() } }
107 when (pages[index]) {
108 Page.Overview -> Overview(app, askPermission) { open(pages.indexOf(it)) }
109 Page.Equalizer -> Equalizer(app)
110 Page.Gestures -> Gestures(app)
111 Page.Earbuds -> Earbuds(app)
112 Page.App -> AppPage(app)
113 }
114 Spacer(Modifier.size(12.dp))
115 }
116 }
117 }
118 }
119 if (app.confirmDual) {
120 Confirm(
121 "Turn dual connection off?",
122 "If two devices are connected to your buds, they may drop one of them.",
123 "Turn off",
124 app::answerDual,
125 )
126 }
127 app.confirmDelete?.let { id ->
128 Confirm(
129 "Delete ${app.buds.preset(id)?.name ?: "this preset"}?",
130 "Its bands are lost. The buds keep their other presets.",
131 "Delete",
132 app::answerDelete,
133 )
134 }
135 app.prompt?.let { NameDialog(app, it) }
136 if (app.confirmFind) {
137 // The sound is loud enough to hurt in the ears.
138 Confirm(
139 "Ring your buds?",
140 "Take them out of your ears first: the sound is loud. They ring until you press Stop.",
141 "Ring",
142 app::answerFind,
143 )
144 }
145 }
146}
147
148/** The buds' name, how the app stands with them, and a way to read them again. */
149@Composable
150private fun Header(app: App) {
151 val p = LocalLook.current.p
152 val connection = app.connection
153 val (light, state) = when {
154 connection == Connection.Demo || connection == Connection.Live -> p.fill to "connected"
155 connection == Connection.Connecting -> p.live to "connecting…"
156 connection is Connection.Failed && connection.kind == "busy" -> p.err to "busy"
157 !app.buds.connected -> p.mute to "not connected"
158 else -> p.err to "error"
159 }
160 Column(Modifier.padding(start = 26.dp, end = 18.dp, top = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
161 Row(verticalAlignment = Alignment.CenterVertically) {
162 Words(app.buds.name, 19.sp, weight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
163 if (app.demo) Tag("demo")
164 }
165 Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
166 Light(light, 8.dp, blinking = connection == Connection.Connecting)
167 Label(state, modifier = Modifier.weight(1f))
168 if (app.permitted) Pill("Refresh") { app.read() }
169 }
170 }
171}
172
173/** The pages, as on the desktop's sidebar: the open one large with the live dot, the rest dim. */
174@Composable
175private fun PageWords(pages: List<Page>, open: Int, onOpen: (Int) -> Unit) {
176 val look = LocalLook.current
177 Row(
178 Modifier
179 .fillMaxWidth()
180 .horizontalScroll(rememberScrollState())
181 .padding(horizontal = 12.dp, vertical = 6.dp),
182 verticalAlignment = Alignment.CenterVertically,
183 ) {
184 pages.forEachIndexed { index, page ->
185 val isOpen = index == open
186 val size by animateFloatAsState(if (isOpen) 38f else 26f, Motion.eased(Motion.PAGE, look.motion), label = "word")
187 val color by animateColorAsState(
188 if (isOpen) look.p.fg else look.p.mute,
189 Motion.eased(Motion.PAGE, look.motion),
190 label = "word",
191 )
192 // The open word scrolls into view once it has grown.
193 val requester = remember { BringIntoViewRequester() }
194 LaunchedEffect(isOpen) {
195 if (isOpen) {
196 if (look.motion) delay(Motion.PAGE.toLong())
197 requester.bringIntoView()
198 }
199 }
200 Row(
201 Modifier
202 .bringIntoViewRequester(requester)
203 .clip(RoundedCornerShape(20.dp))
204 .clickable(remember { MutableInteractionSource() }, indication = null) { onOpen(index) }
205 .padding(horizontal = 8.dp, vertical = 4.dp),
206 verticalAlignment = Alignment.CenterVertically,
207 horizontalArrangement = Arrangement.spacedBy(10.dp),
208 ) {
209 if (isOpen) Light(look.p.live, 11.dp)
210 // Doto is never set under 26 sp.
211 BasicText(page.word.uppercase(), style = TextStyle(fontFamily = look.fonts.dot, fontSize = size.sp, color = color))
212 }
213 }
214 }
215}
216
217/** Asks for a preset's name: a new one's, or a new name for one. */
218@Composable
219private fun NameDialog(app: App, asked: NamePrompt) {
220 val look = LocalLook.current
221 val p = look.p
222 val heading = asked.renaming?.let { "Rename ${app.buds.preset(it)?.name ?: "the preset"}" } ?: "New preset"
223 val focus = remember { FocusRequester() }
224 Dialog(onDismissRequest = app::cancelName) {
225 CompositionLocalProvider(LocalLook provides look) {
226 Column(
227 Modifier
228 .clip(RoundedCornerShape(28.dp))
229 .background(p.s1)
230 .padding(24.dp),
231 verticalArrangement = Arrangement.spacedBy(18.dp),
232 ) {
233 Words(heading, 22.sp, weight = FontWeight.SemiBold)
234 BasicTextField(
235 asked.text,
236 app::typeName,
237 Modifier
238 .fillMaxWidth()
239 .focusRequester(focus)
240 .clip(RoundedCornerShape(percent = 50))
241 .background(p.s2)
242 .border(1.dp, p.fill, RoundedCornerShape(percent = 50))
243 .padding(horizontal = 20.dp, vertical = 12.dp),
244 textStyle = TextStyle(fontFamily = look.fonts.ui, fontSize = 16.sp, color = p.fg),
245 keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done),
246 keyboardActions = KeyboardActions(onDone = { app.submitName() }),
247 singleLine = true,
248 cursorBrush = SolidColor(p.fill),
249 )
250 asked.problem?.let { Words(it, 13.sp, p.err) }
251 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) {
252 Pill("Cancel", onClick = app::cancelName)
253 Pill(if (asked.renaming == null) "Create" else "Rename", filled = true, onClick = app::submitName)
254 }
255 }
256 }
257 }
258 LaunchedEffect(Unit) { focus.requestFocus() }
259}
260
261/** A question over the dimmed page: Cancel, or [action]. */
262@Composable
263private fun Confirm(heading: String, words: String, action: String, answer: (Boolean) -> Unit) {
264 val look = LocalLook.current
265 Dialog(onDismissRequest = { answer(false) }) {
266 CompositionLocalProvider(LocalLook provides look) {
267 Column(
268 Modifier
269 .clip(RoundedCornerShape(28.dp))
270 .background(look.p.s1)
271 .padding(24.dp),
272 verticalArrangement = Arrangement.spacedBy(10.dp),
273 ) {
274 Words(heading, 22.sp, weight = FontWeight.SemiBold)
275 Words(words, 13.5.sp, look.p.fgDim)
276 Row(
277 Modifier
278 .fillMaxWidth()
279 .padding(top = 16.dp),
280 horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
281 ) {
282 Pill("Cancel") { answer(false) }
283 Pill(action, filled = true) { answer(true) }
284 }
285 }
286 }
287 }
288}