AboutScreen.kt
260 lines · 10.9 KB
Changed in: 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 | } |


