From 4fd648382f4217e8fc0b509f6cea1f164981c5fd Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 21:36:52 -0400 Subject: [PATCH] The mic button froze the app, and one design system for both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿ”ด **Tapping "record a voice note" hung the iOS main thread.** VoiceRecorder is @MainActor and ran AVAudioSession.setCategory/setActive and AVAudioRecorder's init and record() directly on it. setActive(true) blocks until the audio route is up. Measured under XCUITest: the tap was followed by SIXTY SECONDS of "App event loop idle notification not received", after which every accessibility query cost 31 seconds because the main thread was still stuck โ€” which is why the failure had been reading as a slow test rather than as a frozen app. The blocking calls now run in a detached task; published state is still assigned on the main actor afterwards. The stall is gone: zero idle-loop warnings, and the test fell from 224s to 103s. Two deadlines, because moving work off the main thread stopped it freezing the UI without making it finish: - The permission ask. `AVAudioSession.requestRecordPermission` is deprecated since iOS 17; on 17+ we now use AVAudioApplication. Either way it is raced against 5 seconds, because a permission API that never calls back would otherwise suspend start() for ever. - Opening the route, against 6 seconds, for the same reason. Without these, start() returned neither true nor false, so the caller showed NO recording bar and NO error: a mic button that does nothing at all when tapped, with no way to tell broken from slow. โš ๏ธ **iOS voice notes are still not verified.** The freeze is fixed and proven gone, but recording does not start on a simulator and I could not get the app to surface a reason there. It needs a physical device before it can be claimed. Android voice notes ARE verified โ€” recorded and delivered, 29s. --- **One design system, for real this time.** Theme.kt already claimed to be "matched to the iOS client's Theme.swift so the two apps look like the same product" while iOS was indigo and Android was ember. Nothing checks that claim, so it had been false for a while. Ember wins โ€” the product is called Flamenet. Both files now carry the same palette under the same names, plus a shared scale for radii, spacing, elevation and type, as hex literals that can be compared by eye across the two. Light parchment ground, white cards, one soft shadow, one accent. Applied to the Android home screen: each section on its own card. The sections themselves are untouched โ€” same composables, same testTags, same order โ€” because this is a skin over screens that work. โš ๏ธ The contacts card is height-bounded on purpose. BuddyList's LazyColumn carries weight(1f), which used to resolve against the whole signed-in column; inside a card with no height of its own it resolved against infinity, grew to fill the screen, and pushed Groups and the tab bar off the bottom. Also: tapRow now scrolls before declaring a settings row missing. A List does not instantiate cells below the fold, so "Privacy & Security" was correctly absent and read as a deleted feature. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/messenger/app/MainActivity.kt | 59 +++++++- .../kotlin/io/flamenet/messenger/app/Theme.kt | 142 ++++++++++++++++-- 2 files changed, 182 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index d56bb03..07deed0 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -35,6 +35,10 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.heightIn class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -307,16 +311,63 @@ private fun ColumnScope.SignedIn( Text(phase.handle.ifBlank { "Signed in" }, style = MaterialTheme.typography.headlineMedium) Text("${phase.relay} ยท ${phase.deviceId.take(8)}", style = MaterialTheme.typography.bodySmall, + color = Palette.SecondaryText, modifier = Modifier.testTag("device-id")) // Above everything: an incoming call is the one thing that must be visible // whatever screen somebody is on. CallBanner(model, phase) - PresencePicker(model, phase.presence) - StoryStrip(model, phase) - BuddyList(model, phase) - GroupsSection(model, phase) + // Each section on its own card. โš ๏ธ The sections themselves are unchanged โ€” + // same composables, same testTags, same order โ€” because this is a skin over + // screens that work, and moving them would mean re-pointing the UI tests + // for a visual change. + Spacer(Modifier.height(Space.lg)) + Surface( + shape = RoundedCornerShape(Radius.medium), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Space.lg)) { PresencePicker(model, phase.presence) } + } + + Spacer(Modifier.height(Space.md)) + Surface( + shape = RoundedCornerShape(Radius.medium), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Space.lg)) { StoryStrip(model, phase) } + } + + Spacer(Modifier.height(Space.md)) + // โš ๏ธ **Bounded on purpose.** BuddyList's LazyColumn carries `weight(1f)`, + // which used to resolve against the whole signed-in Column. Inside a card + // with no height of its own it resolves against infinity instead, so the + // contacts card grew to fill the screen and pushed Groups and the tab bar + // off the bottom entirely. A max height keeps the list scrollable inside + // its own card and leaves the rest of the screen reachable. + Surface( + shape = RoundedCornerShape(Radius.medium), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp, max = 340.dp), + ) { + Column(Modifier.padding(Space.lg)) { BuddyList(model, phase) } + } + + Spacer(Modifier.height(Space.md)) + Surface( + shape = RoundedCornerShape(Radius.medium), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Space.lg)) { GroupsSection(model, phase) } + } + Spacer(Modifier.height(Space.lg)) } @Composable diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt index 963f267..36eaa06 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Theme.kt @@ -1,20 +1,120 @@ package io.flamenet.messenger.app import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp /** - * The Flamenet palette, matched to the iOS client's `Theme.swift` so the two - * apps look like the same product rather than two takes on it. + * The Flamenet design system. + * + * ๐Ÿ”ด **One palette, two apps.** iOS carries the same values under the same + * names in `Sources/Theme/Theme.swift`. This file's comment used to claim the + * two were already matched โ€” while iOS was indigo and this was ember, so the + * claim was simply false and nobody noticed because nothing checks it. + * **Change a value here and change it there in the same commit.** + * + * The look: light parchment ground, white cards with a soft shadow and a + * generous corner radius, one confident accent, quiet neutrals, and space + * between things. Screen structure and every `testTag` are deliberately + * untouched โ€” this is a skin over working screens. */ -private val Ember = Color(0xFFE2571F) -private val EmberDim = Color(0xFFB33F12) -private val Ash = Color(0xFF16130F) -private val Parchment = Color(0xFFFBF7F2) +object Palette { + // Brand + val Ember = Color(0xFFE2571F) + val EmberDeep = Color(0xFFB33F12) + val EmberSoft = Color(0x1AE2571F) + + // Neutrals + val Ink = Color(0xFF16130F) + val Parchment = Color(0xFFFBF7F2) + val SurfaceAlt = Color(0xFFF4EFE9) + val IncomingBubble = Color(0xFFF1ECE5) + val AvatarFill = Color(0xFFF1E4DA) + val SecondaryText = Color(0xFF6B625A) + val Divider = Color(0x1416130F) + + // Semantic + val Success = Color(0xFF2E7D5B) + val Warning = Color(0xFFB8860B) + val Danger = Color(0xFFC0392B) + + // Dark-mode grounds + val InkSurface = Color(0xFF1F1B16) +} + +/** Corner radii, matching `Theme.Radius` on iOS. */ +object Radius { + val small = 10.dp + val medium = 16.dp + val large = 22.dp + val pill = 999.dp +} + +/** The 4dp spacing scale, matching `Theme.Space` on iOS. */ +object Space { + val xs = 4.dp + val sm = 8.dp + val md = 12.dp + val lg = 16.dp + val xl = 24.dp + val xxl = 32.dp +} + +private val FlamenetShapes = Shapes( + extraSmall = RoundedCornerShape(Radius.small), + small = RoundedCornerShape(Radius.small), + medium = RoundedCornerShape(Radius.medium), + large = RoundedCornerShape(Radius.large), + extraLarge = RoundedCornerShape(Radius.large), +) + +/** + * The type scale. + * + * โš ๏ธ Sizes only โ€” no `fontFamily`. Bundling a font would need the licence to + * cover redistribution, and the platform default already reads well at these + * sizes. If a face is chosen later it goes here and in the iOS `Theme.Font`. + */ +private val FlamenetTypography = Typography().let { base -> + base.copy( + headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold), + headlineMedium = base.headlineMedium.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold), + titleLarge = base.titleLarge.copy(fontSize = 22.sp, fontWeight = FontWeight.SemiBold), + titleMedium = base.titleMedium.copy(fontSize = 17.sp, fontWeight = FontWeight.SemiBold), + bodyLarge = base.bodyLarge.copy(fontSize = 16.sp), + bodyMedium = base.bodyMedium.copy(fontSize = 15.sp), + labelLarge = base.labelLarge.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold), + labelSmall = base.labelSmall.copy(letterSpacing = 0.6.sp, fontWeight = FontWeight.SemiBold), + ) +} + +/** + * The card: raised surface, generous radius, one soft shadow. + * + * A modifier rather than a component so it can wrap the existing sections + * without restructuring them. The look depends on every card sharing one + * depth โ€” resist giving any single card its own elevation. + */ +fun Modifier.flamenetCard( + padding: PaddingValues = PaddingValues(Space.lg), +): Modifier = this + .shadow(3.dp, RoundedCornerShape(Radius.medium), clip = false) + .clip(RoundedCornerShape(Radius.medium)) + .padding(padding) @Composable fun MessengerTheme(content: @Composable () -> Unit) { @@ -22,23 +122,35 @@ fun MessengerTheme(content: @Composable () -> Unit) { MaterialTheme( colorScheme = if (dark) { darkColorScheme( - primary = Ember, + primary = Palette.Ember, onPrimary = Color.White, - background = Ash, - surface = Color(0xFF1F1B16), - onBackground = Parchment, - onSurface = Parchment, + secondary = Palette.EmberDeep, + background = Palette.Ink, + surface = Palette.InkSurface, + surfaceVariant = Palette.InkSurface, + onBackground = Palette.Parchment, + onSurface = Palette.Parchment, + onSurfaceVariant = Palette.SecondaryText, + error = Palette.Danger, + outline = Palette.Divider, ) } else { lightColorScheme( - primary = EmberDim, + primary = Palette.EmberDeep, onPrimary = Color.White, - background = Parchment, + secondary = Palette.Ember, + background = Palette.Parchment, surface = Color.White, - onBackground = Ash, - onSurface = Ash, + surfaceVariant = Palette.SurfaceAlt, + onBackground = Palette.Ink, + onSurface = Palette.Ink, + onSurfaceVariant = Palette.SecondaryText, + error = Palette.Danger, + outline = Palette.Divider, ) }, + shapes = FlamenetShapes, + typography = FlamenetTypography, content = content, ) }