feat: hold every screen's content to a readable line, and centre it in the window
Phase 6, step 5, first half. Every one of the 40 screens rendered a single column that filled whatever width it was given, so on a 1800dp desktop window a paragraph became a 1800dp line -- long enough that the eye loses the start of the next one -- and a six-character text field stretched to 1700dp. M3: *"across all breakpoints, adjust margins and type styles to keep text between 40–60 characters per line."* **The measure is derived, not written down.** `readableContentWidth()` is `bodyLarge`'s font size converted through the current density, times half an em per character, times sixty: 480dp at the default text size. Writing `480.dp` instead would be the same number today and wrong for anybody who has turned text size up -- at 200% the same column holds thirty characters, silently, because the text still fits. Deriving it means the column widens with the type and keeps its sixty. `AverageCharacterAdvance` is the one estimate in it, named and documented, because a proportional face has no character width and half an em is the standard figure for mixed-case Latin prose. Only the ceiling is enforced. The floor needs nothing: a 400dp compact window less its two 16dp margins holds about 46 characters, which is inside the range, and no cap can add characters to a window that has none. There is a test for exactly that, so the claim is checked rather than asserted in a comment. **The column is centred; the text is not.** Those are opposite things and it is worth being explicit, because "centre it" is how the second one gets done by accident. A centred column still has one straight leading edge for every row, avatar and icon to align to, which is what the grids-and-spacing page asks for. Centred text has none. The 91 `TextAlign.Center` uses are a separate question and a separate commit. **Applied at 49 sites in one pass**, at the point every screen consumes its `Scaffold`'s padding -- the one place in each file that is reliably the top of the content. Below 480dp it is not a cap, an inset or a centring; it is nothing, so no phone layout moves. **Verified by measuring a real composition, not by reading the code.** `readableContent()` is `fillMaxWidth` then `wrapContentWidth` then `widthIn`, and every permutation of those three compiles and renders something that looks right in a phone-width preview. This needed `compose.desktop.uiTestJUnit4` in `jvmTest` -- pinned to the same 1.11.1 as the rest of Compose Multiplatform, test-only -- and `runDesktopComposeUiTest(width = 1400)`, which gives a window that genuinely is 1400 pixels across at density 1. Four assertions, and they bite: swapping the last two modifiers makes the 1400dp case report `Actual width is 1400.0.dp, expected 480.0.dp`, which is the "centred but never capped" failure the doc comment names. The same test also pins `currentBreakpoint()` to the real window at all five widths -- 400, 700, 1000, 1400, 1800 -- with the screen margin following. A version of it that measured the parent's constraints rather than the window would answer `Compact` everywhere and pass every unit test in the suite. 37 theme tests green; android and desktop both compile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5bb0042b1
commit
05e80bf099
44 files changed
+380
-29
No files matched your search
@@ -131,6 +131,15 @@ kotlin {
|
||||
// needs a coroutine, and a scheduler, to assert it in.
|
||||
implementation(libs.kotlinx.coroutinesTest)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
// A layout modifier cannot be asserted by reading it. `readableContent()` is
|
||||
// three modifiers whose order decides whether the content is capped, centred,
|
||||
// both or neither, and every ordering compiles and renders something -- so the
|
||||
// check has to be a measurement of a real composition. Pinned to the same
|
||||
// version as the rest of Compose Multiplatform; test-only.
|
||||
implementation(compose.desktop.uiTestJUnit4)
|
||||
implementation(libs.kotlin.testJunit)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutinesSwing)
|
||||
|
||||
+2
@@ -69,6 +69,7 @@ import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -121,6 +122,7 @@ fun ActiveProfileScreen(
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+2
-1
@@ -85,6 +85,7 @@ import mantra.composeapp.generated.resources.source_dialect
|
||||
import mantra.composeapp.generated.resources.url
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -229,7 +230,7 @@ fun AddArtifactScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
// TODO: Check that we have direct message relays for this user...
|
||||
Column(
|
||||
|
||||
+2
-1
@@ -66,6 +66,7 @@ import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_
|
||||
import mantra.composeapp.generated.resources.add_chapter_to
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -217,7 +218,7 @@ fun AddChapterScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
|
||||
) {
|
||||
if (!addChapterUIState.canSign) {
|
||||
|
||||
+2
-1
@@ -69,6 +69,7 @@ import mantra.composeapp.generated.resources.language
|
||||
import mantra.composeapp.generated.resources.propose_dialect
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -195,7 +196,7 @@ fun AddDialectScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+2
-1
@@ -48,6 +48,7 @@ import mantra.composeapp.generated.resources.once_invited_will_be_able_to_receiv
|
||||
import mantra.composeapp.generated.resources.to_join_the_chat_room
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -135,7 +136,7 @@ fun AddMemberToChatRoomConfirmationScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
|
||||
|
||||
+2
-1
@@ -67,6 +67,7 @@ import mantra.composeapp.generated.resources.translate_into_which_dialect
|
||||
import mantra.composeapp.generated.resources.translate
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -218,7 +219,7 @@ fun AddTranslationArtifactVersionScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
|
||||
) {
|
||||
if (!addTranslationUIState.canSign) {
|
||||
|
||||
+2
-1
@@ -66,6 +66,7 @@ import mantra.composeapp.generated.resources.chapter
|
||||
import mantra.composeapp.generated.resources.words_characters
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -120,7 +121,7 @@ fun ArtifactDetailScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
|
||||
) {
|
||||
// Chapters
|
||||
|
||||
+2
-1
@@ -50,6 +50,7 @@ import mantra.composeapp.generated.resources.chunks
|
||||
import mantra.composeapp.generated.resources.words_characters
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -103,7 +104,7 @@ fun ChapterDetailScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
|
||||
) {
|
||||
item {
|
||||
|
||||
+2
-1
@@ -41,6 +41,7 @@ import mantra.composeapp.generated.resources.what_should_people_know_about_you
|
||||
import mantra.composeapp.generated.resources.what_will_be_discussed_in_this_chat_room
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@Composable
|
||||
fun ChatRoomCreationScreen(
|
||||
@@ -57,7 +58,7 @@ fun ChatRoomCreationScreen(
|
||||
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
|
||||
modifier = Modifier.imePadding()) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxWidth()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxWidth()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
-1
@@ -94,6 +94,7 @@ import mantra.composeapp.generated.resources.recovered_of_event_s
|
||||
import mantra.composeapp.generated.resources.recovered_of_still_unreadable
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -146,7 +147,7 @@ fun ChatRoomDetailScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250),
|
||||
|
||||
+2
-1
@@ -69,6 +69,7 @@ import mantra.composeapp.generated.resources.private_message_to
|
||||
import mantra.composeapp.generated.resources.private_to
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -150,7 +151,7 @@ fun ChatRoomMessagingScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth()
|
||||
|
||||
+2
@@ -61,6 +61,7 @@ import mantra.composeapp.generated.resources.you_are_about_to_create_a_nostr_pro
|
||||
import mantra.composeapp.generated.resources.you_will_be_in_full_control_of_this_profile
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -85,6 +86,7 @@ fun CreateProfileScreen(
|
||||
modifier = Modifier.imePadding()) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+6
-3
@@ -45,6 +45,7 @@ import mantra.composeapp.generated.resources.back
|
||||
import mantra.composeapp.generated.resources.not_now
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
/**
|
||||
* The frame every ChillDKG approval screen sits in: the view model, the states
|
||||
@@ -95,7 +96,7 @@ internal fun DkgApprovalScaffold(
|
||||
) { padding ->
|
||||
when (val state = dkgRitualViewModel.dkgRitualUIState) {
|
||||
is DkgRitualUIState.Loading -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
@@ -103,7 +104,7 @@ internal fun DkgApprovalScaffold(
|
||||
}
|
||||
|
||||
is DkgRitualUIState.Error -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
@@ -118,7 +119,7 @@ internal fun DkgApprovalScaffold(
|
||||
// on the way in, which looks exactly like the screen not working.
|
||||
if (state.session == null) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
@@ -138,6 +139,7 @@ internal fun DkgApprovalScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding)
|
||||
.readableContent()
|
||||
.padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space150)
|
||||
@@ -168,6 +170,7 @@ internal fun DkgApprovalScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(padding)
|
||||
.readableContent()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.containerPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)
|
||||
|
||||
@@ -101,6 +101,7 @@ import mantra.composeapp.generated.resources.of_members_will_be_needed_to_sign_w
|
||||
import mantra.composeapp.generated.resources.shared_key_for
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
/**
|
||||
* The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17
|
||||
@@ -240,6 +241,7 @@ fun DkgRitualScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.readableContent()
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(MaterialTheme.spacing.space125),
|
||||
|
||||
+4
-2
@@ -71,6 +71,7 @@ import mantra.composeapp.generated.resources.signed_their_part
|
||||
import mantra.composeapp.generated.resources.events_signed_together
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
/**
|
||||
* One signing session, and the member's decision about it.
|
||||
@@ -136,7 +137,7 @@ fun FrostSigningScreen(
|
||||
is FrostSigningUIState.Loading -> Loading(padding)
|
||||
|
||||
is FrostSigningUIState.Error -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
@@ -163,6 +164,7 @@ fun FrostSigningScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.readableContent()
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(MaterialTheme.spacing.space250),
|
||||
@@ -320,7 +322,7 @@ fun FrostSigningScreen(
|
||||
@Composable
|
||||
private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
|
||||
@@ -65,6 +65,7 @@ import mantra.composeapp.generated.resources.something_went_wrong
|
||||
import press.mantra.compose.ui.composable.widgets.ErrorState
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -96,7 +97,7 @@ fun HomeScreen(
|
||||
HomeScreenUIState.Error -> {
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
ErrorState()
|
||||
@@ -173,7 +174,7 @@ fun HomeScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
PrimaryScrollableTabRow(
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.space125),
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import mantra.composeapp.generated.resources.functionality_coming_soon_2
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@Composable
|
||||
fun ImplementationPendingScreen(
|
||||
@@ -27,7 +28,7 @@ fun ImplementationPendingScreen(
|
||||
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.space250).fillMaxSize(),
|
||||
|
||||
+2
@@ -58,6 +58,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import press.mantra.compose.ui.composable.widgets.rememberNotifier
|
||||
import mantra.composeapp.generated.resources.key_package_published
|
||||
import mantra.composeapp.generated.resources.key_package_rotated
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -116,6 +117,7 @@ fun KeyPackageManagementScreen(
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -31,6 +31,7 @@ import mantra.composeapp.generated.resources.sign_in
|
||||
import mantra.composeapp.generated.resources.sign_in_to_torch_via_nsec_or_remote_signer
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@Composable
|
||||
fun LandingScreen(
|
||||
@@ -41,6 +42,7 @@ fun LandingScreen(
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@Composable
|
||||
fun LoadingScreen(
|
||||
@@ -18,6 +19,7 @@ fun LoadingScreen(
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
press.mantra.compose.ui.composable.widgets.LoadingDataIndicator(
|
||||
text = text
|
||||
|
||||
+2
-1
@@ -41,6 +41,7 @@ import mantra.composeapp.generated.resources.we_couldn_t_find_your_nostr_event_p
|
||||
import press.mantra.compose.ui.composable.widgets.ErrorState
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -107,7 +108,7 @@ fun NostrEventDetailScreen(
|
||||
else -> {
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
+4
-3
@@ -65,6 +65,7 @@ import mantra.composeapp.generated.resources.waiting_for_you
|
||||
import mantra.composeapp.generated.resources.of_them_could_not_be_read
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
/**
|
||||
* Everything the group has asked its shared key to sign.
|
||||
@@ -128,7 +129,7 @@ fun ProposalListScreen(
|
||||
) { padding ->
|
||||
when (val state = proposalListViewModel.proposalListUIState) {
|
||||
is ProposalListUIState.Loading -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
@@ -136,7 +137,7 @@ fun ProposalListScreen(
|
||||
}
|
||||
|
||||
is ProposalListUIState.Error -> Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600))
|
||||
@@ -155,7 +156,7 @@ fun ProposalListScreen(
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(padding).fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.padding(padding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
|
||||
) {
|
||||
if (state.proposals.isEmpty()) {
|
||||
|
||||
+2
-1
@@ -48,6 +48,7 @@ import mantra.composeapp.generated.resources.search_member_functionality
|
||||
import mantra.composeapp.generated.resources.search_message_functionality_will_be_here
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -104,7 +105,7 @@ fun SearchMemberToAddToChatRoomScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
|
||||
+2
-1
@@ -55,6 +55,7 @@ import mantra.composeapp.generated.resources.next_with
|
||||
import mantra.composeapp.generated.resources.selected
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
/**
|
||||
* Second step of group creation: pick who is in the group.
|
||||
@@ -153,7 +154,7 @@ fun SelectChatRoomMembersScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
if (selectChatRoomMembersUIState.profiles.isEmpty()) {
|
||||
Column(
|
||||
|
||||
+2
@@ -72,6 +72,7 @@ import mantra.composeapp.generated.resources.was_created_but_its_shared_key_cere
|
||||
import mantra.composeapp.generated.resources.you_and_others
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
/**
|
||||
* Last step of group creation: convenient (one admin) or robust (everyone
|
||||
@@ -189,6 +190,7 @@ fun SelectChatRoomTypeScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.readableContent()
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(MaterialTheme.spacing.space125),
|
||||
|
||||
+2
@@ -54,6 +54,7 @@ import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_
|
||||
import press.mantra.compose.ui.composable.widgets.ErrorState
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -106,6 +107,7 @@ fun ShareProfileScreen(
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
|
||||
@@ -46,6 +46,7 @@ import mantra.composeapp.generated.resources.this_will_give_you_read_only_access
|
||||
import mantra.composeapp.generated.resources.this_will_give_you_write_access_to_the
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -66,6 +67,7 @@ fun SignInToProfileScreen(
|
||||
modifier = Modifier.imePadding()) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
-1
@@ -33,6 +33,7 @@ import mantra.composeapp.generated.resources.view_invites
|
||||
import mantra.composeapp.generated.resources.who_will_you_be_passing_the_aux_to
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@Composable
|
||||
fun SocialPreconditionScreen(
|
||||
@@ -69,7 +70,7 @@ fun SocialPreconditionScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250),
|
||||
|
||||
+2
@@ -64,6 +64,7 @@ import mantra.composeapp.generated.resources.translated_text
|
||||
import mantra.composeapp.generated.resources.translation
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -197,6 +198,7 @@ fun TranslateChunkScreen(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.readableContent()
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(MaterialTheme.spacing.space250),
|
||||
|
||||
+2
-1
@@ -59,6 +59,7 @@ import mantra.composeapp.generated.resources.chapter
|
||||
import mantra.composeapp.generated.resources.chunks_translated
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -117,7 +118,7 @@ fun TranslationArtifactVersionDetailScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125)
|
||||
) {
|
||||
// Chapters the source version has and this translation
|
||||
|
||||
+2
-1
@@ -54,6 +54,7 @@ import mantra.composeapp.generated.resources.chapter_translation
|
||||
import mantra.composeapp.generated.resources.this_chapter_has_no_chunks
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -107,7 +108,7 @@ fun TranslationChapterScreen(
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
// Header row: dialect names.
|
||||
item {
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.everything_is_cryptographical_sound
|
||||
import mantra.composeapp.generated.resources.torch_will_be_broadcast_what_you_publish_to
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -31,6 +32,7 @@ fun UnannouncedProfileScreen() {
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.events_are_indexed_so_that_we_can_d
|
||||
import mantra.composeapp.generated.resources.everything_is_cryptographical_sound_just_2
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -31,6 +32,7 @@ fun UnindexedProfileScreen() {
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.all_broadcasts_are_queued_so_that_w
|
||||
import mantra.composeapp.generated.resources.everything_is_cryptographical_sound_just_3
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -31,6 +32,7 @@ fun UnqueuedProfileScreen() {
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
@@ -26,6 +26,7 @@ import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_
|
||||
import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -45,6 +46,7 @@ fun UnqueuedProfileSynchronizationScreen(
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.as_long_as_you_control_your_keys_th
|
||||
import mantra.composeapp.generated.resources.your_profile_is_almost_ready_just_getting_it
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -31,6 +32,7 @@ fun UnsignedProfileScreen() {
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_
|
||||
import mantra.composeapp.generated.resources.we_are_searching_the_internet_to_find_your
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -33,6 +34,7 @@ fun UnsyncedProfileScreen(
|
||||
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
@@ -58,6 +58,7 @@ import mantra.composeapp.generated.resources.what_vibrations_do_you_want_to_send
|
||||
import mantra.composeapp.generated.resources.replying_to
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -86,6 +87,7 @@ fun WriteNewNoteScreen(
|
||||
modifier = Modifier.imePadding()) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
.readableContent()
|
||||
) {
|
||||
|
||||
Column(
|
||||
|
||||
+2
-1
@@ -69,6 +69,7 @@ import mantra.composeapp.generated.resources.send_message
|
||||
import mantra.composeapp.generated.resources.unfollow
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -146,7 +147,7 @@ fun MetadataEventDetail(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
|
||||
LazyColumn(
|
||||
|
||||
+2
-1
@@ -58,6 +58,7 @@ import mantra.composeapp.generated.resources.reply_to
|
||||
import press.mantra.compose.ui.composable.widgets.ErrorState
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
|
||||
import press.mantra.compose.ui.theme.readableContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -197,7 +198,7 @@ fun TextNoteEventDetail(
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize()
|
||||
modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize()
|
||||
) {
|
||||
|
||||
LazyColumn(
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* M3's upper bound on line length: *"across all breakpoints, adjust margins and type
|
||||
* styles to keep text between 40–60 characters per line."*
|
||||
*
|
||||
* The lower bound is not enforced here, and does not need to be. A window narrower than
|
||||
* this holds fewer characters by arithmetic, and no cap can add any -- 40 is a floor on
|
||||
* the type scale and the margin, which the compact margin of 16dp already respects: a
|
||||
* 400dp phone at the default text size holds about 46 characters of `bodyLarge`.
|
||||
*/
|
||||
const val MaxCharactersPerLine = 60
|
||||
|
||||
/**
|
||||
* The width of one character of English prose, as a fraction of the font size.
|
||||
*
|
||||
* There is no exact answer -- a proportional face has no character width -- and this is
|
||||
* the standard estimate for mixed-case Latin text with its spaces: half an em. It is the
|
||||
* number to change if the app ever takes a font whose average advance is visibly wider or
|
||||
* narrower than the system stack's, and changing it moves the measure on every screen at
|
||||
* once, which is the reason it is a named constant rather than a `480.dp` somewhere.
|
||||
*/
|
||||
private const val AverageCharacterAdvance = 0.5f
|
||||
|
||||
/**
|
||||
* The widest a column of [charactersPerLine] characters should be.
|
||||
*
|
||||
* Derived from the type scale rather than fixed, and that is the point: `sp` carries the
|
||||
* reader's font scale, so at 200% text size the column doubles and still holds sixty
|
||||
* characters. A hardcoded `480.dp` would hold thirty, which is the failure the 40–60 rule
|
||||
* is about -- and it would fail silently, because the text still fits.
|
||||
*/
|
||||
@Composable
|
||||
fun readableContentWidth(charactersPerLine: Int = MaxCharactersPerLine): Dp {
|
||||
val bodySize = MaterialTheme.typography.bodyLarge.fontSize
|
||||
// `bodyLarge` is declared in sp and this is the only role prose is set in, but a type
|
||||
// scale is a value somebody can edit, and an em-relative or unspecified size here
|
||||
// would convert to nonsense rather than fail. 16dp is what the baseline scale gives.
|
||||
val bodySizeDp = if (bodySize.isSp) with(LocalDensity.current) { bodySize.toDp() } else 16.dp
|
||||
return readableWidthFor(bodySizeDp, charactersPerLine)
|
||||
}
|
||||
|
||||
/**
|
||||
* [readableContentWidth] without the composition, so the arithmetic can be asserted.
|
||||
*/
|
||||
fun readableWidthFor(fontSize: Dp, charactersPerLine: Int = MaxCharactersPerLine): Dp =
|
||||
fontSize * AverageCharacterAdvance * charactersPerLine
|
||||
|
||||
/**
|
||||
* Holds this element to a readable measure and centres it in whatever space it is given.
|
||||
*
|
||||
* The one change that decides whether a screen written for a phone survives a 1800dp
|
||||
* window. Without it a paragraph runs the full width and becomes unreadable -- the eye
|
||||
* loses the start of the next line -- and a form's fields stretch to 1700dp for a
|
||||
* six-character input. With it the content keeps the proportions it was designed at and
|
||||
* the window's extra width becomes margin, which is what M3's single-pane canonical
|
||||
* layout does.
|
||||
*
|
||||
* The centring is of the *column*, not of the text inside it. Those are opposite things:
|
||||
* a centred column still has a straight leading edge for every row, avatar and icon to
|
||||
* align to, which is what the grids-and-spacing page asks for. Centred text has none.
|
||||
*
|
||||
* Applied at the top of a `Scaffold`'s content, so a screen gets it once:
|
||||
*
|
||||
* Scaffold(...) { innerPadding ->
|
||||
* Column(modifier = Modifier.padding(innerPadding).readableContent()) { … }
|
||||
* }
|
||||
*
|
||||
* A no-op on any window narrower than the measure, which is every phone.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.readableContent(charactersPerLine: Int = MaxCharactersPerLine): Modifier =
|
||||
this
|
||||
// fillMaxWidth first so that the centring has the whole window to centre within;
|
||||
// wrapContentWidth then relaxes the minimum so the child may be narrower, and
|
||||
// widthIn caps it. Reordering these silently loses either the cap or the centring.
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.CenterHorizontally)
|
||||
.widthIn(max = readableContentWidth(charactersPerLine))
|
||||
@@ -0,0 +1,70 @@
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The reading measure against M3's 40–60 characters per line.
|
||||
*
|
||||
* The whole point of deriving the cap rather than writing `480.dp` is that it follows the
|
||||
* reader's text size, and that is the property no screenshot catches: a hardcoded column
|
||||
* still looks correct at 200% text scale, it just holds thirty characters instead of
|
||||
* sixty.
|
||||
*/
|
||||
class ReadableMeasureTest {
|
||||
|
||||
@Test
|
||||
fun `sixty characters of the default body size is 480dp`() {
|
||||
// bodyLarge is 16sp in the baseline scale, and 1sp is 1dp at the default text
|
||||
// size. Half an em per character times sixty.
|
||||
assertEquals(480.dp, readableWidthFor(16.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the measure follows the text size rather than the window`() {
|
||||
// A reader at 200% text size gets a column twice as wide, and still sixty
|
||||
// characters. This is the assertion a constant cannot pass, and the reason the
|
||||
// function takes a font size at all.
|
||||
assertEquals(
|
||||
readableWidthFor(16.dp) * 2f,
|
||||
readableWidthFor(32.dp),
|
||||
"doubling the text size did not double the measure",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cap is the top of M3's range and not the bottom`() {
|
||||
// 40 is the floor and 60 the ceiling; a max-width enforces the ceiling. Capping
|
||||
// at 40 would be the same mistake in the other direction -- a column too narrow
|
||||
// to read comfortably on any window wide enough to matter.
|
||||
assertEquals(MaxCharactersPerLine, 60)
|
||||
assertTrue(readableWidthFor(16.dp, 40) < readableWidthFor(16.dp, 60))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone window holds a line inside the range`() {
|
||||
// The check that the floor needs no enforcement. A 400dp compact window less its
|
||||
// two 16dp margins is 368dp, which at 8dp a character is 46 -- inside 40 to 60,
|
||||
// so the cap is a no-op there and nothing has to widen anything.
|
||||
val compactContent = 400.dp - spacingFor(Breakpoint.Compact).screenMargin * 2f
|
||||
val charactersPerLine = compactContent / (readableWidthFor(16.dp) / 60f)
|
||||
|
||||
assertTrue(
|
||||
charactersPerLine in 40f..60f,
|
||||
"a 400dp window fits $charactersPerLine characters, outside M3's 40-60",
|
||||
)
|
||||
assertTrue(
|
||||
compactContent < readableWidthFor(16.dp),
|
||||
"the measure is narrower than a phone, so it would crop rather than cap",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a desktop window is held to the measure rather than filled`() {
|
||||
// The case this exists for. 1800dp of window, 480dp of text, and the remaining
|
||||
// 1320dp becomes margin instead of a line nobody can track back to its start.
|
||||
assertTrue(readableWidthFor(16.dp) < 1800.dp)
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package press.mantra.compose.ui.theme
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
|
||||
import androidx.compose.ui.test.assertWidthIsEqualTo
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.runDesktopComposeUiTest
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* What the theme does to a real composition in a window of a given width.
|
||||
*
|
||||
* [BreakpointTest] and [ReadableMeasureTest] assert arithmetic. This asserts the two
|
||||
* things arithmetic cannot reach, in a window that genuinely is 1400 pixels wide:
|
||||
*
|
||||
* - that `currentBreakpoint()` reads the window at all. It goes through
|
||||
* `currentWindowDpSize()`, `LocalWindowInfo` and `LocalDensity`, and a version of it
|
||||
* that measured the wrong thing -- the parent's constraints, say -- would return
|
||||
* `Compact` everywhere and pass every unit test in the suite.
|
||||
* - that `readableContent()` caps *and* centres. It is `fillMaxWidth` then
|
||||
* `wrapContentWidth` then `widthIn`, and every permutation of those three compiles and
|
||||
* renders something plausible at phone width. Swap the last two and the content is
|
||||
* centred but never capped; drop the first and it is capped but hugs the left edge.
|
||||
*
|
||||
* The test density is 1, so a pixel is a dp and the window widths below are the ones the
|
||||
* phase's acceptance criterion names.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class ReadableContentLayoutJvmTest {
|
||||
|
||||
/** 60 characters of 16sp `bodyLarge` at the default text scale. */
|
||||
private val measure = 480.dp
|
||||
|
||||
@Test
|
||||
fun `a desktop window caps the content and centres it`() = inWindow(1400) {
|
||||
// (1400 - 480) / 2 each side. The window's extra width becomes margin, which is
|
||||
// what M3's single-pane canonical layout does -- not a 1400dp line of text, and
|
||||
// not a 480dp column pinned against one edge.
|
||||
onNodeWithTag(Content).assertWidthIsEqualTo(measure)
|
||||
onNodeWithTag(Content).assertLeftPositionInRootIsEqualTo(460.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone window is left exactly as it was`() = inWindow(400) {
|
||||
// The claim that let this be applied to all 49 screen roots in one pass: below the
|
||||
// measure it is not a cap, an inset or a centring -- it is nothing. If this fails,
|
||||
// every phone layout in the app moved.
|
||||
onNodeWithTag(Content).assertWidthIsEqualTo(400.dp)
|
||||
onNodeWithTag(Content).assertLeftPositionInRootIsEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the measure itself is the last width left alone`() = inWindow(480) {
|
||||
onNodeWithTag(Content).assertWidthIsEqualTo(measure)
|
||||
onNodeWithTag(Content).assertLeftPositionInRootIsEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the theme classifies the window it is actually in`() {
|
||||
listOf(
|
||||
400 to Breakpoint.Compact,
|
||||
700 to Breakpoint.Medium,
|
||||
1000 to Breakpoint.Expanded,
|
||||
1400 to Breakpoint.Large,
|
||||
1800 to Breakpoint.ExtraLarge,
|
||||
).forEach { (windowWidth, expected) ->
|
||||
var seen: Breakpoint? = null
|
||||
var margin: Dp? = null
|
||||
runDesktopComposeUiTest(width = windowWidth, height = 900) {
|
||||
setContent {
|
||||
TorchTheme {
|
||||
seen = MaterialTheme.breakpoint
|
||||
margin = MaterialTheme.spacing.screenMargin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(expected, seen, "a ${windowWidth}dp window")
|
||||
assertEquals(
|
||||
if (expected == Breakpoint.Compact) 16.dp else 24.dp,
|
||||
margin,
|
||||
"the screen margin in a ${windowWidth}dp window",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes a `readableContent()` column in a window [windowWidth] pixels across.
|
||||
*
|
||||
* The tagged node is a child of the modifier rather than the modifier's own node:
|
||||
* `fillMaxWidth` makes that node the full window, and it is the child inside the cap
|
||||
* whose width and position are the thing under test.
|
||||
*/
|
||||
private fun inWindow(windowWidth: Int, assertions: androidx.compose.ui.test.ComposeUiTest.() -> Unit) =
|
||||
runDesktopComposeUiTest(width = windowWidth, height = 900) {
|
||||
setContent { TaggedContent() }
|
||||
assertions()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaggedContent() {
|
||||
TorchTheme {
|
||||
Box(Modifier.readableContent()) {
|
||||
Box(Modifier.fillMaxWidth().height(8.dp).testTag(Content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val Content = "content"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user