| 1 | package io.github.krolyxon.budctl |
| 2 | |
| 3 | import androidx.compose.animation.animateColorAsState |
| 4 | import androidx.compose.animation.core.animateFloatAsState |
| 5 | import androidx.compose.animation.core.tween |
| 6 | import androidx.compose.foundation.background |
| 7 | import androidx.compose.foundation.border |
| 8 | import androidx.compose.foundation.clickable |
| 9 | import androidx.compose.foundation.horizontalScroll |
| 10 | import androidx.compose.foundation.interaction.MutableInteractionSource |
| 11 | import androidx.compose.foundation.layout.Arrangement |
| 12 | import androidx.compose.foundation.layout.Box |
| 13 | import androidx.compose.foundation.layout.Column |
| 14 | import androidx.compose.foundation.layout.Row |
| 15 | import androidx.compose.foundation.layout.Spacer |
| 16 | import androidx.compose.foundation.layout.fillMaxSize |
| 17 | import androidx.compose.foundation.layout.fillMaxWidth |
| 18 | import androidx.compose.foundation.layout.padding |
| 19 | import androidx.compose.foundation.layout.safeDrawingPadding |
| 20 | import androidx.compose.foundation.layout.size |
| 21 | import androidx.compose.foundation.pager.HorizontalPager |
| 22 | import androidx.compose.foundation.pager.rememberPagerState |
| 23 | import androidx.compose.foundation.relocation.BringIntoViewRequester |
| 24 | import androidx.compose.foundation.relocation.bringIntoViewRequester |
| 25 | import androidx.compose.foundation.rememberScrollState |
| 26 | import androidx.compose.foundation.shape.RoundedCornerShape |
| 27 | import androidx.compose.foundation.text.BasicText |
| 28 | import androidx.compose.foundation.text.BasicTextField |
| 29 | import androidx.compose.foundation.text.KeyboardActions |
| 30 | import androidx.compose.foundation.text.KeyboardOptions |
| 31 | import androidx.compose.foundation.verticalScroll |
| 32 | import androidx.compose.runtime.Composable |
| 33 | import androidx.compose.runtime.CompositionLocalProvider |
| 34 | import androidx.compose.runtime.LaunchedEffect |
| 35 | import androidx.compose.runtime.getValue |
| 36 | import androidx.compose.runtime.remember |
| 37 | import androidx.compose.runtime.rememberCoroutineScope |
| 38 | import androidx.compose.ui.Alignment |
| 39 | import androidx.compose.ui.Modifier |
| 40 | import androidx.compose.ui.draw.clip |
| 41 | import androidx.compose.ui.focus.FocusRequester |
| 42 | import androidx.compose.ui.focus.focusRequester |
| 43 | import androidx.compose.ui.graphics.SolidColor |
| 44 | import androidx.compose.ui.graphics.graphicsLayer |
| 45 | import androidx.compose.ui.platform.LocalContext |
| 46 | import androidx.compose.ui.text.TextStyle |
| 47 | import androidx.compose.ui.text.font.FontWeight |
| 48 | import androidx.compose.ui.text.input.ImeAction |
| 49 | import androidx.compose.ui.text.input.KeyboardCapitalization |
| 50 | import androidx.compose.ui.unit.dp |
| 51 | import androidx.compose.ui.unit.sp |
| 52 | import androidx.compose.ui.window.Dialog |
| 53 | import kotlin.math.abs |
| 54 | import kotlinx.coroutines.delay |
| 55 | import 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 |
| 63 | fun 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 |
| 150 | private 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 |
| 175 | private 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 |
| 219 | private 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 |
| 263 | private 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 | } |