AboutScreen.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.about |
| 2 | ||
| 3 | import androidx.activity.compose.BackHandler | |
| 4 | import androidx.compose.foundation.Image | |
| 5 | import androidx.compose.foundation.background | |
| 6 | import androidx.compose.foundation.clickable | |
| 7 | import androidx.compose.foundation.layout.Arrangement | |
| 8 | import androidx.compose.foundation.layout.Box | |
| 9 | import androidx.compose.foundation.layout.Column | |
| 10 | import androidx.compose.foundation.layout.Row | |
| 11 | import androidx.compose.foundation.layout.Spacer | |
| 12 | import androidx.compose.foundation.layout.aspectRatio | |
| 13 | import androidx.compose.foundation.layout.fillMaxHeight | |
| 14 | import androidx.compose.foundation.layout.fillMaxSize | |
| 15 | import androidx.compose.foundation.layout.fillMaxWidth | |
| 16 | import androidx.compose.foundation.layout.height | |
| 17 | import androidx.compose.foundation.layout.padding | |
| 18 | import androidx.compose.foundation.layout.size | |
| 19 | import androidx.compose.foundation.layout.sizeIn | |
| 20 | import androidx.compose.foundation.rememberScrollState | |
| 21 | import androidx.compose.foundation.verticalScroll | |
| 22 | import androidx.compose.runtime.Composable | |
| 23 | import androidx.compose.runtime.remember | |
| 24 | import androidx.compose.ui.Alignment | |
| 25 | import androidx.compose.ui.Modifier | |
| 26 | import androidx.compose.ui.res.painterResource | |
| 27 | import androidx.compose.ui.text.style.TextAlign | |
| 28 | import androidx.compose.ui.unit.dp | |
| 29 | import androidx.tv.material3.MaterialTheme | |
| 30 | import androidx.tv.material3.Text | |
| 31 | import dev.stave.R | |
| 32 | import dev.stave.ui.components.staveFocusable | |
| 33 | import dev.stave.ui.theme.AccentAmber | |
| 34 | import dev.stave.ui.theme.OnAmber | |
| 35 | import dev.stave.ui.theme.OnMuted | |
| 36 | import dev.stave.ui.theme.OnPrimary | |
| 37 | import dev.stave.ui.theme.OnQuiet | |
| 38 | import dev.stave.ui.theme.adaptiveScreenPaddingHorizontal | |
| 39 | import dev.stave.ui.theme.adaptiveScreenPaddingVertical | |
| 40 | import dev.stave.ui.theme.isCompactWidth | |
| 41 | import dev.stave.ui.theme.withInterDigits | |
| 42 | ||
| 43 | /** | |
| 44 | * About screen with adaptive layout: | |
| 45 | * - **Android TV** (uiMode == UI_MODE_TYPE_TELEVISION): the original | |
| 46 | * side-by-side AboutExpanded layout, byte-identical to the pre-phone-port | |
| 47 | * baseline. | |
| 48 | * - **Phone landscape** (orientation == LANDSCAPE, not TV): horizontal | |
| 49 | * AboutExpanded layout — large badge on the left, info on the right — | |
| 50 | * same shape as the pre-phone-port About page used to have. The vertical | |
| 51 | * AboutCompact layout looked correct in portrait but felt cramped in | |
| 52 | * landscape where the wide-and-short viewport begs for a horizontal split. | |
| 53 | * - **Phone portrait** (anything else): vertical AboutCompact layout with a | |
| 54 | * larger badge sized to the available shorter dimension. | |
| 55 | * | |
| 56 | * Tablets in landscape get the AboutExpanded treatment too; tablets in | |
| 57 | * portrait fall through to AboutCompact. If a tablet-specific layout is | |
| 58 | * needed later we can split that out. | |
| 59 | */ | |
| 60 | @Composable | |
| 61 | fun AboutScreen(onBack: () -> Unit) { | |
| 62 | BackHandler(onBack = onBack) | |
| 63 | ||
| 64 | val configuration = androidx.compose.ui.platform.LocalConfiguration.current | |
| 65 | val isAndroidTv = | |
| 66 | (configuration.uiMode and android.content.res.Configuration.UI_MODE_TYPE_MASK) == | |
| 67 | android.content.res.Configuration.UI_MODE_TYPE_TELEVISION | |
| 68 | val isLandscape = configuration.orientation == | |
| 69 | android.content.res.Configuration.ORIENTATION_LANDSCAPE | |
| 70 | if (isAndroidTv || isLandscape) { | |
| 71 | AboutExpanded() | |
| 72 | } else { | |
| 73 | AboutCompact() | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | @Composable | |
| 78 | private fun AboutCompact() { | |
| 79 | val config = androidx.compose.ui.platform.LocalConfiguration.current | |
| 80 | // Badge at "full resolution" — fills the smaller screen dimension up | |
| 81 | // to a generous fraction so the source's 512x512 detail isn't down- | |
| 82 | // scaled. Portrait phones get ~80% width (so on a 411dp-wide Pixel 8a | |
| 83 | // that's 329dp, rendered at 2.7x density ≈ 888px — slightly upscaled | |
| 84 | // from the 512px source, comfortable for the generous brand mark). | |
| 85 | // Height-limited paths (landscape phone routed to compact, short | |
| 86 | // tablets) clamp to 65% of the height so it doesn't push the rest | |
| 87 | // of the column off the screen. No hard dp cap — the user wants the | |
| 88 | // badge to dominate the page, not sit in a tidy 320dp circle. | |
| 89 | val badgeSize = minOf( | |
| 90 | config.screenWidthDp.dp * 0.80f, | |
| 91 | config.screenHeightDp.dp * 0.65f, | |
| 92 | ) | |
| 93 | Column( | |
| 94 | Modifier | |
| 95 | .fillMaxSize() | |
| 96 | .padding( | |
| 97 | horizontal = adaptiveScreenPaddingHorizontal(), | |
| 98 | vertical = adaptiveScreenPaddingVertical(), | |
| 99 | ) | |
| 100 | .verticalScroll(rememberScrollState()), | |
| 101 | horizontalAlignment = Alignment.CenterHorizontally, | |
| 102 | ) { | |
| 103 | Image( | |
| 104 | painter = painterResource(R.drawable.earhorn_badge), | |
| 105 | contentDescription = null, | |
| 106 | modifier = Modifier.size(badgeSize), | |
| 107 | ) | |
| 108 | Spacer(Modifier.height(20.dp)) | |
| 109 | ||
| 110 | Text( | |
| 111 | "Earhorn Music", | |
| 112 | style = MaterialTheme.typography.headlineMedium, | |
| 113 | color = OnPrimary, | |
| 114 | textAlign = TextAlign.Center, | |
| 115 | ) | |
| 116 | Spacer(Modifier.height(4.dp)) | |
| 117 | Text( | |
| 118 | "A UPnP music player for Android TV, phones, GNU/Linux, and Android Auto", | |
| 119 | style = MaterialTheme.typography.bodyMedium, | |
| 120 | color = OnMuted, | |
| 121 | textAlign = TextAlign.Center, | |
| 122 | modifier = Modifier.fillMaxWidth(), | |
| 123 | ) | |
| 124 | Spacer(Modifier.height(20.dp)) | |
| 125 | Text( | |
| 126 | withInterDigits(appVersionLabel()), | |
| 127 | style = MaterialTheme.typography.labelLarge, | |
| 128 | color = AccentAmber, | |
| 129 | ) | |
| 130 | Spacer(Modifier.height(24.dp)) | |
| 131 | Text( | |
| 132 | "A Faveat Software Project", | |
| 133 | style = MaterialTheme.typography.bodyMedium, | |
| 134 | color = OnQuiet, | |
| 135 | ) | |
| 136 | Spacer(Modifier.height(20.dp)) | |
| 137 | val context = androidx.compose.ui.platform.LocalContext.current | |
| 138 | val interaction = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } | |
| 139 | Box( | |
| 140 | Modifier | |
| 141 | .background(AccentAmber, androidx.compose.foundation.shape.RoundedCornerShape(50)) | |
| 142 | .staveFocusable(interaction, cornerRadiusDp = 50) | |
| 143 | .clickable(interaction, indication = null) { | |
| 144 | runCatching { | |
| 145 | context.startActivity( | |
| 146 | android.content.Intent( | |
| 147 | android.content.Intent.ACTION_VIEW, | |
| 148 | android.net.Uri.parse("https://ko-fi.com/faveat"), | |
| 149 | ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) | |
| 150 | ) | |
| 151 | } | |
| 152 | } | |
| 153 | .padding(horizontal = 28.dp, vertical = 12.dp), | |
| 154 | contentAlignment = Alignment.Center, | |
| 155 | ) { | |
| 156 | Text("Say Thanks ☕", style = MaterialTheme.typography.titleSmall, color = OnAmber) | |
| 157 | } | |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | @Composable | |
| 162 | private fun AboutExpanded() { | |
| 163 | Row( | |
| 164 | Modifier | |
| 165 | .fillMaxSize() | |
| 166 | .padding( | |
| 167 | horizontal = adaptiveScreenPaddingHorizontal(), | |
| 168 | vertical = adaptiveScreenPaddingVertical(), | |
| 169 | ), | |
| 170 | horizontalArrangement = Arrangement.spacedBy(48.dp), | |
| 171 | verticalAlignment = Alignment.CenterVertically, | |
| 172 | ) { | |
| 173 | // Left: badge — fills 85% of available height as a square so its | |
| 174 | // visual presence matches the portrait (Compact) layout's badge. | |
| 175 | // Earlier value was 0.5f which produced a ~200dp badge in landscape | |
| 176 | // vs ~329dp in portrait — the disparity made the brand mark feel | |
| 177 | // diminished in landscape. The text Column to the right has | |
| 178 | // weight(1f) so it still claims all remaining horizontal width; | |
| 179 | // there's enough room on a landscape phone (~800–900dp wide) even | |
| 180 | // with the badge consuming ~350dp. | |
| 181 | Image( | |
| 182 | painter = painterResource(R.drawable.earhorn_badge), | |
| 183 | contentDescription = null, | |
| 184 | modifier = Modifier | |
| 185 | .fillMaxHeight(0.85f) | |
| 186 | .aspectRatio(1f), | |
| 187 | ) | |
| 188 | ||
| 189 | // Right: text details, vertically centered. | |
| 190 | Column( | |
| 191 | Modifier.weight(1f), | |
| 192 | horizontalAlignment = Alignment.Start, | |
| 193 | verticalArrangement = Arrangement.Center, | |
| 194 | ) { | |
| 195 | Text( | |
| 196 | "Earhorn Music", | |
| 197 | style = MaterialTheme.typography.displayLarge, | |
| 198 | color = OnPrimary, | |
| 199 | ) | |
| 200 | Spacer(Modifier.height(4.dp)) | |
| 201 | Text( | |
| 202 | "A UPnP music player for Android TV", | |
| 203 | style = MaterialTheme.typography.titleMedium, | |
| 204 | color = OnMuted, | |
| 205 | ) | |
| 206 | Spacer(Modifier.height(24.dp)) | |
| 207 | Text( | |
| 208 | withInterDigits(appVersionLabel()), | |
| 209 | style = MaterialTheme.typography.labelLarge, | |
| 210 | color = AccentAmber, | |
| 211 | ) | |
| 212 | Spacer(Modifier.height(28.dp)) | |
| 213 | Text( | |
| 214 | "A Faveat Software Project", | |
| 215 | style = MaterialTheme.typography.bodyMedium, | |
| 216 | color = OnQuiet, | |
| 217 | ) | |
| 218 | Spacer(Modifier.height(24.dp)) | |
| 219 | val context = androidx.compose.ui.platform.LocalContext.current | |
| 220 | val interaction = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } | |
| 221 | Box( | |
| 222 | Modifier | |
| 223 | .background(AccentAmber, androidx.compose.foundation.shape.RoundedCornerShape(50)) | |
| 224 | .staveFocusable(interaction, cornerRadiusDp = 50) | |
| 225 | .clickable(interaction, indication = null) { | |
| 226 | runCatching { | |
| 227 | context.startActivity( | |
| 228 | android.content.Intent( | |
| 229 | android.content.Intent.ACTION_VIEW, | |
| 230 | android.net.Uri.parse("https://ko-fi.com/faveat"), | |
| 231 | ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) | |
| 232 | ) | |
| 233 | } | |
| 234 | } | |
| 235 | .padding(horizontal = 28.dp, vertical = 12.dp), | |
| 236 | contentAlignment = Alignment.Center, | |
| 237 | ) { | |
| 238 | Text("Say Thanks ☕", style = MaterialTheme.typography.titleSmall, color = OnAmber) | |
| 239 | } | |
| 240 | } | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | /** The app's real version, read from the installed package at runtime. | |
| 245 | * Derived — never hardcoded — so version bumps in build.gradle.kts | |
| 246 | * propagate here automatically. (The previous hardcoded "Version 0.0.1" | |
| 247 | * survived at least one real release unchanged.) Includes the flavor's | |
| 248 | * versionNameSuffix, so libre builds truthfully show e.g. "0.1.1-libre". | |
| 249 | */ | |
| 250 | @Composable | |
| 251 | private fun appVersionLabel(): String { | |
| 252 | val context = androidx.compose.ui.platform.LocalContext.current | |
| 253 | return remember { | |
| 254 | runCatching { | |
| 255 | "Version " + context.packageManager | |
| 256 | .getPackageInfo(context.packageName, 0) | |
| 257 | .versionName | |
| 258 | }.getOrDefault("Version ?") | |
| 259 | } | |
| 260 | } |


