diff --git a/.gitea/workflows/material-design-conformance.yml b/.gitea/workflows/material-design-conformance.yml new file mode 100644 index 00000000..18f2890f --- /dev/null +++ b/.gitea/workflows/material-design-conformance.yml @@ -0,0 +1,62 @@ +# Material Design conformance, as a gate rather than a habit. +# +# docs/material-design-conformance.md drove nine classes of defect to zero across eight +# phases. Every one of them is the kind that comes back one call site at a time -- a +# hardcoded colour on a screen somebody was in a hurry on, a `10.dp` typed rather than +# reached for -- and none of them is visible in a diff unless a reviewer is looking for it. +# The budgets in docs/scripts/m3-audit.sh are what look. +# +# Gitea Actions, because the remote is a Gitea instance. The syntax is GitHub Actions'; a +# runner has to be registered against the repository for either job to run at all. +name: Material Design conformance + +on: + push: + branches: [mantra] + pull_request: + +jobs: + # Grep over the source tree. No gradle, no android SDK, no submodules, and no network: + # this job is why the audit is a shell script rather than a gradle plugin, and it should + # stay runnable on a bare container. + budgets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - name: Check the conformance budgets + run: docs/scripts/m3-audit.sh --check + + # The assertions that need a compiler. Much heavier than the job above: the composite + # build reaches four levels of submodule and cross-compiles secp256k1's C sources, so a + # cold run is minutes rather than seconds. Split out so a runner can be pointed at + # `budgets` alone where that is all the capacity there is for. + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The chain is lightning-kmp-app -> experimental/lightning-kmp -> + # experimental/bitcoin-kmp -> experimental/secp256k1-kmp -> native/secp256k1. + # Without every level, gradle fails during configuration with + # "Project with path ':library' not found", which reads like a build script + # error and is not one. + submodules: recursive + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # :secp256k1-kmp:jni:android is an android library module, so the SDK has to be + # present even for a jvm-only test run -- the failure otherwise is + # "SDK location not found" during configuration. + - uses: android-actions/setup-android@v3 + + - name: Theme, layout and motion tests + run: ./gradlew :composeApp:jvmTest --no-daemon + + - name: Android compilation + run: ./gradlew :composeApp:compileDebugKotlinAndroid --no-daemon diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3c68fa57 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,181 @@ +# Conventions for UI code + +Eight phases of work brought this app's 43 screens onto Material Design 3; the full +account, with the numbers and the reasoning, is in +[docs/material-design-conformance.md](docs/material-design-conformance.md). What follows +is the short version — the rules a new screen has to follow, in the place they are needed, +which is while it is being written rather than while it is being reviewed. + +Most of these are enforced. `./gradlew check` runs `docs/scripts/m3-audit.sh --check`, +which fails on a budget that has been exceeded or a floor that has been undercut. Where a +rule below has a number beside it, that number is the budget. + +## Spacing comes from the scale — never a `.dp` literal + +```kotlin +Modifier.padding(MaterialTheme.spacing.containerPadding) // yes +Modifier.padding(16.dp) // no +``` + +M3's eighteen stops live on `Spacing`, with eight semantic names over them — +`screenMargin`, `containerPadding`, `compactPadding`, `relatedGap`, `itemGap`, +`sectionGap`, `emphasisGap`, `targetGap`, `paneGap`. Reach for a semantic name first and +a raw stop (`space125`) only when none of them says the job. + +The semantic names are what adapt: `screenMargin` widens from 16dp to 24dp at the medium +breakpoint without a call site changing. That is the whole reason the scale exists rather +than a file of constants. + +**Budget: 0 dp literals in spacing positions.** A `.dp` in a *dimension* position — an +avatar's size, a hairline border — is fine and is counted separately. + +## Colour comes from a role — never a `Color(0x…)` + +```kotlin +MaterialTheme.colorScheme.onSurfaceVariant // yes +MaterialTheme.extendedColors.bluePill.onContainer // yes, for the brand pair +Color(0xFF888888) // no +onSurfaceVariant.copy(alpha = 0.5f) // almost never +``` + +Six schemes are declared — light and dark, each with medium and high contrast variants — +and the platform's contrast setting selects between them. A colour written at a call site +belongs to none of them and will be wrong in five. + +`.copy(alpha = …)` on a content role is how nine contrast failures got in: an alpha over +an unknown background has no ratio until it is composited, and the composite is usually +under 4.5:1. The exception M3 states is the 38% disabled state. + +Where a colour genuinely cannot come from a role — a QR code's modules, a control over an +arbitrary photograph — mark it at the site: + +```kotlin +// m3-color-exempt: the modules of a QR code have to be pure black on pure white +``` + +**Budget: 0 hardcoded colours outside `ui/theme/`.** `ColorSchemeContrastTest` measures +every pair in all six schemes; it runs in `:composeApp:jvmTest`. + +## Text comes from the catalogue, in sentence case + +```kotlin +Text(stringResource(Res.string.publish_new_key_package)) // yes +Text("Publish New Key Package") // no, twice over +``` + +Strings live in `composeApp/src/commonMain/composeResources/values/strings.xml`. +Interpolation is a format argument (`%1$s`), not a `"${…}"`. + +Capitalisation is **sentence case everywhere** — titles, headings, labels, menu items, +buttons — which is M3's rule and not a preference. Proper nouns keep their capitals. + +Compose Resources is not aapt: it does *not* unescape `\'` and does *not* collapse `%%`, +though it does process `\n`. `StringCatalogueJvmTest` asserts each escape the app depends +on; add to it rather than assuming a family rule. + +A file whose strings are sample text rather than UI text — a gallery of colour pairings, +say — marks itself once at the top: + +```kotlin +// m3-string-exempt: these words are sample text for looking at colour pairings +``` + +**Budget: 0 title-case strings.** Literals in composables are reported without a budget — +39 remain, all of them terms of a `+` concatenation. + +## Every target is 48dp, and every icon has a decided description + +```kotlin +Modifier.clickable { … }.minimumInteractiveComponentSize() // yes +Icon(Icons.Default.Search, contentDescription = "Search") // yes +Icon(Icons.Default.Add, contentDescription = Decorative) // yes, when the label is beside it +Icon(Icons.Default.Add, contentDescription = null) // no — say which +``` + +`IconButton` and `FilledIconButton` enforce 48dp themselves; a bare `Modifier.clickable` +does not, and three of the app's nineteen were text-sized before this rule. + +`Decorative` is the same `null` the compiler sees, and it records that somebody looked. An +icon carrying state the surrounding text does not repeat needs a real description. + +**Budgets: 0 unguarded `.clickable`, 0 untriaged `contentDescription = null`.** + +## A screen has four states, and says so + +Loading, empty, error, loaded. `ErrorState`, `EmptyState` and `LoadingDataIndicator` are +the shared ones; `ErrorState` takes an `onRetry`, and passing `null` is a decision rather +than a default. `EmptyState`'s message is required, because one shared default is how five +different absences all came to say "No events were found". + +Report outcomes through the snackbar host: + +```kotlin +val notify = rememberNotifier(rememberCoroutineScope()) +val published = stringResource(Res.string.key_package_published) // read outside the handler +… +onClick = { viewModel.publish { notify(published) } } +``` + +Both `rememberNotifier` and `stringResource` are composable and an `onClick` lambda is +not, so read them above the handler. The notifier takes the caller's scope on purpose: +"saved" is usually shown as the screen navigates away, and a message launched in the +departing composable's scope would be cancelled with it. + +Wrap the state `when` so the change is a transition rather than a cut: + +```kotlin +ScreenStateTransition(viewModel.uiState) { uiState -> + when (val state = uiState) { … } +} +``` + +It only works where the `when` is the composable's whole body — `AnimatedContent` is a +layout node, so wrapping one inside a `Column` takes its branches out of `ColumnScope`. + +## Layout adapts to the window, not to the composable + +```kotlin +Modifier.padding(innerPadding).readableContent() // on every screen's content root +MaterialTheme.breakpoint.isAtLeast(Breakpoint.Expanded) +``` + +`readableContent()` holds content to sixty characters of `bodyLarge` — derived from the +type scale, so it follows the reader's text size — and centres the column, not the text. +Centring text loses the leading edge that rows, avatars and icons align to; centre a block +only when it is the only thing on the screen. + +Two panes from `Breakpoint.Expanded` up and never below, which is M3's rule for dense +content and also what `calculatePaneScaffoldDirective` does. `listPaneWidthFor` gives the +width. + +Read the *window* through `MaterialTheme.breakpoint`, not the local constraints. A pane +300dp wide inside a 1400dp window is still in a large layout. + +**Floors: at least 12 adaptive API uses, at least 2 navigation components.** These regress +by being removed, so the audit checks them from below. + +## Motion comes from the scheme + +```kotlin +MaterialTheme.motionScheme.defaultSpatialSpec() // things that move +MaterialTheme.motionScheme.defaultEffectsSpec() // things that fade +tween(300) // no +``` + +`MotionSchemeKeyTokens` is `internal` to material3 and cannot be reached from here; +`MaterialTheme.motionScheme` is the public surface. Honour `MaterialTheme.reducedMotion` +— it means drop the movement, not the transition. + +## Checking your work + +```bash +./gradlew :composeApp:m3Audit +``` + +```bash +./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:jvmTest +``` + +`docs/scripts/` also holds the tools each phase was done with — +`m3-spacing-positions.py`, `m3-touch-targets.py`, `m3-title-case.py` and the two string +extractors — each of which takes `--list` to show the sites rather than the count. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 290eae80..cde561e0 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -86,6 +86,8 @@ kotlin { implementation(libs.compose.runtime) implementation(libs.compose.foundation) implementation(libs.compose.material3) + implementation(libs.compose.material3.adaptive) + implementation(libs.compose.material3.adaptive.navigation.suite) implementation (libs.compose.material.icons.core) implementation (libs.compose.material.icons.extended) implementation(libs.compose.ui) @@ -130,6 +132,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) @@ -158,8 +169,8 @@ android { applicationId = "press.mantra.android" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "0.1.0" + versionCode = 2 + versionName = "0.1.1" } packaging { resources { @@ -210,6 +221,69 @@ room3 { schemaDirectory("$projectDir/schemas") } +/** + * The Material Design conformance audit, as a build task. + * + * `docs/scripts/m3-audit.sh --check` counts what the phases in + * docs/material-design-conformance.md drove to zero -- hardcoded colours, dp literals in + * spacing positions, bare `.clickable`, title case, untriaged `contentDescription = null` + * -- and exits 1 when one of them has come back. It also holds two floors, for the + * adaptive and navigation work, which regress by being *removed*. + * + * Wired into `check` rather than left as a script somebody remembers to run: the whole + * point of a budget is that it is enforced at the moment the number moves, and a number + * that is only checked when a person thinks to look is a number that drifts. + * + * It reads the source tree with grep and needs no gradle, no android SDK and no + * submodules, so it is also the one part of this build that a bare CI runner can do. + */ +val m3Audit = tasks.register("m3Audit") { + group = "verification" + description = "Checks the Material Design conformance budgets in docs/material-design-conformance.md." + + val script = rootProject.layout.projectDirectory.file("docs/scripts/m3-audit.sh").asFile + val uiSources = rootProject.layout.projectDirectory + .dir("composeApp/src/commonMain/kotlin/press/mantra/compose/ui") + val projectDirectory = rootProject.layout.projectDirectory.asFile + + inputs.file(script).withPropertyName("auditScript") + inputs.dir(uiSources).withPropertyName("uiSources") + // No outputs, so this would run every time. A marker file is what makes it + // up-to-date-able, and it is the only thing the task writes. + val marker = layout.buildDirectory.file("m3-audit/passed.txt") + outputs.file(marker) + + doLast { + // Windows has no bash unless somebody installed one. Skipping loudly beats + // failing a build for a reason that has nothing to do with the change under it; + // the CI runner and every developer machine here are unix. + val bash = listOf("/bin/bash", "/usr/bin/bash").firstOrNull { File(it).canExecute() } + if (bash == null) { + logger.warn("m3Audit: no bash found, skipping. Run docs/scripts/m3-audit.sh --check by hand.") + marker.get().asFile.apply { parentFile.mkdirs() }.writeText("skipped: no bash\n") + return@doLast + } + + val result = providers.exec { + commandLine(bash, script.absolutePath, "--check") + workingDir = projectDirectory + isIgnoreExitValue = true + } + val text = result.standardOutput.asText.get() + val exit = result.result.get().exitValue + logger.lifecycle(text) + if (exit != 0) { + throw GradleException( + "Material Design conformance budgets exceeded. See the report above and " + + "docs/material-design-conformance.md." + ) + } + marker.get().asFile.apply { parentFile.mkdirs() }.writeText(text) + } +} + +tasks.named("check") { dependsOn(m3Audit) } + compose.desktop { application { mainClass = "press.mantra.desktop.MainKt" diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Motion.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Motion.android.kt new file mode 100644 index 00000000..d93cdf17 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Motion.android.kt @@ -0,0 +1,58 @@ +package press.mantra.compose.ui.theme + +import android.database.ContentObserver +import android.provider.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext + +/** + * Android has no "reduce motion" switch. What it has is **Remove animations**, under + * Accessibility, and what that does is set the three animation duration scales to zero -- + * so the honest reading is `ANIMATOR_DURATION_SCALE == 0`. + * + * The platform already applies that scale to `ValueAnimator`, but **not to Compose**: + * Compose animations run on its own clock and ignore it entirely. So an app that draws its + * own transitions has to read the setting itself, which is what this is for. + * + * `TRANSITION_ANIMATION_SCALE` and `WINDOW_ANIMATION_SCALE` are the other two the switch + * sets. Reading one of the three is enough: the accessibility toggle writes all three + * together, and a developer-options user who has set only one has made a deliberate choice + * about a different thing. + */ +@Composable +actual fun platformReducedMotion(): Boolean { + val context = LocalContext.current + val resolver = remember(context) { context.contentResolver } ?: return false + + var reduced by remember(resolver) { mutableStateOf(animationsRemoved(resolver)) } + + // The setting is changed from the Accessibility screen, which means leaving the app and + // coming back -- but a split screen, a tablet with two apps, or a quick settings tile + // all change it without the app going away. An observer costs one registration and + // removes the whole class of "it only took effect after a restart". + DisposableEffect(resolver) { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + reduced = animationsRemoved(resolver) + } + } + resolver.registerContentObserver( + Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE), + false, + observer, + ) + onDispose { resolver.unregisterContentObserver(observer) } + } + + return reduced +} + +private fun animationsRemoved(resolver: android.content.ContentResolver): Boolean = + runCatching { + Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + }.getOrDefault(false) diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt index 0d8559cc..2f20eef1 100644 --- a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/theme/Theme.android.kt @@ -1,26 +1,79 @@ package press.mantra.compose.ui.theme +import android.app.UiModeManager +import android.content.Context import android.os.Build import androidx.compose.material3.ColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +/** + * Android 14 (API 34) added a three-step contrast setting under Accessibility > Display. + * `UiModeManager.getContrast()` reports it as a float, and the platform documents the + * three positions as 0.0, 0.5 and 1.0. Values between are treated as the nearer step + * rather than rejected -- the API returns a float, so a future finer-grained slider + * should degrade to the closest scheme this app has rather than to Standard. + * + * Below API 34 there is nothing to read and the answer is [ThemeContrast.Standard]. The + * app's minSdk is 26. + */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme { - return when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } +actual fun platformThemeContrast(): ThemeContrast { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return ThemeContrast.Standard - darkTheme -> darkScheme - else -> lightScheme + val context = LocalContext.current + val uiModeManager = remember(context) { + context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager + } ?: return ThemeContrast.Standard + + var contrast by remember(uiModeManager) { + mutableStateOf(contrastStepFor(uiModeManager.contrast)) } -} \ No newline at end of file + + // The setting can be changed while the app is in the foreground, and unlike a theme + // or locale change it does not restart the activity or arrive as a Configuration + // update -- so without this listener the new value would only take effect on the + // next cold start, which is the case the setting exists for. + DisposableEffect(uiModeManager, context) { + val listener = UiModeManager.ContrastChangeListener { value -> + contrast = contrastStepFor(value) + } + // context.mainExecutor rather than ContextCompat.getMainExecutor: it needs API 28 + // and this whole branch is already gated on 34, and it keeps androidx.core off + // this file's imports -- composeApp does not declare it, it only arrives + // transitively through activity-compose. + uiModeManager.addContrastChangeListener(context.mainExecutor, listener) + onDispose { uiModeManager.removeContrastChangeListener(listener) } + } + + return contrast +} + +/** Nearest of the platform's three documented positions. */ +private fun contrastStepFor(value: Float): ThemeContrast = when { + value < 0.25f -> ThemeContrast.Standard + value < 0.75f -> ThemeContrast.Medium + else -> ThemeContrast.High +} + +/** + * Material You, from Android 12 (API 31). + * + * No contrast argument is needed. From API 34 the `android.R.color.system_*` resources + * these are built from shift with the contrast setting themselves, so a dynamic scheme + * already carries it; passing this app's own contrast on top would apply it twice. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? { + if (!dynamicColor || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + + val context = LocalContext.current + return if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index bb7d28ba..ce647bc2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1,1497 +1,351 @@ - Machankura - - Channels watcher - Shows up when you need to start Phoenix. - - Payment finalisation - Tells you when Phoenix needs to be started to settle a pending payment. - - Payment rejected - Shows up when Phoenix cannot receive a payment because of a liquidity issue. - - Payment received - Shows up when you receive a payment while the app is in the background. - - Running in the background - Tells you when Phoenix is running in the background. - - Swap timeout - Tells you when a swap is going to timeout. - - - - Creating your wallet… - Wallet creation failed - This seed has already been imported - The wallet could not be written at this time. Try again later. - - Restore my wallet - Next - Restoring your wallet… - Use a custom Electrum server - - - - Etiam porttitor egestas faucibus. Curabitur condimentum eros non ipsum elementum egestas. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ut facilisis lectus. Integer massa tellus, suscipit sit amet felis vitae, blandit consectetur dolor. Fusce volutpat id magna id vestibulum. Integer a erat lacinia, placerat risus a, fermentum justo. Etiam euismod tincidunt dolor vel posuere. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Curabitur non euismod dui. Morbi enim dui, blandit sed erat sit amet, porta pulvinar odio. Cras metus felis, vestibulum eu consequat vitae, consectetur quis nulla. Fusce vulputate, elit et luctus sodales, metus metus elementum sem, eget commodo nunc nunc in ex. Sed aliquam eros nibh, ac volutpat turpis accumsan vitae. Cras suscipit ipsum accumsan aliquam interdum. - - Praesent ut nisi fringilla, pharetra dui sit amet, ornare urna. Donec at ultrices nunc. Fusce gravida metus vitae viverra egestas. In hac habitasse platea dictumst. Proin consequat fringilla felis, vehicula vehicula turpis ullamcorper nec. Pellentesque urna massa, blandit cursus metus et, ultrices consectetur neque. Suspendisse hendrerit venenatis mi ac tincidunt. Morbi hendrerit orci vitae erat luctus, at dignissim turpis accumsan. Integer elementum est eu tincidunt ullamcorper. Phasellus varius porttitor vestibulum. Maecenas faucibus ullamcorper diam, ac commodo dui fringilla sed. Aliquam arcu velit, porta eu sem vel, rhoncus bibendum dolor. - \nEtiam porttitor egestas faucibus. Curabitur condimentum eros non ipsum elementum egestas. Nam tempor euismod erat eget scelerisque. Integer sit amet laoreet erat. Duis enim turpis, vehicula eu justo vitae, dapibus auctor mauris. Aenean euismod eleifend dui a aliquet. Aliquam eleifend malesuada tortor ornare volutpat. In augue tortor, gravida et volutpat elementum, iaculis non sapien. Etiam dolor nisi, pulvinar a rhoncus ac, eleifend ac libero. In lobortis enim vitae ultricies viverra. Maecenas accumsan elementum sem, nec pharetra urna maximus maximus. - \nIn sit amet volutpat ligula, ac pretium dolor. Phasellus posuere rhoncus magna quis fermentum. Ut risus turpis, fermentum facilisis mollis in, porttitor eget erat. Donec luctus egestas ligula et interdum. Phasellus vitae hendrerit sem, at vehicula nulla. Curabitur mollis risus quis metus euismod ullamcorper. Nam eu aliquet mi. Duis id urna ac urna iaculis blandit. Morbi eros dui, congue a posuere efficitur, imperdiet a nisi. Morbi non orci non lorem aliquet tincidunt. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce pulvinar, mi vitae sollicitudin dignissim, nunc urna facilisis massa, ut scelerisque mi felis in sapien. - \nNam felis felix, tristique commodo odio eget, imperdiet viverra erat. Donec venenatis magna pulvinar, finibus leo id, gravida augue. Integer ante leo, bibendum ac nibh quis, auctor commodo quam. Sed luctus vitae quam vel condimentum. Mauris eu rhoncus mauris. Fusce enim diam, consequat a odio sit amet, accumsan cursus nisl. Etiam lectus nunc, lacinia id purus sit amet, pulvinar auctor odio. Maecenas vitae arcu sit amet est cursus maximus. Nullam ac sapien non nibh tempor rhoncus. Mauris dignissim cursus libero quis egestas. - - - - - Unlock to continue - PIN code - System lock - - - - Initialising… - Preparing wallet… - Decrypting… - Starting wallet… - Opening wallet… - Select a wallet - - Could not start the wallet - Unable to read wallet data. - Try again - Unhandled file serialisation - Decryption failure:\n%1$s - Android keystore failure:\n%1$s - Manual recover - - - - Wallet recovery - This screen lets you manually recover a single wallet by entering its 12-words recovery phrase.\n\nWords must be entered in the correct order, and separated with a single space. - Enter word #%1$d - No more than 12 words! - This seed is not valid - - Import seed - Try again - Checking seed… - - An error occurred. - This seed does not match any existing wallet data. - Failed to perform keystore operations. - Recovering wallet… - - - - - - + - Waiting for confirmation - Payment pending - Payment confirmed - Payment complete - Payment has failed - - - - QR Code of the invoice/address - - Address - Synchronizing address… - Bitcoin address - Share this Bitcoin address with… - - Generating… - Single use - Bolt11 - Reusable - Bolt12 - Amount - Description - Could not generate invoice - Lightning payment - Share this Lightning payment code with… - - Bitcoin address - This QR code is a classic Bitcoin address.\n\nIt can be read by almost any Bitcoin service/wallet, but payments will be slower to arrive. - Lightning address - Lightning - This QR code is a Lightning invoice.\n\nLightning payments are very fast and usually cheaper, but some wallets and services may not support them yet.\n\nIn that case, swipe to the left to get a regular Bitcoin address. - Lightning Bolt11 - Lightning Bolt12 - Bitcoin URI - - What is this? - This Lightning address uses the modern Bip353 standard that works with Bolt12 payment requests.\n\nIt is more private than LNURL-based Lightning addresses, and can even be self-hosted.\n\nHowever, it\'s is bleeding edge tech ; some wallets or services do not understand it yet and won\'t be able to pay you. - Learn more. - - Customise this Bolt11 invoice - Customise this Bolt12 invoice - Amount (optional) - Amount to receive - Description (optional) - Enter a description for this invoice - Generate - - - - Balance - Amount is too large. - Amount cannot be negative. - This is not a valid amount. - This amount exceeds your balance. - Cannot pay more than %1$s. - This amount is below the requested amount of %1$s. - Send to - Description - Fee - N/A - Loading fee… - Amountless invoice - The invoice for this payment does not request a specific amount. This may be exploited by malicious nodes during the payment.\n\nTo be safe, ask the recipient to specify an amount when generating the invoice. - Waiting for channels… - Pay - Confirm & Pay - Try again - - Message - Tap to attach a message… - Attach a custom message - The recipient will see this message - Enter a message… - Fetching payment details… - Payment has failed - Could not retrieve payment details within a reasonable time.\n\nThe recipient may be offline or unreachable. - - Pay on-chain - On-chain transactions are typically slower and best suited for large payments. - Pay with Lightning - Lightning payments are fast and best suited for small payments. - - Address - Fee rate - Retrieving current feerate… - - Please enter a valid feerate. - Total exceeds your balance - Payment has failed - - Prepare transaction - Preparing transaction… - Executing splice… - Miner fee - Uses an effective feerate of %1$s sat/vbyte. - Total - - - - Tap to grant camera permission - Camera permission has been denied - Zoom or tap the QR to focus - - - - Send - No contacts yet… - No matches for search… - satoshi@domain, bc1q..., lnbc... - - Paste - Scan QR code - Choose image - This image could not be processed. - No QR code found in this image. - Reading input… - Fetching data from service… - Resolving payment request over DNS… - - You have on-chain funds on the final wallet, but none on Lightning. - View final wallet - - - - - ≈ %1$s - !? - Exchange rate unavailable - - just now - N/A - Processing data… - Loading data… - Loading preferences… - Copied to clipboard! - - ₿%1$s - - Open link in a browser - Open transaction in an explorer - Open address in an explorer - This field cannot be blank - Please enter a valid amount - Please enter a valid number - This field must be an integer - - Go back - Next - Copy - Share - OK - Save - Delete - Confirm - Cancel - Close - - - - Drain my wallet - Loading… - Checking balance… - Balance: %1$s (≈ %2$s) - The wallet does not have any channels that are eligible for closing. - Review closing - This address uses a different blockchain - This address uses unsupported features - This address is not supported - Closing has been initiated. The closing transaction is in your transactions list. - - No channels eligible for closing. - - - - Payment channels - Import channels - Spend channel address - Overview - Balance - Balance is the aggregated balance of your active channels. It\'s what you can spend over Lightning. - Inbound liquidity - Inbound liquidity is what your channels can receive over Lightning without having to go on-chain and pay fees. - Loading channel data… - You don\'t have any channels yet.\n\nA new payment channel will be created automatically when needed. - - - - Channel details - No active channel exists for that identifier. - Channel id - State - Balance - Inbound liquidity - - Active commitments - Inactive commitments - Funding tx: - Balance: - Capacity: - Triggered by: - - Display raw data - Share - Channels data - Share channel data - - - - Import raw channel data - This screen is a debugging tool that can be used to manually import encrypted channels data.\n\nUse with caution. - Data blob - Import - Importing data… - Import successful - You must now restart Phoenix. - Import has failed - Data are malformed. A encrypted hex blob is expected. - Data could not be decrypted by this wallet. - Version %1$d is not supported - - - - Spend channel address - This screen is a debugging tool that helps recover funds that have been accidentally sent to a channel\'s outpoint. - Amount - Tx index - Raw channel data - Remote funding pubkey - Unsigned tx - Sign - Signing… - - Signature successfully generated. - Public key - Signature - - Failed to sign data - Invalid amount - Invalid tx index - Malformed channel data - Cannot decrypt channel data - Unhandled channel state [%1$s] - Malformed channel version [%1$s] - Malformed remote funding pubkey [%1$s] - Malformed unsigned tx [%1$s] - Malformed remote funding pubkey [%1$s] - Malformed transaction [%1$s] - Invalid signature - - - - Loading… - +%1$s - - FAQ - Use the Receive and Send buttons at the bottom of this screen to get started! - Show all payments… - Desync! - Certificate - Invalid address - Connecting… - Tor - Request liquidity - You currently have %1$d payment(s) pending in your wallet.\n\nKeep the app open to make sure these payments settle properly without issues. - - - - Notifications - Important messages - Recent activity - No notifications yet - - - - Recovery phrase - Unlocking seed… - Could not unlock seed - BIP39 seed with standard BIP84 derivation path - Loading preferences… - Backup confirmation - - - - Application logs - Exporting logs… - Error: logs could not be exported - View logs… - View logs with… - Share logs… - Phoenix App logs - Share Phoenix logs… - - - - Loading payment details… - Could not find payment details - - LIQUIDITY ADDED %1$s - CHANNEL MANAGEMENT %1$s - COMPLETE %1$s - SENT %1$s - Pending… - FAILED\nNo money has been sent. - - Not received yet. - Waiting for channel to open - RECEIVED %1$s - - Waiting for confirmations - Fetching status… - 0 confirmation - Tap to accelerate - %1$d confirmation(s) - Confirmed on-chain - - Message - Sent by - Unknown - Be careful with messages from unknown sources - - Serviced by - Message - Link - Open link - Message - Decrypting message… - - Description - Note - Sent to - Bitcoin miners - Fees - Error - No description - This payment happened following a conflict in a channel. - - On-chain payment - Closing channel - Migration from legacy app - Bump transactions - Manual liquidity (+%1$s) - Channel management - Swap-out to %1$s - On-chain deposit - - to %1$s - from %1$s - - Add a custom description to this payment - Description - Attach note - Edit note - Technical details - - - - Technical details - Type of payment - Channel closing - Incoming on-chain payment (splice) - Incoming on-chain payment (new channel) - Incoming payment (legacy pay-to-open) - Incoming on-chain payment (legacy swap-in) - Incoming Lightning payment (bolt11) - Incoming Lightning payment (bolt12) - Outgoing Lightning payment (bolt11) - Outgoing Lightning payment (bolt12) - Outgoing on-chain payment - Outgoing on-chain payment (legacy swap) - Accelerate on-chain transactions - Inbound liquidity request (manual) - Inbound liquidity request (auto) - - Deposit address - Bitcoin address - - Spliced channel - Local inputs - - #%1$s: - - Liquidity requested - - Closing type - Mutual - Local - Remote - Revoked - Other - - Target public key - Payment Hash - Preimage - Cryptographic proof that the recipient successfully received the payment. - Bolt11 invoice - Invoice description - Bolt12 invoice - Offer - Metadata - Payer key - Your offer key - Purchase type - - Payment status - Successful - Confirming - Pending - Failed - - Payment parts (%1$d) - Part - Hops - - Received via - Channel operation (new or splice-in) - Lightning payment - Fee credit - Channel id - Transaction - - Created at - Completed at - Elapsed - %1$s ms - - Amount requested - Amount sent (fees included) - Amount received - Fee credit accrued - Amount added to fee credit - ≈ %1$s (now) - ≈ %1$s (then) - - - - Add contact - Send a payment - - Create new contact… - - Name cannot be left empty - Invalid Bolt12 code - Code already attached to \"%1$s\" - Invalid Lightning address - Address already attached to \"%1$s\" - This code or address is invalid - - Add an address for this contact - Edit Bolt12 code - Edit Lightning address for this contact. - - Label (optional) - Bolt12 code or Lightning address - Lightning address - Bolt12 offer code - - Do you want to delete this address? - Do you want to delete this contact? - - Attach a name to a Bolt12 code - Name - Enter a name - Pay with your Bolt12 key - If they know your own Bolt12 payment code, they will be able to tell when payments are from you. - Use a throwaway id when you pay this contact. - Addresses - Add Bolt12 code or Lightning address… - Add new… - - Search by name - No contacts found… - - Take a photo - Browse images - Delete picture - - - - Access control - - System authentication unavailable - No suitable authentication hardware on this device. - The biometric hardware is not available. Try again later. - Please enroll a PIN/Schema/Fingerprint in Android first. - The hardware is unsafe. An Android security update is required. - Not supported by this version of Android. - Too many attempts, try again later. - Unhandled hardware vendor error - Authentication attempt timed out - Authentication has been cancelled - This version of Android is not compatible. - Unhandled error code: %1$d - - Accessing the application - System authentication - Secures app entry behind the Android user credentials - Lock PIN - Secures app entry behind a 6-digits PIN code - - Lock timeout - After %1$s minute(s) of inactivity - Never - - Sending payments - Spending PIN - If enabled, a PIN code is required to spend funds from the wallet. - - Misc - Shuffle PIN keypad - - - - Legacy mode - Tap here for more info - How does it work? - Phoenix authenticates with a key unique to %1$s. This unique key becomes your password for your account there. - Privacy - The service will not have access to your wallet whatsoever. They cannot see your balance, payments, or keys. - Legacy mode - Phoenix uses a non-standard scheme on this service to be compatible with older versions of the app. The associated account will not be portable to other wallets. - Change scheme - Sign-in - Try again - Signing-in to\n%1$s - Authentication success. - Authentication failure: - Network error. Check your internet connection and try again. - An unknown error occurred. Try again. - - - - Default - Use a standard scheme compliant with the LNURL specifications. This is the recommended option for new wallets, and what the Phoenix iOS app uses. - Android Legacy - Use a legacy scheme to connect to accounts created with the old Phoenix Android app. - - - - Redeem - Requesting funds… - Amount must be at least %1$s. - Amount cannot exceed %1$s. - Withdrawal has failed: - - - - The service %1$s returned an error. Contact the helpdesk of this service if the problem persists.\n\nService message details : \"%2$s\" - The service %1$s returned an HTTP error (%2$s). Contact their helpdesk if needed. - The service %1$s returned a malformed message. - Could not connect to service %1$s. - This appears to be a website (not a lightning invoice):\n\n%1$s - Service %1$s doesn\'t support lightning addresses, or doesn\'t know this user. - - - - Served by - Description - Attach a message - My message - You can attach a message to the payment. This message will be sent to the recipient. - Pay - Requesting invoice… - Paying invoice… - - Amount must be at least %1$s - Amount must be at most %1$s - - Payment has failed. - The invoice returned by %1$s does not use the same chain as your wallet. - The invoice returned by %1$s is already in progress. - The invoice returned by %1$s has already been paid. - The invoice returned by %1$s has an incorrect amount. - The invoice returned by %1$s is malformed. - - - - Display options - Filter by name - Bitcoin unit - Satoshi - 1 sat is 0.00000001 btc - Bit - 1 bit is 0.000001 btc - Milli-Bitcoin - 1 mbtc is 0.001 btc - Bitcoin - Fiat currency - Application theme - Dark theme - Light theme - Follow system - Application language - - - - Electrum server - To secure your payment channels Phoenix monitors the Bitcoin blockchain through Electrum servers.\n\nBy default, random servers are used. You can also configure Phoenix to connect only to your own server. - Block height - Use the TLS port (default 50002). - For onion services, use the plain TCP port, not the TLS one. - Since you\'ve enabled Tor, you should use an onion address for this server. - No, I don\'t want to use an onion address - - Disconnected from Electrum - Disconnected from %1$s - Connecting to (random) %1$s - Connecting to %1$s - Connected to %1$s - - You are using a custom server - This server provided an unknown certificate. Connection is rejected. - Tor is enabled. This server should use an onion address. - - Use a custom server - Server address (host:port) - This address is invalid. - Connect - Checking certificate… - Failed to connect - This address cannot be resolved. - Untrusted certificate - SHA1 Fingerprint - SHA256 Fingerprint - Issuer - Subject - Valid until - Copy certificate - Trust certificate - - - - Tor - Enable Tor - How it works - - - - Connections status - Some connections are not established yet. The app will not function correctly until they are. - Your device has no Internet connection. The app will not function properly.\n\nPlease check your device\'s setting. - Electrum - Peer - Manage connection for %1$s - Connecting… - Connected - Disconnected - Bad certificate! - Invalid address! - Tor is enabled! - Make sure your Tor VPN is active and running. - - - - About Phoenix - Phoenix version: %1$s - Any questions? Check the FAQ - Support - Privacy - Terms - - - - Payment options - - Incoming payments - Outgoing payments - LNURL - - Invoice description - No description set… - Default description - Your invoices will use this description by default. You can override it on a case-by-case basis. - Invoice description - - Invoice expiry - Invoice expiry - Invoices that you create are stale after this delay. Default value is 1 week. - 1 hour - 1 day - 1 week (default) - 2 weeks - 3 weeks - %1$s seconds - - LNURL authentication scheme - - Bitcoin address format - Legacy - A less efficient and less private format that does not rotate addresses. However, it is compatible with almost every services and wallets. - Taproot (recommended) - Default format, with better privacy, cheaper fees and address rotation. Some services or wallets may however not understand the address. - - Enable overpayment - You\'ll be able to overpay Lightning invoices up to 2 times the amount requested. Useful for manual tipping, or as a privacy measure. - Disabled (default) - - - - Argentine Peso (official rate) - Argentine Peso - Cuban Peso (official rate) - Cuban Peso - Lebanese Pound (official rate) - Lebanese Pound - - - - Local payments - Export - No payments yet… - Today - Yesterday - Earlier this week - Last week - - Export payments - CSV export - Export your local successful payments in CSV format. Useful for accounting purposes. - Start date - End date - Include origin/destination - Include description - Export - No successful payments yet - Please pick a valid start/end date - Exporting payments… - Copy data to clipboard… - Phoenix - payments from %1$s to %2$s - Share Phoenix payments… - Share file - Export failed - No payments found. - - Database export - Encrypt and export your payments database. This can be used to migrate your payments history from this device to another. - Export database - Export has failed - The file can be found in your device\'s public folders. - - - - (inclusive) - - - - Wallet info - Legacy descriptor - Descriptor - User public key - Swap addresses - Master public key - (Path: %1$s) - - Ready for swap - Waiting for %1$d confirmations - +%1$d more… - Confirmed balance - Unconfirmed balance - +%1$s incoming - Loading wallet data… - - Swap-in addresses - Synchronizing… - Taproot - Legacy - - Lightning - Node id - Show legacy node id - Legacy node id - - Final wallet - Spend - - - - Channel management - Retrieving feerate… - My fee setting - Advanced channels management - Retrieving policy… - - - - Feerate - %1$s sat/vbyte - - Prepare payment - Estimating fees… - You will pay %1$s to the Bitcoin miners - Execute payment - Executing payment… - Payment complete - Payment failed - Cannot proceed - - - - Unknown mempool state - Phoenix was unable to retrieve the current state of the mempool and cannot estimate the speed of your transactions.\n\nCheck the mempool manually on an explorer, and use an adequate value! - ≈ 10 minutes - ≈ 30 minutes` - ≈ 1 hour - Low feerate - - - - You don\'t have any channels - Aborted by peer [%1$s] - Unable to create a new commitment - There\'s another splice in progress - Aborted due to an error - Channel is disconnected - Funding has failed [%1$s] - Not enough funds - Cannot start transaction session with the peer - Interactive tx session failed [%1$s] - Invalid splice-out pubkey script - A splice payment is already in progress - Invalid liquidity-ads request: [%1$s] - Invalid channel parameters: [%1$s] - Unexpected error: [%1$s] - - - - Delete wallet - This screens allows you delete this wallet from your device. - All data for this wallet will be deleted. This includes your payments history. - Save payments history - Review - - Confirm wallet deletion - The wallet will be completely deleted from this device. - This wallet\'s seed and its payments history will be deleted from the disk. You will be prompted to use another wallet, or create a new one. - Other wallets that you have already imported will not be deleted. - Don\'t lose your funds - I understand that if I lose the recovery phrase after deleting the wallet, any remaining funds would be permanently lost. - Delete wallet - Shutting down… - Deleting preferences… - Deleting seed… - Deleting databases… - The wallet has been successfully reset. - Reset failed - - - - Swap-in signer - This debugging tool lets you sign swap-in inputs. Only use if you understand what it does. - Unsigned tx - Server nonce - Sign - Signing… - User signature - Invalid unsigned transaction - Check that the input is complete and not missing any character. - Failed to sign input - - - - This screen allows you to link a Bolt12 code or a Lightning address to a name.\n\nThese contacts data are specific to Phoenix and stored locally. - - - - Experimental features - - Bip353 DNS address - No address yet… - Claim my address - Claiming address… - Failed to claim address - - - - Enter Lock PIN - Enter Spending PIN - Enter Spending PIN to view the seed] - Enter PIN to continue - Create Lock PIN - Create Spending PIN - Confirm PIN - - Checking PIN… - Incorrect - Locked for %1$s - - An error occurred - Malformed PIN - PIN mismatch! - Error when saving PIN - - - - APDU service for Phoenix to emulate a NFC tag - AID for the NFC tag emulated by Phoenix - - NFC is busy - NFC is not available - NFC is disabled - Tag emulation is not supported - - Nfc - Hold near the NFC reader - Ready to scan - Hold near the NFC device to read it - - - - Currency Converter - Done - Enter amount in %1$s - Add new currency… - Last refreshed: %1$s - Other… - Select a currency - No match found… - - - - Wallet - Add new wallet - Lock - - - Enter a name - Default Wallet - If a default wallet is selected, it will be automatically opened on app launch. - Hidden Wallet (WIP) - Wallet will not be visible in selector screens. To access the wallet, you must enter its lock PIN. - Pick an avatar - - - - I understand. - - - - Phoenix is running in the background - %1$s Received %2$s - - Please start wallet - An incoming settlement is pending. - - A payment is pending - Start Phoenix so the payment can be finalised in due course. - - Missed incoming payment - Phoenix was unable to start in the background. - - On-chain deposit pending (+%1$s) - Payment rejected (+%1$s) - Automated channel management is disabled. Tap for details. - Automated channel management is disabled. This deposit will expire by %1$s. - The fee was %1$s, but your max fee was set to %2$s. Tap for details. - The fee was %1$s, but your max fee was set to %2$s. This deposit will expire by %3$s. - The fee was %1$s which is more than %2$s%% of the amount received. Tap for details. - The fee was %1$s which is more than %2$s%% of the amount received. This deposit will expire by %3$s. - Payment amount is too low. - An error occurred during funding. Please try again later. - - Please start Phoenix - Some of your channels may have closed. - - - - General - Fees - Privacy & Security - Advanced - Danger zone - - Display - Wallet info - Channel management - Recovery phrase - Access control - Payment channels - Logs - Electrum server - Delete wallet - Close channels - Force-close channels - Tor - About - Payment options - Payment history - Notifications - Contacts - Currency converter - Add liquidity - - - - Confirming - Waiting for confirmation first before they can be swapped to Lightning. - - Waiting for swap - Will deploy to Lightning when mining fees are below %1$s. - Will remain on-chain because automated channels management is disabled. - Will deploy to Lightning when conditions apply. - Attention! Some funds will expire soon and won\'t be eligible for a swap anymore. - - Expired - Cannot be swapped anymore, after 4 months waiting. These funds must be spent manually. - - Final wallet - These funds come from closed Lightning channels. They must be spent manually. - On-chain balance - - Background processing restricted - Phoenix may not be able to receive payments when it is in the background, or when it is closed. - This happens because: - The device is in power saving mode - FCM notifications unavailable - If you\'re on GrapheneOS or CalyxOS, install Google Play Services to get FCM notifications. Check the FAQ for guidance. - - - - Settings - Send - Receive - - - - Create new wallet - Restore my wallet - - Phoenix is only on Lightning - Phoenix will only display funds that have already been managed by Phoenix. Funds attached to a seed generated by another application will not appear here (this includes on-chain funds). - Beware of using the same seed in parallel - Do not use the same seed simultaneously on different devices. This can cause conflicts between the two instances of Phoenix, and result in Lightning channels being closed. - - Your wallet\'s seed is a list of 12 English words. Type-in each word of this list in the box below. - Enter word #%1$s - This is not a valid word. - This seed is valid - You can now proceed and restore your wallet - This seed is not valid - Make sure you entered the correct words in the right order. - - Import payments history - Optional. Use this button to restore a payments database file from another device. - Will restore payments history - Use another file - Cannot restore payments - Try again - The file cannot be decrypted. Make sure you are using a Phoenix database file (not a CSV), and that this file matches the wallet you\'re restoring. - This file cannot be opened. Try again. - This file could not be written to the application\'s data folder. Try again. - Restore wallet - - - - Update required - This version of Phoenix (v%1$s) is not compatible with your wallet. Please update, or use a compatible version. - Update on Google Play - - - - Payment will fail - On-chain fee expected - Tap to know more - - Dismiss - Enable automated channels - Configure fee limit - - An on-chain operation will be likely required for you to receive this amount.\n\nThe fee is estimated to be around %1$s. - An on-chain operation will likely be required for you to receive this amount. - - A fee of %1$s is expected to receive this amount, and that fee is above your limit of %2$s.\n\nIncrease this limit in the channel management settings. - A fee of %1$s is expected to receive this amount, and that fee is above your limit of %2$s.\n\nIncrease this limit in the channel management settings, or request additional liquidity. - A fee of %1$s is expected to receive this amount, and that fee is more than %2$s%% of the amount.\n\nIncrease this limit in the channel management settings. - A fee of %1$s is expected to receive this amount, and that fee is more than %2$s%% of the amount.\n\nIncrease this limit in the channel management settings, or request additional liquidity. - - Inbound liquidity is insufficient for this amount, and you have disabled automated channel management. - - - - Tor is enabled - Phoenix may have issues receiving payments. Make sure the app stays open in the foreground and that connection is stable. - - - - This invoice is expired. - This payment is already being processed. Please wait for it to complete. - This payment has already been paid. - This payment does not use the same blockchain as your wallet. - Failed to process this LNURL link. Make sure it is valid. - This type of LNURL is not supported yet. - This is not a supported payment request. - Unable to retrieve data for this address. You may be experiencing a connectivity issue. - Name \"%1$s\" is not found on \"%2$s\". - This address uses an invalid Bip21 resource. - This address uses an invalid Bolt12 offer. - This address is hosted on an unsecure DNS. DNSSEC must be enabled. - - - - Must be at least %1$s - Must be no more than %1$s - - - - Bitcoin address - Send all funds to a Bitcoin address. All payments channels will be closed. - Confirm closing - All the funds will be sent to: - Miner fees estimated to: - Fee cost could not be estimated. - - Force-close channels - This screen allows you to unilaterally close your channels.\n\nThis is not a \"fix-everything\" magic button: it is here as a safety measure and should only be used in extreme scenarios. For example, if your peer (ACINQ) disappears permanently, preventing you from spending your money. In all other cases, if you experience issues with Phoenix you should contact support.\n\nForce closing channels will cost you money (to cover the on-chain fees) and will cause your funds to be locked for days.\n\nDo not uninstall the app until your channels are fully closed, or you will lose money.\n\nDo not use this feature if you don\'t fully understand what it does. - Funds will eventually be sent to the final wallet: - - Confirm force-closing - All the funds will be sent to your final wallet, after a significant delay. - Force-close all my channels - - - - Backup your wallet to prevent losing your bitcoins. - Backup my wallet - - Enable Android notifications - Notifications are disabled in Android settings. Phoenix won\'t be able to notify you when a payment is processing. - Enable - - A deposit will expire soon. - View details - - Phoenix regularly monitors the blockchain when in the background, but was unable to do so the last few days.\n\nMake sure Android does not block Phoenix, and that it can connect to Electrum. - Dismiss - - An update is available - A critical update is available. You should update Phoenix as soon as possible. - Update on Google Play - - On-chain fees are high. - See how Phoenix is affected - - Cannot access the Tor network. Phoenix will not function correctly. - Fix it - - On-chain funds pending (+%1$s) - An incoming payment has been recently rejected - %1$d incoming payments recently rejected - Payment rejected (+%1$s) - Automated channel management is disabled. - The fee was %1$s, but your max fee was set to %2$s. - The fee was %1$s which is more than %2$s%% of the amount. - Tap to configure. - View details - - Watchtower report - %1$d channel was successfully checked on %2$s. No issues were found. - %1$d channels were successfully checked on %2$s. No issues were found. - Watchtower alert - Revoked commitments were found on %1$s for channel(s): %2$s. This channel may be closed. - - - - The recovery phrase (sometimes called a seed), is a list of 12 English words. It allows you to recover full access to your funds if needed.\n\nOnly you alone possess this seed. Keep it private.\n\nDo not share this seed with anyone.\nBeware of phishing. The developers of Phoenix will never ask for your seed.\n\nDo not lose this seed.\nSave it somewhere safe (not on this phone). If you lose your seed and your phone, you\'ve lost your funds. - Display seed - KEEP THIS SEED SAFE.\nDO NOT SHARE. - You have not backed up your recovery phrase! - If you do not back it up and you lose access to Phoenix, you will lose your funds! - I have saved my recovery phrase somewhere safe. - I understand that if I lose my phone and my recovery phrase, then I will lose the funds in my wallet. - - - - Miner fees - Fees paid to the Bitcoin network miners to process the on-chain transaction. - Service fees - Fees paid for the creation of a new payment channel. This is not always required. - - Liquidity - Service fees - Fees paid for the liquidity service. - Miner fees - Fees paid to the Bitcoin network miners to process the on-chain transaction. - Caused by - This liquidity was required to receive a payment. - See how to optimise - - - - You can anonymously sign-in and authorize an action on: - You can redeem funds from\n%1$s. - The withdrawal request has been sent to %1$s.\n\nIt may take some time before they send the funds. Please keep the app online in the meanwhile. - - - - Enabling Tor - This requires installing a third-party Tor Proxy VPN app such as Orbot. - Tor can improve privacy, but may cause performance issues and missed payments. - Disabling Tor - If you disable this option, your IP address may be revealed to various service providers. - Are you sure you want to proceed ? - Processing changes… - - No access to the Tor network - Fix it - Phoenix needs access to Tor to function properly. - Make sure your Tor Proxy VPN app is up and running, and that it\'s connected to Tor. - If you don\'t have a Tor VPN app, install one. We recommend Orbot. - Open Tor settings - Open Orbot page - - - - Phoenix is a Bitcoin wallet using the Lightning network for sending and receiving payments.\n\nIt is a free open source software, developed by ACINQ under the Apache 2.0 License. - Safeguarding your key - This wallet is self-custodial: you have sole custody of the wallet\'s 12-words seed key.\n\nThis key gives access to your money. Do not reveal it to anyone, and beware of phishing. - Exchange rates - Bitcoin/fiat exchange rates are retrieved from various third-party APIs:\n\n- Blockchain.info\n- Coinbase.com\n- Bluelytics.com.ar\n- Yadio.io\n\nThose rates may not be accurate. Always check the actual Bitcoin amount before sending a payment. - - - - Welcome! - With Phoenix, sending and receiving bitcoins is easy and safe. - Next - - Bitcoin supercharged - Phoenix uses payment channels to make Bitcoin fast and private. - Next - - Your key, your bitcoins - Phoenix is self-custodial. You take control. - You can restore your wallet at anytime using your secret key. Keep it safe! - Get started - - - - Swap-in wallet - The swap-in wallet manages on-chain funds deposited to Phoenix.\n\nIt swaps them automatically to Lightning when possible, according to your channels management setting. - See how it works - Tap to configure - There are no swaps in progress. - - On-chain funds are automatically swapped into Lightning if the fee paid to miners is less than %1$s (can be configured). - Funds not swapped after %1$d months are recoverable on-chain. - Automated channels management is disabled. No swap will occur, funds will remain on-chain. - - A swap attempt failed %1$s - Channels management was disabled. - This swap will expire in a day! - This swap will expire in %1$s days. - - Timed out - These funds will be available from %1$s days onwards. - - Cancelled funds - These funds were not swapped in time. Tap to spend. - - The final wallet is where funds are sent when your Lightning channels are closed or when there is a problem. It usually should be empty. - - - - Incoming payments sometimes require on-chain transactions. This does not always happen, only when needed. - Fees are currently estimated at around %1$s (≈%2$s). - Automated channel management - Incoming payments that require on-chain operations will be rejected. - - Max fee amount - Payments whose fees exceed that value will be rejected. - This value is too low. - Below the expected fee. Some payments may be rejected. - - Advanced options - Channel management is disabled. It can be enabled in the Channel management setting screen. - Attention! - This screen is for advanced users. Do not change these settings unless you understand their purposes. - - Additional verifications - Percentage check - Check the fee relative to the amount received. This option is useful as a sanity check for small payments. - Policy overrides - Skip absolute fee check for Lightning - When enabled, incoming Lightning payments will ignore the absolute max fee limit. Only the percentage check will apply.\n\nAttention: if the Bitcoin mempool feerate is high, incoming LN payments requiring an on-chain operation could be expensive. - Save policy - Request inbound liquidity - - - Phoenix allows you to receive payments on Bitcoin\'s blockchain (L1) and Bitcoin\'s Lightning layer (L2). - \n\n - - the blockchain layer (L1) is slower, and generally much more expensive (requires miner fees) - \n - - the Lightning layer (L2) is much faster, and generally much cheaper (especially for smaller payments) - \n\n - When you receive a payment on L1, Phoenix will automatically move the funds to L2 IF the miner fees adhere to your configured fee policy. - \n\n - Payments you receive on L2 can be received instantly and for zero fees. However, occasionally an L1 operation is also required in order to manage the L2 payment channel. This can be done automatically IF the miner fees adhere to your configured fee policy. - - - - - No channels yet! - You first need funds in the wallet to use this feature. - Plan ahead your liquidity - Inbound liquidity lets you avoid on-chain transaction fees for future payments received over Lightning.\n\nBy requesting more liquidity now, you can save fees later. - More info - Current liquidity - - Request liquidity - Estimate liquidity cost - Estimating cost… - Miner fee - Mining fee contribution for the underlying on-chain transaction. - Service fee - This fee goes to the service providing the liquidity. - Duration - 1 year - As you receive funds, liquidity will be consumed and become your balance. After one year, the remaining unused liquidity will be reclaimed by the service. - The total fee is more than 25% of the liquidity amount requested. - The total fees exceed your balance. - - Review - You are requesting an initial amount of liquidity. Liquidity is not constant over time: as you receive funds over Lightning, the liquidity will be consumed and become your balance. - After one year, the remaining unused liquidity will be reclaimed by the service. - - Accept - Processing splice… - - Liquidity successfully added! - Amount added: %1$s - - Liquidity request has failed - Channels are not available. Try again later. - The requested amount is invalid. - - - - Unconfirmed - tap to accelerate - - - - Accelerate my transactions. - You can make all your unconfirmed transactions use a higher feerate to encourage miners to favour your payments. - This feerate is less than what your unconfirmed transactions are already using. Use a higher feerate. - - - - Channels are closing. - Channels are already processing a splice. Try again later. - Fee is insufficient. - This payment exceeds your balance. - The payment amount is too big - try splitting it in several parts. - The payment amount is too small. - The expiry of this payment is too far in the future. - The payment was rejected by the recipient. This particular invoice may have already been paid. - The recipient is offline. - The payment could not be relayed to the recipient (probably insufficient inbound liquidity). - An error occurred on a node in the payment route. The payment may succeed if you try again. - You have too many pending payments. Try again once they are settled. - - The ID of the payment is not valid. Try again. - This invoice has already been paid. - Your channel is not connected yet. Wait for a stable connection and try again. - Your channel is still in the process of being opened. Wait and try again. - This invoice uses unsupported features. Make sure you\'re on the latest Phoenix version. - The payment amount is invalid. - The payment could not be sent through your existing channels. - Recipient is not reachable, or does not have enough inbound liquidity. - An unknown error occurred and payment has failed. - The wallet was restarted while the payment was processing. - - - - Low feerate! - Transactions with insufficient feerate may linger for days or weeks without confirming. - Choosing the feerate is your responsibility. Once sent, this transaction cannot be cancelled, only accelerated with higher fees. - Are you sure you want to proceed? - - - - Channel size impacted - Funds sent on-chain are taken from your side of the channel, reducing the channel size by the same amount. Your inbound liquidity remains unchanged. - Don\'t show this message again. - - - - Spend cancelled swap-ins - No cancelled swap-ins yet. - Available: %1$s (%2$s) - Use this screen to spend on-chain deposits that were not swapped in time. This does not affect your Lightning channels. - Make sure the destination address is valid, and use a reasonable feerate. - Estimate fees - Estimating fees… - Broadcast - Broadcasting… - Transaction error. - This address is not valid. - Cannot create the refund transaction. - Transaction published. - You can find the transaction below. It will not appear in your payments history, so make a copy of its ID now if needed. - - - - Spend funds from final wallet - No funds available - Amount available - Use this screen to spend funds from your final wallet. These funds come from channels that have been closed in the past. This does not affect your existing Lightning channels. - - - - This is a human-readable address for your Bolt12 payment request. - Want a prettier address? Use third-party services, or self-host the address! - \ No newline at end of file + + Mantra + Add a dialect the group can translate into + Add artifact to library + Add artifact to the group library + Add chapter + Add dialect + Add to group + Add translation + After this there is no turning back. + All broadcasts are queued so that we can manage data usage on metered connections. + Any amount + ARTICLE + Artifact detail + As long as you control your keys there can be no dispute about who YOU actually is. + Back + Backup confirmation + Be sure to keep this nsec safe. + Be the first to comment. + Bio + BIP39 seed with the standard BIP84 derivation path. The profile\'s nostr key comes off the same seed, so these 12 words restore both. + Block user + Cancel + Change account + Chapter detail + Chapter name + Chapter translation + Chapters + Choose who to chat with + Close + Cloud backup + Copy URL + Could not unlock your phrase. Please try again. + Country + Create chat + Create new chat + Create profile + Create project + Create the #admins group + Creating new chat. + Currently no contacts. Please search and chat with a few people. + Currently no messages have been shared.\nBreak the ice. + Delete group + Details + Dialect name + Dialects + Direct message detail + Direct message functionality will be here. + Direct message via npub + Display recovery phrase + Don't sign + Download + Download and securely store everything needed to recover this profile and the coins it holds. + Edit profile + eg. Chapter 1 — The Beginning + eg. First Edition + eg. https://harper.com/2-kill-Bird + eg. Lesotho + eg. Sesotho + eg. st + eg. To Kill a Mocking Bird + Emergency kit + end this + ENDED + Encrypt and back your recovery information up to your Google Drive or iCloud. + Enter the name you want to use for your group + Enter the name you want to use for your profile + Enter the nsec or npub (read only) that you want to sign in as + Enter the translation for this chunk + Events are indexed so that we can deliver a premium local first experience. + Everything else + Everything is cryptographical sound. Just announcing your profile to the world. + Everything is cryptographical sound. Just indexing your profile on the device. + Everything is cryptographical sound. Just need to queue your profile and announce it to the world. + Expired + ✗ Failed + follow + follow back + functionality coming soon. + Has not taken part yet + Hide + How many admins have to approve a change? + How many members will it take to sign? + I have saved my recovery phrase somewhere safe. + I understand that if I lose this phone and my recovery phrase, I lose this profile and the funds in its wallet. + If you are new to Mantra or just want to create a fresh profile + Initial version label + Input npub... or nip05 + Introduce yourself + Invite + Invite a friend + Invite new member + It is cryptographical secure, and decentralized, putting you in total control of your digital profile + Just you for now + Keep the feed alive. + Keep this phrase safe.\nDo not share it. + Key + Key package management + Key recovery + Language + Learn more + Leave group + Library + Lightning invoice + LIVE + Live stream + Loading + Loading article... + Loading author information + Loading author information... + loading information... + Loading note... + Loading stream... + Loading preferences… + Lose this phone before you do, and the profile goes with it. + Lock prompt coming soon. + Malformed note + Mantra + Members + members + Name + Name (eg. Alan Turing) + Name (eg. Group Discussions) + Name of artifact + Network relays + No wallet is open on this device, so there is no phrase to show. + Not backed up yet + New chat + Next + No artifacts exists in this groups library. + No chapters. + No chapters yet. + No chat message relays were found for this user. + No chunks. + No dialects have been defined in this group yet. Add one from the group's detail screen first. + No dialects have been defined in this group. + No messages. Go to a profile and send them a message. + No one selected yet + No one to add yet. + No projects exists in this group. + No translations yet. + No versions. + Not now + Nothing was created and no key exists. It is safe to run it again. + nsec, npub, nip-05 (static address) + Open chat + Original + Original text + Original text (markdown) + ✓ Paid + Paste the chapter's markdown. Blank lines separate paragraphs into chunks. + Pay + Pay now + Post + post functionality coming soon. + Private to you + Messages + Profile + Pick a conversation to read it here. + Profile is ready + Profiles + Projects + Proposals + Propose + Propose artifact + Propose chapter + Propose dialect + Propose translation + Publish new key package + Re-broadcast + Read to the end of the list to sign. + Recovery phrase + Ready to sign + Recents + Reindex events + reposted + Review + Review and confirm + Review and contribute + Review and join + Say what now? + Search + Search for people and chat with them first — everyone you know locally shows up here. + Search hashtags + Search member functionality + Search message functionality will be here. + See how deep the rabbit hole goes + Select a wallet + Send message + Share profile + Shared key + Sign + Sign in + Sign in to nsec + Sign in to Mantra via nsec, or remote signer + Sign in with an npub + Sign out + Sign with the group's key + Signed their part + Skip for now + Something went wrong + Something went wrong and we were unable to sign in to your provided profile. Please try again later. + Something went wrong and we were unable to sign you up. Please try again later. + Something went wrong and we were unable to write a new note. Please try again later. + Source dialect + Start + Start a group chat + Start chat + Start chat via npub or nip05 + Start key ceremony + Startup error + Taking part with these members + Tap to load + Tell friends to join you so your feed stays lively and fresh. + The above will be your new note. + The above will be your profile. + The ceremony was abandoned. + The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum. + The group has a shared key. + The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only way back to this profile: the key that signs as you, and the wallet that holds your coins, are both derived from it.\n\nOnly you have this phrase. Keep it private — nobody from mantra will ever ask you for it.\n\nDo not lose it. Write it down and keep it somewhere safe that is not this phone. If you lose both the phone and the phrase, this profile and its funds are gone for good. + These are your keys. Keep them safe so they can keep unlocking this profile and its coins, even when you lose or change your phone. + This artifact has no version for a translation to be of. + This artifact has no version for a chapter to attach to. + This chapter has no chunks. + This device holds no phrase for the profile that is signed in. + This decides who can change the group later. You can't switch afterwards. + This group has no shared key, so it cannot sign them in. + This group has not been asked to sign anything yet. + This is fixed once the ceremony runs. Changing it later means generating a new key. + This is the last thing the ceremony needs from you. + This will be shown when people open the chat for more details. + This will be shown when people open your profile. + This will be the display name for this chat room. + This will be the display name for your profile and also important for search. + This will give you read only access to the profile. + This will give you write access to the profile. + Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. + Translate chunk + Translate into which dialect? + Translated text + Translation + Translation detail + Translations + Transmit note + Trending notes functionality coming soon. In the meantime search for what you are looking for. + Try again + Type out what you would like to publish + Unfollow + Unlocking your phrase… + Unsupported event kind + Untitled article + Url + Version + Versions + View and accept invites you may have received to stay connected with others. + View invites + Waiting for you + Waiting for your signature + We are looking for your profile on as many relays as possible. Nostr aims to be decentralized by distributing data to multiple nodse. + We are searching the internet to find your profile and complete sign in. + We couldn't find the local profile. Please try again later. + We couldn't find your nostr event. Please try again later. + What's your comment on the below + What's your reply to the above + What should people know about you? + What vibrations do you want to send out? + What will be discussed in this chat room. + Write down and secure the 12 word phrase that this profile and its wallet are derived from. + Who will you be passing the aux to? + You + You and 1 other + You are about to create a NOSTR profile. + You can still carry on and invite people later. + You have not backed up your recovery phrase + You only live once. Lose this phone and the profile goes with it, along with anything it holds. + You said you wrote it down + YOLO + You will be in full control of this profile. If you would like to use it for the long term please remember to backup the profile/keys. + Your profile is almost ready... just getting it's first cryptographic signature together. + Your share of it is on this device only. Your wallet backup restores it — nobody else's share can. + + Add chapter to %1$s + Add people to %1$s + Chapter %1$s + Chapter %1$s · %2$s words · %3$s characters + Chunk %1$s + Chunks (%1$s) + %1$s/%2$s chunks translated + %1$s event(s) still unreadable%2$s + %1$s events, signed together + Everyone has to be online at the same time — the ceremony can only finish once all %1$s of you have taken part. + "%1$s" functionality coming soon + How should %1$s be run? + Invite %1$s + %1$s key packages + Next with %1$s + nostr:%1$s.. + nostr:%1$s... + %1$s of %2$s + %1$s of %2$s members will be needed to sign with this key. + %1$s of them could not be read + Once invited will be able to receive and send private message sent to all the %1$s members in the chat room. + Private message to %1$s + Private to %1$s + %1$s proposals are waiting for your signature + Recovered %1$s of %2$s event(s)%3$s + Recovered %1$s of %2$s · %3$s still unreadable%4$s + Reply privately to %1$s + Reply to %1$s + Replying to %1$s + Searching for %1$s on "%2$s" + %1$s selected + %1$s sent a private message to %2$s + Shared key for %1$s + to join the %1$s chat room. + Translate %1$s + Unsupported event kind: %1$s + %1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Mantra. + %1$s was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own. + %1$s words · %2$s characters + #%1$s + You and %1$s others + You aren't following anyone yet. + Nobody is following you yet. + Nothing in this feed yet. + No replies to this yet. + Nothing matched that search. + Key package published + Key package rotated + diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt index ae98ef76..bd2ce2d2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/MantraApp.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import press.mantra.compose.ui.composable.navigation.MantraNavHost +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost import press.mantra.compose.ui.theme.TorchTheme import fr.acinq.phoenix.PhoenixGlobal @@ -19,11 +20,18 @@ fun MantraApp( Surface( modifier = Modifier.fillMaxSize() ) { - MantraNavHost( - mantraGlobal = mantraGlobal, - phoenixGlobal = phoenixGlobal, - navController = navController - ) + // One host state for the whole app rather than one per screen. Only one + // Scaffold is composed at a time under a NavHost, so the message renders in + // whichever screen is on top -- and a view model coroutine reporting an + // outcome does not have to be handed a state through the parameter list of + // every composable between it and the Scaffold. + ProvideSnackbarHost { + MantraNavHost( + mantraGlobal = mantraGlobal, + phoenixGlobal = phoenixGlobal, + navController = navController + ) + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt index ca5b48ec..f6e7e758 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/Profile.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.room3.Entity import androidx.room3.ForeignKey @@ -27,6 +26,7 @@ import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.theme.TorchTheme import kotlin.time.Clock import kotlin.time.Instant +import press.mantra.compose.ui.theme.ConformancePreviews @Entity( foreignKeys = [ @@ -170,7 +170,7 @@ data class Profile( } } -@Preview +@ConformancePreviews @Composable fun ProfileRenderAsListItemPreview() { val profile = Profile( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt index 246e840f..f016a4f1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalQuotedNostrEvent.kt @@ -11,7 +11,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.room3.Embedded import androidx.room3.Relation @@ -23,6 +22,7 @@ import press.mantra.compose.database.model.QuotedRelation import press.mantra.compose.ui.composable.widgets.content.RichContent import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.theme.ConformancePreviews data class LocalQuotedNostrEvent( @Embedded val quotedRelation: press.mantra.compose.database.model.QuotedRelation, @@ -110,7 +110,7 @@ data class LocalQuotedNostrEvent( } } -@Preview +@ConformancePreviews @Composable private fun LocalQuotedNostrEventPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index d3d569e4..a2c8112c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -480,7 +480,7 @@ class DatabaseChatRepository( // TODO: Process this in the notary... sendChatMessage( - text = "Left Group", + text = "Left group", localChatRoom = localChatRoom, // TODO: Add Information/LeaveChatGroup Message Type... ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index 2f35337c..3f90c02c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.NostrEvent @@ -46,12 +45,34 @@ import press.mantra.compose.ui.composable.navigation.routes.KeyPackageManagement import press.mantra.compose.ui.composable.navigation.routes.KeyRecoveryRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute +import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.view.model.ActiveProfileViewModel import press.mantra.compose.ui.view.state.ActiveProfileUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.change_account +import mantra.composeapp.generated.resources.edit_profile +import mantra.composeapp.generated.resources.key_package_management +import mantra.composeapp.generated.resources.network_relays +import mantra.composeapp.generated.resources.profile +import mantra.composeapp.generated.resources.key_recovery +import mantra.composeapp.generated.resources.share_profile +import mantra.composeapp.generated.resources.sign_out +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please +import press.mantra.compose.ui.composable.widgets.ErrorState +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -73,293 +94,305 @@ fun ActiveProfileScreen( ), ) - when(val activeProfileUIState = activeProfileViewModel.activeProfileUIState) { - ActiveProfileUIState.Error -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Something went wrong") + ScreenStateTransition(activeProfileViewModel.activeProfileUIState) { uiState -> + when (val activeProfileUIState = uiState) { + ActiveProfileUIState.Error -> { + ErrorState() } - } - is ActiveProfileUIState.Loaded -> { - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = "Profile" - ) - }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" + is ActiveProfileUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.profile) ) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - ProfileAvatar( - profile = activeProfileUIState.localNostrEvent.profile, - publicKey = activeUserPublicKey - ) - - Column { - activeProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> - Text( - text = humanReadableNameOrPubkey, - style = MaterialTheme.typography.titleLarge - ) - } - activeProfileUIState.localNostrEvent.profile?.about?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium - ) - } - - Spacer( - modifier = Modifier.height(10.dp) - ) - - Text( - text = activeUserPublicKey.hexToNpubHrp(), - style = MaterialTheme.typography.labelSmall - ) - } - } - } - - item { - Row( - modifier = Modifier.fillMaxWidth().padding(10.dp) - ) { - Spacer( - modifier = Modifier.weight(1f) - ) - - TextButton( + }, + navigationIcon = { + IconButton( onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Edit Profile") - ) + onNavigateBack.invoke() } ) { Icon( - Icons.Default.Edit, - contentDescription = "Edit profile" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text( - "Edit Profile" + Icons.Default.ArrowBack, + contentDescription = "Back" ) } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + .readableContent() + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + ProfileAvatar( + profile = activeProfileUIState.localNostrEvent.profile, + publicKey = activeUserPublicKey + ) - Spacer( - modifier = Modifier.weight(1f) - ) + Column { + activeProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> + Text( + text = humanReadableNameOrPubkey, + style = MaterialTheme.typography.titleLarge + ) + } + activeProfileUIState.localNostrEvent.profile?.about?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium + ) + } - Button( + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space125) + ) + + Text( + text = activeUserPublicKey.hexToNpubHrp(), + style = MaterialTheme.typography.labelSmall + ) + } + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125) + ) { + Spacer( + modifier = Modifier.weight(1f) + ) + + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Edit profile") + ) + } + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit profile" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + stringResource(Res.string.edit_profile) + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + + // The list this sits in is otherwise TextButtons -- + // edit profile, key packages, change account, profile + // keys, network relays. Two of the seven were filled + // Buttons, which is M3's highest emphasis and is meant + // for one action per screen. Sharing is the useful one, + // so it keeps medium emphasis rather than maximum. + FilledTonalButton( + onClick = { + onNavigateToRoute.invoke( + ShareProfileRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = nostrEventId + ) + ) + }, + ) { + Icon( + Icons.Default.QrCode, + contentDescription = "QR code" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + Text( + stringResource(Res.string.share_profile) + ) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + } + } + + item { + TextButton( onClick = { onNavigateToRoute.invoke( - ShareProfileRoute( + KeyPackageManagementRoute( activeUserPublicKey = activeUserPublicKey, nostrEventId = nostrEventId ) ) - }, + } ) { + Icon( - Icons.Default.QrCode, - contentDescription = "QR code" + Icons.Default.Bento, + contentDescription = "Key packages" ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) + Text( - "Share Profile" + stringResource(Res.string.key_package_management) ) } - - Spacer( - modifier = Modifier.weight(1f) - ) } - } - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - KeyPackageManagementRoute( - activeUserPublicKey = activeUserPublicKey, - nostrEventId = nostrEventId + + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Change account") ) + } + ) { + + Icon( + Icons.Default.ImportExport, + contentDescription = "Change profile" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + stringResource(Res.string.change_account) ) } - ) { - - Icon( - Icons.Default.Bento, - contentDescription = "Key Packages" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text( - "Key Package Management" - ) } - } - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Change Account") - ) - } - ) { - - Icon( - Icons.Default.ImportExport, - contentDescription = "Change Profile" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text( - "Change Account" - ) - } - } - - - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - KeyRecoveryRoute( - activeUserPublicKey = activeUserPublicKey + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + KeyRecoveryRoute( + activeUserPublicKey = activeUserPublicKey + ) ) + } + ) { + Icon( + Icons.Default.Key, + contentDescription = Decorative ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.key_recovery)) } - ) { - Icon( - Icons.Default.Key, - contentDescription = "Key Recovery" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Key Recovery") } - } - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Network Relays") + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Network relays") + ) + + } + ) { + Icon( + Icons.Default.Hub, + contentDescription = "Network relays" ) + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + stringResource(Res.string.network_relays) + ) } - ) { - Icon( - Icons.Default.Hub, - contentDescription = "Network Relays" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text( - "Network Relays" - ) } - } - item { - Button( - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Sign Out") + item { + // Signing out was the *other* filled Button -- the most + // prominent control on the screen given to its most + // destructive action. It now matches how leaving and + // deleting a group are already treated in + // ChatRoomDetailScreen: a TextButton in the error colour. + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Sign out") + ) + } + ) { + Icon( + Icons.Default.Logout, + contentDescription = "Logout" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + stringResource(Res.string.sign_out) ) } - ) { - Icon( - Icons.Default.Logout, - contentDescription = "Logout" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text( - "Sign out" - ) } } } } } - } - ActiveProfileUIState.Loading -> { - LoadingDataIndicator() + ActiveProfileUIState.Loading -> { + LoadingDataIndicator() - LaunchedEffect(true) { - activeProfileViewModel.loadNostrFeed() + LaunchedEffect(true) { + activeProfileViewModel.loadNostrFeed() + } } - } - ActiveProfileUIState.NotFound -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("We couldn't find the local profile. Please try again later.") + ActiveProfileUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + } } } } } -@Preview +@ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt index 10dc902c..c3b1df70 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddArtifactScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -48,7 +49,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom @@ -67,6 +67,26 @@ import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.view.model.AddArtifactViewModel import press.mantra.compose.ui.view.state.AddArtifactUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.add_artifact_to_the_group_library +import mantra.composeapp.generated.resources.direct_message_detail +import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here +import mantra.composeapp.generated.resources.eg_first_edition +import mantra.composeapp.generated.resources.eg_https_harper_com_2_kill_bird +import mantra.composeapp.generated.resources.eg_to_kill_a_mocking_bird +import mantra.composeapp.generated.resources.initial_version_label +import mantra.composeapp.generated.resources.name_of_artifact +import mantra.composeapp.generated.resources.no_dialects_have_been_defined_in_this_group +import mantra.composeapp.generated.resources.propose_artifact +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -95,276 +115,282 @@ fun AddArtifactScreen( ) ) - when (val addArtifactUIState = addArtifactViewModel.addArtifactUIState) { - is AddArtifactUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = addArtifactUIState.message, - ) - } - } - is AddArtifactUIState.Loaded -> { - val nameFieldState = rememberTextFieldState() - val urlFieldState = rememberTextFieldState() - val versionLabelFieldState = rememberTextFieldState("1.0") - - // The id of the group dialect this artifact is written in. Null until - // one is picked; dialects are defined from the group detail screen. - var selectedDialectId: String? by remember { mutableStateOf(null) } - - // The two things the form cannot ask for again. A dialect has to - // already exist and nothing here can create one; a shared key has to - // have been ceremonied and this is not where that happens. Either - // missing is a dead end rather than something to propose and be told - // about afterwards, and the button says so. - val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - Scaffold( - topBar = { - TopAppBar( - title = { - addArtifactUIState.localChatRoom.RenderChatRoomTitleText() - }, - actions = { - - } + ScreenStateTransition(addArtifactViewModel.addArtifactUIState) { uiState -> + when (val addArtifactUIState = uiState) { + is AddArtifactUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canAddArtifact) { - Modifier - } else { - // Looking unavailable is not being unavailable: - // without this a screen reader still announces - // a button it is happy to press. - Modifier.semantics { disabled() } - }, - containerColor = if (canAddArtifact) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canAddArtifact) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!canAddArtifact) return@ExtendedFloatingActionButton - - addArtifactViewModel.addArtifact( - localChatRoom = addArtifactUIState.localChatRoom, - nameField = nameFieldState, - urlField = urlFieldState, - versionLabelField = versionLabelFieldState, - dialectId = selectedDialectId, - onSuccess = { sessionId -> - // Onto the session rather than to the - // artifact. Nothing has been created yet - // -- the artifact appears when enough - // members sign -- so a detail screen for - // a row that does not exist would read as - // a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed Artifact") - ) - } - ) - - } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose artifact" - ) - Text("Propose Artifact") - } - } + Text( + text = addArtifactUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() - ) { - // TODO: Check that we have direct message relays for this user... - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Add Artifact to the group library") + } + is AddArtifactUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val urlFieldState = rememberTextFieldState() + val versionLabelFieldState = rememberTextFieldState("1.0") - if (!addArtifactUIState.canSign) { - Text( - text = "This group has no shared key, so it cannot sign an " + - "artifact into its library. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } + // The id of the group dialect this artifact is written in. Null until + // one is picked; dialects are defined from the group detail screen. + var selectedDialectId: String? by remember { mutableStateOf(null) } - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = nameFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.DriveFileRenameOutline, - contentDescription = "Name of the artifact" - ) - }, - label = { - Text( - text = "Name of Artifact" - ) - }, - placeholder = { - Text( - text = "eg. To Kill a Mocking Bird" - ) + // The two things the form cannot ask for again. A dialect has to + // already exist and nothing here can create one; a shared key has to + // have been ceremonied and this is not where that happens. Either + // missing is a dead end rather than something to propose and be told + // about afterwards, and the button says so. + val canAddArtifact = selectedDialectId != null && addArtifactUIState.canSign + + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + addArtifactUIState.localChatRoom.RenderChatRoomTitleText() }, + actions = { + + } ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canAddArtifact) { + Modifier + } else { + // Looking unavailable is not being unavailable: + // without this a screen reader still announces + // a button it is happy to press. + Modifier.semantics { disabled() } + }, + containerColor = if (canAddArtifact) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canAddArtifact) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!canAddArtifact) return@ExtendedFloatingActionButton - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = urlFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Link, - contentDescription = "Link to the artifact" - ) - }, - label = { - Text( - text = "Url" - ) - }, - placeholder = { - Text( - text = "eg. https://harper.com/2-kill-Bird" - ) - }, - ) + addArtifactViewModel.addArtifact( + localChatRoom = addArtifactUIState.localChatRoom, + nameField = nameFieldState, + urlField = urlFieldState, + versionLabelField = versionLabelFieldState, + dialectId = selectedDialectId, + onSuccess = { sessionId -> + // Onto the session rather than to the + // artifact. Nothing has been created yet + // -- the artifact appears when enough + // members sign -- so a detail screen for + // a row that does not exist would read as + // a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed artifact") + ) + } + ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = versionLabelFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.LocalOffer, - contentDescription = "Verison label" - ) - }, - label = { - Text( - text = "Initial Version Label" - ) - }, - placeholder = { - Text( - text = "eg. First Edition" - ) - }, - ) - - Text("Source dialect") - - if (addArtifactUIState.dialects.isEmpty()) { - Text( - text = "No dialects have been defined in this group yet. Add one from the group's detail screen first.", - style = MaterialTheme.typography.bodySmall - ) - } else { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - addArtifactUIState.dialects.forEach { dialect -> - FilterChip( - selected = selectedDialectId == dialect.id, - onClick = { selectedDialectId = dialect.id }, - label = { Text(dialect.name) } + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose artifact" ) + Text(stringResource(Res.string.propose_artifact)) } } - } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + // TODO: Check that we have direct message relays for this user... + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.add_artifact_to_the_group_library)) - // TODO: Add Visibility + if (!addArtifactUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign an " + + "artifact into its library. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = "Name of the artifact" + ) + }, + label = { + Text( + text = stringResource(Res.string.name_of_artifact) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_to_kill_a_mocking_bird) + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = urlFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Link, + contentDescription = "Link to the artifact" + ) + }, + label = { + Text( + text = stringResource(Res.string.url) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_https_harper_com_2_kill_bird) + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = versionLabelFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.LocalOffer, + contentDescription = "Verison label" + ) + }, + label = { + Text( + text = stringResource(Res.string.initial_version_label) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_first_edition) + ) + }, + ) + + Text(stringResource(Res.string.source_dialect)) + + if (addArtifactUIState.dialects.isEmpty()) { + Text( + text = stringResource(Res.string.no_dialects_have_been_defined_in_this_group), + style = MaterialTheme.typography.bodySmall + ) + } else { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + addArtifactUIState.dialects.forEach { dialect -> + FilterChip( + selected = selectedDialectId == dialect.id, + onClick = { selectedDialectId = dialect.id }, + label = { Text(dialect.name) } + ) + } + } + } + + // TODO: Add Visibility + } } } } - } - AddArtifactUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { + AddArtifactUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = "Direct Message Detail", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.direct_message_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - Text( - text = "Direct message functionality will be here.", - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.direct_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } @@ -376,7 +402,7 @@ fun AddArtifactScreen( } } -@Preview +@ConformancePreviews @Composable private fun AddArtifactScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index a4b2c60a..d2627f34 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -33,7 +34,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -52,6 +52,22 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.AddChapterViewModel import press.mantra.compose.ui.view.state.AddChapterUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.add_chapter +import mantra.composeapp.generated.resources.chapter_name +import mantra.composeapp.generated.resources.eg_chapter_1_the_beginning +import mantra.composeapp.generated.resources.original_text_markdown +import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_lines +import mantra.composeapp.generated.resources.propose_chapter +import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -81,202 +97,208 @@ fun AddChapterScreen( ) ) - when (val addChapterUIState = addChapterViewModel.addChapterUIState) { - is AddChapterUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text(text = addChapterUIState.message) + ScreenStateTransition(addChapterViewModel.addChapterUIState) { uiState -> + when (val addChapterUIState = uiState) { + is AddChapterUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addChapterUIState.message) + } } - } - is AddChapterUIState.Loaded -> { - val nameFieldState = rememberTextFieldState() - val originalTextFieldState = rememberTextFieldState() + is AddChapterUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val originalTextFieldState = rememberTextFieldState() - // Live counts / paragraph (chunk) preview from the markdown text. - val originalText = originalTextFieldState.text.toString() - val wordCount = Markdown.wordCount(originalText) - val characterCount = Markdown.characterCount(originalText) - val paragraphCount = Markdown.splitParagraphs(originalText).size + // Live counts / paragraph (chunk) preview from the markdown text. + val originalText = originalTextFieldState.text.toString() + val wordCount = Markdown.wordCount(originalText) + val characterCount = Markdown.characterCount(originalText) + val paragraphCount = Markdown.splitParagraphs(originalText).size - // A chapter hangs off a version, and the group has to be able to - // sign; without both there is nothing this screen can propose. - val artifactVersion = addChapterUIState.artifactVersion + // A chapter hangs off a version, and the group has to be able to + // sign; without both there is nothing this screen can propose. + val artifactVersion = addChapterUIState.artifactVersion - // The chapter and a chunk per paragraph are signed in one session, - // and a session signs a bounded number of events. Past that the - // chapter has to be split in two, which is worth saying while there - // is still a cursor in the text rather than after a failed propose. - val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER + // The chapter and a chunk per paragraph are signed in one session, + // and a session signs a bounded number of events. Past that the + // chapter has to be split in two, which is worth saying while there + // is still a cursor in the text rather than after a failed propose. + val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER - val canProposeChapter = artifactVersion != null && - addChapterUIState.canSign && - paragraphCount > 0 && - !tooManyChunks + val canProposeChapter = artifactVersion != null && + addChapterUIState.canSign && + paragraphCount > 0 && + !tooManyChunks - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() - Scaffold( - topBar = { - TopAppBar( - title = { Text("Add chapter to ${addChapterUIState.artifact.name}") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canProposeChapter) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canProposeChapter) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canProposeChapter) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (artifactVersion == null || !canProposeChapter) { - return@ExtendedFloatingActionButton - } - - addChapterViewModel.addChapter( - localChatRoom = addChapterUIState.localChatRoom, - artifactVersion = artifactVersion, - nameField = nameFieldState, - originalTextField = originalTextFieldState, - onSuccess = { sessionId -> - // Onto the session rather than back to - // the artifact. Nothing has been created - // yet -- the chapter appears when enough - // members sign -- so landing on the list - // it is not in would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed Chapter") - ) - } + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.add_chapter_to, addChapterUIState.artifact.name)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose chapter" - ) - Text("Propose Chapter") + }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canProposeChapter) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canProposeChapter) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canProposeChapter) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (artifactVersion == null || !canProposeChapter) { + return@ExtendedFloatingActionButton + } + + addChapterViewModel.addChapter( + localChatRoom = addChapterUIState.localChatRoom, + artifactVersion = artifactVersion, + nameField = nameFieldState, + originalTextField = originalTextFieldState, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the artifact. Nothing has been created + // yet -- the chapter appears when enough + // members sign -- so landing on the list + // it is not in would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed chapter") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose chapter" + ) + Text(stringResource(Res.string.propose_chapter)) + } } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (!addChapterUIState.canSign) { - Text( - text = "This group has no shared key, so it cannot sign a " + - "chapter into existence. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } else if (artifactVersion == null) { - Text( - text = "This artifact has no version for a chapter to attach to.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } else if (tooManyChunks) { - Text( - text = "$paragraphCount chunks is more than the group can sign in " + - "one go. Split the chapter so that no part of it is over " + - "${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error ) } - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = nameFieldState, - leadingIcon = { - Icon( - Icons.Default.DriveFileRenameOutline, - contentDescription = "Name of the chapter" + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (!addChapterUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "chapter into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (artifactVersion == null) { + Text( + text = stringResource(Res.string.this_artifact_has_no_version_for_a_chapter), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (tooManyChunks) { + Text( + text = "$paragraphCount chunks is more than the group can sign in " + + "one go. Split the chapter so that no part of it is over " + + "${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error ) - }, - label = { Text("Chapter Name") }, - placeholder = { Text("eg. Chapter 1 — The Beginning") }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth().weight(1f), - state = originalTextFieldState, - label = { Text("Original text (markdown)") }, - placeholder = { Text("Paste the chapter's markdown. Blank lines separate paragraphs into chunks.") }, - ) - - Text( - // The chunk count is what the group is asked to sign - // alongside the chapter, so it is a count of the work - // being proposed rather than a curiosity about the text. - text = "$wordCount words · $characterCount characters · " + - "$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " + - "of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}", - style = MaterialTheme.typography.labelMedium, - color = if (tooManyChunks) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.typography.labelMedium.color } - ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + state = nameFieldState, + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = "Name of the chapter" + ) + }, + label = { Text(stringResource(Res.string.chapter_name)) }, + placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth().weight(1f), + state = originalTextFieldState, + label = { Text(stringResource(Res.string.original_text_markdown)) }, + placeholder = { Text(stringResource(Res.string.paste_the_chapter_s_markdown_blank_lines)) }, + ) + + Text( + // The chunk count is what the group is asked to sign + // alongside the chapter, so it is a count of the work + // being proposed rather than a curiosity about the text. + text = "$wordCount words · $characterCount characters · " + + "$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " + + "of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}", + style = MaterialTheme.typography.labelMedium, + color = if (tooManyChunks) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.typography.labelMedium.color + } + ) + } } } - } - AddChapterUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Add Chapter", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + AddChapterUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.add_chapter), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -288,7 +310,7 @@ fun AddChapterScreen( } } -@Preview +@ConformancePreviews @Composable private fun AddChapterScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt index 40b8cf69..ee088cd2 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddDialectScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -38,7 +39,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -54,6 +54,23 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.AddDialectViewModel import press.mantra.compose.ui.view.state.AddDialectUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.add_a_dialect_the_group_can_translate_into +import mantra.composeapp.generated.resources.add_dialect +import mantra.composeapp.generated.resources.country +import mantra.composeapp.generated.resources.dialect_name +import mantra.composeapp.generated.resources.eg_lesotho +import mantra.composeapp.generated.resources.eg_sesotho +import mantra.composeapp.generated.resources.eg_st +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -80,229 +97,235 @@ fun AddDialectScreen( ) ) - when (val addDialectUIState = addDialectViewModel.addDialectUIState) { - is AddDialectUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = addDialectUIState.message, - ) - } - } - is AddDialectUIState.Loaded -> { - val nameFieldState = rememberTextFieldState() - val countryFieldState = rememberTextFieldState() - val languageFieldState = rememberTextFieldState() - - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() - - Scaffold( - topBar = { - TopAppBar( - title = { - addDialectUIState.localChatRoom.RenderChatRoomTitleText() - }, - actions = { - - } + ScreenStateTransition(addDialectViewModel.addDialectUIState) { uiState -> + when (val addDialectUIState = uiState) { + is AddDialectUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (addDialectUIState.canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (addDialectUIState.canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (addDialectUIState.canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton - - addDialectViewModel.addDialect( - localChatRoom = addDialectUIState.localChatRoom, - nameField = nameFieldState, - countryField = countryFieldState, - languageField = languageFieldState, - onSuccess = { sessionId -> - // Onto the session rather than back to - // the group. Nothing has been created - // yet -- the dialect appears when enough - // members sign -- so landing on the list - // it is not in would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed Dialect") - ) - } - ) - } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose dialect" - ) - Text("Propose Dialect") - } - } + Text( + text = addDialectUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Add a dialect the group can translate into") + } + is AddDialectUIState.Loaded -> { + val nameFieldState = rememberTextFieldState() + val countryFieldState = rememberTextFieldState() + val languageFieldState = rememberTextFieldState() - if (!addDialectUIState.canSign) { - Text( - text = "This group has no shared key, so it cannot sign a " + - "dialect into existence. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() + + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { + addDialectUIState.localChatRoom.RenderChatRoomTitleText() + }, + actions = { + + } + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (addDialectUIState.canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (addDialectUIState.canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (addDialectUIState.canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!addDialectUIState.canSign) return@ExtendedFloatingActionButton + + addDialectViewModel.addDialect( + localChatRoom = addDialectUIState.localChatRoom, + nameField = nameFieldState, + countryField = countryFieldState, + languageField = languageFieldState, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the group. Nothing has been created + // yet -- the dialect appears when enough + // members sign -- so landing on the list + // it is not in would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed dialect") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose dialect" + ) + Text(stringResource(Res.string.propose_dialect)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.add_a_dialect_the_group_can_translate_into)) + + if (!addDialectUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "dialect into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = nameFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Title, + contentDescription = "Name of the dialect" + ) + }, + label = { + Text( + text = stringResource(Res.string.dialect_name) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_sesotho) + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = countryFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Public, + contentDescription = "Country of the dialect" + ) + }, + label = { + Text( + text = stringResource(Res.string.country) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_lesotho) + ) + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor), + state = languageFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent + ), + leadingIcon = { + Icon( + Icons.Default.Translate, + contentDescription = "Language of the dialect" + ) + }, + label = { + Text( + text = stringResource(Res.string.language) + ) + }, + placeholder = { + Text( + text = stringResource(Res.string.eg_st) + ) + }, ) } - - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = nameFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Title, - contentDescription = "Name of the dialect" - ) - }, - label = { - Text( - text = "Dialect Name" - ) - }, - placeholder = { - Text( - text = "eg. Sesotho" - ) - }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = countryFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Public, - contentDescription = "Country of the dialect" - ) - }, - label = { - Text( - text = "Country" - ) - }, - placeholder = { - Text( - text = "eg. Lesotho" - ) - }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor), - state = languageFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent - ), - leadingIcon = { - Icon( - Icons.Default.Translate, - contentDescription = "Language of the dialect" - ) - }, - label = { - Text( - text = "Language" - ) - }, - placeholder = { - Text( - text = "eg. st" - ) - }, - ) } } } - } - AddDialectUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { + AddDialectUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = "Add Dialect", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.add_dialect), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } @@ -314,7 +337,7 @@ fun AddDialectScreen( } } -@Preview +@ConformancePreviews @Composable private fun AddDialectScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt index 4ad91099..4c6cca36 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddMemberToChatRoomConfirmationScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom @@ -37,6 +36,20 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.AddMemberToChatRoomConfirmationViewModel import press.mantra.compose.ui.view.state.AddMemberToChatRoomConfirmationUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.direct_message_detail +import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here +import mantra.composeapp.generated.resources.invite +import mantra.composeapp.generated.resources.invite_2 +import mantra.composeapp.generated.resources.once_invited_will_be_able_to_receive_and +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -62,131 +75,134 @@ fun AddMemberToChatRoomConfirmationScreen( ) ) - when (val addMemberToChatRoomConfirmationUIState = addMemberToChatRoomConfirmationViewModel.addMemberToChatRoomConfirmationUIState) { - is AddMemberToChatRoomConfirmationUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = addMemberToChatRoomConfirmationUIState.message, - textAlign = TextAlign.Center - ) - } - } - is AddMemberToChatRoomConfirmationUIState.Loaded -> { - Scaffold( -// topBar = { -// TopAppBar( -// title = { -// val title = if (addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject != null) { -// "Invite new member to ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject}" -// } else { -// "Invite new member to chat" -// } -// Text( -// text = title, -// ) -// }, -// actions = { -// } -// ) -// } - bottomBar = { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - addMemberToChatRoomConfirmationViewModel.inviteToChatRoom( - localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom, - peerPublicKey = addMemberToChatRoomConfirmationUIState.profile.publicKey, - peerKeyPackage = addMemberToChatRoomConfirmationUIState.marmotKeyPackage, - onInviteSent = onInviteSent - ) - } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Invite new member" - ) - - Text("Invite ${addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey()}") - } - }, - actions = { - - } + ScreenStateTransition(addMemberToChatRoomConfirmationViewModel.addMemberToChatRoomConfirmationUIState) { uiState -> + when (val addMemberToChatRoomConfirmationUIState = uiState) { + is AddMemberToChatRoomConfirmationUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = addMemberToChatRoomConfirmationUIState.message, + textAlign = TextAlign.Center ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() - ) { + } + is AddMemberToChatRoomConfirmationUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + // topBar = { + // TopAppBar( + // title = { + // val title = if (addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject != null) { + // "Invite new member to ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject}" + // } else { + // "Invite new member to chat" + // } + // Text( + // text = title, + // ) + // }, + // actions = { + // } + // ) + // } + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + addMemberToChatRoomConfirmationViewModel.inviteToChatRoom( + localChatRoom = addMemberToChatRoomConfirmationUIState.localChatRoom, + peerPublicKey = addMemberToChatRoomConfirmationUIState.profile.publicKey, + peerKeyPackage = addMemberToChatRoomConfirmationUIState.marmotKeyPackage, + onInviteSent = onInviteSent + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Invite new member" + ) + + Text(stringResource(Res.string.invite_2, addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey())) + } + }, + actions = { + + } + ) + } + ) { innerPadding -> Column( - modifier = Modifier.weight(1f).fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { - Spacer( - modifier = Modifier.weight(1f) - ) + Column( + modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + Spacer( + modifier = Modifier.weight(1f) + ) - Text("Invite") + Text(stringResource(Res.string.invite)) - Text( - text = addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey() - ) + Text( + text = addMemberToChatRoomConfirmationUIState.profile.humanReadableNameOrPubkey() + ) - Text( - text = "to join the ${addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: ""} chat room." - ) + Text( + text = stringResource(Res.string.to_join_the_chat_room, addMemberToChatRoomConfirmationUIState.localChatRoom.chatRoom.subject ?: "") + ) - Text( - text = "Once invited will be able to receive and send private message sent to all the ${addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size} members in the chat room.", - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.once_invited_will_be_able_to_receive_and, addMemberToChatRoomConfirmationUIState.localChatRoom.localParticipants.size), + textAlign = TextAlign.Center + ) - Spacer( - modifier = Modifier.weight(3f) - ) + Spacer( + modifier = Modifier.weight(3f) + ) + } } } } - } - AddMemberToChatRoomConfirmationUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { + AddMemberToChatRoomConfirmationUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = "Direct Message Detail", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.direct_message_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - Text( - text = "Direct message functionality will be here.", - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.direct_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } @@ -198,7 +214,7 @@ fun AddMemberToChatRoomConfirmationScreen( } } -@Preview +@ConformancePreviews @Composable private fun ChatRoomDetailScreenPreview() { TorchTheme { @@ -217,7 +233,7 @@ private fun ChatRoomDetailScreenPreview() { chatRoom = ChatRoom( id = "hex", userPublicKey = "hex", - subject = "Room Title", + subject = "Room title", description = "See something... say something.", initialGiftWrapPayloadId = "sdfaer", mlsGroupState = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt index 0e3c5a1b..366a627a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddTranslationArtifactVersionScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -57,6 +56,19 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.AddTranslationArtifactVersionViewModel import press.mantra.compose.ui.view.state.AddTranslationArtifactVersionUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.add_translation +import mantra.composeapp.generated.resources.propose_translation +import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -86,208 +98,211 @@ fun AddTranslationArtifactVersionScreen( ) ) - when (val addTranslationUIState = addTranslationArtifactVersionViewModel.addTranslationArtifactVersionUIState) { - is AddTranslationArtifactVersionUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text(text = addTranslationUIState.message) + ScreenStateTransition(addTranslationArtifactVersionViewModel.addTranslationArtifactVersionUIState) { uiState -> + when (val addTranslationUIState = uiState) { + is AddTranslationArtifactVersionUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = addTranslationUIState.message) + } } - } - is AddTranslationArtifactVersionUIState.Loaded -> { - // A translation is into a dialect the group already signed into - // existence. Nothing is chosen to begin with: picking the first one - // for somebody would be choosing the language of the work. - var selectedDialectId: String? by remember { mutableStateOf(null) } - val selectedDialect = addTranslationUIState.dialects - .firstOrNull { dialect -> dialect.id == selectedDialectId } + is AddTranslationArtifactVersionUIState.Loaded -> { + // A translation is into a dialect the group already signed into + // existence. Nothing is chosen to begin with: picking the first one + // for somebody would be choosing the language of the work. + var selectedDialectId: String? by remember { mutableStateOf(null) } + val selectedDialect = addTranslationUIState.dialects + .firstOrNull { dialect -> dialect.id == selectedDialectId } - // A translation hangs off a version, and the group has to be able to - // sign; without both there is nothing this screen can propose. - val artifactVersion = addTranslationUIState.artifactVersion - val chapterCount = addTranslationUIState.chapters.size + // A translation hangs off a version, and the group has to be able to + // sign; without both there is nothing this screen can propose. + val artifactVersion = addTranslationUIState.artifactVersion + val chapterCount = addTranslationUIState.chapters.size - // The translation and a chapter apiece are signed together, and a - // session signs a bounded number of events -- so a long artifact is - // proposed in more than one, and the admins answer once per - // session. That is a number worth seeing before the tap. - val sessionCount = 1 + ( - (chapterCount - AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION) - .coerceAtLeast(0) + FrostSigningManager.MAX_BATCH_SIZE - 1 - ) / FrostSigningManager.MAX_BATCH_SIZE + // The translation and a chapter apiece are signed together, and a + // session signs a bounded number of events -- so a long artifact is + // proposed in more than one, and the admins answer once per + // session. That is a number worth seeing before the tap. + val sessionCount = 1 + ( + (chapterCount - AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION) + .coerceAtLeast(0) + FrostSigningManager.MAX_BATCH_SIZE - 1 + ) / FrostSigningManager.MAX_BATCH_SIZE - val canProposeTranslation = artifactVersion != null && - selectedDialect != null && - addTranslationUIState.canSign + val canProposeTranslation = artifactVersion != null && + selectedDialect != null && + addTranslationUIState.canSign - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() - Scaffold( - topBar = { - TopAppBar( - title = { Text("Translate ${addTranslationUIState.artifact.name}") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (canProposeTranslation) { - Modifier - } else { - // Looking unavailable is not being unavailable. - Modifier.semantics { disabled() } - }, - containerColor = if (canProposeTranslation) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (canProposeTranslation) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) { - return@ExtendedFloatingActionButton - } - - addTranslationArtifactVersionViewModel.addTranslation( - localChatRoom = addTranslationUIState.localChatRoom, - artifact = addTranslationUIState.artifact, - artifactVersion = artifactVersion, - dialect = selectedDialect, - chapters = addTranslationUIState.chapters, - onSuccess = { sessionId -> - // Onto the session rather than back to - // the artifact. Nothing has been created - // yet -- the translation appears when - // enough members sign -- so landing on - // the list it is not in would read as a - // failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed Translation") - ) - } + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - ) { - Icon( - Icons.Default.Translate, - contentDescription = "Propose translation" - ) - Text("Propose Translation") + }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (canProposeTranslation) { + Modifier + } else { + // Looking unavailable is not being unavailable. + Modifier.semantics { disabled() } + }, + containerColor = if (canProposeTranslation) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (canProposeTranslation) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (artifactVersion == null || selectedDialect == null || !canProposeTranslation) { + return@ExtendedFloatingActionButton + } + + addTranslationArtifactVersionViewModel.addTranslation( + localChatRoom = addTranslationUIState.localChatRoom, + artifact = addTranslationUIState.artifact, + artifactVersion = artifactVersion, + dialect = selectedDialect, + chapters = addTranslationUIState.chapters, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the artifact. Nothing has been created + // yet -- the translation appears when + // enough members sign -- so landing on + // the list it is not in would read as a + // failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed translation") + ) + } + ) + } + ) { + Icon( + Icons.Default.Translate, + contentDescription = "Propose translation" + ) + Text(stringResource(Res.string.propose_translation)) + } } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (!addTranslationUIState.canSign) { - Text( - text = "This group has no shared key, so it cannot sign a " + - "translation into existence. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } else if (artifactVersion == null) { - Text( - text = "This artifact has no version for a translation to be of.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error ) } - - Text("Translate into which dialect?") - - if (addTranslationUIState.dialects.isEmpty()) { - // The only way out of this screen, and it is not on it: - // a dialect is the group's too, and asking for one is - // its own quorum from the room's own detail screen. - Text( - text = "This group has no dialects yet. Add one from the group's " + - "details before translating anything into it.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { - addTranslationUIState.dialects.forEach { dialect -> - FilterChip( - selected = selectedDialectId == dialect.id, - onClick = { selectedDialectId = dialect.id }, - label = { Text(dialect.name) } + if (!addTranslationUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "translation into existence. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } else if (artifactVersion == null) { + Text( + text = stringResource(Res.string.this_artifact_has_no_version_for_a), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error ) } - } - Text( - // What the group is being asked to sign, counted before - // the tap rather than described after it: the chapters - // are the rest of the batch. The empty case is worth - // its own sentence, so that a translation of an artifact - // nobody has written into yet does not read as broken. - text = if (chapterCount == 0) { - "The artifact has no chapters yet, so the group would sign the " + - "translation on its own. Chapters signed afterwards are put " + - "into it as they are added." - } else { - "The group signs the translation and " + - "$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"}" + - (if (sessionCount == 1) "" else ", in $sessionCount sessions to sign") + - ". Chunks are translated one at a time afterwards." - }, - style = MaterialTheme.typography.labelMedium, - ) + Text(stringResource(Res.string.translate_into_which_dialect)) + + if (addTranslationUIState.dialects.isEmpty()) { + // The only way out of this screen, and it is not on it: + // a dialect is the group's too, and asking for one is + // its own quorum from the room's own detail screen. + Text( + text = "This group has no dialects yet. Add one from the group's " + + "details before translating anything into it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + addTranslationUIState.dialects.forEach { dialect -> + FilterChip( + selected = selectedDialectId == dialect.id, + onClick = { selectedDialectId = dialect.id }, + label = { Text(dialect.name) } + ) + } + } + + Text( + // What the group is being asked to sign, counted before + // the tap rather than described after it: the chapters + // are the rest of the batch. The empty case is worth + // its own sentence, so that a translation of an artifact + // nobody has written into yet does not read as broken. + text = if (chapterCount == 0) { + "The artifact has no chapters yet, so the group would sign the " + + "translation on its own. Chapters signed afterwards are put " + + "into it as they are added." + } else { + "The group signs the translation and " + + "$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"}" + + (if (sessionCount == 1) "" else ", in $sessionCount sessions to sign") + + ". Chunks are translated one at a time afterwards." + }, + style = MaterialTheme.typography.labelMedium, + ) + } } } - } - AddTranslationArtifactVersionUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Add Translation", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + AddTranslationArtifactVersionUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.add_translation), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -299,7 +314,7 @@ fun AddTranslationArtifactVersionScreen( } } -@Preview +@ConformancePreviews @Composable private fun AddTranslationArtifactVersionScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt index 7c486a7e..627f7b97 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ArtifactDetailScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -48,6 +47,27 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.ArtifactDetailViewModel import press.mantra.compose.ui.view.state.ArtifactDetailUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.add_chapter +import mantra.composeapp.generated.resources.add_translation +import mantra.composeapp.generated.resources.artifact_detail +import mantra.composeapp.generated.resources.chapters +import mantra.composeapp.generated.resources.details +import mantra.composeapp.generated.resources.no_chapters_yet +import mantra.composeapp.generated.resources.no_translations_yet +import mantra.composeapp.generated.resources.no_versions +import mantra.composeapp.generated.resources.translations +import mantra.composeapp.generated.resources.version +import mantra.composeapp.generated.resources.versions +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,230 +92,233 @@ fun ArtifactDetailScreen( ) ) - when (val artifactDetailUIState = artifactDetailViewModel.artifactDetailUIState) { - is ArtifactDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text(text = artifactDetailUIState.message) - } - } - - is ArtifactDetailUIState.Loaded -> { - val artifact = artifactDetailUIState.artifact - Scaffold( - topBar = { - TopAppBar( - title = { Text(artifact.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + ScreenStateTransition(artifactDetailViewModel.artifactDetailUIState) { uiState -> + when (val artifactDetailUIState = uiState) { + is ArtifactDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Chapters - item { - Text( - text = "Chapters", - style = MaterialTheme.typography.labelMedium - ) - } - if (artifactDetailUIState.chapters.isEmpty()) { - item { Text("No chapters yet.") } - } else { - items( - items = artifactDetailUIState.chapters, - key = { chapter -> chapter.id } - ) { chapter -> - Card( - onClick = { - onNavigateToRoute.invoke( - ChapterDetailRoute( - activeUserPublicKey = activeUserPublicKey, - chapterId = chapter.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = artifactDetailUIState.message) + } + } + + is ArtifactDetailUIState.Loaded -> { + val artifact = artifactDetailUIState.artifact + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(artifact.name) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - ) { - ListItem( - leadingContent = { - Icon( - Icons.Default.Article, - contentDescription = "Chapter" + }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + // Chapters + item { + Text( + text = stringResource(Res.string.chapters), + style = MaterialTheme.typography.labelMedium + ) + } + if (artifactDetailUIState.chapters.isEmpty()) { + item { Text(stringResource(Res.string.no_chapters_yet)) } + } else { + items( + items = artifactDetailUIState.chapters, + key = { chapter -> chapter.id } + ) { chapter -> + Card( + onClick = { + onNavigateToRoute.invoke( + ChapterDetailRoute( + activeUserPublicKey = activeUserPublicKey, + chapterId = chapter.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) ) - }, - overlineContent = { Text("Chapter ${chapter.index}") }, - headlineContent = { Text(chapter.name) }, - supportingContent = { - Text("${chapter.wordCount} words · ${chapter.characterCount} characters") } - ) - } - } - } - - item { - TextButton( - enabled = artifactDetailUIState.versions.isNotEmpty(), - onClick = { - onNavigateToRoute.invoke( - AddChapterRoute( - activeUserPublicKey = activeUserPublicKey, - artifactId = artifact.id, - chatRoomId = chatRoomId, - relayHint = relayHint + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Article, + contentDescription = "Chapter" + ) + }, + overlineContent = { Text(stringResource(Res.string.chapter, chapter.index)) }, + headlineContent = { Text(chapter.name) }, + supportingContent = { + Text(stringResource(Res.string.words_characters, chapter.wordCount, chapter.characterCount)) + } ) - ) + } } - ) { - Icon( - Icons.Default.PostAdd, - contentDescription = "Add chapter" - ) - Spacer(modifier = Modifier.width(10.dp)) - Text("Add Chapter") } - } - item { HorizontalDivider() } - - // Translations - item { - Text( - text = "Translations", - style = MaterialTheme.typography.labelMedium - ) - } - if (artifactDetailUIState.translations.isEmpty()) { - item { Text("No translations yet.") } - } else { - items( - items = artifactDetailUIState.translations, - key = { translation -> translation.id } - ) { translation -> - Card( + item { + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), onClick = { onNavigateToRoute.invoke( - TranslationArtifactVersionDetailRoute( + AddChapterRoute( activeUserPublicKey = activeUserPublicKey, - translationArtifactVersionId = translation.id, + artifactId = artifact.id, chatRoomId = chatRoomId, relayHint = relayHint ) ) } ) { - ListItem( - leadingContent = { - Icon( - Icons.Default.Translate, - contentDescription = "Translation" - ) - }, - headlineContent = { Text(translation.name) }, - supportingContent = { Text(translation.visibility) } + Icon( + Icons.Default.PostAdd, + contentDescription = "Add chapter" ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_chapter)) } } - } - item { - TextButton( - enabled = artifactDetailUIState.versions.isNotEmpty(), - onClick = { - onNavigateToRoute.invoke( - AddTranslationRoute( - activeUserPublicKey = activeUserPublicKey, - artifactId = artifact.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.Translate, - contentDescription = "Add translation" + item { HorizontalDivider() } + + // Translations + item { + Text( + text = stringResource(Res.string.translations), + style = MaterialTheme.typography.labelMedium ) - Spacer(modifier = Modifier.width(10.dp)) - Text("Add Translation") } - } - - item { HorizontalDivider() } - - // Details - item { - Text( - text = "Details", - style = MaterialTheme.typography.labelMedium - ) - } - item { - Card { - DetailRow(label = "Name", value = artifact.name) - DetailRow(label = "Url", value = artifact.url) - DetailRow(label = "Visibility", value = artifact.visibility) - DetailRow(label = "License", value = artifact.license) - DetailRow(label = "Dialect", value = artifact.dialectId) - DetailRow(label = "Author", value = artifact.publicKey) - DetailRow(label = "Created", value = artifact.createdAt.toString()) + if (artifactDetailUIState.translations.isEmpty()) { + item { Text(stringResource(Res.string.no_translations_yet)) } + } else { + items( + items = artifactDetailUIState.translations, + key = { translation -> translation.id } + ) { translation -> + Card( + onClick = { + onNavigateToRoute.invoke( + TranslationArtifactVersionDetailRoute( + activeUserPublicKey = activeUserPublicKey, + translationArtifactVersionId = translation.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = "Translation" + ) + }, + headlineContent = { Text(translation.name) }, + supportingContent = { Text(translation.visibility) } + ) + } + } } - } - // Versions - item { - Text( - text = "Versions", - style = MaterialTheme.typography.labelMedium - ) - } - if (artifactDetailUIState.versions.isEmpty()) { - item { Text("No versions.") } - } else { - items( - items = artifactDetailUIState.versions, - key = { version -> version.id } - ) { version -> - Card { - ListItem( - headlineContent = { Text(version.versionLabel) }, - overlineContent = { Text("Version") } + item { + TextButton( + enabled = artifactDetailUIState.versions.isNotEmpty(), + onClick = { + onNavigateToRoute.invoke( + AddTranslationRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.Translate, + contentDescription = "Add translation" ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.add_translation)) + } + } + + item { HorizontalDivider() } + + // Details + item { + Text( + text = stringResource(Res.string.details), + style = MaterialTheme.typography.labelMedium + ) + } + item { + Card { + DetailRow(label = "Name", value = artifact.name) + DetailRow(label = "Url", value = artifact.url) + DetailRow(label = "Visibility", value = artifact.visibility) + DetailRow(label = "License", value = artifact.license) + DetailRow(label = "Dialect", value = artifact.dialectId) + DetailRow(label = "Author", value = artifact.publicKey) + DetailRow(label = "Created", value = artifact.createdAt.toString()) + } + } + + // Versions + item { + Text( + text = stringResource(Res.string.versions), + style = MaterialTheme.typography.labelMedium + ) + } + if (artifactDetailUIState.versions.isEmpty()) { + item { Text(stringResource(Res.string.no_versions)) } + } else { + items( + items = artifactDetailUIState.versions, + key = { version -> version.id } + ) { version -> + Card { + ListItem( + headlineContent = { Text(version.versionLabel) }, + overlineContent = { Text(stringResource(Res.string.version)) } + ) + } } } } } } - } - ArtifactDetailUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Artifact Detail", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + ArtifactDetailUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.artifact_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -307,7 +330,7 @@ fun ArtifactDetailScreen( } } -@Preview +@ConformancePreviews @Composable private fun ArtifactDetailScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt index 54cfe1c0..f4c91cfa 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChapterDetailScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -38,6 +37,21 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.ChapterDetailViewModel import press.mantra.compose.ui.view.state.ChapterDetailUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.chapter_detail +import mantra.composeapp.generated.resources.no_chunks +import mantra.composeapp.generated.resources.original_text +import mantra.composeapp.generated.resources.chapter_words_characters +import mantra.composeapp.generated.resources.chunk +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -61,113 +75,116 @@ fun ChapterDetailScreen( ) ) - when (val chapterDetailUIState = chapterDetailViewModel.chapterDetailUIState) { - is ChapterDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text(text = chapterDetailUIState.message) - } - } - - is ChapterDetailUIState.Loaded -> { - val chapter = chapterDetailUIState.chapter - Scaffold( - topBar = { - TopAppBar( - title = { Text(chapter.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + ScreenStateTransition(chapterDetailViewModel.chapterDetailUIState) { uiState -> + when (val chapterDetailUIState = uiState) { + is ChapterDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - item { - Text( - text = "Chapter ${chapter.index} · ${chapter.wordCount} words · ${chapter.characterCount} characters", - style = MaterialTheme.typography.labelMedium - ) - } + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = chapterDetailUIState.message) + } + } - // Original text (markdown, shown as-is). - item { - Text( - text = "Original text", - style = MaterialTheme.typography.labelMedium + is ChapterDetailUIState.Loaded -> { + val chapter = chapterDetailUIState.chapter + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(chapter.name) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, ) } - item { - Card { + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { Text( - modifier = Modifier.padding(16.dp), - text = chapter.originalText, - style = MaterialTheme.typography.bodyMedium + text = stringResource(Res.string.chapter_words_characters, chapter.index, chapter.wordCount, chapter.characterCount), + style = MaterialTheme.typography.labelMedium ) } - } - item { HorizontalDivider() } - - // Chunks - item { - Text( - text = "Chunks (${chapterDetailUIState.chunks.size})", - style = MaterialTheme.typography.labelMedium - ) - } - if (chapterDetailUIState.chunks.isEmpty()) { - item { Text("No chunks.") } - } else { - items( - items = chapterDetailUIState.chunks, - key = { chunk -> chunk.id } - ) { chunk -> + // Original text (markdown, shown as-is). + item { + Text( + text = stringResource(Res.string.original_text), + style = MaterialTheme.typography.labelMedium + ) + } + item { Card { - ListItem( - leadingContent = { - Icon( - Icons.Default.Segment, - contentDescription = "Chunk" - ) - }, - overlineContent = { Text("Chunk ${chunk.index}") }, - headlineContent = { Text(chunk.text) }, - supportingContent = { - Text("${chunk.wordCount} words · ${chunk.characterCount} characters") - } + Text( + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding), + text = chapter.originalText, + style = MaterialTheme.typography.bodyMedium ) } } + + item { HorizontalDivider() } + + // Chunks + item { + Text( + text = stringResource(Res.string.chunks, chapterDetailUIState.chunks.size), + style = MaterialTheme.typography.labelMedium + ) + } + if (chapterDetailUIState.chunks.isEmpty()) { + item { Text(stringResource(Res.string.no_chunks)) } + } else { + items( + items = chapterDetailUIState.chunks, + key = { chunk -> chunk.id } + ) { chunk -> + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.Segment, + contentDescription = "Chunk" + ) + }, + overlineContent = { Text(stringResource(Res.string.chunk, chunk.index)) }, + headlineContent = { Text(chunk.text) }, + supportingContent = { + Text(stringResource(Res.string.words_characters, chunk.wordCount, chunk.characterCount)) + } + ) + } + } + } } } } - } - ChapterDetailUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Chapter Detail", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + ChapterDetailUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.chapter_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -179,7 +196,7 @@ fun ChapterDetailScreen( } } -@Preview +@ConformancePreviews @Composable private fun ChapterDetailScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt index 8a2917b5..6f938a16 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomCreationScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.material.icons.Icons @@ -21,13 +22,25 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.view.model.ChatRoomCreationViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.choose_who_to_chat_with +import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your +import mantra.composeapp.generated.resources.name_eg_group_discussions +import mantra.composeapp.generated.resources.this_will_be_shown_when_people_open_the_chat +import mantra.composeapp.generated.resources.this_will_be_the_display_name_for_this_chat +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 +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun ChatRoomCreationScreen( @@ -38,15 +51,19 @@ fun ChatRoomCreationScreen( factory = ChatRoomCreationViewModel.factory() ) - Scaffold { innerPadding -> + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding()) { innerPadding -> Column( - modifier = Modifier.padding(innerPadding).fillMaxWidth() + modifier = Modifier.padding(innerPadding).readableContent().fillMaxWidth() ) { Column( - modifier = Modifier.fillMaxWidth().padding(10.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { TextField( modifier = Modifier.fillMaxWidth(), @@ -57,7 +74,7 @@ fun ChatRoomCreationScreen( Text( text = it, modifier = Modifier.padding( - bottom = 8.dp + bottom = MaterialTheme.spacing.compactPadding ) ) } @@ -68,13 +85,13 @@ fun ChatRoomCreationScreen( ), label = { Text( - text = "Name (eg. Group Discussions)", + text = stringResource(Res.string.name_eg_group_discussions), maxLines = 1, ) }, placeholder = { Text( - text = "Enter the name you want to use for your group", + text = stringResource(Res.string.enter_the_name_you_want_to_use_for_your), maxLines = 1, ) }, @@ -87,9 +104,9 @@ fun ChatRoomCreationScreen( ) Text( - text = "This will be the display name for this chat room.", - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.this_will_be_the_display_name_for_this_chat), + style = MaterialTheme.typography.labelMedium ) TextField( @@ -101,7 +118,7 @@ fun ChatRoomCreationScreen( Text( text = it, modifier = Modifier.padding( - bottom = 8.dp + bottom = MaterialTheme.spacing.compactPadding ) ) } @@ -112,14 +129,14 @@ fun ChatRoomCreationScreen( ), label = { Text( - text = "What will be discussed in this chat room.", + text = stringResource(Res.string.what_will_be_discussed_in_this_chat_room), maxLines = 1, ) }, placeholder = { Text( - text = "What should people know about you?", + text = stringResource(Res.string.what_should_people_know_about_you), maxLines = 1, ) }, @@ -132,9 +149,9 @@ fun ChatRoomCreationScreen( ) Text( - text = "This will be shown when people open the chat for more details.", - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.this_will_be_shown_when_people_open_the_chat), + style = MaterialTheme.typography.labelMedium ) @@ -147,7 +164,7 @@ fun ChatRoomCreationScreen( } ) { Text( - "Choose who to chat with" + stringResource(Res.string.choose_who_to_chat_with) ) } } @@ -155,7 +172,7 @@ fun ChatRoomCreationScreen( } } -@Preview +@ConformancePreviews @Composable private fun LoadingScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 9f69910f..59436e3f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom @@ -68,6 +67,35 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.add_artifact_to_library +import mantra.composeapp.generated.resources.add_dialect +import mantra.composeapp.generated.resources.create_project +import mantra.composeapp.generated.resources.delete_group +import mantra.composeapp.generated.resources.dialects +import mantra.composeapp.generated.resources.direct_message_detail +import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here +import mantra.composeapp.generated.resources.invite_new_member +import mantra.composeapp.generated.resources.leave_group +import mantra.composeapp.generated.resources.library +import mantra.composeapp.generated.resources.members_2 +import mantra.composeapp.generated.resources.no_artifacts_exists_in_this_groups_library +import mantra.composeapp.generated.resources.no_dialects_have_been_defined_in_this_group_2 +import mantra.composeapp.generated.resources.no_projects_exists_in_this_group +import mantra.composeapp.generated.resources.projects +import mantra.composeapp.generated.resources.proposals +import mantra.composeapp.generated.resources.reindex_events +import mantra.composeapp.generated.resources.shared_key +import mantra.composeapp.generated.resources.event_s_still_unreadable +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -94,261 +122,297 @@ fun ChatRoomDetailScreen( ) ) - when (val chatRoomDetailUIState = chatRoomDetailViewModel.chatRoomDetailUIState) { - is ChatRoomDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = chatRoomDetailUIState.message, - ) - } - } - is ChatRoomDetailUIState.Loaded -> { - Scaffold( - topBar = { - TopAppBar( - title = { - chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() - }, + ScreenStateTransition(chatRoomDetailViewModel.chatRoomDetailUIState) { uiState -> + when (val chatRoomDetailUIState = uiState) { + is ChatRoomDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = chatRoomDetailUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() - ) { - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + } + is ChatRoomDetailUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() + }, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { - item { - chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + item { + chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { + Text( + text = it, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + } + } + + item { + HorizontalDivider() + } + + item { + // Artifacts Text( - text = it, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge + text = stringResource(Res.string.library), + style = MaterialTheme.typography.labelMedium ) } - } - item { - HorizontalDivider() - } - - item { - // Artifacts - Text( - text = "Library", - style = MaterialTheme.typography.labelMedium - ) - } - - if (chatRoomDetailUIState.artifacts.isEmpty()) { - item { - Text("No artifacts exists in this groups library.") + if (chatRoomDetailUIState.artifacts.isEmpty()) { + item { + Text(stringResource(Res.string.no_artifacts_exists_in_this_groups_library)) + } + } else { + items( + items = chatRoomDetailUIState.artifacts, + key = { artifact -> artifact.id } + ) { artifact -> + Card( + onClick = { + onNavigateToRoute.invoke( + ArtifactDetailRoute( + activeUserPublicKey = activeUserPublicKey, + artifactId = artifact.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.LibraryBooks, + contentDescription = "Artifact" + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = "View artifact" + ) + }, + headlineContent = { + Text(text = artifact.name) + }, + supportingContent = { + Text(text = artifact.url) + } + ) + } + } } - } else { - items( - items = chatRoomDetailUIState.artifacts, - key = { artifact -> artifact.id } - ) { artifact -> - Card( + + item { + TextButton( onClick = { + // Navigate to the invite user screen... onNavigateToRoute.invoke( - ArtifactDetailRoute( - activeUserPublicKey = activeUserPublicKey, - artifactId = artifact.id, + AddArtifactRoute( chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, relayHint = relayHint ) ) } ) { - ListItem( - leadingContent = { - Icon( - Icons.Default.LibraryBooks, - contentDescription = "Artifact" - ) - }, - trailingContent = { - Icon( - Icons.Default.ChevronRight, - contentDescription = "View artifact" - ) - }, - headlineContent = { - Text(text = artifact.name) - }, - supportingContent = { - Text(text = artifact.url) - } + + Icon( + Icons.Default.LibraryBooks, + contentDescription = "Add new artifact" ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.add_artifact_to_library)) } } - } - item { - TextButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - AddArtifactRoute( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint - ) - ) - } - ) { - - Icon( - Icons.Default.LibraryBooks, - contentDescription = "Add new artifact" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Add Artifact to Library") - } - } - - item { - HorizontalDivider() - } - - item { - // Dialects - Text( - text = "Dialects", - style = MaterialTheme.typography.labelMedium - ) - } - - if (chatRoomDetailUIState.dialects.isEmpty()) { item { - Text("No dialects have been defined in this group.") + HorizontalDivider() } - } else { - items( - items = chatRoomDetailUIState.dialects, - key = { dialect -> dialect.id } - ) { dialect -> - Card { - ListItem( - leadingContent = { - Icon( - Icons.Default.Translate, - contentDescription = "Dialect" - ) - }, - headlineContent = { - Text(text = dialect.name) - }, - supportingContent = { - Text(text = "${dialect.language} \u00b7 ${dialect.country}") - } - ) - } - } - } - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - AddDialectRoute( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint + item { + // Dialects + Text( + text = stringResource(Res.string.dialects), + style = MaterialTheme.typography.labelMedium + ) + } + + if (chatRoomDetailUIState.dialects.isEmpty()) { + item { + Text(stringResource(Res.string.no_dialects_have_been_defined_in_this_group_2)) + } + } else { + items( + items = chatRoomDetailUIState.dialects, + key = { dialect -> dialect.id } + ) { dialect -> + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.Translate, + contentDescription = "Dialect" + ) + }, + headlineContent = { + Text(text = dialect.name) + }, + supportingContent = { + Text(text = "${dialect.language} \u00b7 ${dialect.country}") + } ) - ) + } } - ) { - - Icon( - Icons.Default.Translate, - contentDescription = "Add new dialect" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Add Dialect") } - } - item { - HorizontalDivider() - } - - // TODO: Add projects... - - item { - // Artifacts - Text( - text = "Projects", - style = MaterialTheme.typography.labelMedium - ) - } - - item { - Text("No projects exists in this group.") - } - - item { - TextButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Add new project" - ) - ) - } - ) { - - Icon( - Icons.Default.Schema, - contentDescription = "create new projec" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Create project") - } - } - - item { - HorizontalDivider() - } - - item { - Text( - text = "members", - style = MaterialTheme.typography.labelMedium - ) - } - - // A shared threshold key only means something where every - // member is an equal admin, which is the NIP-17 (robust) case. - // MLS rooms have one admin and no group key to share. - if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) { item { TextButton( onClick = { onNavigateToRoute.invoke( - DkgRitualRoute( + AddDialectRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) + ) + } + ) { + + Icon( + Icons.Default.Translate, + contentDescription = "Add new dialect" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.add_dialect)) + } + } + + item { + HorizontalDivider() + } + + // TODO: Add projects... + + item { + // Artifacts + Text( + text = stringResource(Res.string.projects), + style = MaterialTheme.typography.labelMedium + ) + } + + item { + Text(stringResource(Res.string.no_projects_exists_in_this_group)) + } + + item { + TextButton( + onClick = { + // Navigate to the invite user screen... + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Add new project" + ) + ) + } + ) { + + Icon( + Icons.Default.Schema, + contentDescription = "create new projec" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.create_project)) + } + } + + item { + HorizontalDivider() + } + + item { + Text( + text = stringResource(Res.string.members_2), + style = MaterialTheme.typography.labelMedium + ) + } + + // A shared threshold key only means something where every + // member is an equal admin, which is the NIP-17 (robust) case. + // MLS rooms have one admin and no group key to share. + if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState == null) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + } + ) { + Icon( + Icons.Default.Key, + contentDescription = "Shared key" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.shared_key)) + } + } + } + + // Every room, not only the one that holds the key. A + // ceremony needs a NIP-17 group, but a signing message is + // a marmot inner event -- see `FrostSigningManager.broadcast` + // -- so the room a group actually proposes in is the MLS + // one, which is the last place this should be missing from. + // + // The transcript carries a proposal past as it happens; + // this is where a member goes to find one that has scrolled + // away, or to see what the group has signed. + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ProposalListRoute( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId ) @@ -356,232 +420,230 @@ fun ChatRoomDetailScreen( } ) { Icon( - Icons.Default.Key, - contentDescription = "Shared key" + Icons.Default.Draw, + contentDescription = "Proposals" ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) - Text("Shared Key") + Text(stringResource(Res.string.proposals)) } } - } - // Every room, not only the one that holds the key. A - // ceremony needs a NIP-17 group, but a signing message is - // a marmot inner event -- see `FrostSigningManager.broadcast` - // -- so the room a group actually proposes in is the MLS - // one, which is the last place this should be missing from. - // - // The transcript carries a proposal past as it happens; - // this is where a member goes to find one that has scrolled - // away, or to see what the group has signed. - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - ProposalListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) - ) - } - ) { - Icon( - Icons.Default.Draw, - contentDescription = "Proposals" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Proposals") - } - } - - item { - TextButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - SearchMemberToAddToChatRoomRoute( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint - ) - ) - } - ) { - - Icon( - Icons.Default.PersonAdd, - contentDescription = "Invite new member" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Invite New Member") - } - } - - items( - items = chatRoomDetailUIState.localChatRoom.localParticipants, - key = { localParticipant -> localParticipant.participant.participantPublicKey} - ) { localParticipant -> - - Card( - onClick = { - onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Profile detail" - ) - ) - } - ) { - ListItem( - leadingContent = { - ProfileAvatar( - publicKey = localParticipant.participant.participantPublicKey, - profile = localParticipant.profile - ) - }, - trailingContent = { - Icon( - Icons.Default.ChevronRight, - contentDescription = "View profile" - ) - }, - headlineContent = { - Text( - text = localParticipant.profile?.humanReadableNameOrPubkey() ?: localParticipant.participant.participantPublicKey - ) - }, - supportingContent = { - localParticipant.profile?.about?.let { - Text( - text = it + item { + TextButton( + onClick = { + // Navigate to the invite user screen... + onNavigateToRoute.invoke( + SearchMemberToAddToChatRoomRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint ) - } + ) } + ) { - ) + Icon( + Icons.Default.PersonAdd, + contentDescription = "Invite new member" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.invite_new_member)) + } } - } + items( + items = chatRoomDetailUIState.localChatRoom.localParticipants, + key = { localParticipant -> localParticipant.participant.participantPublicKey} + ) { localParticipant -> + Card( + onClick = { + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Profile detail" + ) + ) + } + ) { + ListItem( + leadingContent = { + ProfileAvatar( + publicKey = localParticipant.participant.participantPublicKey, + profile = localParticipant.profile + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = "View profile" + ) + }, + headlineContent = { + Text( + text = localParticipant.profile?.humanReadableNameOrPubkey() ?: localParticipant.participant.participantPublicKey + ) + }, + supportingContent = { + localParticipant.profile?.about?.let { + Text( + text = it + ) + } + } - item { - HorizontalDivider() - } - - item { - - TextButton( - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.secondary - ), - enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt == null, // Disable after we leave group... - onClick = { - chatRoomDetailViewModel.leaveGroup( - localChatRoom = chatRoomDetailUIState.localChatRoom, - onPopBackToRoute = onPopBackToRoute ) } - ) { - Icon( - Icons.Default.Unsubscribe, - contentDescription = "Leave Group" - ) - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Leave Group") } - } - item { - TextButton( - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ), - enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt != null, - onClick = { - chatRoomDetailViewModel.softDeleteGroup( - localChatRoom = chatRoomDetailUIState.localChatRoom, - onPopBackToRoute = onPopBackToRoute - ) - } - ) { - Icon( - Icons.Default.DeleteForever, - contentDescription = "Delete Group" - ) - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Delete Group") - } - } - - // Only MLS rooms have group events to read again. A NIP-17 - // room's messages are gift wraps, which carry no ordering - // for anything to go wrong with. - if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { item { HorizontalDivider() } item { - ReindexMarmotGroupEventsButton( - reindexState = chatRoomDetailViewModel.reindexState, - onReindex = chatRoomDetailViewModel::reindexMarmotGroupEvents + + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.secondary + ), + enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt == null, // Disable after we leave group... + onClick = { + chatRoomDetailViewModel.leaveGroup( + localChatRoom = chatRoomDetailUIState.localChatRoom, + onPopBackToRoute = onPopBackToRoute + ) + } + ) { + Icon( + Icons.Default.Unsubscribe, + contentDescription = "Leave group" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.leave_group)) + } + + // What it actually does: sets leftGroupAt and posts a line to + // the room. Said here because the button fires immediately -- + // there is no confirmation step to say it in. + Text( + text = "Posts a line to the room saying you left, and " + + "lets you delete it from this device afterwards.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = MaterialTheme.spacing.space600, + end = MaterialTheme.spacing.containerPadding, + bottom = MaterialTheme.spacing.compactPadding, + ) ) } + + item { + TextButton( + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + enabled = chatRoomDetailUIState.localChatRoom.chatRoom.leftGroupAt != null, + onClick = { + chatRoomDetailViewModel.softDeleteGroup( + localChatRoom = chatRoomDetailUIState.localChatRoom, + onPopBackToRoute = onPopBackToRoute + ) + } + ) { + Icon( + Icons.Default.DeleteForever, + contentDescription = "Delete group" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.delete_group)) + } + + // A soft delete: it sets deletedAt on the local row. Saying + // "delete" without saying where would leave somebody believing + // the messages are gone from the relays and from the other + // members, which is the opposite of true. + Text( + text = "Removes the room from this device. The messages " + + "stay on the relays and with the other members.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = MaterialTheme.spacing.space600, + end = MaterialTheme.spacing.containerPadding, + bottom = MaterialTheme.spacing.compactPadding, + ) + ) + } + + // Only MLS rooms have group events to read again. A NIP-17 + // room's messages are gift wraps, which carry no ordering + // for anything to go wrong with. + if (chatRoomDetailUIState.localChatRoom.chatRoom.mlsGroupState != null) { + item { + HorizontalDivider() + } + + item { + ReindexMarmotGroupEventsButton( + reindexState = chatRoomDetailViewModel.reindexState, + onReindex = chatRoomDetailViewModel::reindexMarmotGroupEvents + ) + } + } } } } } - } - ChatRoomDetailUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { + ChatRoomDetailUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = "Direct Message Detail", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.direct_message_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - Text( - text = "Direct message functionality will be here.", - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.direct_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } @@ -609,7 +671,7 @@ private fun ReindexMarmotGroupEventsButton( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(5.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50) ) { TextButton( enabled = reindexState !is ChatRoomDetailViewModel.ReindexState.Running, @@ -628,10 +690,10 @@ private fun ReindexMarmotGroupEventsButton( } Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) - Text("Reindex Events") + Text(stringResource(Res.string.reindex_events)) } when (reindexState) { @@ -654,10 +716,10 @@ private fun ReindexMarmotGroupEventsButton( "Nothing to reindex · ${report.stored - report.predatingMembership} " + "event(s) all read$beforeJoining" report.recovered > 0 && report.failed > 0 -> - "Recovered ${report.recovered} of ${report.unresolved} · ${report.failed} still unreadable$beforeJoining" + stringResource(Res.string.recovered_of_still_unreadable, report.recovered, report.unresolved, report.failed, beforeJoining) report.recovered > 0 -> - "Recovered ${report.recovered} of ${report.unresolved} event(s)$beforeJoining" - else -> "${report.failed} event(s) still unreadable$beforeJoining" + stringResource(Res.string.recovered_of_event_s, report.recovered, report.unresolved, beforeJoining) + else -> stringResource(Res.string.event_s_still_unreadable, report.failed, beforeJoining) }, style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center @@ -678,7 +740,7 @@ private fun ReindexMarmotGroupEventsButton( } } -@Preview +@ConformancePreviews @Composable private fun ChatRoomMessagingScreenPreview() { TorchTheme { @@ -694,7 +756,7 @@ private fun ChatRoomMessagingScreenPreview() { chatRoom = ChatRoom( id = "", userPublicKey = "", - subject = "Message Title", + subject = "Message title", description = "Description of a chat room so that members now why they are here.", initialGiftWrapPayloadId = "sdfaer", mlsGroupState = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index 8bf6f8da..b0e48883 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom @@ -56,6 +55,22 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.ChatRoomMessagingViewModel import press.mantra.compose.ui.view.state.ChatRoomMessagingUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.composable.widgets.chat.ChatTranscript +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.creating_new_chat +import mantra.composeapp.generated.resources.direct_message_detail +import mantra.composeapp.generated.resources.direct_message_functionality_will_be_here +import mantra.composeapp.generated.resources.say_what_now +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -81,347 +96,357 @@ fun ChatRoomMessagingScreen( ) ) - when (val chatRoomDetailUIState = chatRoomMessagingViewModel.chatRoomMessagingUIState) { - is ChatRoomMessagingUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = chatRoomDetailUIState.message, - ) - } - } - is ChatRoomMessagingUIState.Loaded -> { - val textFieldState = rememberTextFieldState() - - val chatMessageListViewModel: press.mantra.compose.ui.view.model.ChatMessageListViewModel = viewModel( - factory = press.mantra.compose.ui.view.model.ChatMessageListViewModel.factory( - localChatRoom = chatRoomDetailUIState.localChatRoom, - nostrRepository = nostrRepository, - chatRepository = chatRepository, - frostSigningRepository = frostSigningRepository - ) - ) - - Scaffold( - topBar = { - TopAppBar( - title = { - chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() - }, - actions = { - IconButton( - onClick = { - // Navigate to the invite user screen... - onNavigateToRoute.invoke( - ChatRoomDetailRoute( - chatRoomId = chatRoomId, - activeUserPublicKey = activeUserPublicKey, - relayHint = relayHint - ) - ) - } - ) { - Icon( - Icons.Default.MoreVert, - contentDescription = "View details..." - ) - } - } + ScreenStateTransition(chatRoomMessagingViewModel.chatRoomMessagingUIState) { uiState -> + when (val chatRoomDetailUIState = uiState) { + is ChatRoomMessagingUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = chatRoomDetailUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() - ) { - Column( - modifier = Modifier.weight(1f).fillMaxWidth() - ) { - key(true) { - chatMessageListViewModel.RenderMessages( - onOpenSharedKey = { - onNavigateToRoute.invoke( - DkgRitualRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId + } + is ChatRoomMessagingUIState.Loaded -> { + val textFieldState = rememberTextFieldState() + + val chatMessageListViewModel: press.mantra.compose.ui.view.model.ChatMessageListViewModel = viewModel( + factory = press.mantra.compose.ui.view.model.ChatMessageListViewModel.factory( + localChatRoom = chatRoomDetailUIState.localChatRoom, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository + ) + ) + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText() + }, + actions = { + IconButton( + onClick = { + // Navigate to the invite user screen... + onNavigateToRoute.invoke( + ChatRoomDetailRoute( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + relayHint = relayHint + ) ) + } + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "View details..." ) - }, - onOpenSigning = { sessionId -> - // Two reasons to open the queue instead of the one - // proposal. A line written before chat rows named - // their session cannot say which proposal it is - // about, and the room may have several -- the list - // is the honest answer there, showing all of them - // with their own state rather than guessing at one. - // - // The other is that the proposal they tapped is - // not the only one waiting on them, in which case - // it is not the whole of what is being asked and a - // screen showing only it would say it was. - onNavigateToRoute.invoke( - if (sessionId == null || - chatMessageListViewModel.hidesOtherDecisions(sessionId) - ) { + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + Column( + modifier = Modifier.weight(1f).fillMaxWidth() + ) { + key(true) { + ChatTranscript( + viewModel = chatMessageListViewModel, + onOpenSharedKey = { + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + }, + onOpenSigning = { sessionId -> + // Two reasons to open the queue instead of the one + // proposal. A line written before chat rows named + // their session cannot say which proposal it is + // about, and the room may have several -- the list + // is the honest answer there, showing all of them + // with their own state rather than guessing at one. + // + // The other is that the proposal they tapped is + // not the only one waiting on them, in which case + // it is not the whole of what is being asked and a + // screen showing only it would say it was. + onNavigateToRoute.invoke( + if (sessionId == null || + chatMessageListViewModel.hidesOtherDecisions(sessionId) + ) { + ProposalListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + } else { + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + } + ) + }, + onOpenProposals = { + onNavigateToRoute.invoke( ProposalListRoute( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId ) - } else { - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - } - ) - }, - onOpenProposals = { - onNavigateToRoute.invoke( - ProposalListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId ) - ) - } - ) - } - } - - key(true) { - chatMessageListViewModel.initiate() - } - - // TODO: Check that we have direct message relays for this user... - - Column( - modifier = Modifier.fillMaxWidth() - .background(BottomAppBarDefaults.containerColor) - .navigationBarsPadding() - ) { - // An armed composer has to be impossible to miss. The whole risk - // of this feature is somebody sending to the room what they meant - // for one person, or the reverse, and by the time either is on the - // wire it cannot be taken back. Hence three signals at once: the - // chip, the placeholder, and the tinted field. - val directMessageRecipient = chatMessageListViewModel.directMessageRecipient - - if (directMessageRecipient != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primaryContainer) - .padding(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer + } ) - - Text( - modifier = Modifier.weight(1f), - text = "Private to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}", - color = MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.labelMedium - ) - - // The one way back to the room, and it has to be one tap. - IconButton( - onClick = { chatMessageListViewModel.cancelDirectMessage() } - ) { - Icon( - Icons.Default.Close, - contentDescription = "Send to the whole group instead", - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } } } - Box( - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = textFieldState, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - focusedContainerColor = if (directMessageRecipient != null) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - } else { - Color.Transparent - }, - unfocusedContainerColor = if (directMessageRecipient != null) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - } else { - Color.Transparent - } - ), - placeholder = { - Text( - text = if (directMessageRecipient != null) { - "Private message to ${chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)}" - } else { - "Say what now?" - } - ) - }, - trailingIcon = { - if (textFieldState.text.isEmpty()) { - IconButton( - onClick = { - - } - ) { - Icon( - Icons.Default.Mic, - contentDescription = "Voice" - ) - } - - } else { - IconButton( - onClick = { - chatMessageListViewModel.sendMessage( - textFieldState = textFieldState - ) - } - ) { - Icon( - Icons.AutoMirrored.Filled.Send, - contentDescription = "Send" - ) - } - } - } - ) + key(true) { + chatMessageListViewModel.initiate() } -// Row( -// modifier = Modifier.padding(4.dp), -// horizontalArrangement = Arrangement.spacedBy(4.dp) -// ) { -// IconButton( -// onClick = { -// -// } -// ) { -// Icon( -// Icons.Default.InsertEmoticon, -// contentDescription = "Add emoji" -// ) -// } -// -// IconButton( -// onClick = { -// -// } -// ) { -// Icon( -// Icons.Default.AlternateEmail, -// contentDescription = "Mention profile" -// ) -// } -// -// IconButton( -// onClick = { -// -// } -// ) { -// Icon( -// Icons.Default.Collections, -// contentDescription = "Attach media" -// ) -// } -// -// IconButton( -// onClick = { -// -// } -// ) { -// Icon( -// Icons.Default.LocationOn, -// contentDescription = "Send location" -// ) -// } -// } + // TODO: Check that we have direct message relays for this user... + + // The composer handles its own bottom inset, which is why this + // screen is the one text-field screen without `imePadding()` on its + // Scaffold. Adding it here would pad twice while the keyboard is up: + // the ime inset already covers the navigation bar area this row is + // separately reserving. Getting the combination right wants a device + // with a keyboard open, not a compiler. + Column( + modifier = Modifier.fillMaxWidth() + .background(BottomAppBarDefaults.containerColor) + .navigationBarsPadding() + ) { + // An armed composer has to be impossible to miss. The whole risk + // of this feature is somebody sending to the room what they meant + // for one person, or the reverse, and by the time either is on the + // wire it cannot be taken back. Hence three signals at once: the + // chip, the placeholder, and the tinted field. + val directMessageRecipient = chatMessageListViewModel.directMessageRecipient + + if (directMessageRecipient != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = MaterialTheme.spacing.containerPadding, vertical = MaterialTheme.spacing.space75), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = Decorative, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + modifier = Modifier.weight(1f), + text = stringResource(Res.string.private_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium + ) + + // The one way back to the room, and it has to be one tap. + IconButton( + onClick = { chatMessageListViewModel.cancelDirectMessage() } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Send to the whole group instead", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + + Box( + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + state = textFieldState, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + focusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + }, + unfocusedContainerColor = if (directMessageRecipient != null) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + Color.Transparent + } + ), + placeholder = { + Text( + text = if (directMessageRecipient != null) { + stringResource(Res.string.private_message_to, chatMessageListViewModel.nameFor(directMessageRecipient.participantPublicKey)) + } else { + stringResource(Res.string.say_what_now) + } + ) + }, + trailingIcon = { + if (textFieldState.text.isEmpty()) { + IconButton( + onClick = { + + } + ) { + Icon( + Icons.Default.Mic, + contentDescription = "Voice" + ) + } + + } else { + IconButton( + onClick = { + chatMessageListViewModel.sendMessage( + textFieldState = textFieldState + ) + } + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send" + ) + } + } + } + ) + } + + // Row( + // modifier = Modifier.padding(4.dp), + // horizontalArrangement = Arrangement.spacedBy(4.dp) + // ) { + // IconButton( + // onClick = { + // + // } + // ) { + // Icon( + // Icons.Default.InsertEmoticon, + // contentDescription = "Add emoji" + // ) + // } + // + // IconButton( + // onClick = { + // + // } + // ) { + // Icon( + // Icons.Default.AlternateEmail, + // contentDescription = "Mention profile" + // ) + // } + // + // IconButton( + // onClick = { + // + // } + // ) { + // Icon( + // Icons.Default.Collections, + // contentDescription = "Attach media" + // ) + // } + // + // IconButton( + // onClick = { + // + // } + // ) { + // Icon( + // Icons.Default.LocationOn, + // contentDescription = "Send location" + // ) + // } + // } + } } } } - } - ChatRoomMessagingUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { + ChatRoomMessagingUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = "Direct Message Detail", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Text( - text = "Direct message functionality will be here.", - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator( - fillScreen = false - ) - - Spacer( - modifier = Modifier.weight(2f) - ) - } - } - ChatRoomMessagingUIState.InitiatingNewChat -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer( - modifier = Modifier.weight(1f) - ) - - Text( - text = "Creating new chat.", - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - LoadingDataIndicator( - fillScreen = false - ) - - Spacer( - modifier = Modifier.weight(2f) - ) - - LaunchedEffect(true) { - chatRoomMessagingViewModel.initiateNewChat( - onNavigateToRouteAndPopUpInclusive + Spacer( + modifier = Modifier.weight(1f) ) + Text( + text = stringResource(Res.string.direct_message_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.direct_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + } + } + ChatRoomMessagingUIState.InitiatingNewChat -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer( + modifier = Modifier.weight(1f) + ) + + Text( + text = stringResource(Res.string.creating_new_chat), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + LoadingDataIndicator( + fillScreen = false + ) + + Spacer( + modifier = Modifier.weight(2f) + ) + + LaunchedEffect(true) { + chatRoomMessagingViewModel.initiateNewChat( + onNavigateToRouteAndPopUpInclusive + ) + } } } } @@ -434,7 +459,7 @@ fun ChatRoomMessagingScreen( } } -@Preview +@ConformancePreviews @Composable private fun ChatRoomMessagingScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt index 7de489f3..4720d7f8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.material.icons.Icons @@ -28,12 +29,39 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.repository.MarmotRepository +import press.mantra.compose.ui.theme.LocalExtendedColors import press.mantra.compose.ui.view.model.CreateProfileViewModel import press.mantra.compose.ui.view.state.CreateProfileUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.after_this_there_is_no_turning_back +import mantra.composeapp.generated.resources.bio +import mantra.composeapp.generated.resources.create_profile +import mantra.composeapp.generated.resources.end_this +import mantra.composeapp.generated.resources.enter_the_name_you_want_to_use_for_your_2 +import mantra.composeapp.generated.resources.introduce_yourself +import mantra.composeapp.generated.resources.it_is_cryptographical_secure_and +import mantra.composeapp.generated.resources.name +import mantra.composeapp.generated.resources.name_eg_alan_turing +import mantra.composeapp.generated.resources.next +import mantra.composeapp.generated.resources.profile_is_ready +import mantra.composeapp.generated.resources.see_how_deep_the_rabbit_hole_goes +import mantra.composeapp.generated.resources.something_went_wrong_and_we_were_unable_to_2 +import mantra.composeapp.generated.resources.start +import mantra.composeapp.generated.resources.the_above_will_be_your_profile +import mantra.composeapp.generated.resources.this_will_be_shown_when_people_open_your +import mantra.composeapp.generated.resources.this_will_be_the_display_name_for_your +import mantra.composeapp.generated.resources.what_should_people_know_about_you +import mantra.composeapp.generated.resources.you_are_about_to_create_a_nostr_profile +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -51,35 +79,40 @@ fun CreateProfileScreen( marmotRepository = marmotRepository ) ) - Scaffold { innerPadding -> + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding()) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) ) { when (val createAccountUIState = createProfileViewModel.createProfileUIState.value) { is CreateProfileUIState.Declaration -> { Text( - "Create Profile", + stringResource(Res.string.create_profile), style = MaterialTheme.typography.headlineSmall ) Text( - "You are about to create a NOSTR profile.", + stringResource(Res.string.you_are_about_to_create_a_nostr_profile), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center ) Text( - "It is cryptographical secure, and decentralized, putting you in total control of your digital profile", + stringResource(Res.string.it_is_cryptographical_secure_and), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center ) @@ -95,13 +128,13 @@ fun CreateProfileScreen( } ) { Text( - text = "Start" + text = stringResource(Res.string.start) ) } } is CreateProfileUIState.InputPrompt -> { Text( - "Create Profile", + stringResource(Res.string.create_profile), style = MaterialTheme.typography.headlineSmall ) @@ -118,7 +151,7 @@ fun CreateProfileScreen( Text( text = it, modifier = Modifier.padding( - bottom = 8.dp + bottom = MaterialTheme.spacing.compactPadding ) ) } @@ -129,13 +162,13 @@ fun CreateProfileScreen( ), label = { Text( - text = "Name (eg. Alan Turing)", + text = stringResource(Res.string.name_eg_alan_turing), maxLines = 1, ) }, placeholder = { Text( - text = "Enter the name you want to use for your profile", + text = stringResource(Res.string.enter_the_name_you_want_to_use_for_your_2), maxLines = 1, ) }, @@ -148,9 +181,9 @@ fun CreateProfileScreen( ) Text( - text = "This will be the display name for your profile and also important for search.", - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.this_will_be_the_display_name_for_your), + style = MaterialTheme.typography.labelMedium ) TextField( @@ -162,7 +195,7 @@ fun CreateProfileScreen( Text( text = it, modifier = Modifier.padding( - bottom = 8.dp + bottom = MaterialTheme.spacing.compactPadding ) ) } @@ -173,14 +206,14 @@ fun CreateProfileScreen( ), label = { Text( - text = "Introduce yourself", + text = stringResource(Res.string.introduce_yourself), maxLines = 1, ) }, placeholder = { Text( - text = "What should people know about you?", + text = stringResource(Res.string.what_should_people_know_about_you), maxLines = 1, ) }, @@ -193,9 +226,9 @@ fun CreateProfileScreen( ) Text( - text = "This will be shown when people open your profile.", - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.this_will_be_shown_when_people_open_your), + style = MaterialTheme.typography.labelMedium ) Button( @@ -215,39 +248,43 @@ fun CreateProfileScreen( } ) { Text( - "Next" + stringResource(Res.string.next) ) } } is CreateProfileUIState.ConfirmInput -> { + // A label/value list, so every one of the five is full width and + // starts at the same edge. Centred, each floated at its own width + // and "Name" began somewhere other than the name under it. Text( - text = "Name", + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.name), style = MaterialTheme.typography.labelLarge ) Text( + modifier = Modifier.fillMaxWidth(), text = createAccountUIState.name, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center + style = MaterialTheme.typography.bodyLarge ) Text( - text = "Bio", + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.bio), style = MaterialTheme.typography.labelLarge ) Text( + modifier = Modifier.fillMaxWidth(), text = createAccountUIState.bio, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center + style = MaterialTheme.typography.bodyLarge ) - - Text( - text = "The above will be your profile.", + modifier = Modifier.fillMaxWidth(), + text = stringResource(Res.string.the_above_will_be_your_profile), style = MaterialTheme.typography.bodySmall ) @@ -256,7 +293,7 @@ fun CreateProfileScreen( ) Text( - text = "You will be in full control of this profile. If you would like to use it for the long term please remember to backup the profile/keys.", + text = stringResource(Res.string.you_will_be_in_full_control_of_this_profile), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center ) @@ -272,7 +309,7 @@ fun CreateProfileScreen( } ) { Text( - text = "Create Profile" + text = stringResource(Res.string.create_profile) ) } } @@ -283,7 +320,7 @@ fun CreateProfileScreen( modifier = Modifier.weight(1f) ) Text( - text = "Something went wrong and we were unable to sign you up. Please try again later.", + text = stringResource(Res.string.something_went_wrong_and_we_were_unable_to_2), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) @@ -292,42 +329,48 @@ fun CreateProfileScreen( ) } is CreateProfileUIState.ProfileReady -> { + // Both pills come from the extended families rather than from a + // literal paired with Color.White/Color.DarkGray by eye. The old + // pairings read 4.57:1 and 2.90:1 against their containers, and + // RedPill carried alpha 0.749, so composited over the surface the + // first was really 3.50:1. Both are below the 4.5:1 floor. + val extendedColors = LocalExtendedColors.current Spacer( modifier = Modifier.weight(1f) ) Text( - text = "Profile is ready", + text = stringResource(Res.string.profile_is_ready), style = MaterialTheme.typography.headlineLarge, textAlign = TextAlign.Center ) Text( - text = "After this there is no turning back." + text = stringResource(Res.string.after_this_there_is_no_turning_back) ) Button( colors = ButtonDefaults.buttonColors( - containerColor = press.mantra.compose.ui.theme.RedPill, - contentColor = Color.White + containerColor = extendedColors.redPill.color, + contentColor = extendedColors.redPill.onColor ), onClick = { createProfileViewModel.createAccount(writeSeed) } ) { Text( - text = "See how deep the rabbit hole goes" + text = stringResource(Res.string.see_how_deep_the_rabbit_hole_goes) ) } HorizontalDivider( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) Button( colors = ButtonDefaults.buttonColors( - containerColor = press.mantra.compose.ui.theme.BluePill, - contentColor = Color.DarkGray + containerColor = extendedColors.bluePill.color, + contentColor = extendedColors.bluePill.onColor ), onClick = { // TODO: Delete everything and close the app @@ -337,7 +380,7 @@ fun CreateProfileScreen( } ) { Text( - text = "end this" + text = stringResource(Res.string.end_this) ) } Spacer( @@ -350,7 +393,7 @@ fun CreateProfileScreen( } } -@Preview +@ConformancePreviews @Composable fun CreateAccountScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt index c8933b8d..afa9db37 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt @@ -37,6 +37,15 @@ import press.mantra.compose.ui.view.state.DkgRitualUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +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 @@ -78,6 +87,7 @@ internal fun DkgApprovalScaffold( } Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { TopAppBar( title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) } @@ -86,18 +96,18 @@ internal fun DkgApprovalScaffold( ) { padding -> when (val state = dkgRitualViewModel.dkgRitualUIState) { is DkgRitualUIState.Loading -> Column( - modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(50.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) CircularProgressIndicator() } is DkgRitualUIState.Error -> Column( - modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(50.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) Text(text = state.message, textAlign = TextAlign.Center) } @@ -109,10 +119,10 @@ 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(20.dp), + modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(50.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) CircularProgressIndicator() } @@ -129,14 +139,15 @@ internal fun DkgApprovalScaffold( modifier = Modifier .fillMaxWidth() .padding(padding) - .padding(20.dp), + .readableContent() + .padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space150) ) { - Spacer(modifier = Modifier.height(40.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space500)) Icon( imageVector = Icons.Default.CheckCircle, - contentDescription = null, + contentDescription = Decorative, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -147,7 +158,7 @@ internal fun DkgApprovalScaffold( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) - TextButton(onClick = onDone) { Text(text = "Back") } + TextButton(onClick = onDone) { Text(text = stringResource(Res.string.back)) } } return@Scaffold @@ -159,15 +170,16 @@ internal fun DkgApprovalScaffold( modifier = Modifier .fillMaxWidth() .padding(padding) + .readableContent() .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200) ) { body(state) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space150), verticalAlignment = Alignment.CenterVertically ) { // Declining is deliberately not a button that refuses on the @@ -179,7 +191,7 @@ internal fun DkgApprovalScaffold( onClick = onDone, enabled = !isActionPending ) { - Text(text = "Not now") + Text(text = stringResource(Res.string.not_now)) } Button( @@ -208,7 +220,7 @@ internal fun DkgApprovalScaffold( internal fun DkgApprovalPoint(text: String) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp) + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { Text(text = "•", style = MaterialTheme.typography.bodyMedium) Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt index 8f2bb597..8d32954c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgJoinApprovalScreen.kt @@ -14,6 +14,7 @@ import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.ui.theme.spacing /** * Asks whether to join a shared key ceremony somebody else opened. @@ -58,7 +59,7 @@ fun DkgJoinApprovalScreen( Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) ) { DkgApprovalPoint("Your device publishes the key it will be identified by for the rest of the ceremony.") DkgApprovalPoint("You will be asked twice more before anything of your key material goes out.") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index ab1d81b6..e9ed0058 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -75,6 +74,35 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.create_the_admins_group +import mantra.composeapp.generated.resources.how_many_members_will_it_take_to_sign +import mantra.composeapp.generated.resources.key +import mantra.composeapp.generated.resources.members +import mantra.composeapp.generated.resources.nothing_was_created_and_no_key_exists_it_is +import mantra.composeapp.generated.resources.review_and_confirm +import mantra.composeapp.generated.resources.review_and_contribute +import mantra.composeapp.generated.resources.review_and_join +import mantra.composeapp.generated.resources.shared_key +import mantra.composeapp.generated.resources.start_key_ceremony +import mantra.composeapp.generated.resources.the_ceremony_was_abandoned +import mantra.composeapp.generated.resources.the_group_can_hold_one_key_together_split_so +import mantra.composeapp.generated.resources.the_group_has_a_shared_key +import mantra.composeapp.generated.resources.this_is_fixed_once_the_ceremony_runs +import mantra.composeapp.generated.resources.try_again +import mantra.composeapp.generated.resources.your_share_of_it_is_on_this_device_only_your +import mantra.composeapp.generated.resources.everyone_has_to_be_online_at_the_same_time +import mantra.composeapp.generated.resources.of +import mantra.composeapp.generated.resources.of_members_will_be_needed_to_sign_with_this +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews /** * The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17 @@ -107,175 +135,179 @@ fun DkgRitualScreen( ) ) - when (val dkgRitualUIState = dkgRitualViewModel.dkgRitualUIState) { - is DkgRitualUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text(text = dkgRitualUIState.message, textAlign = TextAlign.Center) + ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState) { uiState -> + when (val dkgRitualUIState = uiState) { + is DkgRitualUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = dkgRitualUIState.message, textAlign = TextAlign.Center) + } } - } - is DkgRitualUIState.Loaded -> { - val session = dkgRitualUIState.session - val participantCount = dkgRitualUIState.participantCount - val canStartRitual = dkgRitualViewModel.canStartRitual() - val isActionPending = dkgRitualViewModel.isActionPending.value + is DkgRitualUIState.Loaded -> { + val session = dkgRitualUIState.session + val participantCount = dkgRitualUIState.participantCount + val canStartRitual = dkgRitualViewModel.canStartRitual() + val isActionPending = dkgRitualViewModel.isActionPending.value - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = "Shared key for ${dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"}", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) - }, - bottomBar = { - val pending = dkgRitualUIState.pendingApproval - - // The ceremony is stopped, waiting on this member, and will stay - // stopped until they act -- so this outranks everything else the - // bar could offer. - if (pending != null) { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - onNavigateToRoute( - when (pending) { - DkgApprovalStep.HOST_KEY -> DkgJoinApprovalRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) - DkgApprovalStep.ROUND_1 -> DkgRound1ApprovalRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) - DkgApprovalStep.ROUND_2 -> DkgRound2ApprovalRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) - } - ) - } - ) { - Icon(Icons.Default.Key, contentDescription = null) - Text( - text = when (pending) { - DkgApprovalStep.HOST_KEY -> "Review and join" - DkgApprovalStep.ROUND_1 -> "Review and contribute" - DkgApprovalStep.ROUND_2 -> "Review and confirm" - } - ) - } - }, - actions = {} - ) - - return@Scaffold - } - - // Any member can open a ritual — the coordinator has no say in - // the key — but only when there isn't one already running. - if (canStartRitual) { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { dkgRitualViewModel.startRitual() } - ) { - if (isActionPending) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) - } else { - Icon(Icons.Default.Key, contentDescription = "Start") - } - - Text( - text = if (session == null) "Start key ceremony" else "Try again" - ) - } - }, - actions = { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text( - modifier = Modifier.padding(start = 15.dp), - text = "${dkgRitualViewModel.threshold.value} of $participantCount", - style = MaterialTheme.typography.labelLarge + text = stringResource(Res.string.shared_key_for, dkgRitualUIState.localChatRoom.chatRoom.subject ?: "this group"), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } ) - } - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (session == null) { - Text( - text = "The group can hold one key together, split so that no single member holds it. Signing with it takes a quorum.", - style = MaterialTheme.typography.bodyMedium - ) + }, + bottomBar = { + val pending = dkgRitualUIState.pendingApproval - Text( - text = "Everyone has to be online at the same time — the ceremony can only finish once all $participantCount of you have taken part.", - style = MaterialTheme.typography.bodyMedium - ) - - if (canStartRitual) { - QuorumStepper( - threshold = dkgRitualViewModel.threshold.value, - participantCount = participantCount, - quorumRange = dkgRitualViewModel.quorumRange(), - onThresholdChange = dkgRitualViewModel::setThreshold + // The ceremony is stopped, waiting on this member, and will stay + // stopped until they act -- so this outranks everything else the + // bar could offer. + if (pending != null) { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + onNavigateToRoute( + when (pending) { + DkgApprovalStep.HOST_KEY -> DkgJoinApprovalRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + DkgApprovalStep.ROUND_1 -> DkgRound1ApprovalRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + DkgApprovalStep.ROUND_2 -> DkgRound2ApprovalRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + } + ) + } + ) { + Icon(Icons.Default.Key, contentDescription = Decorative) + Text( + text = when (pending) { + DkgApprovalStep.HOST_KEY -> stringResource(Res.string.review_and_join) + DkgApprovalStep.ROUND_1 -> stringResource(Res.string.review_and_contribute) + DkgApprovalStep.ROUND_2 -> stringResource(Res.string.review_and_confirm) + } + ) + } + }, + actions = {} ) - } else { - Text( - text = "A shared key needs at least " + - "${ChillDkgRitualManager.MINIMUM_PARTICIPANTS} members to split it between.", - style = MaterialTheme.typography.labelLarge + + return@Scaffold + } + + // Any member can open a ritual — the coordinator has no say in + // the key — but only when there isn't one already running. + if (canStartRitual) { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { dkgRitualViewModel.startRitual() } + ) { + if (isActionPending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Icon(Icons.Default.Key, contentDescription = "Start") + } + + Text( + text = if (session == null) stringResource(Res.string.start_key_ceremony) else stringResource(Res.string.try_again) + ) + } + }, + actions = { + Text( + modifier = Modifier.padding(start = MaterialTheme.spacing.space200), + text = stringResource(Res.string.of, dkgRitualViewModel.threshold.value, participantCount), + style = MaterialTheme.typography.labelLarge + ) + } ) } - } else { - RitualProgress( - session = session, - participantCount = participantCount, - activeUserPublicKey = activeUserPublicKey, - members = dkgRitualUIState.ritualMembers, - hostKeyParticipants = dkgRitualUIState.hostKeyParticipants, - round1Participants = dkgRitualUIState.round1Participants, - round2Participants = dkgRitualUIState.round2Participants, - isActionPending = isActionPending, - adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn, - onCreateAdminGroup = { - dkgRitualViewModel.createAdminGroup(onNavigateToRoute) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.space125), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (session == null) { + Text( + text = stringResource(Res.string.the_group_can_hold_one_key_together_split_so), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = stringResource(Res.string.everyone_has_to_be_online_at_the_same_time, participantCount), + style = MaterialTheme.typography.bodyMedium + ) + + if (canStartRitual) { + QuorumStepper( + threshold = dkgRitualViewModel.threshold.value, + participantCount = participantCount, + quorumRange = dkgRitualViewModel.quorumRange(), + onThresholdChange = dkgRitualViewModel::setThreshold + ) + } else { + Text( + text = "A shared key needs at least " + + "${ChillDkgRitualManager.MINIMUM_PARTICIPANTS} members to split it between.", + style = MaterialTheme.typography.labelLarge + ) } - ) + } else { + RitualProgress( + session = session, + participantCount = participantCount, + activeUserPublicKey = activeUserPublicKey, + members = dkgRitualUIState.ritualMembers, + hostKeyParticipants = dkgRitualUIState.hostKeyParticipants, + round1Participants = dkgRitualUIState.round1Participants, + round2Participants = dkgRitualUIState.round2Participants, + isActionPending = isActionPending, + adminGroupBlockedOn = dkgRitualUIState.adminGroupBlockedOn, + onCreateAdminGroup = { + dkgRitualViewModel.createAdminGroup(onNavigateToRoute) + } + ) + } } } } - } - DkgRitualUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Shared key", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + DkgRitualUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.shared_key), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -328,18 +360,18 @@ private fun RitualProgress( ) ) { Row( - modifier = Modifier.fillMaxWidth().padding(15.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.ErrorOutline, contentDescription = null) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("The ceremony was abandoned.", style = MaterialTheme.typography.titleSmall) + Icon(Icons.Default.ErrorOutline, contentDescription = Decorative) + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap)) { + Text(stringResource(Res.string.the_ceremony_was_abandoned), style = MaterialTheme.typography.titleSmall) session.failureReason?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } Text( - "Nothing was created and no key exists. It is safe to run it again.", + stringResource(Res.string.nothing_was_created_and_no_key_exists_it_is), style = MaterialTheme.typography.labelMedium ) } @@ -349,7 +381,7 @@ private fun RitualProgress( } Text( - text = "${session.threshold} of $participantCount members will be needed to sign with this key.", + text = stringResource(Res.string.of_members_will_be_needed_to_sign_with_this, session.threshold, participantCount), style = MaterialTheme.typography.bodyMedium ) @@ -396,25 +428,25 @@ private fun RitualProgress( ) ) { Column( - modifier = Modifier.fillMaxWidth().padding(15.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) ) { Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Default.CheckCircle, contentDescription = null) - Text("The group has a shared key.", style = MaterialTheme.typography.titleSmall) + Icon(Icons.Default.CheckCircle, contentDescription = Decorative) + Text(stringResource(Res.string.the_group_has_a_shared_key), style = MaterialTheme.typography.titleSmall) } session.thresholdPublicKey?.let { thresholdPublicKey -> val clipboardManager = LocalClipboardManager.current - Text(text = "Key", style = MaterialTheme.typography.labelMedium) + Text(text = stringResource(Res.string.key), style = MaterialTheme.typography.labelMedium) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { // In full, and wrapping rather than ellipsised. This is the @@ -443,7 +475,7 @@ private fun RitualProgress( } Text( - text = "Your share of it is on this device only. Your wallet backup restores it — nobody else's share can.", + text = stringResource(Res.string.your_share_of_it_is_on_this_device_only_your), style = MaterialTheme.typography.labelMedium ) @@ -472,9 +504,9 @@ private fun RitualProgress( if (isActionPending) { CircularProgressIndicator(modifier = Modifier.size(20.dp)) } else { - Icon(Icons.Default.Groups, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = "Create the #admins group") + Icon(Icons.Default.Groups, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space100)) + Text(text = stringResource(Res.string.create_the_admins_group)) } } } @@ -501,10 +533,10 @@ private fun RitualRoster( ) { Card(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier.fillMaxWidth().padding(15.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { - Text(text = "Members", style = MaterialTheme.typography.titleSmall) + Text(text = stringResource(Res.string.members), style = MaterialTheme.typography.titleSmall) members .sortedBy { it.participant.participantPublicKey } @@ -522,16 +554,22 @@ private fun RitualRoster( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { + // Not decorative: the name beside it says who, and only the + // icon says whether they have contributed. Icon( imageVector = if (publicKey in hostKeyParticipants) { Icons.Default.CheckCircle } else { Icons.Default.RadioButtonUnchecked }, - contentDescription = null + contentDescription = if (publicKey in hostKeyParticipants) { + "Contributed" + } else { + "Not yet contributed" + } ) Text( @@ -563,22 +601,24 @@ private fun RitualStep( ) { Card(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier.fillMaxWidth().padding(15.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { + // Not decorative: the title says which round, the count says how far + // along, and only the icon says whether it has finished. Icon( imageVector = if (isDone) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked, - contentDescription = null + contentDescription = if (isDone) "Complete" else "In progress" ) Text(text = title, style = MaterialTheme.typography.titleSmall) Spacer(modifier = Modifier.weight(1f)) Text( - text = "$count of $total", + text = stringResource(Res.string.of, count, total), style = MaterialTheme.typography.labelLarge ) } @@ -602,17 +642,17 @@ private fun QuorumStepper( onThresholdChange: (Int) -> Unit, ) { Column( - modifier = Modifier.fillMaxWidth().padding(top = 5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp) + modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50) ) { Text( - text = "How many members will it take to sign?", + text = stringResource(Res.string.how_many_members_will_it_take_to_sign), style = MaterialTheme.typography.titleSmall ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(15.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200), verticalAlignment = Alignment.CenterVertically ) { FilledIconButton( @@ -623,7 +663,7 @@ private fun QuorumStepper( } Text( - text = "$threshold of $participantCount", + text = stringResource(Res.string.of, threshold, participantCount), style = MaterialTheme.typography.titleMedium ) @@ -636,13 +676,13 @@ private fun QuorumStepper( } Text( - text = "This is fixed once the ceremony runs. Changing it later means generating a new key.", + text = stringResource(Res.string.this_is_fixed_once_the_ceremony_runs), style = MaterialTheme.typography.labelMedium ) } } -@Preview +@ConformancePreviews @Composable private fun DkgRitualScreenPreview() { TorchTheme { @@ -655,7 +695,7 @@ private fun DkgRitualScreenPreview() { chatRoom = ChatRoom( id = "", userPublicKey = "", - subject = "Group Discussions", + subject = "Group discussions", description = "See something... say something.", initialGiftWrapPayloadId = null, mlsGroupState = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt index 20877131..f8b3babe 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound1ApprovalScreen.kt @@ -14,6 +14,11 @@ import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.taking_part_with_these_members +import mantra.composeapp.generated.resources.you /** * Asks whether to contribute to the key itself. @@ -57,7 +62,7 @@ fun DkgRound1ApprovalScreen( Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) ) { DkgApprovalPoint( "Your device publishes its contribution to the key. Every member's is mixed in, " + @@ -68,20 +73,20 @@ fun DkgRound1ApprovalScreen( } Text( - text = "Taking part with these members", + text = stringResource(Res.string.taking_part_with_these_members), style = MaterialTheme.typography.titleSmall ) Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) ) { state.ritualMembers.forEach { member -> val key = member.participant.participantPublicKey Text( text = if (key == activeUserPublicKey) { - "You" + stringResource(Res.string.you) } else { member.profile?.humanReadableNameOrPubkey() ?: key.take(12) }, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt index 6cf33f02..035c4039 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRound2ApprovalScreen.kt @@ -14,6 +14,10 @@ import press.mantra.compose.repository.DkgRepository import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.this_is_the_last_thing_the_ceremony_needs /** * Asks whether to confirm the combined result. @@ -59,7 +63,7 @@ fun DkgRound2ApprovalScreen( Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) ) { DkgApprovalPoint( "Your device has checked the combined result against what it sent, and they match." @@ -73,7 +77,7 @@ fun DkgRound2ApprovalScreen( } Text( - text = "This is the last thing the ceremony needs from you.", + text = stringResource(Res.string.this_is_the_last_thing_the_ceremony_needs), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt index 8032eabb..36181177 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/FrostSigningScreen.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.Event @@ -56,6 +55,23 @@ import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.FrostSigningViewModel import press.mantra.compose.ui.view.state.FrostSigningUIState +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.don_t_sign +import mantra.composeapp.generated.resources.has_not_taken_part_yet +import mantra.composeapp.generated.resources.members +import mantra.composeapp.generated.resources.read_to_the_end_of_the_list_to_sign +import mantra.composeapp.generated.resources.ready_to_sign +import mantra.composeapp.generated.resources.sign +import mantra.composeapp.generated.resources.sign_with_the_group_s_key +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 +import press.mantra.compose.ui.theme.ConformancePreviews /** * One signing session, and the member's decision about it. @@ -99,11 +115,12 @@ fun FrostSigningScreen( } Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { TopAppBar( title = { Text( - text = "Sign with the group's key", + text = stringResource(Res.string.sign_with_the_group_s_key), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -120,10 +137,10 @@ fun FrostSigningScreen( is FrostSigningUIState.Loading -> Loading(padding) is FrostSigningUIState.Error -> Column( - modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(50.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) Text(text = state.message, textAlign = TextAlign.Center) } @@ -147,10 +164,11 @@ fun FrostSigningScreen( Column( modifier = Modifier .padding(padding) + .readableContent() .fillMaxSize() .verticalScroll(scrollState) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(15.dp) + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200) ) { WhatIsBeingSigned(proposed, state.items.size) @@ -172,7 +190,7 @@ fun FrostSigningScreen( HorizontalDivider() Text( - text = "Members", + text = stringResource(Res.string.members), style = MaterialTheme.typography.labelMedium ) @@ -222,9 +240,9 @@ fun FrostSigningScreen( supportingContent = { Text( text = when { - member in state.signed -> "Signed their part" - member in state.offeredNonce -> "Ready to sign" - else -> "Has not taken part yet" + member in state.signed -> stringResource(Res.string.signed_their_part) + member in state.offeredNonce -> stringResource(Res.string.ready_to_sign) + else -> stringResource(Res.string.has_not_taken_part_yet) } ) } @@ -258,7 +276,7 @@ fun FrostSigningScreen( if (readable && !seenEverything) { Text( - text = "Read to the end of the list to sign.", + text = stringResource(Res.string.read_to_the_end_of_the_list_to_sign), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -266,7 +284,7 @@ fun FrostSigningScreen( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { Button( @@ -274,9 +292,9 @@ fun FrostSigningScreen( !frostSigningViewModel.isActionPending.value, onClick = { frostSigningViewModel.approve(onNavigateBack) } ) { - Icon(Icons.Default.Draw, contentDescription = null) - Spacer(modifier = Modifier.width(10.dp)) - Text("Sign") + Icon(Icons.Default.Draw, contentDescription = Decorative) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.sign)) } // Declining stays available whatever the screen could @@ -291,7 +309,7 @@ fun FrostSigningScreen( enabled = !frostSigningViewModel.isActionPending.value, onClick = { frostSigningViewModel.decline(onNavigateBack) } ) { - Text("Don't sign") + Text(stringResource(Res.string.don_t_sign)) } } } @@ -304,10 +322,10 @@ fun FrostSigningScreen( @Composable private fun Loading(padding: androidx.compose.foundation.layout.PaddingValues) { Column( - modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(50.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) CircularProgressIndicator() } } @@ -337,10 +355,10 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { return } - Column(verticalArrangement = Arrangement.spacedBy(15.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200)) { if (events.size > 1) { Text( - text = "${events.size} events, signed together", + text = stringResource(Res.string.events_signed_together, events.size), style = MaterialTheme.typography.labelMedium ) } @@ -366,7 +384,7 @@ private fun WhatIsBeingSigned(events: List, expected: Int) { private fun OneThingBeingSigned(event: Event) { val summary = ProposedEvent.summarize(event) - Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50)) { Text(text = summary.label, style = MaterialTheme.typography.labelMedium) Text(text = summary.detail, style = MaterialTheme.typography.titleMedium) @@ -389,7 +407,7 @@ private fun statusOf(session: FrostSigningSession): String = when (session.stage FrostSigningStage.FAILED -> "Abandoned. Nothing was signed, and it is safe to ask again." } -@Preview +@ConformancePreviews @Composable private fun FrostSigningScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt index eb6c84d2..8c885c84 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/HomeScreen.kt @@ -1,20 +1,23 @@ package press.mantra.compose.ui.composable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Scaffold @@ -22,19 +25,18 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.NostrEvent @@ -42,12 +44,10 @@ import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.NostrRepository -import press.mantra.compose.ui.composable.navigation.routes.ActiveProfileRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.composable.widgets.dialogs.NewChatBottomSheetDialog import press.mantra.compose.ui.composable.widgets.dialogs.StartDirectMessageToNpubOrNip05Dialog -import press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.ChatRoomListViewModel import press.mantra.compose.ui.view.model.HomeScreenType @@ -56,6 +56,25 @@ import press.mantra.compose.ui.view.state.HomeScreenUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.mantra +import mantra.composeapp.generated.resources.new_chat +import press.mantra.compose.ui.composable.widgets.ErrorState +import androidx.compose.material3.SnackbarHost +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import androidx.compose.ui.Alignment +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.theme.breakpoint +import press.mantra.compose.ui.theme.listPaneWidthFor +import press.mantra.compose.ui.theme.readableContent +import mantra.composeapp.generated.resources.pick_a_conversation_to_read_it_here +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -65,15 +84,40 @@ fun HomeScreen( onNavigateToRoute: (Route) -> Unit, onNavigateToDirectMessageDetail: (Route) -> Unit, onNavigateToChatRoomCreation: () -> Unit, - onNavigateToSearch: () -> Unit, nostrRepository: NostrRepository, - chatRepository: ChatRepository + chatRepository: ChatRepository, + // Only the detail pane uses this, and only from the expanded breakpoint up. It is a + // required parameter rather than a nullable one because a home screen that silently + // loses its detail pane on a desktop is a worse failure than a compile error. + frostSigningRepository: FrostSigningRepository, ) { val sheetState = rememberModalBottomSheetState() val scope = rememberCoroutineScope() var showBottomSheet by remember { mutableStateOf(false) } val openNpubDialog = remember { mutableStateOf(false) } + // Null below the expanded breakpoint, which is where the room list is the whole screen + // and tapping a room navigates, exactly as it did before panes existed. + val listPaneWidth = listPaneWidthFor(MaterialTheme.breakpoint) + + // The room the detail pane is showing. Two saveable strings rather than the route + // object, because that is all the route carries and both survive a process death that + // a `@Serializable` route would need a Saver to survive. + var selectedChatRoomId by rememberSaveable { mutableStateOf(null) } + var selectedRelayHint by rememberSaveable { mutableStateOf(null) } + + // Tapping a room means two different things at two widths, and the list does not need + // to know which: it hands over a `ChatRoomMessagingRoute` either way, and this decides + // whether that is a destination to push or a selection to make. + val onOpenChatRoom: (Route) -> Unit = { route -> + if (listPaneWidth != null && route is ChatRoomMessagingRoute) { + selectedChatRoomId = route.chatRoomId + selectedRelayHint = route.relayHint + } else { + onNavigateToDirectMessageDetail(route) + } + } + val homeScreenViewModel: HomeViewModel = viewModel( factory = HomeViewModel.factory( activeUserPublicKey = activeUserPublicKey, @@ -83,182 +127,215 @@ fun HomeScreen( ), ) - when(val homeScreenUIState = homeScreenViewModel.homeScreenUIState) { - HomeScreenUIState.Error -> { - Scaffold { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = "Something went wrong" - ) - - Spacer( - modifier = Modifier.weight(2f) - ) - } - } - - } - is HomeScreenUIState.Loaded -> { - Scaffold( - modifier = Modifier, - topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary - ), - title = { - Text( - "Torch" - ) - }, - navigationIcon = { - IconButton( - onClick = { - onNavigateToRoute.invoke( - ActiveProfileRoute( - activeUserPublicKey = activeUserPublicKey, - nostrEventId = homeScreenUIState.profileWithFollowing.nostrEvent.id - ) - ) - } - ) { - ProfileAvatar( - profile = homeScreenUIState.profileWithFollowing.profile, - publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey - ) - } - }, - actions = { - IconButton( - onClick = { - onNavigateToSearch.invoke() - } - ) { - Icon( - Icons.Default.Search, - contentDescription = "Search" - ) - } - } - ) - - }, - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - showBottomSheet = true - }, - icon = { - Icon( - Icons.Default.Add, - contentDescription = "New Chat" - ) - }, - text = { - Text("New Chat") - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() - ) { - PrimaryScrollableTabRow( - modifier = Modifier.padding(10.dp), - edgePadding = 10.dp, - selectedTabIndex = homeScreenViewModel.pagerState.currentPage, - ) { - HomeScreenType.entries.forEachIndexed { index, destination -> - Tab( - selected = homeScreenViewModel.pagerState.currentPage == index, - onClick = { - scope.launch { - homeScreenViewModel.pagerState.animateScrollToPage(destination.ordinal) - } - }, - text = { - Text( - text = destination.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - ) - } - } - + ScreenStateTransition(homeScreenViewModel.homeScreenUIState) { uiState -> + when (val homeScreenUIState = uiState) { + HomeScreenUIState.Error -> { + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( - modifier = Modifier.weight(1f) + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize(), + verticalArrangement = Arrangement.Center, ) { - HorizontalPager( - state = homeScreenViewModel.pagerState, - ) { pageIndex -> - when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) { - HomeScreenType.Messages -> { - val chatRoomListViewModel: ChatRoomListViewModel = viewModel( - key = homeScreenTypeType.name, - factory = ChatRoomListViewModel.factory( - initialChatRoomListUIState = press.mantra.compose.ui.view.state.ChatRoomListUIState.Loading, - publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey, - nostrRepository = nostrRepository, - chatRepository = chatRepository - ), - ) + ErrorState() + } + } - key(true) { - chatRoomListViewModel.RenderFeed( - onNavigateToDirectMessageDetail = onNavigateToDirectMessageDetail + } + is HomeScreenUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier, + topBar = { + // No colour override. It was `containerColor = primaryContainer` with + // `titleContentColor = primary`, which in the light scheme is #000000 + // on #1B1B1B -- 1.22:1, a black title on a near-black bar. The + // default is `surface`/`onSurface` and needs no help. + TopAppBar( + title = { + // "Mantra". The launcher label, the desktop window title, the + // landing screen and the package name all say so; this bar was + // the last place still saying "Torch". `UserAgent.APP_NAME` is + // the other, and is left alone -- it goes on the wire to relay + // operators, so it is a network identity question rather than + // a content one. + Text(stringResource(Res.string.mantra)) + }, + // No navigation icon and no actions. The avatar in the leading + // slot and the search icon in the trailing one were the app's only + // two peer surfaces, and they are now items in the navigation + // component -- a bar on a phone, a rail on a wide window. Leaving + // them here as well would be two routes to one destination, which + // is the thing M3's "swap only functionally equivalent components" + // caution is about. + ) + + }, + floatingActionButton = { + // Only while there is one pane. With two, the scaffold's FAB slot is + // the bottom-right of the *window*, which is on top of the + // transcript's send button; M3 puts a list-detail layout's primary + // action in the list pane, and so does the branch below. + if (listPaneWidth == null) NewChatButton { showBottomSheet = true } + } + ) { innerPadding -> + @Composable + fun ChatRoomListPane(modifier: Modifier) = Column( + modifier = modifier + ) { + PrimaryScrollableTabRow( + modifier = Modifier.padding(MaterialTheme.spacing.space125), + edgePadding = 10.dp, + selectedTabIndex = homeScreenViewModel.pagerState.currentPage, + ) { + HomeScreenType.entries.forEachIndexed { index, destination -> + Tab( + selected = homeScreenViewModel.pagerState.currentPage == index, + onClick = { + scope.launch { + homeScreenViewModel.pagerState.animateScrollToPage(destination.ordinal) + } + }, + text = { + Text( + text = destination.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } + ) + } + } - key(true) { - chatRoomListViewModel.initiate() + Column( + modifier = Modifier.weight(1f) + ) { + HorizontalPager( + state = homeScreenViewModel.pagerState, + ) { pageIndex -> + when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) { + HomeScreenType.Messages -> { + val chatRoomListViewModel: ChatRoomListViewModel = viewModel( + key = homeScreenTypeType.name, + factory = ChatRoomListViewModel.factory( + initialChatRoomListUIState = press.mantra.compose.ui.view.state.ChatRoomListUIState.Loading, + publicKey = homeScreenUIState.profileWithFollowing.profile.publicKey, + nostrRepository = nostrRepository, + chatRepository = chatRepository + ), + ) + + key(true) { + chatRoomListViewModel.RenderFeed( + onNavigateToDirectMessageDetail = onOpenChatRoom + ) + } + + key(true) { + chatRoomListViewModel.initiate() + } } } } } } - } - if (showBottomSheet) { - NewChatBottomSheetDialog( - scope = scope, - sheetState = sheetState, - onSetShowBottomSheetUpdate = { toggle -> - showBottomSheet = toggle - }, - onNavigateToRoute = onNavigateToRoute, - onNavigateToChatRoomCreation = onNavigateToChatRoomCreation, - onStartDirectMessageChatWithNpub = { - openNpubDialog.value = true + if (listPaneWidth == null) { + // Compact and medium: the list is the screen, and a room is a route. + // Byte for byte the layout this screen has always had. + ChatRoomListPane( + Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) + } else { + // Expanded and up: the list keeps a fixed width and the room fills the + // rest. No `readableContent()` on the pair -- capping the two panes + // together to one column's measure is the opposite of what a second + // pane is for. Each pane holds its own content to a measure instead, + // and `ChatRoomMessagingScreen` already does. + Row(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + Box( + modifier = Modifier + .width(listPaneWidth) + .fillMaxHeight() + // The pane's width is the one thing about this layout that + // is a number rather than a rule, and the only way to check + // a number is to measure it in a composition. + .testTag(ChatListPaneTag) + ) { + ChatRoomListPane(Modifier.fillMaxSize()) + + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(MaterialTheme.spacing.containerPadding) + ) { + NewChatButton { showBottomSheet = true } + } + } + + Spacer(modifier = Modifier.width(MaterialTheme.spacing.paneGap)) + + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + val chatRoomId = selectedChatRoomId + if (chatRoomId == null) { + EmptyState( + message = stringResource(Res.string.pick_a_conversation_to_read_it_here) + ) + } else { + // Keyed on the room, so switching rooms rebuilds the + // screen's view models rather than feeding a new id to + // ones already subscribed to another room's relay. + key(chatRoomId) { + ChatRoomMessagingScreen( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = selectedRelayHint, + nostrRepository = nostrRepository, + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository, + // "Replace what is on screen" is a route push on a + // phone and a change of selection here. It fires when + // a conversation that did not exist yet has just been + // created and has an id at last. + onNavigateToRouteAndPopUpInclusive = onOpenChatRoom, + onNavigateToRoute = onNavigateToRoute, + ) + } + } + } } - ) - } + } - when { - openNpubDialog.value -> { - StartDirectMessageToNpubOrNip05Dialog( - activeUserPublicKey = activeUserPublicKey, + if (showBottomSheet) { + NewChatBottomSheetDialog( scope = scope, - toggleOpenDialogSetting = { - openNpubDialog.value = false + sheetState = sheetState, + onSetShowBottomSheetUpdate = { toggle -> + showBottomSheet = toggle }, - onNavigateToRoute = onNavigateToRoute + onNavigateToRoute = onNavigateToRoute, + onNavigateToChatRoomCreation = onNavigateToChatRoomCreation, + onStartDirectMessageChatWithNpub = { + openNpubDialog.value = true + } ) } + + when { + openNpubDialog.value -> { + StartDirectMessageToNpubOrNip05Dialog( + activeUserPublicKey = activeUserPublicKey, + scope = scope, + toggleOpenDialogSetting = { + openNpubDialog.value = false + }, + onNavigateToRoute = onNavigateToRoute + ) + } + } } } - } - HomeScreenUIState.Loading -> { - LoadingDataIndicator() + HomeScreenUIState.Loading -> { + LoadingDataIndicator() + } } } @@ -267,12 +344,12 @@ fun HomeScreen( } } -@Preview +@ConformancePreviews @Composable private fun HomeScreenPreview() { TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { HomeScreen( activeUserPublicKey = "", @@ -300,11 +377,37 @@ It has survived not only five centuries, but also the leap into electronic types ), onNavigateToRoute = {}, onNavigateToChatRoomCreation = {}, - onNavigateToSearch = {}, onNavigateToDirectMessageDetail = {}, nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, - chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, ) } } -} \ No newline at end of file +} + +/** + * The one action the chat list offers, in whichever slot the layout has for it. + * + * Extracted only so that the two branches above cannot drift: on one pane it is the + * `Scaffold`'s floating action button, on two it sits in the bottom corner of the list + * pane, and a "new chat" that reads differently depending on window width would be a + * strange thing to discover. + */ +@Composable +private fun NewChatButton(onClick: () -> Unit) { + ExtendedFloatingActionButton( + onClick = onClick, + icon = { + Icon( + Icons.Default.Add, + // The button's own text says "New chat" immediately beside it. + contentDescription = Decorative, + ) + }, + text = { Text(stringResource(Res.string.new_chat)) }, + ) +} + +/** Addresses the chat list pane from a layout test. See `ChatPaneLayoutJvmTest`. */ +const val ChatListPaneTag = "chat-list-pane" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt index 5bfcf797..364a5477 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ImplementationPendingScreen.kt @@ -4,31 +4,39 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +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 +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun ImplementationPendingScreen( text: String ) { - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { Column( - modifier = Modifier.padding(20.dp).fillMaxSize(), + modifier = Modifier.padding(MaterialTheme.spacing.space250).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( - "\"$text\" functionality coming soon", + stringResource(Res.string.functionality_coming_soon_2, text), ) } @@ -36,12 +44,12 @@ fun ImplementationPendingScreen( } } -@Preview +@ConformancePreviews @Composable private fun ImplementationPendingScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { ImplementationPendingScreen( "Sign in" diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt index 72b85696..67c481f0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyPackageManagementScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.MarmotKeyPackageBundle @@ -43,6 +42,24 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.view.model.KeyPackageManagementViewModel import press.mantra.compose.ui.view.state.KeyPackageManagementUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.key_package_management +import mantra.composeapp.generated.resources.publish_new_key_package +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please +import mantra.composeapp.generated.resources.key_packages +import press.mantra.compose.ui.composable.widgets.ErrorState +import androidx.compose.material3.SnackbarHost +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -55,6 +72,12 @@ fun KeyPackageManagementScreen( marmotRepository: MarmotRepository ) { + // Read outside the click handlers: stringResource and rememberNotifier are both + // composable, and a lambda passed to onClick is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val publishedMessage = stringResource(Res.string.key_package_published) + val rotatedMessage = stringResource(Res.string.key_package_rotated) + val keyPackageManagementViewModel: KeyPackageManagementViewModel = viewModel( factory = KeyPackageManagementViewModel.factory( nostrEventId = nostrEventId, @@ -64,161 +87,163 @@ fun KeyPackageManagementScreen( ), ) - when(val keyPackageManagementUIState = keyPackageManagementViewModel.keyPackageManagementUIState) { - KeyPackageManagementUIState.Error -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Something went wrong") + ScreenStateTransition(keyPackageManagementViewModel.keyPackageManagementUIState) { uiState -> + when (val keyPackageManagementUIState = uiState) { + KeyPackageManagementUIState.Error -> { + ErrorState() } - } - is KeyPackageManagementUIState.Loaded -> { - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = "Key Package Management" - ) - }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" - ) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - Button( - onClick = { - keyPackageManagementViewModel.publishNewKeyPackage() - } - ) { - Icon( - Icons.Default.LockReset, - contentDescription = "Publish new key package" - ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - + is KeyPackageManagementUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text( - text = "Publish New Key Package" + text = stringResource(Res.string.key_package_management) + ) + }, + navigationIcon = { + IconButton( + onClick = { + onNavigateBack.invoke() + } + ) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + .readableContent() + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Button( + onClick = { + keyPackageManagementViewModel.publishNewKeyPackage { + notify(publishedMessage) + } + } + ) { + Icon( + Icons.Default.LockReset, + contentDescription = "Publish new key package" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text( + text = stringResource(Res.string.publish_new_key_package) + ) + } + } + item { + Text( + stringResource(Res.string.key_packages, keyPackageManagementUIState.keyPackageBundles.size) ) } - } - item { - Text( - "${keyPackageManagementUIState.keyPackageBundles.size} Key Packages" - ) - } - items( - items = keyPackageManagementUIState.keyPackageBundles - ) { marmotKeyPackageBundle -> + items( + items = keyPackageManagementUIState.keyPackageBundles + ) { marmotKeyPackageBundle -> - Card { - ListItem( - leadingContent = { - if (marmotKeyPackageBundle.rotated) { - Icon( - Icons.Default.Unpublished, - contentDescription = "Key package rotated" - ) - } else if (marmotKeyPackageBundle.consumed) { - Icon( - Icons.Default.DoneAll, - contentDescription = "Consumed key package event" - ) - } else { - // TODO: Get broadcast status of key package... - Icon( - Icons.Default.VerifiedUser, - contentDescription = "Key package published" - ) - } - }, - headlineContent = { - Text( - text = marmotKeyPackageBundle.id, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis - ) - }, - supportingContent = { - Row( - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(), - style = MaterialTheme.typography.labelSmall - ) - - Spacer( - modifier = Modifier.weight(1f) - ) - } - - }, - trailingContent = { - if (marmotKeyPackageBundle.rotated.not() && marmotKeyPackageBundle.consumed.not()) { - IconButton( - onClick = { - keyPackageManagementViewModel.rotateKeyPackage( - marmotKeyPackageBundle - ) - } - ) { + Card { + ListItem( + leadingContent = { + if (marmotKeyPackageBundle.rotated) { Icon( - Icons.Default.LockReset, - contentDescription = "Rotate Key" + Icons.Default.Unpublished, + contentDescription = "Key package rotated" + ) + } else if (marmotKeyPackageBundle.consumed) { + Icon( + Icons.Default.DoneAll, + contentDescription = "Consumed key package event" + ) + } else { + // TODO: Get broadcast status of key package... + Icon( + Icons.Default.VerifiedUser, + contentDescription = "Key package published" ) } - } + }, + headlineContent = { + Text( + text = marmotKeyPackageBundle.id, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + }, + supportingContent = { + Row( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = marmotKeyPackageBundle.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall + ) - } - ) + Spacer( + modifier = Modifier.weight(1f) + ) + } + + }, + trailingContent = { + if (marmotKeyPackageBundle.rotated.not() && marmotKeyPackageBundle.consumed.not()) { + IconButton( + onClick = { + keyPackageManagementViewModel.rotateKeyPackage( + marmotKeyPackageBundle + ) { + notify(rotatedMessage) + } + } + ) { + Icon( + Icons.Default.LockReset, + contentDescription = "Rotate key" + ) + } + } + + } + ) + } } } } } } - } - KeyPackageManagementUIState.Loading -> { - LoadingDataIndicator() - } - KeyPackageManagementUIState.NotFound -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("We couldn't find the local profile. Please try again later.") + KeyPackageManagementUIState.Loading -> { + LoadingDataIndicator() + } + KeyPackageManagementUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + } } } } } -@Preview +@ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt index 86da7d00..5e2e387b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/KeyRecoveryScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons @@ -38,17 +39,34 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.cloud_backup +import mantra.composeapp.generated.resources.download_and_securely_store_everything +import mantra.composeapp.generated.resources.emergency_kit +import mantra.composeapp.generated.resources.encrypt_and_back_your_recovery_information +import mantra.composeapp.generated.resources.key_recovery +import mantra.composeapp.generated.resources.not_backed_up_yet +import mantra.composeapp.generated.resources.recovery_phrase +import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_so_they +import mantra.composeapp.generated.resources.write_down_and_secure_the_12_word_phrase +import mantra.composeapp.generated.resources.yolo +import mantra.composeapp.generated.resources.you_only_live_once_lose_this_phone_and_the +import mantra.composeapp.generated.resources.you_said_you_wrote_it_down +import org.jetbrains.compose.resources.stringResource import press.mantra.compose.extensions.hexToNpubHrp import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing /** * Everything that can put this profile back on another phone. @@ -80,7 +98,7 @@ fun KeyRecoveryScreen( TopAppBar( title = { Text( - text = "Key Recovery" + text = stringResource(Res.string.key_recovery) ) }, navigationIcon = { @@ -99,26 +117,27 @@ fun KeyRecoveryScreen( } ) { innerPadding -> LazyColumn( - modifier = Modifier.fillMaxWidth().padding(innerPadding).padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .padding(innerPadding) + .readableContent() + .padding(horizontal = MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), horizontalAlignment = Alignment.CenterHorizontally ) { item { Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "These are your keys. Keep them safe so they can keep unlocking " + - "this profile and its coins, even when you lose or change your phone.", + text = stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center ) - Spacer( - modifier = Modifier.height(10.dp) - ) - Text( text = activeUserPublicKey.hexToNpubHrp(), style = MaterialTheme.typography.labelSmall, @@ -130,20 +149,19 @@ fun KeyRecoveryScreen( item { KeyRecoveryOption( icon = Icons.Default.Spellcheck, - title = "Recovery Phrase", - description = "Write down and secure the 12 word phrase that this profile and " + - "its wallet are derived from.", + title = stringResource(Res.string.recovery_phrase), + description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase), containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, status = if (showBackupNotice) { KeyRecoveryStatus( icon = Icons.Default.Warning, - text = "Not backed up yet" + text = stringResource(Res.string.not_backed_up_yet) ) } else { KeyRecoveryStatus( icon = Icons.Default.CheckCircle, - text = "You said you wrote it down" + text = stringResource(Res.string.you_said_you_wrote_it_down) ) }, onClick = { @@ -155,12 +173,11 @@ fun KeyRecoveryScreen( item { KeyRecoveryOption( icon = Icons.Default.AddToDrive, - title = "Cloud Backup", - description = "Encrypt and back your recovery information up to your Google " + - "Drive or iCloud.", + title = stringResource(Res.string.cloud_backup), + description = stringResource(Res.string.encrypt_and_back_your_recovery_information), onClick = { onNavigateToRoute.invoke( - ImplementationPendingRoute("Cloud Backup") + ImplementationPendingRoute("Cloud backup") ) } ) @@ -169,12 +186,11 @@ fun KeyRecoveryScreen( item { KeyRecoveryOption( icon = Icons.Default.LocalHospital, - title = "Emergency Kit", - description = "Download and securely store everything needed to recover this " + - "profile and the coins it holds.", + title = stringResource(Res.string.emergency_kit), + description = stringResource(Res.string.download_and_securely_store_everything), onClick = { onNavigateToRoute.invoke( - ImplementationPendingRoute("Emergency Kit") + ImplementationPendingRoute("Emergency kit") ) } ) @@ -183,9 +199,8 @@ fun KeyRecoveryScreen( item { KeyRecoveryOption( icon = Icons.Default.VolunteerActivism, - title = "YOLO", - description = "You only live once. Lose this phone and the profile goes with " + - "it, along with anything it holds.", + title = stringResource(Res.string.yolo), + description = stringResource(Res.string.you_only_live_once_lose_this_phone_and_the), containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer, onClick = { @@ -196,7 +211,7 @@ fun KeyRecoveryScreen( item { Spacer( - modifier = Modifier.height(10.dp) + modifier = Modifier.height(MaterialTheme.spacing.itemGap) ) } } @@ -215,10 +230,10 @@ private fun KeyRecoveryOption( icon: ImageVector, title: String, description: String, + onClick: () -> Unit, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, contentColor: Color = MaterialTheme.colorScheme.onSurface, status: KeyRecoveryStatus? = null, - onClick: () -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), @@ -229,19 +244,21 @@ private fun KeyRecoveryOption( ) ) { Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) ) { Row( verticalAlignment = Alignment.CenterVertically ) { Icon( icon, - contentDescription = title + contentDescription = Decorative ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) Text( @@ -261,12 +278,12 @@ private fun KeyRecoveryOption( ) { Icon( it.icon, - contentDescription = null, - modifier = Modifier.width(16.dp) + contentDescription = Decorative, + modifier = Modifier.size(16.dp) ) Spacer( - modifier = Modifier.width(6.dp) + modifier = Modifier.width(MaterialTheme.spacing.relatedGap) ) Text( @@ -279,7 +296,7 @@ private fun KeyRecoveryOption( } } -@Preview +@ConformancePreviews @Composable private fun KeyRecoveryScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt index 01e373ee..4be7fa3d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LandingScreen.kt @@ -17,8 +17,21 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.create_profile +import mantra.composeapp.generated.resources.if_you_are_new_to_torch_or_just_want_to +import mantra.composeapp.generated.resources.keep_the_feed_alive +import mantra.composeapp.generated.resources.learn_more +import mantra.composeapp.generated.resources.mantra +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 +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun LandingScreen( @@ -26,29 +39,30 @@ fun LandingScreen( onNavigateToSignIn: () -> Unit, onNavigateToCreateProfile: () -> Unit ) { - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Mantra", + text = stringResource(Res.string.mantra), style = MaterialTheme.typography.headlineLarge ) Text( - text = "Keep the feed alive." + text = stringResource(Res.string.keep_the_feed_alive) ) TextButton( onClick = onNavigateToLearnMore ) { Text( - text = "Learn More", + text = stringResource(Res.string.learn_more), style = MaterialTheme.typography.labelLarge, ) } @@ -62,34 +76,34 @@ fun LandingScreen( ) { Text( - "Sign in", + stringResource(Res.string.sign_in), style = MaterialTheme.typography.bodyLarge ) } Text( - text = "Sign in to Torch via nsec, or remote signer", - modifier = Modifier.padding(10.dp), + text = stringResource(Res.string.sign_in_to_torch_via_nsec_or_remote_signer), + modifier = Modifier.padding(MaterialTheme.spacing.space125), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center ) Spacer( - modifier = Modifier.height(20.dp) + modifier = Modifier.height(MaterialTheme.spacing.space250) ) Button( onClick = onNavigateToCreateProfile ) { Text( - "Create Profile", + stringResource(Res.string.create_profile), style = MaterialTheme.typography.bodyLarge ) } Text( - text = "If you are new to Torch or just want to create a fresh profile", - modifier = Modifier.padding(10.dp), + text = stringResource(Res.string.if_you_are_new_to_torch_or_just_want_to), + modifier = Modifier.padding(MaterialTheme.spacing.space125), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center ) @@ -98,7 +112,7 @@ fun LandingScreen( } } -@Preview +@ConformancePreviews @Composable private fun LandingScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt index 361ddf9c..47e1115c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/LoadingScreen.kt @@ -7,15 +7,19 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.runtime.Composable 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 +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun LoadingScreen( text: String? = null ) { - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { press.mantra.compose.ui.composable.widgets.LoadingDataIndicator( text = text @@ -24,7 +28,7 @@ fun LoadingScreen( } } -@Preview +@ConformancePreviews @Composable private fun LoadingScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt index 68607acf..1483e70b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/NostrEventDetailScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -12,7 +13,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.NostrEvent @@ -30,6 +30,19 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.functionality_coming_soon +import mantra.composeapp.generated.resources.post_functionality_coming_soon +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.we_couldn_t_find_your_nostr_event_please_try +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -54,95 +67,91 @@ fun NostrEventDetailScreen( ), ) - when(val feedListUIState = nostrEventDetailViewModel.nostrEventDetailUIState) { - NostrEventDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Something went wrong") + ScreenStateTransition(nostrEventDetailViewModel.nostrEventDetailUIState) { uiState -> + when (val feedListUIState = uiState) { + NostrEventDetailUIState.Error -> { + ErrorState() } - } - is NostrEventDetailUIState.Loaded -> { + is NostrEventDetailUIState.Loaded -> { - when(feedListUIState.localNostrEvent.nostrEvent.kind) { - MetadataEvent.KIND -> { - MetadataEventDetail( - activeUserPublicKey = activeUserPublicKey, - localNostrEvent = feedListUIState.localNostrEvent, - onNavigateBack = onNavigateBack, - onNavigateToEvent = onNavigateToEvent, - onNavigateToChatRoom = onNavigateToDirectMessage, - onNavigateToEditProfile = onNavigateToEditProfile, - nostrRepository = nostrRepository - ) - } - TextNoteEvent.KIND -> { - TextNoteEventDetail( - feedListUIState.localNostrEvent, - onNavigateBack = onNavigateBack, - onNavigateToWriteAReply = onNavigateToWriteAReply, - onNavigateToNostrEvent = onNavigateToQuoteNostrEvent, - nostrRepository = nostrRepository - ) - } - RepostEvent.KIND -> { - // TODO: RePost... - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(feedListUIState.localNostrEvent.nostrEvent.content) - - Text("post functionality coming soon.") + when(feedListUIState.localNostrEvent.nostrEvent.kind) { + MetadataEvent.KIND -> { + MetadataEventDetail( + activeUserPublicKey = activeUserPublicKey, + localNostrEvent = feedListUIState.localNostrEvent, + onNavigateBack = onNavigateBack, + onNavigateToEvent = onNavigateToEvent, + onNavigateToChatRoom = onNavigateToDirectMessage, + onNavigateToEditProfile = onNavigateToEditProfile, + nostrRepository = nostrRepository + ) } - } - else -> { - Scaffold { innerPadding -> + TextNoteEvent.KIND -> { + TextNoteEventDetail( + feedListUIState.localNostrEvent, + onNavigateBack = onNavigateBack, + onNavigateToWriteAReply = onNavigateToWriteAReply, + onNavigateToNostrEvent = onNavigateToQuoteNostrEvent, + nostrRepository = nostrRepository + ) + } + RepostEvent.KIND -> { + // TODO: RePost... Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(feedListUIState.localNostrEvent.nostrEvent.content) + Text(feedListUIState.localNostrEvent.nostrEvent.content) - Text("functionality coming soon.") + Text(stringResource(Res.string.post_functionality_coming_soon)) + } + } + else -> { + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(feedListUIState.localNostrEvent.nostrEvent.content) + + Text(stringResource(Res.string.functionality_coming_soon)) + } } } } } } - } - NostrEventDetailUIState.Loading -> { - LoadingDataIndicator() + NostrEventDetailUIState.Loading -> { + LoadingDataIndicator() - LaunchedEffect(true) { - nostrEventDetailViewModel.loadNostrFeed() + LaunchedEffect(true) { + nostrEventDetailViewModel.loadNostrFeed() + } } - } - NostrEventDetailUIState.NotFound -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("We couldn't find your nostr event. Please try again later.") + NostrEventDetailUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.we_couldn_t_find_your_nostr_event_please_try)) + } } } } } -@Preview +@ConformancePreviews @Composable private fun NostrEventDetailScreenPreview() { TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { NostrEventDetailScreen( initialNostrEventDetailUIState = NostrEventDetailUIState.Loaded( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt index 5a2ee9a3..36cc6159 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ProposalListScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -54,6 +53,19 @@ import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.ProposalListViewModel import press.mantra.compose.ui.view.state.ProposalListUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.everything_else +import mantra.composeapp.generated.resources.proposals +import mantra.composeapp.generated.resources.review +import mantra.composeapp.generated.resources.this_group_has_not_been_asked_to_sign +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 +import press.mantra.compose.ui.theme.ConformancePreviews /** * Everything the group has asked its shared key to sign. @@ -97,11 +109,12 @@ fun ProposalListScreen( } Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, topBar = { TopAppBar( title = { Text( - text = "Proposals", + text = stringResource(Res.string.proposals), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -116,18 +129,18 @@ fun ProposalListScreen( ) { padding -> when (val state = proposalListViewModel.proposalListUIState) { is ProposalListUIState.Loading -> Column( - modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(50.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) CircularProgressIndicator() } is ProposalListUIState.Error -> Column( - modifier = Modifier.fillMaxWidth().padding(padding).padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(50.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) Text(text = state.message, textAlign = TextAlign.Center) } @@ -143,14 +156,14 @@ fun ProposalListScreen( } LazyColumn( - modifier = Modifier.padding(padding).fillMaxSize().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.padding(padding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { if (state.proposals.isEmpty()) { item { Text( - modifier = Modifier.fillMaxWidth().padding(top = 50.dp), - text = "This group has not been asked to sign anything yet.", + modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space600), + text = stringResource(Res.string.this_group_has_not_been_asked_to_sign), textAlign = TextAlign.Center ) } @@ -159,7 +172,7 @@ fun ProposalListScreen( if (state.waitingForYou.isNotEmpty()) { item { Text( - text = "Waiting for you", + text = stringResource(Res.string.waiting_for_you), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary ) @@ -180,7 +193,7 @@ fun ProposalListScreen( if (state.waitingForYou.isNotEmpty()) { item { Text( - text = "Everything else", + text = stringResource(Res.string.everything_else), style = MaterialTheme.typography.labelMedium ) } @@ -225,18 +238,40 @@ private fun ProposalCard( "None of its events could be read" } + // Every colour inside this card is derived from the card, rather than reached for + // independently, because ListItem does not inherit LocalContentColor -- its headline + // comes from ListTokens.ItemLabelTextColor, which is `onSurface`. With the container + // at `primaryContainer` and the headline at `onSurface`, the light scheme drew + // #1B1B1B on #1B1B1B: 1.00:1, invisible, and applied to exactly the proposals that + // await your signature. The icon tint (`primary`) was 1.22:1, the supporting text + // 1.84:1 and the error line 2.67:1. Only the dark scheme was legible, because there + // `primaryContainer` is black. + val cardColors = if (proposal.awaitsYou) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else { + CardDefaults.cardColors() + } + // `primaryContainer` is kept as the highlight so this stays a fix rather than a + // restyle. It is a near-black card in the light scheme, and `secondaryContainer` -- + // the brand gold, 4.56:1 against its own content -- would read more like "this needs + // you". That is a design call, not an accessibility one. + val cardContentColor = cardColors.contentColor + Card( onClick = onClick, - colors = if (proposal.awaitsYou) { - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - } else { - CardDefaults.cardColors() - } + colors = cardColors ) { ListItem( - colors = ListItemDefaults.colors(containerColor = Color.Transparent), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = cardContentColor, + overlineColor = cardContentColor, + supportingColor = cardContentColor, + leadingIconColor = cardContentColor, + trailingIconColor = cardContentColor, + ), leadingContent = { Icon( imageVector = when { @@ -247,12 +282,22 @@ private fun ProposalCard( Icons.Default.ErrorOutline else -> Icons.Default.HourglassEmpty }, - contentDescription = null, + // Carries the stage on its own, and carries it alone on the + // highlighted card where the failure state has no colour to spare. + contentDescription = when { + proposal.awaitsYou -> "Awaiting your signature" + proposal.session.stage == FrostSigningStage.COMPLETE -> "Signed" + proposal.session.stage == FrostSigningStage.FAILED -> "Failed" + else -> "Waiting on others" + }, tint = when { - proposal.session.stage == FrostSigningStage.FAILED -> - MaterialTheme.colorScheme.error - proposal.awaitsYou -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant + // `error` is 2.67:1 on the highlighted card, so on that one the + // failure is carried by the icon shape and the supporting line + // rather than by colour -- which is the more robust signal + // anyway, and the only one available to a monochrome display. + proposal.session.stage == FrostSigningStage.FAILED && + !proposal.awaitsYou -> MaterialTheme.colorScheme.error + else -> cardContentColor } ) }, @@ -263,9 +308,9 @@ private fun ProposalCard( // it saying what it said there. if (proposal.awaitsYou) { Text( - text = "Review", + text = stringResource(Res.string.review), style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary + color = cardContentColor ) } @@ -286,7 +331,7 @@ private fun ProposalCard( ) }, supportingContent = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25)) { if (proposal.eventCount > 1) { Text( text = "with ${proposal.eventCount - 1} more " + @@ -300,8 +345,12 @@ private fun ProposalCard( // rather than left for the screen behind it. if (proposal.unreadable > 0) { Text( - text = "${proposal.unreadable} of them could not be read", - color = MaterialTheme.colorScheme.error + text = stringResource(Res.string.of_them_could_not_be_read, proposal.unreadable), + color = if (proposal.awaitsYou) { + cardContentColor + } else { + MaterialTheme.colorScheme.error + } ) } @@ -355,7 +404,7 @@ private fun statusOf(proposal: ProposalListUIState.Proposal): String { } } -@Preview +@ConformancePreviews @Composable private fun ProposalListScreenPreview() { val session = FrostSigningSession( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt index cd3e89ac..9dae5c95 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/RecoveryPhraseScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -31,6 +30,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -39,29 +39,39 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.StateFlow +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.backup_confirmation +import mantra.composeapp.generated.resources.bip39_seed_with_the_standard_bip84 +import mantra.composeapp.generated.resources.could_not_unlock_your_phrase_please_try +import mantra.composeapp.generated.resources.display_recovery_phrase +import mantra.composeapp.generated.resources.hide +import mantra.composeapp.generated.resources.i_have_saved_my_recovery_phrase_somewhere +import mantra.composeapp.generated.resources.i_understand_that_if_i_lose_this_phone_and +import mantra.composeapp.generated.resources.keep_this_phrase_safe_do_not_share_it +import mantra.composeapp.generated.resources.loading_preferences +import mantra.composeapp.generated.resources.lose_this_phone_before_you_do_and_the +import mantra.composeapp.generated.resources.no_wallet_is_open_on_this_device_so_there +import mantra.composeapp.generated.resources.recovery_phrase +import mantra.composeapp.generated.resources.the_recovery_phrase_sometimes_called_a_seed +import mantra.composeapp.generated.resources.this_device_holds_no_phrase_for_the_profile +import mantra.composeapp.generated.resources.unlocking_your_phrase +import mantra.composeapp.generated.resources.word_position +import mantra.composeapp.generated.resources.you_have_not_backed_up_your_recovery_phrase +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.ErrorState import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing import press.mantra.compose.ui.view.model.RecoveryPhraseViewModel import press.mantra.compose.ui.view.state.RecoveryPhraseUIState -private const val PHRASE_INSTRUCTIONS = - "The recovery phrase (sometimes called a seed) is a list of 12 English words. It is the only " + - "way back to this profile: the key that signs as you, and the wallet that holds your " + - "coins, are both derived from it.\n\n" + - "Only you have this phrase. Keep it private — nobody from mantra will ever ask you " + - "for it.\n\n" + - "Do not lose it. Write it down and keep it somewhere safe that is not this phone. If " + - "you lose both the phone and the phrase, this profile and its funds are gone for good." - -private const val PHRASE_DERIVATION = - "BIP39 seed with the standard BIP84 derivation path. The profile's nostr key comes off the " + - "same seed, so these 12 words restore both." - /** * The twelve words behind this profile, shown once and on request. * @@ -123,7 +133,7 @@ private fun RecoveryPhraseContent( TopAppBar( title = { Text( - text = "Recovery Phrase" + text = stringResource(Res.string.recovery_phrase) ) }, navigationIcon = { @@ -142,14 +152,19 @@ private fun RecoveryPhraseContent( } ) { innerPadding -> LazyColumn( - modifier = Modifier.fillMaxWidth().padding(innerPadding).padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .padding(innerPadding) + .readableContent() + .padding(horizontal = MaterialTheme.spacing.screenMargin), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), horizontalAlignment = Alignment.CenterHorizontally ) { item { Text( - modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), - text = PHRASE_INSTRUCTIONS, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = MaterialTheme.spacing.containerPadding), + text = stringResource(Res.string.the_recovery_phrase_sometimes_called_a_seed), style = MaterialTheme.typography.bodyMedium ) } @@ -164,27 +179,28 @@ private fun RecoveryPhraseContent( ) ) { Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), verticalAlignment = Alignment.CenterVertically ) { Icon( Icons.Default.Warning, - contentDescription = null + contentDescription = Decorative ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) Column { Text( - text = "You have not backed up your recovery phrase", + text = stringResource(Res.string.you_have_not_backed_up_your_recovery_phrase), style = MaterialTheme.typography.titleSmall ) Text( - text = "Lose this phone before you do, and the profile goes " + - "with it.", + text = stringResource(Res.string.lose_this_phone_before_you_do_and_the), style = MaterialTheme.typography.bodySmall ) } @@ -198,7 +214,10 @@ private fun RecoveryPhraseContent( modifier = Modifier.fillMaxWidth() ) { Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), horizontalAlignment = Alignment.CenterHorizontally ) { when (recoveryPhraseUIState) { @@ -208,15 +227,15 @@ private fun RecoveryPhraseContent( ) { Icon( Icons.Default.Visibility, - contentDescription = "Display recovery phrase" + contentDescription = Decorative ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) Text( - text = "Display recovery phrase" + text = stringResource(Res.string.display_recovery_phrase) ) } } @@ -224,7 +243,7 @@ private fun RecoveryPhraseContent( is RecoveryPhraseUIState.Revealing -> { LoadingDataIndicator( fillScreen = false, - text = "Unlocking your phrase…" + text = stringResource(Res.string.unlocking_your_phrase) ) } @@ -233,56 +252,41 @@ private fun RecoveryPhraseContent( words = recoveryPhraseUIState.words ) - Spacer( - modifier = Modifier.height(10.dp) - ) - TextButton( onClick = onHideRecoveryPhrase ) { Icon( Icons.Default.VisibilityOff, - contentDescription = "Hide recovery phrase" + contentDescription = Decorative ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) Text( - text = "Hide" + text = stringResource(Res.string.hide) ) } } is RecoveryPhraseUIState.Error -> { - Text( - text = when (recoveryPhraseUIState) { + ErrorState( + message = when (recoveryPhraseUIState) { is RecoveryPhraseUIState.Error.NoActiveWallet -> - "No wallet is open on this device, so there is no " + - "phrase to show." + stringResource(Res.string.no_wallet_is_open_on_this_device_so_there) is RecoveryPhraseUIState.Error.NoPhraseForThisWallet -> - "This device holds no phrase for the profile that is " + - "signed in." + stringResource(Res.string.this_device_holds_no_phrase_for_the_profile) is RecoveryPhraseUIState.Error.SeedUnreadable -> - "Could not unlock your phrase. Please try again." + stringResource(Res.string.could_not_unlock_your_phrase_please_try) }, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.error + // Retrying a wallet that is not open cannot help: the + // seed file has nothing to match against until one is. + onRetry = when (recoveryPhraseUIState) { + is RecoveryPhraseUIState.Error.NoActiveWallet -> null + else -> onRevealRecoveryPhrase + } ) - - Spacer( - modifier = Modifier.height(10.dp) - ) - - TextButton( - onClick = onRevealRecoveryPhrase - ) { - Text( - text = "Try again" - ) - } } } } @@ -292,7 +296,7 @@ private fun RecoveryPhraseContent( item { Text( modifier = Modifier.fillMaxWidth(), - text = "Backup confirmation", + text = stringResource(Res.string.backup_confirmation), style = MaterialTheme.typography.titleMedium ) } @@ -303,21 +307,22 @@ private fun RecoveryPhraseContent( ) { if (isBackupDone == null || isDisclaimerRead == null) { Text( - modifier = Modifier.fillMaxWidth().padding(16.dp), - text = "Loading preferences…", + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.containerPadding), + text = stringResource(Res.string.loading_preferences), style = MaterialTheme.typography.bodySmall ) } else { RecoveryPhraseCheckbox( checked = isBackupDone, - text = "I have saved my recovery phrase somewhere safe.", + text = stringResource(Res.string.i_have_saved_my_recovery_phrase_somewhere), onCheckedChange = onBackupDoneChange ) RecoveryPhraseCheckbox( checked = isDisclaimerRead, - text = "I understand that if I lose this phone and my recovery " + - "phrase, I lose this profile and the funds in its wallet.", + text = stringResource(Res.string.i_understand_that_if_i_lose_this_phone_and), onCheckedChange = onDisclaimerReadChange ) } @@ -326,8 +331,10 @@ private fun RecoveryPhraseContent( item { Text( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - text = PHRASE_DERIVATION, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = MaterialTheme.spacing.containerPadding), + text = stringResource(Res.string.bip39_seed_with_the_standard_bip84), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center ) @@ -343,15 +350,11 @@ private fun RecoveryPhraseContent( @Composable private fun RecoveryPhraseWords(words: List) { Text( - text = "KEEP THIS PHRASE SAFE.\nDO NOT SHARE IT.", + text = stringResource(Res.string.keep_this_phrase_safe_do_not_share_it), style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center ) - Spacer( - modifier = Modifier.height(16.dp) - ) - // Read down the left column and then the right, the way the words are written on a card: // #1..#6 beside #7..#12 rather than odds beside evens. val pairedWords = remember(words) { @@ -363,7 +366,7 @@ private fun RecoveryPhraseWords(words: List) { Column( modifier = Modifier.widthIn(max = 260.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space75) ) { pairedWords.forEachIndexed { index, (first, second) -> Row( @@ -401,14 +404,14 @@ private fun RecoveryPhraseWord( ) { Text( modifier = Modifier.width(28.dp), - text = "#$position", + text = stringResource(Res.string.word_position, position), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.End, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer( - modifier = Modifier.width(6.dp) + modifier = Modifier.width(MaterialTheme.spacing.space75) ) Text( @@ -428,8 +431,14 @@ private fun RecoveryPhraseCheckbox( Row( modifier = Modifier .fillMaxWidth() + // The whole row toggles, so the row is the target and has to carry the 48dp + // the Checkbox alone would. + .minimumInteractiveComponentSize() .clickable { onCheckedChange(!checked) } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding( + horizontal = MaterialTheme.spacing.containerPadding, + vertical = MaterialTheme.spacing.space150 + ), verticalAlignment = Alignment.CenterVertically ) { Checkbox( @@ -438,7 +447,7 @@ private fun RecoveryPhraseCheckbox( ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) Text( @@ -448,7 +457,7 @@ private fun RecoveryPhraseCheckbox( } } -@Preview +@ConformancePreviews @Composable private fun RecoveryPhraseScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { @@ -475,7 +484,7 @@ private fun RecoveryPhraseScreenPreview() { } } -@Preview +@ConformancePreviews @Composable private fun RecoveryPhraseScreenHiddenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt index 0c9fc6d0..f0215c77 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchMemberToAddToChatRoomScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom @@ -40,6 +39,17 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.SearchMemberToAddToChatRoomViewModel import press.mantra.compose.ui.view.state.SearchMemberToAddToChatRoomUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.currently_no_contacts_please_search_and_chat +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -63,103 +73,106 @@ fun SearchMemberToAddToChatRoomScreen( ) ) - when (val searchMemberToAddToChatRoomUIState = searchMemberToAddToChatRoomViewModel.searchMemberToAddToChatRoomUIState) { - is SearchMemberToAddToChatRoomUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = searchMemberToAddToChatRoomUIState.message, - ) - } - } - is SearchMemberToAddToChatRoomUIState.Loaded -> { - Scaffold( - topBar = { - TopAppBar( - title = { - val title = if (searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject != null) { - "Invite new member to ${searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject}" - } else { - "Invite new member to chat" - } - Text( - text = title, - ) - } + ScreenStateTransition(searchMemberToAddToChatRoomViewModel.searchMemberToAddToChatRoomUIState) { uiState -> + when (val searchMemberToAddToChatRoomUIState = uiState) { + is SearchMemberToAddToChatRoomUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = searchMemberToAddToChatRoomUIState.message, ) } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() - ) { + } + is SearchMemberToAddToChatRoomUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + val title = if (searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject != null) { + "Invite new member to ${searchMemberToAddToChatRoomUIState.localChatRoom.chatRoom.subject}" + } else { + "Invite new member to chat" + } + Text( + text = title, + ) + } + ) + } + ) { innerPadding -> Column( - modifier = Modifier.weight(1f).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { + Column( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { - if (searchMemberToAddToChatRoomUIState.contacts.isEmpty()) { - Spacer( - modifier = Modifier.height(20.dp) - ) - Text( - modifier = Modifier.padding(20.dp), - text = "Currently no contacts. Please search and chat with a few people.", - textAlign = TextAlign.Center - ) + if (searchMemberToAddToChatRoomUIState.contacts.isEmpty()) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space250) + ) + Text( + modifier = Modifier.padding(MaterialTheme.spacing.space250), + text = stringResource(Res.string.currently_no_contacts_please_search_and_chat), + textAlign = TextAlign.Center + ) - Spacer( - modifier = Modifier.height(20.dp) - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth().padding(5.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - reverseLayout = true - ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space250) + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + reverseLayout = true + ) { - items( - items = searchMemberToAddToChatRoomUIState.contacts, - key = { it.publicKey } - ) { profile -> - Card( - onClick = { - onNavigateToRoute.invoke( - AddMemberToChatRoomConfirmationRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - profilePublicKey = profile.publicKey, - relayHint = relayHint, + items( + items = searchMemberToAddToChatRoomUIState.contacts, + key = { it.publicKey } + ) { profile -> + Card( + onClick = { + onNavigateToRoute.invoke( + AddMemberToChatRoomConfirmationRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + profilePublicKey = profile.publicKey, + relayHint = relayHint, + ) ) + } + ) { + ListItem( + leadingContent = { + ProfileAvatar( + profile = profile + ) + }, + headlineContent = { + Text( + profile.humanReadableNameOrPubkey() + ) + }, + supportingContent = { + profile.about?.let { + Text( + text = it, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } + } ) } - ) { - ListItem( - leadingContent = { - ProfileAvatar( - profile = profile - ) - }, - headlineContent = { - Text( - profile.humanReadableNameOrPubkey() - ) - }, - supportingContent = { - profile.about?.let { - Text( - text = it, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - } - } - ) } } } @@ -167,38 +180,38 @@ fun SearchMemberToAddToChatRoomScreen( } } } - } - SearchMemberToAddToChatRoomUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { + SearchMemberToAddToChatRoomUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) - Text( - text = "Search member functionality", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Spacer( + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(Res.string.search_member_functionality), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - Text( - text = "Search message functionality will be here.", - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.search_message_functionality_will_be_here), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } @@ -210,7 +223,7 @@ fun SearchMemberToAddToChatRoomScreen( } } -@Preview +@ConformancePreviews @Composable private fun ChatRoomDetailScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt index 159bf627..0e6d0793 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchResultScreen.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.ui.composable.widgets.feed.ListView @@ -54,6 +53,19 @@ import press.mantra.compose.ui.view.model.SearchResultType import press.mantra.compose.ui.view.model.SearchResultViewModel import press.mantra.compose.ui.view.state.SearchResultListUIState import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.profiles +import mantra.composeapp.generated.resources.search +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.searching_for_on +import mantra.composeapp.generated.resources.nothing_matched_that_search +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.EmptyState +import androidx.compose.material3.SnackbarHost +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -81,7 +93,7 @@ fun SearchResultScreen( var expanded by rememberSaveable { mutableStateOf(true) } - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -108,7 +120,7 @@ fun SearchResultScreen( enabled = false, expanded = expanded, onExpandedChange = { expanded = it }, - placeholder = { Text("Search") }, + placeholder = { Text(stringResource(Res.string.search)) }, leadingIcon = { if (expanded) { IconButton( @@ -145,7 +157,7 @@ fun SearchResultScreen( ) { Icon( Icons.Default.Tune, - contentDescription = "Adjust Search" + contentDescription = "Adjust search" ) } } @@ -199,44 +211,24 @@ fun SearchResultScreen( ) { when(SearchResultType.entries[pageIndex]) { SearchResultType.Profiles -> { - Text("Profiles") + Text(stringResource(Res.string.profiles)) } } when (val searchResultListUIState = searchViewModel.searchResultListUIState) { SearchResultListUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "Something went wrong", - ) - } + ErrorState() } is SearchResultListUIState.Loaded -> { // TODO: Handle gallery UI if (searchResultListUIState.localNostrEvents.isEmpty()) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "No events were found", - ) - } + EmptyState(message = stringResource(Res.string.nothing_matched_that_search)) } else { LazyColumn( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { items( items = searchResultListUIState.localNostrEvents, @@ -263,14 +255,14 @@ fun SearchResultScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) Text( - text = "Searching for ${searchViewModel.searchResultType.name.lowercase()} on \"${searchQuery.lowercase()}\"", + text = stringResource(Res.string.searching_for_on, searchViewModel.searchResultType.name.lowercase(), searchQuery.lowercase()), textAlign = TextAlign.Center ) Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) LoadingIndicator() @@ -289,12 +281,12 @@ fun SearchResultScreen( } } -@Preview +@ConformancePreviews @Composable private fun SearchResultScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { SearchResultScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt index 93d7f572..295fb2c6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SearchScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,7 +49,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.repository.NostrRepository @@ -61,6 +61,17 @@ import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.SearchViewModel import press.mantra.compose.ui.view.state.SearchUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.profiles +import mantra.composeapp.generated.resources.recents +import mantra.composeapp.generated.resources.search +import mantra.composeapp.generated.resources.search_hashtags +import mantra.composeapp.generated.resources.trending_notes_functionality_coming_soon_in +import androidx.compose.material3.SnackbarHost +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -83,7 +94,7 @@ fun SearchScreen( // Controls expansion state of the search bar var expanded by rememberSaveable { mutableStateOf(false) } - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -115,7 +126,7 @@ fun SearchScreen( }, expanded = expanded, onExpandedChange = { expanded = it }, - placeholder = { Text("Search") }, + placeholder = { Text(stringResource(Res.string.search)) }, leadingIcon = { if (expanded) { IconButton( @@ -154,7 +165,7 @@ fun SearchScreen( }, supportingContent = { Text( - text = "Search" + text = stringResource(Res.string.search) ) }, leadingContent = { @@ -175,6 +186,7 @@ fun SearchScreen( } }, modifier = Modifier + .minimumInteractiveComponentSize() .clickable { onNavigateToSearchResult.invoke( SearchResultRoute( @@ -193,7 +205,7 @@ fun SearchScreen( headlineContent = { Text(textFieldState.text.toString()) }, supportingContent = { Text( - text = "Search hashtags" + text = stringResource(Res.string.search_hashtags) ) }, leadingContent = { @@ -217,6 +229,7 @@ fun SearchScreen( } }, modifier = Modifier + .minimumInteractiveComponentSize() .clickable { onNavigateToSearchResult.invoke( SearchResultRoute( @@ -256,6 +269,7 @@ fun SearchScreen( ) }, modifier = Modifier + .minimumInteractiveComponentSize() .clickable { onNavigateToProfile.invoke( NostrEventDetailRoute( @@ -277,14 +291,14 @@ fun SearchScreen( if (searchableProfiles.isNotEmpty()) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), horizontalAlignment = Alignment.Start ) { Column( - modifier = Modifier.padding(10.dp), + modifier = Modifier.padding(MaterialTheme.spacing.space125), ) { Text( - text = "Profiles", + text = stringResource(Res.string.profiles), style = MaterialTheme.typography.titleMedium ) } @@ -292,7 +306,7 @@ fun SearchScreen( LazyRow { items(searchViewModel.searchableProfiles) { profile -> Column( - modifier = Modifier.padding(10.dp).width(70.dp), + modifier = Modifier.padding(MaterialTheme.spacing.space125).width(70.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -336,10 +350,10 @@ fun SearchScreen( item { if (searchViewModel.recentSearches.isNotEmpty()) { Column( - modifier = Modifier.padding(10.dp), + modifier = Modifier.padding(MaterialTheme.spacing.space125), ) { Text( - text = "Recents", + text = stringResource(Res.string.recents), style = MaterialTheme.typography.titleMedium ) } @@ -361,6 +375,7 @@ fun SearchScreen( ) }, modifier = Modifier + .minimumInteractiveComponentSize() .clickable { onNavigateToSearchResult.invoke( SearchResultRoute( @@ -389,7 +404,7 @@ fun SearchScreen( } Column( - modifier = Modifier.padding(20.dp).fillMaxWidth(), + modifier = Modifier.padding(MaterialTheme.spacing.space250).fillMaxWidth(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { @@ -398,7 +413,7 @@ fun SearchScreen( title = {} ) Text( - text = "Trending notes functionality coming soon. In the meantime search for what you are looking for.", + text = stringResource(Res.string.trending_notes_functionality_coming_soon_in), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) @@ -408,12 +423,12 @@ fun SearchScreen( } } -@Preview +@ConformancePreviews @Composable private fun SearchScreenPreview() { TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { SearchScreen( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt index 1d9d40c6..4fb9af0a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomMembersScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.Profile @@ -42,6 +41,22 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.SelectChatRoomMembersViewModel import press.mantra.compose.ui.view.state.SelectChatRoomMembersUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.next +import mantra.composeapp.generated.resources.no_one_selected_yet +import mantra.composeapp.generated.resources.no_one_to_add_yet +import mantra.composeapp.generated.resources.search_for_people_and_chat_with_them_first +import mantra.composeapp.generated.resources.you_can_still_carry_on_and_invite_people +import mantra.composeapp.generated.resources.add_people_to +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews /** * Second step of group creation: pick who is in the group. @@ -70,187 +85,190 @@ fun SelectChatRoomMembersScreen( ) ) - when (val selectChatRoomMembersUIState = selectChatRoomMembersViewModel.selectChatRoomMembersUIState) { - is SelectChatRoomMembersUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = selectChatRoomMembersUIState.message, - textAlign = TextAlign.Center - ) - } - } - is SelectChatRoomMembersUIState.Loaded -> { - val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size - - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = "Add people to $name", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + ScreenStateTransition(selectChatRoomMembersViewModel.selectChatRoomMembersUIState) { uiState -> + when (val selectChatRoomMembersUIState = uiState) { + is SelectChatRoomMembersUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) ) - }, - bottomBar = { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - selectChatRoomMembersViewModel.selectChatRoomType( - onNavigateToRoute = onNavigateToRoute + Text( + text = selectChatRoomMembersUIState.message, + textAlign = TextAlign.Center + ) + } + } + is SelectChatRoomMembersUIState.Loaded -> { + val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.add_people_to, name), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + selectChatRoomMembersViewModel.selectChatRoomType( + onNavigateToRoute = onNavigateToRoute + ) + } + ) { + Icon( + Icons.AutoMirrored.Filled.NavigateNext, + contentDescription = "Next" + ) + + Text( + text = if (selectedCount > 0) { + stringResource(Res.string.next_with, selectedCount) + } else { + stringResource(Res.string.next) + } ) } + }, + actions = { + Text( + modifier = Modifier.padding(start = MaterialTheme.spacing.space200), + text = if (selectedCount > 0) { + stringResource(Res.string.selected, selectedCount) + } else { + stringResource(Res.string.no_one_selected_yet) + }, + style = MaterialTheme.typography.labelLarge + ) + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + if (selectChatRoomMembersUIState.profiles.isEmpty()) { + Column( + modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { - Icon( - Icons.AutoMirrored.Filled.NavigateNext, - contentDescription = "Next" + Spacer( + modifier = Modifier.weight(1f) ) Text( - text = if (selectedCount > 0) { - "Next with $selectedCount" - } else { - "Next" - } + text = stringResource(Res.string.no_one_to_add_yet), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.search_for_people_and_chat_with_them_first), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.you_can_still_carry_on_and_invite_people), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center + ) + + Spacer( + modifier = Modifier.weight(2f) ) } - }, - actions = { - Text( - modifier = Modifier.padding(start = 15.dp), - text = if (selectedCount > 0) { - "$selectedCount selected" - } else { - "No one selected yet" - }, - style = MaterialTheme.typography.labelLarge - ) - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() - ) { - if (selectChatRoomMembersUIState.profiles.isEmpty()) { - Column( - modifier = Modifier.weight(1f).fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Spacer( - modifier = Modifier.weight(1f) - ) + } else { + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth().padding(MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + items( + items = selectChatRoomMembersUIState.profiles, + key = { it.publicKey } + ) { profile -> + val isSelected = selectChatRoomMembersViewModel.isSelected(profile.publicKey) - Text( - text = "No one to add yet.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - - Text( - text = "Search for people and chat with them first — everyone you know locally shows up here.", - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - Text( - text = "You can still carry on and invite people later.", - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - - Spacer( - modifier = Modifier.weight(2f) - ) - } - } else { - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth().padding(5.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - items( - items = selectChatRoomMembersUIState.profiles, - key = { it.publicKey } - ) { profile -> - val isSelected = selectChatRoomMembersViewModel.isSelected(profile.publicKey) - - Card( - onClick = { - selectChatRoomMembersViewModel.toggleMember(profile.publicKey) - } - ) { - ListItem( - leadingContent = { - ProfileAvatar( - profile = profile - ) - }, - headlineContent = { - Text( - profile.humanReadableNameOrPubkey() - ) - }, - supportingContent = { - profile.about?.let { + Card( + onClick = { + selectChatRoomMembersViewModel.toggleMember(profile.publicKey) + } + ) { + ListItem( + leadingContent = { + ProfileAvatar( + profile = profile + ) + }, + headlineContent = { Text( - text = it, - overflow = TextOverflow.Ellipsis, - maxLines = 1 + profile.humanReadableNameOrPubkey() + ) + }, + supportingContent = { + profile.about?.let { + Text( + text = it, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + } + }, + trailingContent = { + Checkbox( + checked = isSelected, + onCheckedChange = { + selectChatRoomMembersViewModel.toggleMember(profile.publicKey) + } ) } - }, - trailingContent = { - Checkbox( - checked = isSelected, - onCheckedChange = { - selectChatRoomMembersViewModel.toggleMember(profile.publicKey) - } - ) - } - ) + ) + } } } } } } } - } - SelectChatRoomMembersUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { + SelectChatRoomMembersUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) + Spacer( + modifier = Modifier.weight(1f) + ) - Text( - text = "Add people to $name", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.add_people_to, name), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } @@ -262,7 +280,7 @@ fun SelectChatRoomMembersScreen( } } -@Preview +@ConformancePreviews @Composable private fun SelectChatRoomMembersScreenPreview() { TorchTheme { @@ -271,7 +289,7 @@ private fun SelectChatRoomMembersScreenPreview() { ) { SelectChatRoomMembersScreen( activeUserPublicKey = "", - name = "Group Discussions", + name = "Group discussions", description = "See something... say something.", initialSelectChatRoomMembersUIState = SelectChatRoomMembersUIState.Loaded( profiles = listOf( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt index 3d840986..9f58557e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectChatRoomTypeScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.Profile @@ -55,6 +54,26 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.data.ActiveWallet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.create_chat +import mantra.composeapp.generated.resources.how_many_admins_have_to_approve_a_change +import mantra.composeapp.generated.resources.just_you_for_now +import mantra.composeapp.generated.resources.open_chat +import mantra.composeapp.generated.resources.this_decides_who_can_change_the_group_later +import mantra.composeapp.generated.resources.you_and_1_other +import mantra.composeapp.generated.resources.how_should_be_run +import mantra.composeapp.generated.resources.of +import mantra.composeapp.generated.resources.was_created_but_couldn_t_be_added_yet_invite +import mantra.composeapp.generated.resources.was_created_but_its_shared_key_ceremony +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews /** * Last step of group creation: convenient (one admin) or robust (everyone @@ -88,207 +107,210 @@ fun SelectChatRoomTypeScreen( ) ) - when (val selectChatRoomTypeUIState = selectChatRoomTypeViewModel.selectChatRoomTypeUIState) { - is SelectChatRoomTypeUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = selectChatRoomTypeUIState.message, - textAlign = TextAlign.Center - ) - } - } - is SelectChatRoomTypeUIState.Loaded -> { - val isActionPending = selectChatRoomTypeViewModel.isActionPending.value - val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value - val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded - val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value - val adminCount = selectChatRoomTypeViewModel.adminCount - val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable - val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST - - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = "How should $name be run?", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + ScreenStateTransition(selectChatRoomTypeViewModel.selectChatRoomTypeUIState) { uiState -> + when (val selectChatRoomTypeUIState = uiState) { + is SelectChatRoomTypeUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) ) - }, - bottomBar = { - BottomAppBar( - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { - selectChatRoomTypeViewModel.createChatRoom( - onNavigateToRoute = onNavigateToRoute - ) - } - ) { - if (isActionPending) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp) - ) - } else { - Icon( - Icons.Default.Check, - contentDescription = "Create chat" - ) - } + Text( + text = selectChatRoomTypeUIState.message, + textAlign = TextAlign.Center + ) + } + } + is SelectChatRoomTypeUIState.Loaded -> { + val isActionPending = selectChatRoomTypeViewModel.isActionPending.value + val selectedChatRoomType = selectChatRoomTypeViewModel.selectedChatRoomType.value + val membersNotAdded = selectChatRoomTypeViewModel.membersNotAdded + val keyCeremonyNotStarted = selectChatRoomTypeViewModel.keyCeremonyNotStarted.value + val adminCount = selectChatRoomTypeViewModel.adminCount + val isRobustAvailable = selectChatRoomTypeViewModel.isRobustAvailable + val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text( - text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { - "Open chat" - } else { - "Create chat" - } + text = stringResource(Res.string.how_should_be_run, name), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - }, - actions = { - Text( - modifier = Modifier.padding(start = 15.dp), - text = when (selectChatRoomTypeUIState.members.size) { - 0 -> "Just you for now" - 1 -> "You and 1 other" - else -> "You and ${selectChatRoomTypeUIState.members.size} others" - }, - style = MaterialTheme.typography.labelLarge - ) - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (membersNotAdded.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Text( - modifier = Modifier.padding(15.dp), - text = "$name was created, but ${membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }} couldn't be added yet. Invite them again from the chat once they're on Torch.", - style = MaterialTheme.typography.bodyMedium - ) - } + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + selectChatRoomTypeViewModel.createChatRoom( + onNavigateToRoute = onNavigateToRoute + ) + } + ) { + if (isActionPending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + Icons.Default.Check, + contentDescription = "Create chat" + ) + } + + Text( + text = if (selectChatRoomTypeViewModel.createdChatRoomId.value != null) { + stringResource(Res.string.open_chat) + } else { + stringResource(Res.string.create_chat) + } + ) + } + }, + actions = { + Text( + modifier = Modifier.padding(start = MaterialTheme.spacing.space200), + text = when (selectChatRoomTypeUIState.members.size) { + 0 -> stringResource(Res.string.just_you_for_now) + 1 -> stringResource(Res.string.you_and_1_other) + else -> stringResource(Res.string.you_and_others, selectChatRoomTypeUIState.members.size) + }, + style = MaterialTheme.typography.labelLarge + ) + } + ) } - - if (keyCeremonyNotStarted) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Text( - modifier = Modifier.padding(15.dp), - text = "$name was created, but its shared key ceremony couldn't be started. Open the chat and start it from the group's details — until then the group has no key of its own.", - style = MaterialTheme.typography.bodyMedium - ) - } - } - - Text( - text = "This decides who can change the group later. You can't switch afterwards.", - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp) - ) - - ChatRoomTypeCard( - icon = Icons.Default.Bolt, - title = "Convenient", - summary = "You are the only admin.", - detail = "You can add or remove people and change the group's name or description on your own, without waiting for anybody.", - footnote = "Nothing about the group can change unless you do it — and nobody else can carry it on if you lose your keys.", - isSelected = selectedChatRoomType == ChatRoomType.CONVENIENT, - onClick = { - selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.CONVENIENT) - } - ) - - ChatRoomTypeCard( - icon = Icons.Default.Groups, - title = "Robust", - summary = "Every member is an admin.", - detail = if (isRobustAvailable) { - "All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of you before it takes effect." - } else { - "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect." - }, - // Says what tapping create actually does, because it is the one - // thing here that asks something of everybody else: the group's - // first act is a ceremony each member has to approve their way - // through before there is a key. - footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.", - isSelected = isRobustSelected, - isEnabled = isRobustAvailable, - disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, - onClick = { - selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST) - } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.space125), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { - // Ask for the quorum only once robust is the choice — before that - // there is no decision to make. - if (isRobustSelected) { - QuorumPicker( - quorum = selectChatRoomTypeViewModel.quorum.value, - adminCount = adminCount, - quorumRange = selectChatRoomTypeViewModel.quorumRange, - explanation = selectChatRoomTypeViewModel.quorumExplanation(), - onQuorumChange = selectChatRoomTypeViewModel::setQuorum - ) + if (membersNotAdded.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text( + modifier = Modifier.padding(MaterialTheme.spacing.space200), + text = stringResource(Res.string.was_created_but_couldn_t_be_added_yet_invite, name, membersNotAdded.joinToString { selectChatRoomTypeViewModel.displayNameFor(it) }), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + if (keyCeremonyNotStarted) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text( + modifier = Modifier.padding(MaterialTheme.spacing.space200), + text = stringResource(Res.string.was_created_but_its_shared_key_ceremony, name), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Text( + text = stringResource(Res.string.this_decides_who_can_change_the_group_later), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space125) + ) + + ChatRoomTypeCard( + icon = Icons.Default.Bolt, + title = "Convenient", + summary = "You are the only admin.", + detail = "You can add or remove people and change the group's name or description on your own, without waiting for anybody.", + footnote = "Nothing about the group can change unless you do it — and nobody else can carry it on if you lose your keys.", + isSelected = selectedChatRoomType == ChatRoomType.CONVENIENT, + onClick = { + selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.CONVENIENT) + } + ) + + ChatRoomTypeCard( + icon = Icons.Default.Groups, + title = "Robust", + summary = "Every member is an admin.", + detail = if (isRobustAvailable) { + "All $adminCount of you administer the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of you before it takes effect." + } else { + "Everyone administers the group together. Any change — adding or removing someone, renaming the group — has to be approved by a quorum of the admins before it takes effect." + }, + // Says what tapping create actually does, because it is the one + // thing here that asks something of everybody else: the group's + // first act is a ceremony each member has to approve their way + // through before there is a key. + footnote = "No single admin can change the group alone, and the group outlives any one of you. Creating the group starts a key ceremony every member takes part in.", + isSelected = isRobustSelected, + isEnabled = isRobustAvailable, + disabledReason = selectChatRoomTypeViewModel.robustUnavailableReason, + onClick = { + selectChatRoomTypeViewModel.selectChatRoomType(ChatRoomType.ROBUST) + } + ) { + // Ask for the quorum only once robust is the choice — before that + // there is no decision to make. + if (isRobustSelected) { + QuorumPicker( + quorum = selectChatRoomTypeViewModel.quorum.value, + adminCount = adminCount, + quorumRange = selectChatRoomTypeViewModel.quorumRange, + explanation = selectChatRoomTypeViewModel.quorumExplanation(), + onQuorumChange = selectChatRoomTypeViewModel::setQuorum + ) + } } } } } - } - SelectChatRoomTypeUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding( - 20.dp - ), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { + SelectChatRoomTypeUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding( + MaterialTheme.spacing.space250 + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { - Spacer( - modifier = Modifier.weight(1f) - ) + Spacer( + modifier = Modifier.weight(1f) + ) - Text( - text = "How should $name be run?", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) + Text( + text = stringResource(Res.string.how_should_be_run, name), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) - LoadingDataIndicator( - fillScreen = false - ) + LoadingDataIndicator( + fillScreen = false + ) - Spacer( - modifier = Modifier.weight(2f) - ) + Spacer( + modifier = Modifier.weight(2f) + ) + } } } } @@ -328,12 +350,12 @@ private fun ChatRoomTypeCard( } ) { Column( - modifier = Modifier.fillMaxWidth().padding(15.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space200), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { RadioButton( @@ -344,7 +366,7 @@ private fun ChatRoomTypeCard( Icon( imageVector = icon, - contentDescription = null + contentDescription = Decorative ) Text( @@ -394,17 +416,17 @@ private fun QuorumPicker( onQuorumChange: (Int) -> Unit, ) { Column( - modifier = Modifier.fillMaxWidth().padding(top = 5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp) + modifier = Modifier.fillMaxWidth().padding(top = MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space50) ) { Text( - text = "How many admins have to approve a change?", + text = stringResource(Res.string.how_many_admins_have_to_approve_a_change), style = MaterialTheme.typography.titleSmall ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(15.dp), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space200), verticalAlignment = Alignment.CenterVertically ) { FilledIconButton( @@ -418,7 +440,7 @@ private fun QuorumPicker( } Text( - text = "$quorum of $adminCount", + text = stringResource(Res.string.of, quorum, adminCount), style = MaterialTheme.typography.titleMedium ) @@ -440,7 +462,7 @@ private fun QuorumPicker( } } -@Preview +@ConformancePreviews @Composable private fun SelectChatRoomTypeScreenPreview() { TorchTheme { @@ -449,7 +471,7 @@ private fun SelectChatRoomTypeScreenPreview() { ) { SelectChatRoomTypeScreen( activeUserPublicKey = "", - name = "Group Discussions", + name = "Group discussions", description = "See something... say something.", memberPublicKeys = listOf("hex", "otherHex"), initialSelectChatRoomTypeUIState = SelectChatRoomTypeUIState.Loaded( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt index ecc03700..eeb75341 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ShareProfileScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.NostrEvent @@ -44,6 +43,19 @@ import press.mantra.compose.ui.view.model.ShareProfileViewModel import press.mantra.compose.ui.view.state.ShareProfileUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.profile +import mantra.composeapp.generated.resources.re_broadcast +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.we_couldn_t_find_the_local_profile_please +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -65,159 +77,157 @@ fun ShareProfileScreen( ), ) - when(val shareProfileUIState = shareProfileViewModel.shareProfileUIState) { - ShareProfileUIState.Error -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("Something went wrong") + ScreenStateTransition(shareProfileViewModel.shareProfileUIState) { uiState -> + when (val shareProfileUIState = uiState) { + ShareProfileUIState.Error -> { + ErrorState() } - } - is ShareProfileUIState.Loaded -> { - Scaffold( - topBar = { - TopAppBar( - title = { - Text( - text = "Profile" - ) - }, - navigationIcon = { - IconButton( - onClick = { - onNavigateBack.invoke() - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back" + is ShareProfileUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.profile) ) - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding) - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(10.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - item { - Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - ProfileAvatar( - profile = shareProfileUIState.localNostrEvent.profile, - publicKey = activeUserPublicKey - ) - - Column { - shareProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> - Text( - text = humanReadableNameOrPubkey, - style = MaterialTheme.typography.titleLarge - ) - } - shareProfileUIState.localNostrEvent.profile?.about?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - - item { - Row( - modifier = Modifier.padding(40.dp) - ) { - QRCodeView( - activeUserPublicKey.hexToNpubHrp() - ) - } - } - - item { - Row( - modifier = Modifier.fillMaxWidth().padding(20.dp) - ) { - val clipboardManager = LocalClipboardManager.current - - TextButton( + }, + navigationIcon = { + IconButton( onClick = { - - clipboardManager.setText( - buildAnnotatedString { - append(activeUserPublicKey.hexToNpubHrp()) - } - ) + onNavigateBack.invoke() } ) { Icon( - Icons.Default.ContentCopy, - contentDescription = "Copy npub" + Icons.Default.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding) + .readableContent() + ) { + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + horizontalAlignment = Alignment.CenterHorizontally + ) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + ProfileAvatar( + profile = shareProfileUIState.localNostrEvent.profile, + publicKey = activeUserPublicKey ) - Spacer( - modifier = Modifier.width(10.dp) - ) - Text( - text = activeUserPublicKey.hexToNpubHrp(), - style = MaterialTheme.typography.labelSmall + Column { + shareProfileUIState.localNostrEvent.profile?.humanReadableNameOrPubkey()?.let { humanReadableNameOrPubkey -> + Text( + text = humanReadableNameOrPubkey, + style = MaterialTheme.typography.titleLarge + ) + } + shareProfileUIState.localNostrEvent.profile?.about?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + item { + Row( + modifier = Modifier.padding(MaterialTheme.spacing.space500) + ) { + QRCodeView( + activeUserPublicKey.hexToNpubHrp() ) } } - } - } + item { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250) + ) { + val clipboardManager = LocalClipboardManager.current - Row( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalArrangement = Arrangement.Center - ) { - Button( - onClick = { - shareProfileViewModel.rebroadcastProfile( - localNostrEvent = shareProfileUIState.localNostrEvent + TextButton( + onClick = { + + clipboardManager.setText( + buildAnnotatedString { + append(activeUserPublicKey.hexToNpubHrp()) + } + ) + } + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy npub" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + Text( + text = activeUserPublicKey.hexToNpubHrp(), + style = MaterialTheme.typography.labelSmall + ) + } + } + + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalArrangement = Arrangement.Center + ) { + Button( + onClick = { + shareProfileViewModel.rebroadcastProfile( + localNostrEvent = shareProfileUIState.localNostrEvent + ) + } + ) { + Text( + stringResource(Res.string.re_broadcast) ) } - ) { - Text( - "Re-broadcast" - ) } } } } - } - ShareProfileUIState.Loading -> { - LoadingDataIndicator() + ShareProfileUIState.Loading -> { + LoadingDataIndicator() - LaunchedEffect(true) { - shareProfileViewModel.loadNostrFeed() + LaunchedEffect(true) { + shareProfileViewModel.loadNostrFeed() + } } - } - ShareProfileUIState.NotFound -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("We couldn't find the local profile. Please try again later.") + ShareProfileUIState.NotFound -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(Res.string.we_couldn_t_find_the_local_profile_please)) + } } } } } -@Preview +@ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt index 70608902..bd544193 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SignInScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Create @@ -25,11 +26,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.be_sure_to_keep_this_nsec_safe +import mantra.composeapp.generated.resources.enter_the_nsec_or_npub_read_only_that_you +import mantra.composeapp.generated.resources.next +import mantra.composeapp.generated.resources.nsec_npub_nip_05_static_address +import mantra.composeapp.generated.resources.sign_in +import mantra.composeapp.generated.resources.sign_in_to_nsec +import mantra.composeapp.generated.resources.sign_in_with_an_npub +import mantra.composeapp.generated.resources.something_went_wrong_and_we_were_unable_to +import mantra.composeapp.generated.resources.this_will_give_you_read_only_access_to_the +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -43,23 +60,28 @@ fun SignInToProfileScreen( nostrRepository ) ) - Scaffold { innerPadding -> + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding()) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) ) { when (val signInToProfileUIState = signInToProfileViewModel.signInToProfileUIState.value) { is press.mantra.compose.ui.view.state.SignInToProfileUIState.InputPrompt -> { Text( - "Sign In", + stringResource(Res.string.sign_in), style = MaterialTheme.typography.headlineSmall ) @@ -72,7 +94,7 @@ fun SignInToProfileScreen( Text( text = it, modifier = Modifier.padding( - bottom = 8.dp + bottom = MaterialTheme.spacing.compactPadding ) ) } @@ -83,13 +105,13 @@ fun SignInToProfileScreen( ), label = { Text( - text = "nsec, npub, nip-05 (static address)", + text = stringResource(Res.string.nsec_npub_nip_05_static_address), maxLines = 1, ) }, placeholder = { Text( - text = "Enter the nsec or npub (read only) that you want to sign in as", + text = stringResource(Res.string.enter_the_nsec_or_npub_read_only_that_you), maxLines = 1, ) }, @@ -125,7 +147,7 @@ fun SignInToProfileScreen( }, ) { Text( - "Next" + stringResource(Res.string.next) ) Icon( Icons.Default.NavigateNext, @@ -140,7 +162,7 @@ fun SignInToProfileScreen( modifier = Modifier.weight(1f) ) Text( - text = "Sign in to Npub", + text = stringResource(Res.string.sign_in_with_an_npub), style = MaterialTheme.typography.headlineMedium ) @@ -164,7 +186,7 @@ fun SignInToProfileScreen( Text( - text = "This will give you read only access to the profile.", + text = stringResource(Res.string.this_will_give_you_read_only_access_to_the), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center ) @@ -180,7 +202,7 @@ fun SignInToProfileScreen( } ) { Text( - text = "Sign In" + text = stringResource(Res.string.sign_in) ) } } @@ -192,12 +214,12 @@ fun SignInToProfileScreen( modifier = Modifier.weight(1f) ) Text( - text = "Sign in to nsec", + text = stringResource(Res.string.sign_in_to_nsec), style = MaterialTheme.typography.headlineMedium ) Text( - text = "Be sure to keep this nsec safe.", + text = stringResource(Res.string.be_sure_to_keep_this_nsec_safe), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium ) @@ -210,7 +232,7 @@ fun SignInToProfileScreen( Text( - text = "This will give you write access to the profile.", + text = stringResource(Res.string.this_will_give_you_write_access_to_the), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center ) @@ -226,7 +248,7 @@ fun SignInToProfileScreen( } ) { Text( - text = "Sign In" + text = stringResource(Res.string.sign_in) ) } } @@ -237,7 +259,7 @@ fun SignInToProfileScreen( modifier = Modifier.weight(1f) ) Text( - text = "Something went wrong and we were unable to sign in to your provided profile. Please try again later.", + text = stringResource(Res.string.something_went_wrong_and_we_were_unable_to), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) @@ -251,7 +273,7 @@ fun SignInToProfileScreen( } } -@Preview +@ConformancePreviews @Composable private fun SignInToProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt index 558c58dc..4d310826 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SocialPreconditionScreen.kt @@ -20,8 +20,20 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.invite_a_friend +import mantra.composeapp.generated.resources.skip_for_now +import mantra.composeapp.generated.resources.tell_friends_to_join_you_so_your_feed_stays +import mantra.composeapp.generated.resources.view_and_accept_invites_you_may_have +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 +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun SocialPreconditionScreen( @@ -30,6 +42,7 @@ fun SocialPreconditionScreen( onNavigateToViewInvites: () -> Unit, ) { Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, bottomBar = { BottomAppBar( modifier = Modifier, @@ -46,7 +59,7 @@ fun SocialPreconditionScreen( ) }, text = { - Text("Skip for now") + Text(stringResource(Res.string.skip_for_now)) }, onClick = { onNavigateToSkipForNow.invoke() @@ -57,16 +70,16 @@ fun SocialPreconditionScreen( } ) { innerPadding -> Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { Text( - "Who will you be passing the aux to?", + stringResource(Res.string.who_will_you_be_passing_the_aux_to), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLarge ) @@ -76,7 +89,7 @@ fun SocialPreconditionScreen( ) Text( - "Tell friends to join you so your feed stays lively and fresh.", + stringResource(Res.string.tell_friends_to_join_you_so_your_feed_stays), textAlign = TextAlign.Center ) @@ -86,7 +99,7 @@ fun SocialPreconditionScreen( } ) { Text( - "Invite a Friend" + stringResource(Res.string.invite_a_friend) ) } @@ -95,7 +108,7 @@ fun SocialPreconditionScreen( ) Text( - "View and accept invites you may have received to stay connected with others.", + stringResource(Res.string.view_and_accept_invites_you_may_have), textAlign = TextAlign.Center ) @@ -104,19 +117,19 @@ fun SocialPreconditionScreen( onNavigateToViewInvites.invoke() } ) { - Text("View invites") + Text(stringResource(Res.string.view_invites)) } } } } } -@Preview +@ConformancePreviews @Composable private fun SocialPreconditionScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { SocialPreconditionScreen( onNavigateToSkipForNow = {}, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 98e8bb28..02d664d9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -26,6 +26,12 @@ import fr.acinq.phoenix.utils.preferences.UserPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault import kotlinx.coroutines.flow.first +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.lock_prompt_coming_soon +import mantra.composeapp.generated.resources.select_a_wallet +import mantra.composeapp.generated.resources.startup_error @Composable fun SovereignWalletStartupScreen( @@ -95,7 +101,7 @@ fun SovereignWalletStartupScreen( } activeWallet != null -> { LoadingDataIndicator( - text = "Opening Wallet" + text = "Opening wallet" ) LaunchedEffect(Unit) { sovereignWalletViewModel.loadSovereignData(activeWallet.id) @@ -130,16 +136,22 @@ fun SovereignWalletStartupScreen( ); loadingWallet = it }, canEdit = false, - modifier = Modifier.padding(horizontal = 24.dp), + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space300), topContent = { - Spacer(Modifier.height(64.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space800)) Text( - text = "Select a wallet", + text = stringResource(Res.string.select_a_wallet), style = MaterialTheme.typography.headlineSmall ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space200)) }, bottomContent = { + // m3-spacing-exempt: room to scroll the + // last wallet clear of the bottom of the + // window, not a step in the spacing + // rhythm. The scale tops out at 72dp and + // rounding to it would put the last row + // back under the edge. Spacer(Modifier.height(128.dp)) } ) @@ -184,7 +196,7 @@ fun SovereignWalletStartupScreen( Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "Startup Error" + text = stringResource(Res.string.startup_error) ) when (startupState) { @@ -196,17 +208,17 @@ fun SovereignWalletStartupScreen( } } } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space200)) HorizontalDivider( modifier = Modifier.width(50.dp) ) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space300)) // BorderButton( // text = stringResource(R.string.startup_error_try_again), // icon = R.drawable.ic_reset, // onClick = onTryAgainClick // ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space100)) // StartErrorShareLogsButton() } } @@ -273,7 +285,7 @@ private fun BoxScope.ScreenLockPrompt( goToWalletSelector: (() -> Unit)?, ) { Text( - text = "Lock prompt coming soon.", + text = stringResource(Res.string.lock_prompt_coming_soon), ) // val scope = rememberCoroutineScope() // diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt index b241f191..72ec6024 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslateChunkScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll @@ -35,7 +36,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -52,6 +52,20 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.TranslateChunkViewModel import press.mantra.compose.ui.view.state.TranslateChunkUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.enter_the_translation_for_this_chunk +import mantra.composeapp.generated.resources.original +import mantra.composeapp.generated.resources.propose_translation +import mantra.composeapp.generated.resources.translate_chunk +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -83,158 +97,165 @@ fun TranslateChunkScreen( ) ) - when (val translateChunkUIState = translateChunkViewModel.translateChunkUIState) { - is TranslateChunkUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text(text = translateChunkUIState.message) + ScreenStateTransition(translateChunkViewModel.translateChunkUIState) { uiState -> + when (val translateChunkUIState = uiState) { + is TranslateChunkUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = translateChunkUIState.message) + } } - } - is TranslateChunkUIState.Loaded -> { - val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText) + is TranslateChunkUIState.Loaded -> { + val translationFieldState = rememberTextFieldState(translateChunkUIState.existingTranslationText) - // M3 gives a FAB no `enabled`, so borrow the disabled colours every - // other button in the app uses rather than inventing a shade here. - val buttonColors = ButtonDefaults.buttonColors() + // M3 gives a FAB no `enabled`, so borrow the disabled colours every + // other button in the app uses rather than inventing a shade here. + val buttonColors = ButtonDefaults.buttonColors() - Scaffold( - topBar = { - TopAppBar( - title = { Text("Translate chunk") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - }, - bottomBar = { - BottomAppBar( - actions = {}, - floatingActionButton = { - ExtendedFloatingActionButton( - modifier = if (translateChunkUIState.canSign) { - Modifier - } else { - // Looking unavailable is not being unavailable: - // without this a screen reader still announces - // a button it is happy to press. - Modifier.semantics { disabled() } - }, - containerColor = if (translateChunkUIState.canSign) { - FloatingActionButtonDefaults.containerColor - } else { - buttonColors.disabledContainerColor - }, - contentColor = if (translateChunkUIState.canSign) { - contentColorFor(FloatingActionButtonDefaults.containerColor) - } else { - buttonColors.disabledContentColor - }, - onClick = { - if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton - - translateChunkViewModel.proposeTranslation( - localChatRoom = translateChunkUIState.localChatRoom, - originalChunk = translateChunkUIState.originalChunk, - translationField = translationFieldState, - onSuccess = { sessionId -> - // Onto the session rather than back to - // the chapter table. Nothing has been - // translated yet -- the chunk is - // translated when enough members sign -- - // so a table still showing it untranslated - // would read as a failure. - onNavigateToRouteAndPopUpInclusive.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId - ) - ) - }, - onFailure = { - onNavigateToRoute.invoke( - ImplementationPendingRoute("Failed Translation") - ) - } + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.translate_chunk)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } - ) { - Icon( - Icons.Default.Add, - contentDescription = "Propose translation" - ) - Text("Propose Translation") + }, + ) + }, + bottomBar = { + BottomAppBar( + actions = {}, + floatingActionButton = { + ExtendedFloatingActionButton( + modifier = if (translateChunkUIState.canSign) { + Modifier + } else { + // Looking unavailable is not being unavailable: + // without this a screen reader still announces + // a button it is happy to press. + Modifier.semantics { disabled() } + }, + containerColor = if (translateChunkUIState.canSign) { + FloatingActionButtonDefaults.containerColor + } else { + buttonColors.disabledContainerColor + }, + contentColor = if (translateChunkUIState.canSign) { + contentColorFor(FloatingActionButtonDefaults.containerColor) + } else { + buttonColors.disabledContentColor + }, + onClick = { + if (!translateChunkUIState.canSign) return@ExtendedFloatingActionButton + + translateChunkViewModel.proposeTranslation( + localChatRoom = translateChunkUIState.localChatRoom, + originalChunk = translateChunkUIState.originalChunk, + translationField = translationFieldState, + onSuccess = { sessionId -> + // Onto the session rather than back to + // the chapter table. Nothing has been + // translated yet -- the chunk is + // translated when enough members sign -- + // so a table still showing it untranslated + // would read as a failure. + onNavigateToRouteAndPopUpInclusive.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) + ) + }, + onFailure = { + onNavigateToRoute.invoke( + ImplementationPendingRoute("Failed translation") + ) + } + ) + } + ) { + Icon( + Icons.Default.Add, + contentDescription = "Propose translation" + ) + Text(stringResource(Res.string.propose_translation)) + } } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (!translateChunkUIState.canSign) { + Text( + text = "This group has no shared key, so it cannot sign a " + + "translation. Run a shared key ceremony first.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - if (!translateChunkUIState.canSign) { + Text( - text = "This group has no shared key, so it cannot sign a " + - "translation. Run a shared key ceremony first.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error + text = stringResource(Res.string.original), + style = MaterialTheme.typography.labelMedium + ) + Card { + Text( + modifier = Modifier.padding(MaterialTheme.spacing.containerPadding), + text = translateChunkUIState.originalChunk.text, + style = MaterialTheme.typography.bodyMedium + ) + } + + Text( + text = stringResource(Res.string.translation), + style = MaterialTheme.typography.labelMedium + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + state = translationFieldState, + label = { Text(stringResource(Res.string.translated_text)) }, + placeholder = { Text(stringResource(Res.string.enter_the_translation_for_this_chunk)) }, ) } - - Text( - text = "Original", - style = MaterialTheme.typography.labelMedium - ) - Card { - Text( - modifier = Modifier.padding(16.dp), - text = translateChunkUIState.originalChunk.text, - style = MaterialTheme.typography.bodyMedium - ) - } - - Text( - text = "Translation", - style = MaterialTheme.typography.labelMedium - ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - state = translationFieldState, - label = { Text("Translated text") }, - placeholder = { Text("Enter the translation for this chunk") }, - ) } } - } - TranslateChunkUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Translate Chunk", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + TranslateChunkUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.translate_chunk), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -246,7 +267,7 @@ fun TranslateChunkScreen( } } -@Preview +@ConformancePreviews @Composable private fun TranslateChunkScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt index 2bb9427b..52a86552 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationArtifactVersionDetailScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -46,6 +45,22 @@ import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.TranslationArtifactVersionDetailViewModel import press.mantra.compose.ui.view.state.TranslationArtifactVersionDetailUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.chapters +import mantra.composeapp.generated.resources.details +import mantra.composeapp.generated.resources.no_chapters +import mantra.composeapp.generated.resources.propose +import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_cannot +import mantra.composeapp.generated.resources.translation_detail +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -74,187 +89,190 @@ fun TranslationArtifactVersionDetailScreen( ) ) - when (val translationDetailUIState = translationDetailViewModel.translationDetailUIState) { - is TranslationArtifactVersionDetailUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text(text = translationDetailUIState.message) - } - } - - is TranslationArtifactVersionDetailUIState.Loaded -> { - val translation = translationDetailUIState.translation - Scaffold( - topBar = { - TopAppBar( - title = { Text(translation.name) }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).fillMaxSize().padding(20.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + ScreenStateTransition(translationDetailViewModel.translationDetailUIState) { uiState -> + when (val translationDetailUIState = uiState) { + is TranslationArtifactVersionDetailUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Chapters the source version has and this translation - // does not. Above the list rather than after it: a chapter - // that is not here is invisible from a list of the ones - // that are. - val localChatRoom = translationDetailUIState.localChatRoom - val missingChapters = translationDetailUIState.missingChapters + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = translationDetailUIState.message) + } + } - if (missingChapters.isNotEmpty()) { - item { - Card { - ListItem( - leadingContent = { - Icon( - Icons.Default.PlaylistAdd, - contentDescription = "Chapters missing from this translation" - ) - }, - headlineContent = { - Text( - "${missingChapters.size} " + - (if (missingChapters.size == 1) "chapter is" else "chapters are") + - " not in this translation yet" - ) - }, - supportingContent = { - Text( - if (!translationDetailUIState.canSign) { - "This group has no shared key, so it cannot sign them in." - } else { - "They were signed into the artifact after this " + - "translation. Ask the group to add them so they " + - "can be translated." - } - ) - }, - trailingContent = { - TextButton( - enabled = localChatRoom != null && - translationDetailUIState.canSign && - !translationDetailViewModel.isActionPending.value, - onClick = { - if (localChatRoom == null) return@TextButton + is TranslationArtifactVersionDetailUIState.Loaded -> { + val translation = translationDetailUIState.translation + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(translation.name) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize().padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + // Chapters the source version has and this translation + // does not. Above the list rather than after it: a chapter + // that is not here is invisible from a list of the ones + // that are. + val localChatRoom = translationDetailUIState.localChatRoom + val missingChapters = translationDetailUIState.missingChapters - translationDetailViewModel.catchUpMissingChapters( - localChatRoom = localChatRoom, - missingChapters = missingChapters, - onSuccess = { sessionId -> - onNavigateToRoute.invoke( - FrostSigningRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - sessionId = sessionId + if (missingChapters.isNotEmpty()) { + item { + Card { + ListItem( + leadingContent = { + Icon( + Icons.Default.PlaylistAdd, + contentDescription = "Chapters missing from this translation" + ) + }, + headlineContent = { + Text( + "${missingChapters.size} " + + (if (missingChapters.size == 1) "chapter is" else "chapters are") + + " not in this translation yet" + ) + }, + supportingContent = { + Text( + if (!translationDetailUIState.canSign) { + stringResource(Res.string.this_group_has_no_shared_key_so_it_cannot) + } else { + "They were signed into the artifact after this " + + "translation. Ask the group to add them so they " + + "can be translated." + } + ) + }, + trailingContent = { + TextButton( + enabled = localChatRoom != null && + translationDetailUIState.canSign && + !translationDetailViewModel.isActionPending.value, + onClick = { + if (localChatRoom == null) return@TextButton + + translationDetailViewModel.catchUpMissingChapters( + localChatRoom = localChatRoom, + missingChapters = missingChapters, + onSuccess = { sessionId -> + onNavigateToRoute.invoke( + FrostSigningRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + sessionId = sessionId + ) ) - ) - }, - onFailure = {} - ) + }, + onFailure = {} + ) + } + ) { + Text(stringResource(Res.string.propose)) } - ) { - Text("Propose") } + ) + } + } + + item { HorizontalDivider() } + } + + // Chapters with translation progress + item { + Text( + text = stringResource(Res.string.chapters), + style = MaterialTheme.typography.labelMedium + ) + } + if (translationDetailUIState.chapters.isEmpty()) { + item { Text(stringResource(Res.string.no_chapters)) } + } else { + items( + items = translationDetailUIState.chapters, + key = { progress -> progress.chapter.id } + ) { progress -> + Card( + onClick = { + onNavigateToRoute.invoke( + TranslationChapterRoute( + activeUserPublicKey = activeUserPublicKey, + translationChapterId = progress.chapter.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) } - ) + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.Article, + contentDescription = "Chapter" + ) + }, + headlineContent = { Text(stringResource(Res.string.chapter, progress.chapter.index)) }, + supportingContent = { + Text(stringResource(Res.string.chunks_translated, progress.translatedChunks, progress.totalChunks)) + } + ) + } } } item { HorizontalDivider() } - } - // Chapters with translation progress - item { - Text( - text = "Chapters", - style = MaterialTheme.typography.labelMedium - ) - } - if (translationDetailUIState.chapters.isEmpty()) { - item { Text("No chapters.") } - } else { - items( - items = translationDetailUIState.chapters, - key = { progress -> progress.chapter.id } - ) { progress -> - Card( - onClick = { - onNavigateToRoute.invoke( - TranslationChapterRoute( - activeUserPublicKey = activeUserPublicKey, - translationChapterId = progress.chapter.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) - ) - } - ) { - ListItem( - leadingContent = { - Icon( - Icons.Default.Article, - contentDescription = "Chapter" - ) - }, - headlineContent = { Text("Chapter ${progress.chapter.index}") }, - supportingContent = { - Text("${progress.translatedChunks}/${progress.totalChunks} chunks translated") - } - ) - } + // Details + item { + Text( + text = stringResource(Res.string.details), + style = MaterialTheme.typography.labelMedium + ) } - } - - item { HorizontalDivider() } - - // Details - item { - Text( - text = "Details", - style = MaterialTheme.typography.labelMedium - ) - } - item { - Card { - DetailRow(label = "Name", value = translation.name) - DetailRow(label = "Dialect", value = translation.dialectId) - DetailRow(label = "Visibility", value = translation.visibility) - DetailRow(label = "License", value = translation.license) - DetailRow(label = "Source version", value = translation.artifactVersionId) - DetailRow(label = "Author", value = translation.publicKey) - DetailRow(label = "Created", value = translation.createdAt.toString()) + item { + Card { + DetailRow(label = "Name", value = translation.name) + DetailRow(label = "Dialect", value = translation.dialectId) + DetailRow(label = "Visibility", value = translation.visibility) + DetailRow(label = "License", value = translation.license) + DetailRow(label = "Source version", value = translation.artifactVersionId) + DetailRow(label = "Author", value = translation.publicKey) + DetailRow(label = "Created", value = translation.createdAt.toString()) + } } } } } - } - TranslationArtifactVersionDetailUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Translation Detail", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + TranslationArtifactVersionDetailUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.translation_detail), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -266,7 +284,7 @@ fun TranslationArtifactVersionDetailScreen( } } -@Preview +@ConformancePreviews @Composable private fun TranslationArtifactVersionDetailScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt index 8d7a5e18..79f3a62a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/TranslationChapterScreen.kt @@ -27,13 +27,13 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -46,6 +46,16 @@ import press.mantra.compose.ui.theme.TorchTheme import press.mantra.compose.ui.view.model.TranslationChapterViewModel import press.mantra.compose.ui.view.state.ChunkTranslationPair import press.mantra.compose.ui.view.state.TranslationChapterUIState +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +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 +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,104 +80,107 @@ fun TranslationChapterScreen( ) ) - when (val translationChapterUIState = translationChapterViewModel.translationChapterUIState) { - is TranslationChapterUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(50.dp)) - Text(text = translationChapterUIState.message) - } - } - - is TranslationChapterUIState.Loaded -> { - Scaffold( - topBar = { - TopAppBar( - title = { Text("Chapter translation") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - ) - } - ) { innerPadding -> - LazyColumn( - modifier = Modifier.padding(innerPadding).fillMaxSize() + ScreenStateTransition(translationChapterViewModel.translationChapterUIState) { uiState -> + when (val translationChapterUIState = uiState) { + is TranslationChapterUIState.Error -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - // Header row: dialect names. - item { - TableRow( - left = { - Text( - text = translationChapterUIState.originalDialectName, - fontWeight = FontWeight.Bold - ) - }, - right = { - Text( - text = translationChapterUIState.translationDialectName, - fontWeight = FontWeight.Bold - ) - }, - ) - HorizontalDivider() - } + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = translationChapterUIState.message) + } + } - if (translationChapterUIState.rows.isEmpty()) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center - ) { - Text("This chapter has no chunks.") - } - } - } else { - items( - items = translationChapterUIState.rows, - key = { pair -> pair.originalChunk.id } - ) { pair -> - ChunkTranslationRow( - pair = pair, - onClick = { - onNavigateToRoute.invoke( - TranslateChunkRoute( - activeUserPublicKey = activeUserPublicKey, - translationChapterId = translationChapterId, - chunkId = pair.originalChunk.id, - chatRoomId = chatRoomId, - relayHint = relayHint - ) + is TranslationChapterUIState.Loaded -> { + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.chapter_translation)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" ) } + }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() + ) { + // Header row: dialect names. + item { + TableRow( + left = { + Text( + text = translationChapterUIState.originalDialectName, + fontWeight = FontWeight.Bold + ) + }, + right = { + Text( + text = translationChapterUIState.translationDialectName, + fontWeight = FontWeight.Bold + ) + }, ) HorizontalDivider() } + + if (translationChapterUIState.rows.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.containerPadding), + contentAlignment = Alignment.Center + ) { + Text(stringResource(Res.string.this_chapter_has_no_chunks)) + } + } + } else { + items( + items = translationChapterUIState.rows, + key = { pair -> pair.originalChunk.id } + ) { pair -> + ChunkTranslationRow( + pair = pair, + onClick = { + onNavigateToRoute.invoke( + TranslateChunkRoute( + activeUserPublicKey = activeUserPublicKey, + translationChapterId = translationChapterId, + chunkId = pair.originalChunk.id, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) + HorizontalDivider() + } + } } } } - } - TranslationChapterUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Spacer(modifier = Modifier.weight(1f)) - Text( - text = "Chapter Translation", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center - ) - LoadingDataIndicator(fillScreen = false) - Spacer(modifier = Modifier.weight(2f)) + TranslationChapterUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.chapter_translation), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + LoadingDataIndicator(fillScreen = false) + Spacer(modifier = Modifier.weight(2f)) + } } } } @@ -191,7 +204,8 @@ private fun ChunkTranslationRow( // there is no translation yet, the original text is shown greyed out as // a placeholder. It stays plain text, laid out like the original cell // beside it, rather than a button with its own shape and padding. - rightModifier = Modifier.clickable(onClick = onClick), + rightModifier = Modifier.minimumInteractiveComponentSize() + .clickable(onClick = onClick), right = { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -222,13 +236,13 @@ private fun TableRow( Row( modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min) ) { - Box(modifier = Modifier.weight(1f).padding(12.dp)) { left() } + Box(modifier = Modifier.weight(1f).padding(MaterialTheme.spacing.space150)) { left() } VerticalDivider(modifier = Modifier.fillMaxHeight()) - Box(modifier = Modifier.weight(1f).then(rightModifier).padding(12.dp)) { right() } + Box(modifier = Modifier.weight(1f).then(rightModifier).padding(MaterialTheme.spacing.space150)) { right() } } } -@Preview +@ConformancePreviews @Composable private fun TranslationChapterScreenPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt index 536d8fe3..8a03a051 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnannouncedProfileScreen.kt @@ -15,36 +15,45 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.everything_is_cryptographical_sound_just +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UnannouncedProfileScreen() { - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) ) { Spacer( modifier = Modifier.weight(1f) ) Text( - text = "Everything is cryptographical sound. Just announcing your profile to the world.", + text = stringResource(Res.string.everything_is_cryptographical_sound_just), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) Text( - text = "Torch will be broadcast what you publish to a distributed set of relays so that it's decentralized.", + text = stringResource(Res.string.torch_will_be_broadcast_what_you_publish_to), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) @@ -67,7 +76,7 @@ fun UnannouncedProfileScreen() { } } -@Preview +@ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt index a0105b4b..44bae335 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnindexedProfileScreen.kt @@ -15,36 +15,45 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.events_are_indexed_so_that_we_can_deliver_a +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UnindexedProfileScreen() { - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) ) { Spacer( modifier = Modifier.weight(1f) ) Text( - text = "Everything is cryptographical sound. Just indexing your profile on the device.", + text = stringResource(Res.string.everything_is_cryptographical_sound_just_2), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) Text( - text = "Events are indexed so that we can deliver a premium local first experience.", + text = stringResource(Res.string.events_are_indexed_so_that_we_can_deliver_a), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) @@ -67,7 +76,7 @@ fun UnindexedProfileScreen() { } } -@Preview +@ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt index e5d15870..96f918ce 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileScreen.kt @@ -15,36 +15,45 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.all_broadcasts_are_queued_so_that_we_can +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UnqueuedProfileScreen() { - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) ) { Spacer( modifier = Modifier.weight(1f) ) Text( - text = "Everything is cryptographical sound. Just need to queue your profile and announce it to the world.", + text = stringResource(Res.string.everything_is_cryptographical_sound_just_3), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) Text( - text = "All broadcasts are queued so that we can manage data usage on metered connections.", + text = stringResource(Res.string.all_broadcasts_are_queued_so_that_we_can), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) @@ -67,7 +76,7 @@ fun UnqueuedProfileScreen() { } } -@Preview +@ConformancePreviews @Composable private fun UnannouncedProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt index 486ada59..6bbcbb9f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnqueuedProfileSynchronizationScreen.kt @@ -16,9 +16,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_as_many +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -35,30 +43,31 @@ fun UnqueuedProfileSynchronizationScreen( ) ) - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) ) { Spacer( modifier = Modifier.weight(1f) ) Text( - text = "We are searching the internet to find your profile and complete sign in.", + text = stringResource(Res.string.we_are_searching_the_internet_to_find_your), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) Text( - text = "We are looking for your profile on as many relays as possible. Nostr aims to be decentralized by distributing data to multiple nodse.", + text = stringResource(Res.string.we_are_looking_for_your_profile_on_as_many), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) @@ -79,7 +88,7 @@ fun UnqueuedProfileSynchronizationScreen( } } -@Preview +@ConformancePreviews @Composable private fun UnqueuedProfileSynchronizationScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt index c4c422f3..fdafc1d0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsignedProfileScreen.kt @@ -15,36 +15,45 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.as_long_as_you_control_your_keys_there_can +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UnsignedProfileScreen() { - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) ) { Spacer( modifier = Modifier.weight(1f) ) Text( - text = "Your profile is almost ready... just getting it's first cryptographic signature together.", + text = stringResource(Res.string.your_profile_is_almost_ready_just_getting_it), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) Text( - text = "As long as you control your keys there can be no dispute about who YOU actually is.", + text = stringResource(Res.string.as_long_as_you_control_your_keys_there_can), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) @@ -61,7 +70,7 @@ fun UnsignedProfileScreen() { } } -@Preview +@ConformancePreviews @Composable private fun UnsignedProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt index 1ce7ea3a..f59d26ea 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/UnsyncedProfileScreen.kt @@ -15,38 +15,47 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.we_are_looking_for_your_profile_on_as_many +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun UnsyncedProfileScreen( unsyncedProfilePublicKey: String ) { - Scaffold { innerPadding -> + Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(20.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space250) ) { Spacer( modifier = Modifier.weight(1f) ) Text( - text = "We are searching the internet to find your profile and complete sign in.", + text = stringResource(Res.string.we_are_searching_the_internet_to_find_your), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) Text( - text = "We are looking for your profile on as many relays as possible. Nostr aims to be decentralized by distributing data to multiple nodse.", + text = stringResource(Res.string.we_are_looking_for_your_profile_on_as_many), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) @@ -63,7 +72,7 @@ fun UnsyncedProfileScreen( } } -@Preview +@ConformancePreviews @Composable private fun UnsyncedProfileScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt index 6568ef55..00bce1e0 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/WriteNewNoteScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions @@ -36,13 +37,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.extensions.toFormattedTimeAndDateString import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.time.Clock +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.next +import mantra.composeapp.generated.resources.something_went_wrong_and_we_were_unable_to_3 +import mantra.composeapp.generated.resources.the_above_will_be_your_new_note +import mantra.composeapp.generated.resources.transmit_note +import mantra.composeapp.generated.resources.type_out_what_you_would_like_to_publish +import mantra.composeapp.generated.resources.what_s_your_comment_on_the_below +import mantra.composeapp.generated.resources.what_s_your_reply_to_the_above +import mantra.composeapp.generated.resources.what_vibrations_do_you_want_to_send_out +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @Composable @@ -64,17 +80,22 @@ fun WriteNewNoteScreen( nostrRepository ) ) - Scaffold { innerPadding -> + // imePadding: this screen has a text field, and without it the software + // keyboard covers whatever is being typed into. + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + modifier = Modifier.imePadding()) { innerPadding -> Column( modifier = Modifier.padding(innerPadding) + .readableContent() ) { Column( modifier = Modifier.fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { when (val writeNewNoteUIState = writeNewNoteViewModel.writeNewNoteUIState) { @@ -97,8 +118,10 @@ fun WriteNewNoteScreen( onNavigateToEvent = onNavigateToNostrEvent ) } else { + // Nothing to show, but the LazyColumn item has to + // render something. 1dp was standing in for zero. Spacer( - modifier = Modifier.height(1.dp) + modifier = Modifier.height(MaterialTheme.spacing.space0) ) } } @@ -110,11 +133,11 @@ fun WriteNewNoteScreen( if (writeNewNoteUIState.inReplyToNostrEvent != null) { Text( modifier = Modifier.padding( - start = 50.dp, - bottom = 10.dp, + start = MaterialTheme.spacing.space600, + bottom = MaterialTheme.spacing.space125, ), - text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}", + text = stringResource(Res.string.replying_to, writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -131,7 +154,7 @@ fun WriteNewNoteScreen( Text( text = it, modifier = Modifier.padding( - bottom = 8.dp + bottom = MaterialTheme.spacing.compactPadding ) ) } @@ -142,18 +165,18 @@ fun WriteNewNoteScreen( label = { Text( text = if (writeNewNoteUIState.inReplyToNostrEvent != null) { - "What's your reply to the above" + stringResource(Res.string.what_s_your_reply_to_the_above) } else if (writeNewNoteUIState.quotedNostrEvent != null) { - "What's your comment on the below" + stringResource(Res.string.what_s_your_comment_on_the_below) } else { - "What vibrations do you want to send out?" + stringResource(Res.string.what_vibrations_do_you_want_to_send_out) }, maxLines = 1, ) }, placeholder = { Text( - text = "Type out what you would like to publish", + text = stringResource(Res.string.type_out_what_you_would_like_to_publish), maxLines = 1, ) }, @@ -233,8 +256,8 @@ fun WriteNewNoteScreen( if (writeNewNoteUIState.quotedNostrEvent?.nostrEvent?.kind == TextNoteEvent.KIND) { Card( modifier = Modifier.padding( - start = 50.dp, - top = 10.dp + start = MaterialTheme.spacing.space600, + top = MaterialTheme.spacing.space125 ) ) { writeNewNoteUIState.quotedNostrEvent.RenderNotePreview( @@ -265,7 +288,7 @@ fun WriteNewNoteScreen( }, ) { Text( - "Next" + stringResource(Res.string.next) ) Icon( Icons.Default.NavigateNext, @@ -293,11 +316,13 @@ fun WriteNewNoteScreen( HorizontalDivider( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) } else { + // Nothing to show, but the LazyColumn item has to + // render something. 1dp was standing in for zero. Spacer( - modifier = Modifier.height(1.dp) + modifier = Modifier.height(MaterialTheme.spacing.space0) ) } } @@ -305,10 +330,10 @@ fun WriteNewNoteScreen( item { Column( modifier = Modifier.fillParentMaxHeight().fillMaxWidth().padding( - 10.dp + MaterialTheme.spacing.space125 ), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { Text( text = writeNewNoteUIState.localProfile.profile.humanReadableNameOrPubkey(), @@ -328,7 +353,7 @@ fun WriteNewNoteScreen( if (writeNewNoteUIState.inReplyToNostrEvent != null) { Text( - text = "replying To ${writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"}", + text = stringResource(Res.string.replying_to, writeNewNoteUIState.inReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "note"), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -391,7 +416,7 @@ fun WriteNewNoteScreen( } Text( - text = "The above will be your new note.", + text = stringResource(Res.string.the_above_will_be_your_new_note), style = MaterialTheme.typography.bodySmall ) @@ -411,7 +436,7 @@ fun WriteNewNoteScreen( } ) { Text( - text = "Transmit Note" + text = stringResource(Res.string.transmit_note) ) } } @@ -422,7 +447,7 @@ fun WriteNewNoteScreen( modifier = Modifier.weight(1f) ) Text( - text = "Something went wrong and we were unable to write a new note. Please try again later.", + text = stringResource(Res.string.something_went_wrong_and_we_were_unable_to_3), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) @@ -440,7 +465,7 @@ fun WriteNewNoteScreen( } } -@Preview +@ConformancePreviews @Composable private fun WriteNewNoteScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 477ef988..88b60107 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -2,10 +2,14 @@ package press.mantra.compose.ui.composable.navigation import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import press.mantra.compose.AppLifecycle @@ -16,6 +20,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.toRoute import press.mantra.compose.MantraGlobal import press.mantra.compose.database.repository.DatabaseChatRepository @@ -101,6 +106,8 @@ import press.mantra.compose.ui.view.model.SynchronizationViewModel import press.mantra.compose.ui.view.state.NavigationUIState import press.mantra.compose.ui.view.state.NostrEventDetailUIState import press.mantra.compose.ui.view.state.SearchUIState +import press.mantra.compose.ui.theme.NavigationMotion +import press.mantra.compose.ui.theme.breakpoint import co.touchlab.kermit.Logger import fr.acinq.phoenix.PhoenixGlobal import kotlinx.coroutines.CoroutineExceptionHandler @@ -367,827 +374,868 @@ fun MantraNavHost( } } - NavHost( + // The navigation component's three items, and the two facts it needs to build their + // routes. + // + // `activeUserPublicKey` is read off whichever top-level route is current -- all three + // carry it -- rather than held here, so it cannot drift from the screen underneath. + // The profile's metadata event id has no such source: `ActiveProfileRoute` is addressed + // by event id and only the home screen ever knew one, so it is observed here for as + // long as a key is signed in. One flow, cancelled and restarted when the key changes. + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val activeUserPublicKey = currentBackStackEntry?.destination?.let { destination -> + when (TopLevelDestination.of(destination)) { + TopLevelDestination.Messages -> currentBackStackEntry?.toRoute()?.activeUserPublicKey + TopLevelDestination.Search -> currentBackStackEntry?.toRoute()?.activeUserPublicKey + TopLevelDestination.Profile -> currentBackStackEntry?.toRoute()?.activeUserPublicKey + null -> null + } + } + + var activeProfileNostrEventId by remember { mutableStateOf(null) } + LaunchedEffect(activeUserPublicKey) { + val publicKey = activeUserPublicKey ?: return@LaunchedEffect + databaseNostrRepository.observeProfileWithFollowing(publicKey).collect { profile -> + activeProfileNostrEventId = profile?.nostrEvent?.id + } + } + + MantraNavigationSuite( navController = navController, - startDestination = LoadingRoute() + breakpoint = MaterialTheme.breakpoint, + activeUserPublicKey = activeUserPublicKey, + activeProfileNostrEventId = activeProfileNostrEventId, ) { - composable { backStackEntry -> - val route = backStackEntry.toRoute() + NavHost( + navController = navController, + startDestination = LoadingRoute(), + // All 43 routes at once, which is the point: navigation-compose's default is + // `fadeIn(tween(700))` in its own internals, so before this the app's + // transitions were a library's literal rather than a decision, and roughly + // three times M3's duration for a full-screen change. These come from the + // theme's MotionScheme and collapse to a fade when the platform asks for + // reduced motion. + enterTransition = NavigationMotion.enter(), + exitTransition = NavigationMotion.exit(), + popEnterTransition = NavigationMotion.popEnter(), + popExitTransition = NavigationMotion.popExit(), + ) { + composable { backStackEntry -> + val route = backStackEntry.toRoute() - SovereignWalletStartupScreen( - sovereignWalletViewModel = sovereignWalletViewModel, - // No seed on this device, so there is no wallet to start: the user has to make or - // restore one, and that lives behind the landing screen. This used to park them on - // a loading screen with nothing left to load. - onNavigateToWalletLandingPage = { - navController.navigate( - route = LandingRoute - ) { - popUpTo(0) - } - }, - onSuccessfulStartup = { - applicationIOScope.launch { - navigationViewModel.loadNostrProfile(route) - } - }, - forceWalletId = null, - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - LoadingScreen( - text = route.text - ) - } - composable { - LandingScreen( - onNavigateToLearnMore = { - navController.navigate( - route = ImplementationPendingRoute( - "Learn more" - ) - ) - }, - onNavigateToSignIn = { - navController.navigate( - route = SignInRoute - ) - }, - onNavigateToCreateProfile = { - navController.navigate( - route = CreateProfileRoute() - ) - } - ) - } - composable { - CreateProfileScreen( - onNavigateToEndThis = { - navController.navigate( - route = BlankRoute - ) { - popUpTo(0) - } - }, - nostrRepository = databaseNostrRepository, - marmotRepository = databaseMarmotRepository, - writeSeed = { words, onSeedWritten, onSeedWriteError -> - sovereignWalletViewModel.writeSeed( - words, - isRestoringWallet = false, - onSeedWritten = { walletId -> - onSeedWritten() - - sovereignWalletViewModel.loadSovereignData(walletId) - sovereignWalletViewModel.listAvailableWallets { - sovereignWalletViewModel.switchToWallet(walletId) - navController.navigate( - route = SovereignWalletStartupRoute - ) - } - }, - onSeedWriteError = { onSeedWriteError() } - ) - } - ) - - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - ChatRoomCreationScreen( - activeUserPublicKey = route.activeUserPublicKey, - onNavigateToRoute = { selectMembersRoute -> - navController.navigate( - selectMembersRoute - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - SelectChatRoomMembersScreen( - activeUserPublicKey = route.activeUserPublicKey, - name = route.name, - description = route.description, - nostrRepository = databaseNostrRepository, - onNavigateToRoute = { selectChatRoomTypeRoute -> - navController.navigate( - selectChatRoomTypeRoute - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - DkgRitualScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, - chatRepository = databaseChatRepository, - dkgRepository = databaseDkgRepository, - onNavigateToRoute = { approvalRoute -> - navController.navigate(route = approvalRoute) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - DkgJoinApprovalScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, - chatRepository = databaseChatRepository, - dkgRepository = databaseDkgRepository, - onDone = { navController.popBackStack() } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - DkgRound1ApprovalScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, - chatRepository = databaseChatRepository, - dkgRepository = databaseDkgRepository, - onDone = { navController.popBackStack() } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - DkgRound2ApprovalScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, - chatRepository = databaseChatRepository, - dkgRepository = databaseDkgRepository, - onDone = { navController.popBackStack() } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - SelectChatRoomTypeScreen( - activeUserPublicKey = route.activeUserPublicKey, - name = route.name, - description = route.description, - memberPublicKeys = route.memberPublicKeys, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - dkgRepository = databaseDkgRepository, - onNavigateToRoute = { chatRoomResultRoute -> - navController.navigate( - chatRoomResultRoute - ) { - // Drop every creation step: backing out of the new chat should land - // on whatever the user was doing before, not back in the form. - popUpTo( - ChatRoomCreationRoute( - activeUserPublicKey = route.activeUserPublicKey - ) + SovereignWalletStartupScreen( + sovereignWalletViewModel = sovereignWalletViewModel, + // No seed on this device, so there is no wallet to start: the user has to make or + // restore one, and that lives behind the landing screen. This used to park them on + // a loading screen with nothing left to load. + onNavigateToWalletLandingPage = { + navController.navigate( + route = LandingRoute ) { - inclusive = true + popUpTo(0) } - } - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - WriteNewNoteScreen( - replyToNostrEventId = route.inReplyToEventId, - activeUserPublicKey = route.activeUserPublicKey, - quotedNostrEventId = route.quotedEventId, - onNostrEventPublished = { - applicationMainScope.launch { - navController.popBackStack() - } - }, - onNavigateToNostrEvent = { hexKey -> - navController.navigate( - route = NostrEventDetailRoute( - activeUserPublicKey = route.activeUserPublicKey, - nostrEventId = hexKey - ) - ) - }, - nostrRepository = databaseNostrRepository - ) - } - composable { - SignInToProfileScreen( - nostrRepository = databaseNostrRepository - ) - } - composable { backStackEntry -> - backStackEntry.toRoute() + }, + onSuccessfulStartup = { + applicationIOScope.launch { + navigationViewModel.loadNostrProfile(route) + } + }, + forceWalletId = null, + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() - UnsignedProfileScreen() - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - UnsyncedProfileScreen( - unsyncedProfilePublicKey = route.publicKey - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - UnqueuedProfileSynchronizationScreen( - unsignedNostrEventId = route.unsignedNostrEventId, - profilePublicKey = route.publicKey, - nostrRepository = databaseNostrRepository - ) - } - composable { - UnindexedProfileScreen() - } - composable { - UnqueuedProfileScreen() - } - composable { backStackEntry -> - backStackEntry.toRoute() + LoadingScreen( + text = route.text + ) + } + composable { + LandingScreen( + onNavigateToLearnMore = { + navController.navigate( + route = ImplementationPendingRoute( + "Learn more" + ) + ) + }, + onNavigateToSignIn = { + navController.navigate( + route = SignInRoute + ) + }, + onNavigateToCreateProfile = { + navController.navigate( + route = CreateProfileRoute() + ) + } + ) + } + composable { + CreateProfileScreen( + onNavigateToEndThis = { + navController.navigate( + route = BlankRoute + ) { + popUpTo(0) + } + }, + nostrRepository = databaseNostrRepository, + marmotRepository = databaseMarmotRepository, + writeSeed = { words, onSeedWritten, onSeedWriteError -> + sovereignWalletViewModel.writeSeed( + words, + isRestoringWallet = false, + onSeedWritten = { walletId -> + onSeedWritten() - UnannouncedProfileScreen() - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - SocialPreconditionScreen( - onNavigateToSkipForNow = { - navController.navigate( - route = HomeRoute( - activeUserPublicKey = route.activeUserPubkey + sovereignWalletViewModel.loadSovereignData(walletId) + sovereignWalletViewModel.listAvailableWallets { + sovereignWalletViewModel.switchToWallet(walletId) + navController.navigate( + route = SovereignWalletStartupRoute + ) + } + }, + onSeedWriteError = { onSeedWriteError() } ) - ) - }, - onNavigateToInviteFriend = { - navController.navigate( - route = ImplementationPendingRoute( - "Invite Friend" - ) - ) - }, - onNavigateToViewInvites = { - navController.navigate( - route = ImplementationPendingRoute( - "View invites" - ) - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - HomeScreen( - activeUserPublicKey = route.activeUserPublicKey, - onNavigateToRoute = { eventRoute -> - navController.navigate( - route = eventRoute - ) - }, - onNavigateToChatRoomCreation = { - navController.navigate( - route = ChatRoomCreationRoute( - activeUserPublicKey = route.activeUserPublicKey - ) - ) - }, - onNavigateToSearch = { - navController.navigate( - route = SearchRoute( - activeUserPublicKey = route.activeUserPublicKey - ) - ) - }, - onNavigateToDirectMessageDetail = { chatRoomDetailRoute -> - navController.navigate( - route = chatRoomDetailRoute - ) - }, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository - ) - } - composable { - Surface( - modifier = Modifier.fillMaxSize(), - color = Color.Black - ) { + } + ) } - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + composable { backStackEntry -> + val route = backStackEntry.toRoute() - ActiveProfileScreen( - activeUserPublicKey = route.activeUserPublicKey, - nostrEventId = route.nostrEventId, - nostrRepository = databaseNostrRepository, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToRoute = { route -> - navController.navigate( - route = route - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - ShareProfileScreen( - activeUserPublicKey = route.activeUserPublicKey, - nostrEventId = route.nostrEventId, - nostrRepository = databaseNostrRepository, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToRoute = { route -> - navController.navigate( - route = route - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - KeyPackageManagementScreen( - activeUserPublicKey = route.activeUserPublicKey, - nostrEventId = route.nostrEventId, - marmotRepository = databaseMarmotRepository, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToRoute = { route -> - navController.navigate( - route = route - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - KeyRecoveryScreen( - activeUserPublicKey = route.activeUserPublicKey, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToRoute = { route -> - navController.navigate( - route = route - ) - } - ) - } - composable { - RecoveryPhraseScreen( - phoenixGlobal = phoenixGlobal, - activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - ChatRoomMessagingScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> - navController.navigate( - route = chatRoomDetailRoute - ) { - popUpTo(route) { - inclusive = true - } - } - }, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - ChatRoomDetailScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - mantraRepository = databaseMantraRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - }, - onPopBackToRoute = { popRoute -> - navController.popBackStack( - route = popRoute, - inclusive = false - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - SearchMemberToAddToChatRoomScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - onNavigateToRoute = { chatRoomDetailRoute -> - navController.navigate( - route = chatRoomDetailRoute - ) - }, - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - AddMemberToChatRoomConfirmationScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - profilePublicKey = route.profilePublicKey, - relayHint = route.relayHint, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - onInviteSent = { - navController.popBackStack( - route = SearchMemberToAddToChatRoomRoute( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint - ), - inclusive = true - ) - }, - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - AddArtifactScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - mantraRepository = databaseMantraRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace this add screen so back returns to the group rather - // than to a form whose proposal has already gone out. - navController.navigate( - route = signingRoute - ) { - popUpTo(route) { - inclusive = true - } - } - }, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - AddDialectScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - nostrRepository = databaseNostrRepository, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace this add screen so back returns to the group rather - // than to a form whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - FrostSigningScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - sessionId = route.sessionId, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - ProposalListScreen( - activeUserPublicKey = route.activeUserPublicKey, - chatRoomId = route.chatRoomId, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToRoute = { signingRoute -> - navController.navigate(route = signingRoute) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - ArtifactDetailScreen( - activeUserPublicKey = route.activeUserPublicKey, - artifactId = route.artifactId, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - mantraRepository = databaseMantraRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - ChapterDetailScreen( - activeUserPublicKey = route.activeUserPublicKey, - chapterId = route.chapterId, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - mantraRepository = databaseMantraRepository, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - TranslationArtifactVersionDetailScreen( - activeUserPublicKey = route.activeUserPublicKey, - translationArtifactVersionId = route.translationArtifactVersionId, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - mantraRepository = databaseMantraRepository, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - TranslationChapterScreen( - activeUserPublicKey = route.activeUserPublicKey, - translationChapterId = route.translationChapterId, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - mantraRepository = databaseMantraRepository, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - TranslateChunkScreen( - activeUserPublicKey = route.activeUserPublicKey, - translationChapterId = route.translationChapterId, - chunkId = route.chunkId, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - mantraRepository = databaseMantraRepository, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace this editor so back returns to the chapter table - // rather than to a form whose proposal has already gone out. - // The table itself is left alone: nothing is translated until - // the group signs, so there is nothing new for it to show. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateToRoute = { actionRoute -> - navController.navigate(route = actionRoute) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - AddTranslationArtifactVersionScreen( - activeUserPublicKey = route.activeUserPublicKey, - artifactId = route.artifactId, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - mantraRepository = databaseMantraRepository, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace this screen so back returns to the artifact rather - // than to a form whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - AddChapterScreen( - activeUserPublicKey = route.activeUserPublicKey, - artifactId = route.artifactId, - chatRoomId = route.chatRoomId, - relayHint = route.relayHint, - mantraRepository = databaseMantraRepository, - chatRepository = databaseChatRepository, - frostSigningRepository = databaseFrostSigningRepository, - onNavigateToRouteAndPopUpInclusive = { signingRoute -> - // Replace this add screen so back returns to the artifact - // rather than to a form whose proposal has already gone out. - navController.navigate(route = signingRoute) { - popUpTo { - inclusive = true - } - } - }, - onNavigateToRoute = { actionRoute -> - navController.navigate( - route = actionRoute - ) - }, - onNavigateBack = { - navController.popBackStack() - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - SearchScreen( - activeUserPublicKey = route.activeUserPublicKey, - initialSearchUIState = SearchUIState.Prompt, - nostrRepository = databaseNostrRepository, - searchRepository = searchRepository, - onNavigateToProfile = { route -> - navController.navigate( - route = route - ) - }, - onNavigateToSearchResult = { route -> - navController.navigate( - route = route - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - SearchResultScreen( - activeUserPublicKey = route.activeUserPublicKey, - searchQuery = route.query, - nostrRepository = databaseNostrRepository, - searchRepository = searchRepository, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToEvent = { route -> - navController.navigate( - route = route - ) - } - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - - NostrEventDetailScreen( - activeUserPublicKey = route.activeUserPublicKey, - initialNostrEventDetailUIState = NostrEventDetailUIState.Loading, - nostrEventId = route.nostrEventId, - nostrRepository = databaseNostrRepository, - onNavigateBack = { - navController.popBackStack() - }, - onNavigateToEvent = { route -> - navController.navigate( - route = route - ) - }, - onNavigateToWriteAReply = { nostrEventId -> - navController.navigate( - route = WriteNewNoteRoute( - activeUserPublicKey = route.activeUserPublicKey, - inReplyToEventId = nostrEventId + ChatRoomCreationScreen( + activeUserPublicKey = route.activeUserPublicKey, + onNavigateToRoute = { selectMembersRoute -> + navController.navigate( + selectMembersRoute ) - ) - }, - onNavigateToEditProfile = { - navController.navigate( - route = ImplementationPendingRoute( - "Edit Profile" + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectChatRoomMembersScreen( + activeUserPublicKey = route.activeUserPublicKey, + name = route.name, + description = route.description, + nostrRepository = databaseNostrRepository, + onNavigateToRoute = { selectChatRoomTypeRoute -> + navController.navigate( + selectChatRoomTypeRoute ) - ) - }, - onNavigateToQuoteNostrEvent = { nostrEventId -> - navController.navigate( - route = WriteNewNoteRoute( - activeUserPublicKey = route.activeUserPublicKey, - quotedEventId = nostrEventId - ) - ) - }, - onNavigateToDirectMessage = { chatRoom -> - navController.navigate( - route = chatRoom - ) { - popUpTo(route) { - inclusive = true + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + DkgRitualScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onNavigateToRoute = { approvalRoute -> + navController.navigate(route = approvalRoute) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + DkgJoinApprovalScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onDone = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + DkgRound1ApprovalScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onDone = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + DkgRound2ApprovalScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onDone = { navController.popBackStack() } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectChatRoomTypeScreen( + activeUserPublicKey = route.activeUserPublicKey, + name = route.name, + description = route.description, + memberPublicKeys = route.memberPublicKeys, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onNavigateToRoute = { chatRoomResultRoute -> + navController.navigate( + chatRoomResultRoute + ) { + // Drop every creation step: backing out of the new chat should land + // on whatever the user was doing before, not back in the form. + popUpTo( + ChatRoomCreationRoute( + activeUserPublicKey = route.activeUserPublicKey + ) + ) { + inclusive = true + } } } - }, - ) - } - composable { backStackEntry -> - val route: ImplementationPendingRoute = backStackEntry.toRoute() - ImplementationPendingScreen( - route.name - ) + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + WriteNewNoteScreen( + replyToNostrEventId = route.inReplyToEventId, + activeUserPublicKey = route.activeUserPublicKey, + quotedNostrEventId = route.quotedEventId, + onNostrEventPublished = { + applicationMainScope.launch { + navController.popBackStack() + } + }, + onNavigateToNostrEvent = { hexKey -> + navController.navigate( + route = NostrEventDetailRoute( + activeUserPublicKey = route.activeUserPublicKey, + nostrEventId = hexKey + ) + ) + }, + nostrRepository = databaseNostrRepository + ) + } + composable { + SignInToProfileScreen( + nostrRepository = databaseNostrRepository + ) + } + composable { backStackEntry -> + backStackEntry.toRoute() + + UnsignedProfileScreen() + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + UnsyncedProfileScreen( + unsyncedProfilePublicKey = route.publicKey + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + UnqueuedProfileSynchronizationScreen( + unsignedNostrEventId = route.unsignedNostrEventId, + profilePublicKey = route.publicKey, + nostrRepository = databaseNostrRepository + ) + } + composable { + UnindexedProfileScreen() + } + composable { + UnqueuedProfileScreen() + } + composable { backStackEntry -> + backStackEntry.toRoute() + + UnannouncedProfileScreen() + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + SocialPreconditionScreen( + onNavigateToSkipForNow = { + navController.navigate( + route = HomeRoute( + activeUserPublicKey = route.activeUserPubkey + ) + ) + }, + onNavigateToInviteFriend = { + navController.navigate( + route = ImplementationPendingRoute( + "Invite friend" + ) + ) + }, + onNavigateToViewInvites = { + navController.navigate( + route = ImplementationPendingRoute( + "View invites" + ) + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + HomeScreen( + activeUserPublicKey = route.activeUserPublicKey, + onNavigateToRoute = { eventRoute -> + navController.navigate( + route = eventRoute + ) + }, + onNavigateToChatRoomCreation = { + navController.navigate( + route = ChatRoomCreationRoute( + activeUserPublicKey = route.activeUserPublicKey + ) + ) + }, + onNavigateToDirectMessageDetail = { chatRoomDetailRoute -> + navController.navigate( + route = chatRoomDetailRoute + ) + }, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + ) + } + composable { + Surface( + modifier = Modifier.fillMaxSize(), + // Nothing is drawn on it, so this is a backdrop rather than a surface + // carrying content. `scrim` is the role for that, and is #000000 in every + // one of this app's schemes -- so the pixels are unchanged and the value + // now moves with the theme instead of standing outside it. + color = MaterialTheme.colorScheme.scrim + ) { + + } + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ActiveProfileScreen( + activeUserPublicKey = route.activeUserPublicKey, + nostrEventId = route.nostrEventId, + nostrRepository = databaseNostrRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { route -> + navController.navigate( + route = route + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ShareProfileScreen( + activeUserPublicKey = route.activeUserPublicKey, + nostrEventId = route.nostrEventId, + nostrRepository = databaseNostrRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { route -> + navController.navigate( + route = route + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + KeyPackageManagementScreen( + activeUserPublicKey = route.activeUserPublicKey, + nostrEventId = route.nostrEventId, + marmotRepository = databaseMarmotRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { route -> + navController.navigate( + route = route + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + KeyRecoveryScreen( + activeUserPublicKey = route.activeUserPublicKey, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { route -> + navController.navigate( + route = route + ) + } + ) + } + composable { + RecoveryPhraseScreen( + phoenixGlobal = phoenixGlobal, + activeWalletStateFlow = sovereignWalletViewModel.activeWalletInUI, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ChatRoomMessagingScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> + navController.navigate( + route = chatRoomDetailRoute + ) { + popUpTo(route) { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ChatRoomDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onPopBackToRoute = { popRoute -> + navController.popBackStack( + route = popRoute, + inclusive = false + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SearchMemberToAddToChatRoomScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + onNavigateToRoute = { chatRoomDetailRoute -> + navController.navigate( + route = chatRoomDetailRoute + ) + }, + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddMemberToChatRoomConfirmationScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + profilePublicKey = route.profilePublicKey, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + onInviteSent = { + navController.popBackStack( + route = SearchMemberToAddToChatRoomRoute( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint + ), + inclusive = true + ) + }, + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddArtifactScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + mantraRepository = databaseMantraRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. + navController.navigate( + route = signingRoute + ) { + popUpTo(route) { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddDialectScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + nostrRepository = databaseNostrRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the group rather + // than to a form whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + FrostSigningScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + sessionId = route.sessionId, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ProposalListScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { signingRoute -> + navController.navigate(route = signingRoute) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ArtifactDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + artifactId = route.artifactId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + ChapterDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + chapterId = route.chapterId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + TranslationArtifactVersionDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + translationArtifactVersionId = route.translationArtifactVersionId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + TranslationChapterScreen( + activeUserPublicKey = route.activeUserPublicKey, + translationChapterId = route.translationChapterId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + TranslateChunkScreen( + activeUserPublicKey = route.activeUserPublicKey, + translationChapterId = route.translationChapterId, + chunkId = route.chunkId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this editor so back returns to the chapter table + // rather than to a form whose proposal has already gone out. + // The table itself is left alone: nothing is translated until + // the group signs, so there is nothing new for it to show. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate(route = actionRoute) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddTranslationArtifactVersionScreen( + activeUserPublicKey = route.activeUserPublicKey, + artifactId = route.artifactId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this screen so back returns to the artifact rather + // than to a form whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + AddChapterScreen( + activeUserPublicKey = route.activeUserPublicKey, + artifactId = route.artifactId, + chatRoomId = route.chatRoomId, + relayHint = route.relayHint, + mantraRepository = databaseMantraRepository, + chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, + onNavigateToRouteAndPopUpInclusive = { signingRoute -> + // Replace this add screen so back returns to the artifact + // rather than to a form whose proposal has already gone out. + navController.navigate(route = signingRoute) { + popUpTo { + inclusive = true + } + } + }, + onNavigateToRoute = { actionRoute -> + navController.navigate( + route = actionRoute + ) + }, + onNavigateBack = { + navController.popBackStack() + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SearchScreen( + activeUserPublicKey = route.activeUserPublicKey, + initialSearchUIState = SearchUIState.Prompt, + nostrRepository = databaseNostrRepository, + searchRepository = searchRepository, + onNavigateToProfile = { route -> + navController.navigate( + route = route + ) + }, + onNavigateToSearchResult = { route -> + navController.navigate( + route = route + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SearchResultScreen( + activeUserPublicKey = route.activeUserPublicKey, + searchQuery = route.query, + nostrRepository = databaseNostrRepository, + searchRepository = searchRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToEvent = { route -> + navController.navigate( + route = route + ) + } + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + NostrEventDetailScreen( + activeUserPublicKey = route.activeUserPublicKey, + initialNostrEventDetailUIState = NostrEventDetailUIState.Loading, + nostrEventId = route.nostrEventId, + nostrRepository = databaseNostrRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToEvent = { route -> + navController.navigate( + route = route + ) + }, + onNavigateToWriteAReply = { nostrEventId -> + navController.navigate( + route = WriteNewNoteRoute( + activeUserPublicKey = route.activeUserPublicKey, + inReplyToEventId = nostrEventId + ) + ) + }, + onNavigateToEditProfile = { + navController.navigate( + route = ImplementationPendingRoute( + "Edit profile" + ) + ) + }, + onNavigateToQuoteNostrEvent = { nostrEventId -> + navController.navigate( + route = WriteNewNoteRoute( + activeUserPublicKey = route.activeUserPublicKey, + quotedEventId = nostrEventId + ) + ) + }, + onNavigateToDirectMessage = { chatRoom -> + navController.navigate( + route = chatRoom + ) { + popUpTo(route) { + inclusive = true + } + } + }, + ) + } + composable { backStackEntry -> + val route: ImplementationPendingRoute = backStackEntry.toRoute() + ImplementationPendingScreen( + route.name + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavigationSuite.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavigationSuite.kt new file mode 100644 index 00000000..044b3373 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavigationSuite.kt @@ -0,0 +1,151 @@ +package press.mantra.compose.ui.composable.navigation + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.navigation.NavDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.navigation.routes.HomeRoute +import press.mantra.compose.ui.theme.Breakpoint + +/** + * The navigation bar, collapsed rail or expanded rail, around whatever the nav host draws. + * + * **Which component, at which breakpoint**, straight from the layout foundation's table: + * + * | breakpoint | component | + * |---------------------|----------------------| + * | compact | navigation bar | + * | medium, expanded | collapsed rail | + * | large, extra-large | expanded rail | + * + * `NavigationSuiteScaffoldDefaults.navigationSuiteType` is not used, and the difference is + * the last row: it stops at `WideNavigationRailCollapsed` and never returns the expanded + * rail, because it classifies with the three-value window size class rather than the five + * breakpoints the May 2026 revision published. Deriving the type from [Breakpoint] instead + * keeps one source of truth for window width in the app and reaches the row the library's + * default cannot. + * + * **Hidden on everything else.** [NavigationSuiteType.None] draws no component at all, and + * that is the case for every screen that is not one of the three: onboarding, a chat room, + * a signing screen, the desktop passphrase gate. A navigation bar belongs on the + * destinations it switches between; on a screen reached by pushing a route and left by + * coming back it is a permanent invitation to lose your place. + * + * @param navController the host's controller, read for the current destination and used to + * navigate. Passed rather than hoisted into callbacks because the top-level navigation + * options below -- `saveState`, `restoreState`, `launchSingleTop` -- are the point of the + * component and belong with it rather than at three call sites. + * @param breakpoint the window's breakpoint, which decides the component. + * @param activeUserPublicKey the signed-in key, or `null` before anybody has signed in -- + * in which case no destination has a route and the component stays hidden. + * @param activeProfileNostrEventId the metadata event addressing the active profile, or + * `null` until it has been read. Only [TopLevelDestination.Profile] needs it. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MantraNavigationSuite( + navController: NavHostController, + breakpoint: Breakpoint, + activeUserPublicKey: String?, + activeProfileNostrEventId: String?, + content: @Composable () -> Unit, +) { + val currentDestination: NavDestination? = + navController.currentBackStackEntryAsState().value?.destination + val selected = TopLevelDestination.of(currentDestination) + + val suiteType = navigationSuiteTypeFor( + breakpoint = breakpoint, + // Not on a top-level destination, or nobody is signed in, so there is nothing for + // the component to switch between. + isTopLevel = selected != null && activeUserPublicKey != null, + ) + + NavigationSuiteScaffold( + navigationSuiteType = suiteType, + navigationItems = { + if (suiteType == NavigationSuiteType.None || activeUserPublicKey == null) return@NavigationSuiteScaffold + + TopLevelDestination.entries.forEach { destination -> + val route = destination.route(activeUserPublicKey, activeProfileNostrEventId) + + NavigationSuiteItem( + navigationSuiteType = suiteType, + selected = destination == selected, + // Disabled rather than absent while the profile's event id is still + // being read. An item that appears and then disappears moves the two + // beside it, and a bar whose items move under a thumb is worse than + // one with a briefly unavailable item. + enabled = route != null, + onClick = { + if (route != null && destination != selected) { + navController.navigate(route) { + // The standard top-level options, and each answers a + // failure this app would otherwise have. Without + // `popUpTo` the back stack grows by one every time + // somebody taps between the three, so back becomes a + // walk through their tapping history rather than a way + // out. Without `launchSingleTop` a second tap on the + // current item pushes a duplicate. `saveState` and + // `restoreState` are what keep a scrolled feed scrolled + // when you come back to it. + // + // Popped to `HomeRoute` and not to the graph's start + // destination, which is the shape the android docs give + // and which would be wrong here: this graph starts at + // `LoadingRoute`, and onboarding clears the stack with + // `popUpTo(0)` on its way to home -- so by the time these + // items exist the start destination is not on the stack at + // all, and popping to it would leave the loading screen + // underneath as the thing back returns to. Home is the + // root of the signed-in graph, and is what back should + // reach from either of the other two. + popUpTo { saveState = true } + launchSingleTop = true + restoreState = true + } + } + }, + icon = { + Icon( + imageVector = destination.icon, + // The label is beside the icon in the bar and in the expanded + // rail, and a reader would hear the name twice. The collapsed + // rail is the exception, and it draws the label as a tooltip + // the accessibility tree still carries. + contentDescription = Decorative, + ) + }, + label = { Text(stringResource(destination.label)) }, + ) + } + }, + content = content, + ) +} + +/** + * The navigation component a window at [breakpoint] should carry, per the table above. + * + * Separate from the composable so the table can be asserted. A breakpoint-to-component + * mapping is exactly the kind of thing that is wrong in one row and looks right in every + * screenshot anybody takes: the two rail rows differ only in whether labels are shown, and + * nobody opens a 1200dp window to check. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +internal fun navigationSuiteTypeFor(breakpoint: Breakpoint, isTopLevel: Boolean): NavigationSuiteType = + when { + !isTopLevel -> NavigationSuiteType.None + breakpoint == Breakpoint.Compact -> NavigationSuiteType.ShortNavigationBarCompact + breakpoint.isAtLeast(Breakpoint.Large) -> NavigationSuiteType.WideNavigationRailExpanded + else -> NavigationSuiteType.WideNavigationRailCollapsed + } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/TopLevelDestination.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/TopLevelDestination.kt new file mode 100644 index 00000000..9ce37302 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/TopLevelDestination.kt @@ -0,0 +1,80 @@ +package press.mantra.compose.ui.composable.navigation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.messages +import mantra.composeapp.generated.resources.profile +import mantra.composeapp.generated.resources.search +import org.jetbrains.compose.resources.StringResource +import press.mantra.compose.ui.composable.navigation.routes.ActiveProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.HomeRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.navigation.routes.SearchRoute + +/** + * The three destinations the navigation component switches between. + * + * The app has one screen that is genuinely a top-level destination -- [Messages], which + * is the home feed -- and two surfaces that were one tap from it through the home screen's + * app bar: the profile avatar in the leading slot and the search icon in the trailing one. + * Promoting those two is an information-architecture decision rather than a component + * swap, and it was taken deliberately: M3's caution is to swap only functionally + * equivalent components, and a navigation bar holding a single item would have been + * strictly worse than the app bar it replaced. + * + * The consequence is that the home screen's app bar no longer carries either icon. Two + * routes to the same destination is what the caution is about; the navigation component is + * now the one route, at every breakpoint. + * + * @property icon the item's icon, and the only thing shown when the rail is collapsed. + * @property label the item's text, shown in the bar and in the expanded rail. + */ +enum class TopLevelDestination(val icon: ImageVector, val label: StringResource) { + Messages(Icons.Default.Forum, Res.string.messages), + Search(Icons.Default.Search, Res.string.search), + Profile(Icons.Default.Person, Res.string.profile); + + companion object { + /** + * Which destination [destination] belongs to, or `null` for everything else. + * + * `null` is what hides the navigation component. A navigation bar belongs on the + * screens it switches between and nowhere else -- it has no meaning during + * onboarding, in a chat room, or on a signing screen, all of which are reached by + * pushing a route and left by coming back. + */ + fun of(destination: NavDestination?): TopLevelDestination? = when { + destination == null -> null + destination.hasRoute(HomeRoute::class) -> Messages + destination.hasRoute(SearchRoute::class) -> Search + destination.hasRoute(ActiveProfileRoute::class) -> Profile + else -> null + } + } + + /** + * The route this item navigates to, or `null` when it cannot yet be built. + * + * Only [Profile] ever returns `null`, and only before the active profile's metadata + * event has been read from the database. `ActiveProfileRoute` is addressed by event id + * rather than by public key, so until that read lands there is no route to construct -- + * and an item that navigates nowhere is worse than one that is visibly disabled. + */ + fun route(activeUserPublicKey: String, activeProfileNostrEventId: String?): Route? = + when (this) { + Messages -> HomeRoute(activeUserPublicKey = activeUserPublicKey) + Search -> SearchRoute(activeUserPublicKey = activeUserPublicKey) + Profile -> activeProfileNostrEventId?.let { + ActiveProfileRoute( + activeUserPublicKey = activeUserPublicKey, + nostrEventId = it, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt index a4e062e3..98737303 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/LoadingDataIndicator.kt @@ -1,7 +1,9 @@ package press.mantra.compose.ui.composable.widgets import androidx.compose.foundation.layout.* -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.LoadingIndicatorDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -10,13 +12,27 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.theme.ConformancePreviews +/** + * The app's one loading state, used at 41 call sites. + * + * It draws M3's `LoadingIndicator` rather than a `CircularProgressIndicator`. That is the + * expressive component for exactly this -- an indeterminate wait with no progress to + * report -- and it is what `MaterialExpressiveTheme` expects to be paired with. + * + * The colour default moved from `secondary` to the component's own + * `LoadingIndicatorDefaults.indicatorColor`. `secondary` is the brand gold, which read as + * a warning rather than as a wait, and the previous version also hardcoded an 80dp width + * where the component has a size of its own. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun LoadingDataIndicator( modifier: Modifier = Modifier.fillMaxWidth(), - color: Color = MaterialTheme.colorScheme.secondary, + color: Color = LoadingIndicatorDefaults.indicatorColor, fillScreen: Boolean = true, text: String? = null ) { @@ -28,21 +44,17 @@ fun LoadingDataIndicator( Spacer(modifier = Modifier.weight(1f)) } - CircularProgressIndicator( - modifier = Modifier.width(80.dp).aspectRatio(1f), - color = color, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) + LoadingIndicator(color = color) text?.let { Spacer( - modifier = Modifier.height(30.dp) + modifier = Modifier.height(MaterialTheme.spacing.space400) ) Text( text = text, - modifier = Modifier.fillMaxWidth().padding(10.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), textAlign = TextAlign.Center ) } @@ -54,7 +66,7 @@ fun LoadingDataIndicator( } -@Preview +@ConformancePreviews @Composable private fun TransferHistoryScreenPreview() { press.mantra.compose.ui.theme.TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/QRCodeView.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/QRCodeView.kt index f4e0a4db..80f9d258 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/QRCodeView.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/QRCodeView.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import press.mantra.compose.ui.theme.BluePill +import press.mantra.compose.ui.theme.LocalExtendedColors import io.github.alexzhirkevich.qrose.options.QrBrush import io.github.alexzhirkevich.qrose.options.QrLogoPadding import io.github.alexzhirkevich.qrose.options.QrLogoShape @@ -26,7 +26,11 @@ import mantra.composeapp.generated.resources.compose_multiplatform class QRCodeBackgroundPainter( private val painter: Painter, - private val backgroundColor: Color = BluePill, + // No default. It used to be the `BluePill` literal, which meant a colour chosen + // outside the theme for a surface that is almost never seen: at the default padding + // of 0.dp the logo painter covers the rect it fills. Making the one call site pass + // it keeps that visible rather than buried in a constructor default. + private val backgroundColor: Color, private val padding: Dp = 0.dp, ): Painter() { override val intrinsicSize: Size = painter.intrinsicSize @@ -61,6 +65,10 @@ class QRCodeBackgroundPainter( fun QRCodeView( data: String, ) { + // m3-color-exempt: a QR code is read by a camera, not a person. Scanners need + // maximum luminance contrast between the modules and their background, so these + // are black and white rather than onSurface and surface -- which in a dynamic + // colour scheme could be two mid tones and unscannable. val qrCodeColor = if (isSystemInDarkTheme()) { Color.White } else { @@ -71,6 +79,7 @@ fun QRCodeView( painter = painterResource( Res.drawable.compose_multiplatform ), + backgroundColor = LocalExtendedColors.current.bluePill.color, ) val painter = rememberQrCodePainter( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt new file mode 100644 index 00000000..0e74302d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt @@ -0,0 +1,200 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.try_again +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.ui.theme.reducedMotion +import press.mantra.compose.ui.theme.spacing + +/** + * The two states every list and every screen has, and neither of which had a home. + * + * Before this file the tree held **16 copies** of + * + * Column(horizontalAlignment = CenterHorizontally) { + * Spacer(Modifier.height(48.dp)) + * Text("Something went wrong") + * } + * + * and five of the same shape saying "No events were found". Not one of the sixteen offered + * a retry, so every failure in this app was a dead end: the message named no cause, and + * the only way out was the back button. + * + * M3's content guidance asks the opposite -- "emphasize the results of the user's + * potential action", "tell users what will happen ... and how they can undo it" -- and its + * structure guidance asks that a screen say what to do next rather than only what + * happened. + * + * These two composables are deliberately plain: an icon, a line, and for [ErrorState] an + * action when the caller can offer one. They are the thing 21 call sites collapse into, + * not a design in their own right. + */ +@Composable +fun ErrorState( + modifier: Modifier = Modifier, + message: String = stringResource(Res.string.something_went_wrong), + /** + * `null` where the caller genuinely has nothing to retry -- a screen whose state came + * from a navigation argument that was already wrong. Passing null is a decision; + * omitting a retry that exists is the thing this parameter is here to make visible. + */ + onRetry: (() -> Unit)? = null, +) { + StateMessage( + modifier = modifier, + icon = Icons.Default.ErrorOutline, + message = message, + // The icon is decorative: the message beside it says what happened, and naming + // this one would have a screen reader announce the trouble twice. + iconDescription = Decorative, + action = onRetry?.let { + { TextButton(onClick = it) { Text(stringResource(Res.string.try_again)) } } + }, + ) +} + +/** + * A list with nothing in it yet. + * + * The message is required rather than defaulted, because "No events were found" told the + * user nothing about which list was empty or what would fill it, and a shared default + * would preserve exactly that. Say what is missing and, where there is one, how to get + * some. + */ +@Composable +fun EmptyState( + message: String, + modifier: Modifier = Modifier, + icon: ImageVector = Icons.Default.Inbox, + action: (@Composable () -> Unit)? = null, +) { + StateMessage( + modifier = modifier, + icon = icon, + message = message, + iconDescription = Decorative, + action = action, + ) +} + +@Composable +private fun StateMessage( + modifier: Modifier, + icon: ImageVector, + message: String, + iconDescription: String?, + action: (@Composable () -> Unit)?, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.spacing.screenMargin, + vertical = MaterialTheme.spacing.emphasisGap, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + ) { + Icon( + imageVector = icon, + contentDescription = iconDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + action?.invoke() + } +} + +/** + * Fades between a screen's states instead of cutting. + * + * Every screen in this app is a `when` over a UI state -- loading, error, empty, loaded -- + * and every one of those transitions was an unannounced cut: the spinner is there in one + * frame and the content is there in the next, with nothing saying they are the same screen + * answering the same question. + * + * M3's fade-through, which is the transition for content that replaces other content + * without being spatially related to it: the outgoing state fades out, the incoming one + * fades in and grows the last few percent into place. `SizeTransform` is off, so a tall + * loaded state does not stretch a short spinner on its way in. + * + * **The content key is the state's class, not the state.** Without it a screen would + * re-run the whole transition every time its loaded data changed -- a message arriving, a + * list growing -- which is a fade over content that did not change state at all. With it, + * the animation runs when the state does and the data flows through untouched. + * + * **`AnimatedContent` is a layout node**, so it must wrap a `when` that is a composable's + * whole body. Where the `when` sits inside a `Column` whose branches use + * `Modifier.weight`, wrapping it would take the branches out of `ColumnScope`; those + * screens are left alone and the reason is here rather than repeated at each of them. + * + * @param state the screen's current UI state. + * @param label named for the animation inspector; the screen's own name is the useful one. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ScreenStateTransition( + state: T, + modifier: Modifier = Modifier, + label: String = "screen state", + content: @Composable (T) -> Unit, +) { + val reduced = MaterialTheme.reducedMotion + val effects = MaterialTheme.motionScheme.defaultEffectsSpec() + val spatial = MaterialTheme.motionScheme.defaultSpatialSpec() + + AnimatedContent( + targetState = state, + modifier = modifier, + contentKey = { it::class }, + transitionSpec = { + if (reduced) { + // Still a transition, still not a cut -- what goes is the movement. The + // scale is the only spatial part of a fade-through. + fadeIn(effects) togetherWith fadeOut(effects) using SizeTransform(clip = false) + } else { + (fadeIn(effects) + scaleIn(spatial, initialScale = EnteringScale)) + .togetherWith(fadeOut(effects)) + .using(SizeTransform(clip = false)) + } + }, + label = label, + ) { targetState -> content(targetState) } +} + +/** + * How small the arriving state starts. + * + * M3's fade-through grows the incoming content from 92%. Any less reads as a zoom, which + * says the two states are the same thing seen closer rather than one replacing the other. + */ +private const val EnteringScale = 0.92f diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Semantics.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Semantics.kt new file mode 100644 index 00000000..fcfbe9e0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Semantics.kt @@ -0,0 +1,26 @@ +package press.mantra.compose.ui.composable.widgets + +/** + * An icon or image that adds nothing a screen reader user would miss. + * + * `contentDescription = null` is the correct Compose API for this -- M3 asks that + * decorative visuals be "annotated as decorative in order to hide them in code", and null + * is how that annotation is spelled. The problem with `null` is not what it does; it is + * that it looks identical whether somebody decided the icon was decorative or never + * thought about it, and 18 of them in this tree were indistinguishable. + * + * `contentDescription = Decorative` compiles to the same null and says which it was. It is + * also greppable, so docs/scripts/m3-audit.sh can count the ones still to triage. + * + * **Use it when the adjacent text already says what the icon says** -- a lock beside + * "Private to Ada", a check beside "The group has a shared key.", an icon inside a button + * whose label is right there. + * + * **Do not use it for an icon carrying state the text does not repeat**: a filled-versus- + * empty circle beside a member's name, a status icon in a list row. Those get a real + * description, and M3's rule for writing one is to name the purpose rather than the + * picture, and never to include the role -- "Contributed", not "green check", and never + * "Contributed icon". + */ +@Suppress("MayBeConstant") // a `const` cannot be nullable, and null is the point +val Decorative: String? = null diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Snackbars.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Snackbars.kt new file mode 100644 index 00000000..c535cc8e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Snackbars.kt @@ -0,0 +1,79 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Somewhere to say what just happened. + * + * The app had **no** `Snackbar`, `SnackbarHost` or `SnackbarHostState` anywhere, across 26 + * `Scaffold`s. Every transient outcome -- an invite that failed, a key package published, + * a message that did not send -- had nowhere to be reported, so the code either said + * nothing or navigated away and hoped. + * + * M3 puts a snackbar host in the `Scaffold` for this, and the reason it is a composition + * local rather than a parameter is where the reporting happens: a view model coroutine + * finishing a network call is several composables below the `Scaffold` that owns the host. + * Threading a `SnackbarHostState` down through every screen's parameter list would be the + * same plumbing repeated 26 times, and the parameter would be forgotten on the 27th. + * + * ``` + * Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { … } + * … + * val notify = rememberNotifier() + * notify("Invite sent") + * ``` + */ +val LocalSnackbarHostState: ProvidableCompositionLocal = + staticCompositionLocalOf { + error( + "No SnackbarHostState. Wrap the screen in ProvideSnackbarHost, or hand the " + + "Scaffold a snackbarHost of its own." + ) + } + +/** + * Provides a host state for everything inside [content]. + * + * Deliberately throws rather than defaulting to a detached `SnackbarHostState()`. A + * default would make `notify(...)` a silent no-op on any screen that forgot the host, + * which is the failure this whole file exists to end -- a message with nowhere to go is + * exactly what the app already had. + */ +@Composable +fun ProvideSnackbarHost( + hostState: SnackbarHostState = remember { SnackbarHostState() }, + content: @Composable (SnackbarHostState) -> Unit, +) { + CompositionLocalProvider(LocalSnackbarHostState provides hostState) { + content(hostState) + } +} + +/** + * A function for showing a message, callable from anywhere under a [ProvideSnackbarHost]. + * + * Takes the scope from the caller so the message survives the composable that sent it + * going away -- which it usually does, since "saved" is shown as the screen navigates + * back. + */ +@Composable +fun rememberNotifier(scope: CoroutineScope): (String) -> Unit { + val hostState = LocalSnackbarHostState.current + return remember(hostState, scope) { + { message -> + scope.launch { + // Short by default. A snackbar is for something the user does not have to + // act on; anything they must read belongs in a dialog or on the screen. + hostState.showSnackbar(message = message, duration = SnackbarDuration.Short) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/Clickable.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/Clickable.kt index f537266c..ae12de87 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/Clickable.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/buttons/Clickable.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.machankura.compose.ui.composable.widgets.buttons +package press.mantra.compose.ui.composable.widgets.buttons import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Indication @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -36,7 +37,23 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +/** + * A tappable surface with no chrome of its own -- vendored from ACINQ's phoenix app. + * + * Two things were changed when it moved into `press.mantra` from the `com.machankura` + * package it was still declaring: + * + * - **`minimumInteractiveComponentSize()` is applied unconditionally.** Its defaults are + * `RectangleShape` and `PaddingValues(0.dp)`, so a `Clickable` is exactly as big as + * whatever is inside it, and the call sites here wrap a 20dp emoji and a row of wallet + * text. The modifier reserves 48x48dp of *layout* -- touch expansion happens at the + * input layer regardless -- which is what keeps adjacent targets from overlapping and + * is what a pointer on desktop has to hit. + * - The modifier ordering. It comes before `.padding(internalPadding)`, since a size + * modifier after it would re-impose the smaller constraint. + */ @Composable fun Clickable( onClick: () -> Unit, @@ -48,7 +65,7 @@ fun Clickable( backgroundColor: Color = Color.Unspecified, // transparent by default! shape: Shape = RectangleShape, clickDescription: String = "", - internalPadding: PaddingValues = PaddingValues(0.dp), + internalPadding: PaddingValues = PaddingValues(MaterialTheme.spacing.space0), indication: Indication? = LocalIndication.current, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, content: @Composable () -> Unit, @@ -60,6 +77,7 @@ fun Clickable( border = border, contentColor = contentColor, modifier = modifier + .minimumInteractiveComponentSize() .clip(shape) .combinedClickable( onClick = onClick, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt new file mode 100644 index 00000000..263092f7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -0,0 +1,779 @@ +package press.mantra.compose.ui.composable.widgets.chat + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.CallMerge +import androidx.compose.material.icons.filled.FactCheck +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.PanTool +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Pending +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import press.mantra.compose.database.model.ChatMessage +import press.mantra.compose.database.model.intermdiate.LocalChatMessage +import press.mantra.compose.extensions.shortened +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.ui.composable.widgets.profile.ProfileColor +import press.mantra.compose.ui.view.model.ChatMessageListViewModel +import press.mantra.compose.ui.view.state.ChatMessageListUIState +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.currently_no_messages_have_been_shared +import mantra.composeapp.generated.resources.loading +import mantra.composeapp.generated.resources.no_chat_message_relays_were_found_for_this +import mantra.composeapp.generated.resources.private_to_you +import mantra.composeapp.generated.resources.review +import mantra.composeapp.generated.resources.waiting_for_your_signature +import mantra.composeapp.generated.resources.you +import mantra.composeapp.generated.resources.private_to +import mantra.composeapp.generated.resources.proposals_are_waiting_for_your_signature +import mantra.composeapp.generated.resources.reply_privately_to +import mantra.composeapp.generated.resources.sent_a_private_message_to +import press.mantra.compose.ui.composable.widgets.ErrorState + +/** + * A chat room's transcript: its messages, the notices between them, and the standing ask + * at its foot. + * + * This was `ChatMessageListViewModel.RenderMessages`, 380 lines of layout inside a 1,100 + * line view model, which is where the only two `BoxWithConstraints` in the app ended up. + * It moves here for the pane work: a transcript that has to render at 400dp as a whole + * screen and at 900dp as the detail half of a two-pane layout is a layout decision, and + * layout decisions that live in a view model cannot be composed twice or previewed once. + * + * The view model comes in as the first parameter rather than as a receiver. It is a + * parameter so the reader can see, at every use, which state is the room's and which is + * this composable's -- `openMessageActionsFor` read bare inside a class body says nothing + * about where it is kept, and there are fourteen such reads in here. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ChatTranscript( + viewModel: ChatMessageListViewModel, + onOpenSharedKey: () -> Unit, + /** + * Opens the signing this line is about, by session id -- or the room's + * whole list of proposals when the line predates [ChatMessage.frostSigningSessionId] + * and cannot say which one it meant. + */ + onOpenSigning: (sessionId: String?) -> Unit, + /** Opens the room's proposals, all of them, whatever their state. */ + onOpenProposals: () -> Unit, +) { + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (val chatRoomDetailMessageListUIState = viewModel.chatMessageListUIState) { + ChatMessageListUIState.Error -> { + ErrorState() + } + is ChatMessageListUIState.Loaded -> { + Spacer( + modifier = Modifier.weight(1f) + ) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + + if (chatRoomDetailMessageListUIState.chatMessageList.isEmpty()) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space250) + ) + Text( + modifier = Modifier.padding(MaterialTheme.spacing.space250), + text = stringResource(Res.string.currently_no_messages_have_been_shared), + textAlign = TextAlign.Center + ) + + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space250) + ) + } else { + + // Which request lines are still asking something of the + // reader. Read off the transcript rather than the session + // -- the rows are what a line rendered days later has -- + // and both rules live on ChatMessage, where they can be + // stated once and tested. + val messages = chatRoomDetailMessageListUIState + .chatMessageList + .map { it.chatMessage } + + val answeredRequests = ChatMessage.answeredRequests(messages) + val settledRequests = ChatMessage.settledRequests(messages) + + // Read out here rather than inside the list, so the + // notice appearing and disappearing is a recomposition + // of this function and not of a lazy item that may not + // be composed at the time. + val awaitingYou = viewModel.proposalsAwaitingYou + + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space50), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + reverseLayout = true + ) { + // First item, and the layout is reversed, so this + // sits under the newest message and above the + // composer -- where the reader already is. + if (awaitingYou.isNotEmpty()) { + item { + ProposalsAwaitingYouNotice( + proposals = awaitingYou, + onClick = onOpenProposals + ) + } + } + + item { + if (viewModel.isReceiverChatMessageRelayListMissing.value) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Card( + onClick = { + // TODO: Open description for chatMessageRelayList + }, + modifier = Modifier.fillMaxWidth(0.79f), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.compactPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(Res.string.no_chat_message_relays_were_found_for_this), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall + ) + } + + Icon( + Icons.Default.Info, + contentDescription = "Warning description" + ) + } + + + } + } + } + } + items( + items = chatRoomDetailMessageListUIState.chatMessageList, + key = { it.chatMessage.id } + ) { localChatMessage -> + // Not somebody's words -- see ChatMessage.DKG_TYPES. + // A bubble would attribute "a shared key ceremony + // started" to the coordinator as if they had said it. + if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = localChatMessage.chatMessage.id in answeredRequests, + isSettled = localChatMessage.chatMessage.id in settledRequests, + onClick = onOpenSharedKey + ) + return@items + } + + // A private message this device cannot open. Everything + // about it is known except the one thing that matters, + // so it is a notice rather than an empty bubble -- + // which would read as the sender having said nothing. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE && + localChatMessage.chatMessage.content.isBlank() + ) { + PrivateMessageNotice( + sender = viewModel.nameFor(localChatMessage.chatMessage.senderPublicKey), + recipient = viewModel.nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey) + ) + return@items + } + + // Catching a member up is nobody's words either, + // and it leads nowhere: the work it delivered is + // in the artifact list, not behind this line. + // Passed as answered and settled because those + // are about requests and this asks nothing -- + // which is what keeps it in the quiet tint. + if (localChatMessage.chatMessage.messageType in ChatMessage.CHRONICLE_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = true, + isSettled = true, + onClick = {} + ) + return@items + } + + // Inviting somebody is nobody's words either, and + // for a while it was not in the room at all: the + // line was written when the Welcome went out, which + // on the deferred path is a relay round trip away + // and may never happen. Passed as answered and + // settled for the same reason the chronicle's are + // -- these report rather than ask, so they stay in + // the quiet tint and offer nothing to review. + if (localChatMessage.chatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = true, + isSettled = true, + onClick = {} + ) + return@items + } + + // Signing lines are the same kind of thing and get + // the same treatment -- nobody said them either -- + // but they lead somewhere else, because what a + // reader needs from one is the event being signed + // rather than the state of the key. + if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) { + RitualNotice( + localChatMessage = localChatMessage, + isAnswered = localChatMessage.chatMessage.id in answeredRequests, + isSettled = localChatMessage.chatMessage.id in settledRequests, + onClick = { + onOpenSigning( + localChatMessage.chatMessage.frostSigningSessionId + ) + } + ) + return@items + } + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth() + ) { + val screenWidth = maxWidth + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (localChatMessage.chatMessage.isUserMessage) { + Arrangement.End + } else { + Arrangement.Start + } + ) { + // Only somebody else's message, and only in a + // group -- a NIP-17 room has no audience for a + // message to be private from. + val canReplyPrivately = + viewModel.localChatRoom.chatRoom.mlsGroupState != null && + !localChatMessage.chatMessage.isUserMessage && + viewModel.participantFor(localChatMessage.chatMessage.senderPublicKey) != null + + Card( + modifier = Modifier.widthIn( + max = screenWidth * 0.8f + ).wrapContentWidth(), + onClick = { + if (canReplyPrivately) { + viewModel.openMessageActions(localChatMessage.chatMessage.id) + } + }, + ) { + Column( + modifier = Modifier.padding(MaterialTheme.spacing.space125), + horizontalAlignment = if (localChatMessage.chatMessage.isUserMessage) { + Alignment.End + } else { + Alignment.Start + }, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space75) + ) { + if (localChatMessage.chatMessage.isUserMessage.not()) { + Text( + text = localChatMessage.profile?.humanReadableNameOrPubkey() ?: localChatMessage.chatMessage.senderPublicKey, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + color = ProfileColor.fromPublicKey(localChatMessage.chatMessage.senderPublicKey), + style = MaterialTheme.typography.labelSmall + ) + } + + // A readable direct message must never + // pass for a public one. The label says + // who the other party is, since that is + // the thing a reader would otherwise + // assume was the whole room. + if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE) { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = Decorative, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = if (localChatMessage.chatMessage.isUserMessage) { + stringResource(Res.string.private_to, viewModel.nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)) + } else { + stringResource(Res.string.private_to_you) + }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall + ) + } + } + + SelectionContainer { + + Text( + text = localChatMessage.chatMessage.content, + style = MaterialTheme.typography.bodyMedium + ) + + } + + Row( + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.space125, + Alignment.End + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = localChatMessage.chatMessage.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall + ) + if (localChatMessage.chatMessage.isUserMessage) { + if (localChatMessage.chatMessageBroadcastNostrEventReceiptRelation != null) { + Icon( + Icons.Default.Check, + contentDescription = "Message sent" + ) + } else if (localChatMessage.chatMessageBroadcastNostrEventRequestRelation != null) { + Icon( + Icons.Default.AccessTime, + contentDescription = "Message sent" + ) + } else if (localChatMessage.chatMessageNostrEventRelation != null) { + Icon( + Icons.Default.Pending, + contentDescription = "Message signed and sealed status" + ) + } else { + Icon( + Icons.Default.KeyOff, + contentDescription = "Unsealed message status" + ) + } + + } + + } + } + } + + DropdownMenu( + expanded = viewModel.openMessageActionsFor == localChatMessage.chatMessage.id, + onDismissRequest = { viewModel.openMessageActions(null) } + ) { + DropdownMenuItem( + text = { + Text(stringResource(Res.string.reply_privately_to, viewModel.nameFor(localChatMessage.chatMessage.senderPublicKey))) + }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = Decorative) + }, + onClick = { + viewModel.participantFor(localChatMessage.chatMessage.senderPublicKey) + ?.let { viewModel.startDirectMessage(it) } + } + ) + } + } + } + } + } + } + } + } + ChatMessageListUIState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + Text( + text = stringResource(Res.string.loading), + textAlign = TextAlign.Center + ) + Spacer( + modifier = Modifier.height(MaterialTheme.spacing.space600) + ) + + LoadingIndicator() + } + } + } + } +} +/** + * What the group is waiting on this member to sign, standing at the foot of the + * transcript. + * + * A proposal announces itself as a line and then the conversation carries it + * upward, but the decision it asks for does not expire with the scroll -- and a + * member who has not answered is what the whole room is waiting on. So the ask + * is restated where the reader already is, under the newest message, and is gone + * the moment nothing is owed. Nothing to dismiss: there is no state here beyond + * whether the group still needs an answer. + * + * Named after what it signs when there is one of them, because "a proposal is + * waiting" is not something anybody can decide about. With several, the count is + * the honest summary -- naming one of several here would say the others were not + * there. + * + * It opens the room's proposals rather than the one it names, in every case. The + * transcript's own lines are the way to one proposal; this is the standing count + * of what is owed, and the queue is the screen that answers the question it + * raises -- including for the one it could not name. + */ +@Composable +private fun ProposalsAwaitingYouNotice( + proposals: List, + onClick: () -> Unit, +) { + val single = proposals.singleOrNull() + + val summary = single?.lead + ?.let { lead -> + listOfNotNull(lead.label, lead.detail.takeIf { it.isNotBlank() }) + .joinToString(" · ") + } + // Two ways for a proposal to have no lead, and they are different + // situations: a session can exist before its proposal has arrived, and a + // proposal can arrive holding events this build cannot read. Said the + // same way the proposal list says it. + ?: single?.let { + if (it.eventCount == 0) { + "Nothing has arrived to sign yet" + } else { + "None of its events could be read" + } + } + + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space150), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Draw, + contentDescription = Decorative + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25) + ) { + Text( + text = if (single != null) { + stringResource(Res.string.waiting_for_your_signature) + } else { + stringResource(Res.string.proposals_are_waiting_for_your_signature, proposals.size) + }, + style = MaterialTheme.typography.labelMedium + ) + + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + // The same word the transcript's own request lines use for the same + // thing, so a member reading down the room is not asked twice in two + // vocabularies. + Text( + text = stringResource(Res.string.review), + style = MaterialTheme.typography.labelLarge + ) + + Icon( + Icons.Default.ChevronRight, + contentDescription = "Open the group's proposals" + ) + } + } +} + +/** + * A private message this device cannot read, as a system line. + * + * Deliberately not a bubble. The group is meant to know that a private message was sent + * and to whom -- that is the honest half of the feature -- but an empty bubble attributed + * to the sender would read as them having said nothing, and a bubble with placeholder text + * would read as them having said the placeholder. + * + * Not tappable: there is nothing behind it to open. See docs/marmot-direct-messages.md. + */ +@Composable +private fun PrivateMessageNotice( + sender: String, + recipient: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.space125), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = Decorative, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(Res.string.sent_a_private_message_to, sender, recipient), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall + ) + } +} + +/** + * A ChillDKG milestone, as a system line across the transcript. + * + * Deliberately not a bubble: nobody said this, and giving it a sender and a side + * would make the coordinator appear to have announced it. It is tappable because + * the point of telling the group is to give them somewhere to go — a ritual only + * finishes once every member's device has taken part, and the ladder that shows + * who it is waiting on lives on the shared-key screen. + */ +@Composable +private fun RitualNotice( + localChatMessage: LocalChatMessage, + isAnswered: Boolean, + isSettled: Boolean, + onClick: () -> Unit, +) { + val chatMessage = localChatMessage.chatMessage + + // One per stage. A ceremony puts a dozen-odd lines in a row into the transcript, + // and with a single icon on all of them the reader has to actually read each to + // tell "somebody joined" from "somebody contributed" from "you are being asked + // for something". A request shares its stage's icon rather than getting a + // distinct one: it is the same step, before rather than after, and the primary + // tint and the Review affordance already say which. + val icon = when (chatMessage.messageType) { + ChatMessage.TYPE_DKG_STARTED -> Icons.Default.Key + ChatMessage.TYPE_DKG_HOST_KEY -> Icons.Default.PersonAdd + ChatMessage.TYPE_DKG_ROUND_1 -> Icons.Default.Upload + ChatMessage.TYPE_DKG_COORDINATOR_ROUND_1 -> Icons.Default.CallMerge + ChatMessage.TYPE_DKG_ROUND_2 -> Icons.Default.FactCheck + ChatMessage.TYPE_DKG_CERTIFICATE -> Icons.Default.WorkspacePremium + ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle + ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline + + ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY -> Icons.Default.PersonAdd + ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload + ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck + + ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw + ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload + ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups + ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw + ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium + ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle + ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline + ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw + + ChatMessage.TYPE_CHRONICLE_REQUESTED -> Icons.Default.History + ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload + ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download + + // An invite made and an invite sent are two separate steps on the deferred + // path, so they get separate icons -- the whole reason both lines exist is + // to be able to see that the first happened and the second did not. + ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd + ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload + ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline + + else -> Icons.Default.PanTool + } + + // The requests are the ritual lines that ask rather than report, and the ones + // the ceremony cannot get past on its own. Everything else here is deliberately + // quiet; these are not. + // An answered request is history, not a summons: it keeps its stage's icon so + // the step is still recognisable, but drops the colour and the call to action. + // So is a settled one -- declined, or signed by a quorum that did not need this + // member. Nothing was answered there, so it gets no tick, but offering to + // review it would be offering a decision that has already gone by. + val isRequest = ( + chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES || + chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES + ) && !isAnswered && !isSettled + + val tint = when { + chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || + chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED || + chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED -> + MaterialTheme.colorScheme.error + isRequest -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Row( + modifier = Modifier + .fillMaxWidth() + .minimumInteractiveComponentSize() + .clickable(onClick = onClick) + .padding(horizontal = MaterialTheme.spacing.space250, vertical = MaterialTheme.spacing.space125), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = Decorative, + tint = tint + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25) + ) { + Text( + text = buildAnnotatedString { + // Somebody opened this ceremony, or somebody walked away from it, + // and which member that was is the point of the line. Resolved + // from the joined profile rather than written into the content, + // so it follows a rename and is not stuck on the "LOADING..." + // placeholder a member is given the moment they are first seen. + if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES || + chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES + ) { + withStyle( + SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey)) + ) { + append( + if (chatMessage.isUserMessage) { + stringResource(Res.string.you) + } else { + localChatMessage.profile?.humanReadableNameOrPubkey() + ?: chatMessage.senderPublicKey.shortened() + } + ) + } + append(" ") + } + + append(chatMessage.content) + }, + style = MaterialTheme.typography.bodySmall, + color = tint + ) + + Text( + text = chatMessage.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (isRequest) { + Text( + text = stringResource(Res.string.review), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } else if (isAnswered) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "You approved this", + tint = MaterialTheme.colorScheme.primary + ) + } + + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "Open the shared key ceremony", + tint = tint + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ArticleCard.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ArticleCard.kt index 4ecd168f..ae095edf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ArticleCard.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ArticleCard.kt @@ -14,12 +14,19 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.article +import mantra.composeapp.generated.resources.loading_article +import mantra.composeapp.generated.resources.untitled_article @Composable private fun ArticleCard( @@ -50,25 +57,26 @@ private fun ArticleCard( color = MaterialTheme.colorScheme.surface, modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp) + .padding(vertical = MaterialTheme.spacing.space75) .then( - if (onArticleClick != null) Modifier.clickable { onArticleClick(30023, author, dTag) } + if (onArticleClick != null) Modifier.minimumInteractiveComponentSize() + .clickable { onArticleClick(30023, author, dTag) } else Modifier ) ) { if (event == null) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(14.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space175) ) { CircularProgressIndicator( modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space100)) Text( - text = "Loading article...", + text = stringResource(Res.string.loading_article), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -88,22 +96,22 @@ private fun ArticleCard( // .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), // ) // } - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + Column(modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space175, vertical = MaterialTheme.spacing.space125)) { Row(verticalAlignment = Alignment.CenterVertically) { Surface( shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.padding(end = 8.dp) + modifier = Modifier.padding(end = MaterialTheme.spacing.compactPadding) ) { Text( - text = "ARTICLE", + text = stringResource(Res.string.article), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space75, vertical = MaterialTheme.spacing.space25) ) } Text( - text = title ?: "Untitled Article", + text = title ?: stringResource(Res.string.untitled_article), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, @@ -118,19 +126,19 @@ private fun ArticleCard( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(top = MaterialTheme.spacing.space50) ) } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 6.dp) + modifier = Modifier.padding(top = MaterialTheme.spacing.space75) ) { press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar( profile = profile, publicKey = localNostrEvent.nostrEvent.pubKey, size = 20.dp ) - Spacer(Modifier.width(6.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space75)) val displayName = profile?.humanReadableNameOrPubkey() ?: "${author.take(8)}...${author.takeLast(4)}" Text( @@ -140,7 +148,8 @@ private fun ArticleCard( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = if (onProfileClick != null) { - Modifier.clickable { onProfileClick(author) } + Modifier.minimumInteractiveComponentSize() + .clickable { onProfileClick(author) } } else Modifier ) if (publishedAt != null) { @@ -151,7 +160,7 @@ private fun ArticleCard( ) }", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/FullScreenImageViewer.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/FullScreenImageViewer.kt index 40709553..46b81e99 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/FullScreenImageViewer.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/FullScreenImageViewer.kt @@ -37,6 +37,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil3.compose.AsyncImage +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import press.mantra.compose.ui.theme.spacing @Composable fun FullScreenImageViewer( @@ -61,7 +64,8 @@ fun FullScreenImageViewer( Box( modifier = Modifier .fillMaxSize() - .background(Color.Black) + .background(MaterialTheme.colorScheme.scrim) + .minimumInteractiveComponentSize() .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -87,10 +91,15 @@ fun FullScreenImageViewer( Row( modifier = Modifier .align(Alignment.TopEnd) - .padding(16.dp) + .padding(MaterialTheme.spacing.containerPadding) ) { val buttonColors = IconButtonDefaults.iconButtonColors( - containerColor = Color.Black.copy(alpha = 0.5f), + // m3-color-exempt: this button floats over an arbitrary + // photograph, so no scheme role is safe behind it. A translucent + // scrim with white on it is M3's own full-screen media treatment + // and the only pairing that holds over both a white sky and a + // black one. + containerColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), contentColor = Color.White ) @@ -104,7 +113,7 @@ fun FullScreenImageViewer( // contentDescription = "Download" // ) // } - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space100)) IconButton( onClick = { clipboardManager.setText(AnnotatedString(imageUrl)) }, colors = buttonColors, @@ -112,7 +121,7 @@ fun FullScreenImageViewer( ) { Icon(Icons.Default.ContentCopy, contentDescription = "Copy URL") } - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space100)) IconButton( onClick = onDismiss, colors = buttonColors, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ImageWithContextMenu.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ImageWithContextMenu.kt index cf03212d..6cb28994 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ImageWithContextMenu.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/ImageWithContextMenu.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -31,6 +32,12 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.copy_url +import mantra.composeapp.generated.resources.download +import mantra.composeapp.generated.resources.tap_to_load @OptIn(ExperimentalFoundationApi::class) @Composable @@ -48,6 +55,7 @@ internal fun ImageWithContextMenu(meta: MediaMeta, onFullScreen: () -> Unit) { .fillMaxWidth() .height(200.dp) .clip(RoundedCornerShape(12.dp)) + .minimumInteractiveComponentSize() .clickable { loaded = true }, color = MaterialTheme.colorScheme.surfaceVariant ) { @@ -62,9 +70,9 @@ internal fun ImageWithContextMenu(meta: MediaMeta, onFullScreen: () -> Unit) { modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space100)) Text( - "Tap to load", + stringResource(Res.string.tap_to_load), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -92,14 +100,14 @@ internal fun ImageWithContextMenu(meta: MediaMeta, onFullScreen: () -> Unit) { onDismissRequest = { showMenu = false } ) { DropdownMenuItem( - text = { Text("Copy URL") }, + text = { Text(stringResource(Res.string.copy_url)) }, onClick = { clipboardManager.setText(AnnotatedString(url)) showMenu = false } ) DropdownMenuItem( - text = { Text("Download") }, + text = { Text(stringResource(Res.string.download)) }, onClick = { showMenu = false // scope.launch { MediaDownloader.downloadMedia(context, url) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LightningInvoiceCard.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LightningInvoiceCard.kt index ac185e3a..0f76c3d6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LightningInvoiceCard.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LightningInvoiceCard.kt @@ -33,6 +33,18 @@ import androidx.compose.ui.unit.dp import fr.acinq.lightning.payment.Bolt11Invoice import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.any_amount +import mantra.composeapp.generated.resources.cancel +import mantra.composeapp.generated.resources.expired +import mantra.composeapp.generated.resources.failed +import mantra.composeapp.generated.resources.lightning_invoice +import mantra.composeapp.generated.resources.paid +import mantra.composeapp.generated.resources.pay +import mantra.composeapp.generated.resources.pay_now @Composable internal fun LightningInvoiceCard( @@ -50,14 +62,14 @@ internal fun LightningInvoiceCard( if (showConfirm) { AlertDialog( onDismissRequest = { showConfirm = false }, - title = { Text("Lightning Invoice") }, + title = { Text(stringResource(Res.string.lightning_invoice)) }, text = { Column { Text( text = "${decoded.amount?.truncateToSatoshi() ?: "Any amount"}" ) if (!decoded.description.isNullOrBlank()) { - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space50)) Text( text = decoded.description!!, style = MaterialTheme.typography.bodySmall, @@ -84,11 +96,11 @@ internal fun LightningInvoiceCard( containerColor = primary, contentColor = onPrimary ) - ) { Text("Pay") } + ) { Text(stringResource(Res.string.pay)) } }, dismissButton = { TextButton(onClick = { showConfirm = false }) { - Text("Cancel") + Text(stringResource(Res.string.cancel)) } } ) @@ -97,27 +109,27 @@ internal fun LightningInvoiceCard( Surface( modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp), + .padding(vertical = MaterialTheme.spacing.space50), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceVariant, border = BorderStroke(1.dp, primary.copy(alpha = 0.4f)) ) { Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space150, vertical = MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { Icon( Icons.Default.Bolt, - contentDescription = null, + contentDescription = Decorative, tint = primary, modifier = Modifier.size(20.dp) ) - Spacer(Modifier.width(10.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space125)) Column(Modifier.weight(1f)) { Text( text = if (decoded.amount != null) "${decoded.amount!!.truncateToSatoshi()}" - else "Any Amount", + else stringResource(Res.string.any_amount), style = MaterialTheme.typography.titleSmall, color = primary ) @@ -131,14 +143,14 @@ internal fun LightningInvoiceCard( } if (isExpired) { Text( - text = "Expired", + text = stringResource(Res.string.expired), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error ) } } if (onPayInvoice != null) { - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space100)) when (payState) { InvoicePayState.Paying -> CircularProgressIndicator( modifier = Modifier.size(24.dp), @@ -146,12 +158,12 @@ internal fun LightningInvoiceCard( strokeWidth = 2.dp ) InvoicePayState.Success -> Text( - text = "✓ Paid", + text = stringResource(Res.string.paid), style = MaterialTheme.typography.labelMedium, color = primary ) InvoicePayState.Failed -> Text( - text = "✗ Failed", + text = stringResource(Res.string.failed), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.error ) @@ -164,8 +176,8 @@ internal fun LightningInvoiceCard( ) ) { Text( - if (isExpired) "Expired" - else "Pay now" + if (isExpired) stringResource(Res.string.expired) + else stringResource(Res.string.pay_now) ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LinkPreview.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LinkPreview.kt index eb2d93d7..3d9f4f13 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LinkPreview.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LinkPreview.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -24,6 +25,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import io.ktor.http.Url +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.composable.widgets.Decorative @Composable internal fun LinkPreview(url: String) { @@ -47,14 +50,15 @@ internal fun LinkPreview(url: String) { color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), modifier = Modifier .fillMaxWidth() - .padding(vertical = 4.dp) + .padding(vertical = MaterialTheme.spacing.space50) + .minimumInteractiveComponentSize() .clickable { uriHandler.openUri(url) } ) { Column { data.image?.let { imageUrl -> AsyncImage( model = imageUrl, - contentDescription = null, + contentDescription = Decorative, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxWidth() @@ -62,7 +66,7 @@ internal fun LinkPreview(url: String) { .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) ) } - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(MaterialTheme.spacing.space150)) { data.siteName?.let { site -> Text( text = site.uppercase(), @@ -93,7 +97,7 @@ internal fun LinkPreview(url: String) { color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 2.dp) + modifier = Modifier.padding(top = MaterialTheme.spacing.space25) ) } data.description?.let { desc -> @@ -103,7 +107,7 @@ internal fun LinkPreview(url: String) { color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(top = MaterialTheme.spacing.space50) ) } } @@ -118,7 +122,8 @@ internal fun LinkPreview(url: String) { maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier - .padding(vertical = 2.dp) + .padding(vertical = MaterialTheme.spacing.space25) + .minimumInteractiveComponentSize() .clickable { uriHandler.openUri(url) } ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCard.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCard.kt index e25166da..32b9ee83 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCard.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCard.kt @@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing @Composable private fun LiveStreamCard( @@ -44,9 +46,10 @@ private fun LiveStreamCard( color = MaterialTheme.colorScheme.surface, modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp) + .padding(vertical = MaterialTheme.spacing.space75) .then( - if (onLiveStreamClick != null) Modifier.clickable { + if (onLiveStreamClick != null) Modifier.minimumInteractiveComponentSize() + .clickable { onLiveStreamClick(author, dTag, segmentRelayHints.firstOrNull()) } else Modifier diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCardContent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCardContent.kt index 01b571fb..6b4f135f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCardContent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LiveStreamCardContent.kt @@ -12,12 +12,20 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.ended +import mantra.composeapp.generated.resources.live +import mantra.composeapp.generated.resources.live_stream +import mantra.composeapp.generated.resources.loading_stream @Composable internal fun LiveStreamCardContent( @@ -34,16 +42,16 @@ internal fun LiveStreamCardContent( if (event == null) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(14.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space175) ) { CircularProgressIndicator( modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space100)) Text( - text = "Loading stream...", + text = stringResource(Res.string.loading_stream), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -78,37 +86,40 @@ internal fun LiveStreamCardContent( // .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), // ) } - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + Column(modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space175, vertical = MaterialTheme.spacing.space125)) { Row(verticalAlignment = Alignment.CenterVertically) { if (status == "live") { Surface( shape = RoundedCornerShape(4.dp), - color = Color(0xFFE53935), - modifier = Modifier.padding(end = 8.dp) + // Was #E53935 with a white label: 4.23:1, under the floor + // for text this size. `error` is the role for a red that has + // to be read, and carries its own `onError`. + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(end = MaterialTheme.spacing.compactPadding) ) { Text( - text = "LIVE", + text = stringResource(Res.string.live), style = MaterialTheme.typography.labelSmall, - color = Color.White, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + color = MaterialTheme.colorScheme.onError, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space75, vertical = MaterialTheme.spacing.space25) ) } } else if (status == "ended") { Surface( shape = RoundedCornerShape(4.dp), color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.padding(end = 8.dp) + modifier = Modifier.padding(end = MaterialTheme.spacing.compactPadding) ) { Text( - text = "ENDED", + text = stringResource(Res.string.ended), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.space75, vertical = MaterialTheme.spacing.space25) ) } } Text( - text = title ?: "Live Stream", + text = title ?: stringResource(Res.string.live_stream), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, @@ -123,19 +134,19 @@ internal fun LiveStreamCardContent( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(top = MaterialTheme.spacing.space50) ) } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 6.dp) + modifier = Modifier.padding(top = MaterialTheme.spacing.space75) ) { press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar( profile = profile, publicKey = event.pubKey, size = 20.dp ) - Spacer(Modifier.width(6.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space75)) val displayName = profile?.humanReadableNameOrPubkey() ?: "${author.take(8)}...${author.takeLast(4)}" Text( @@ -145,7 +156,8 @@ internal fun LiveStreamCardContent( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = if (onProfileClick != null) { - Modifier.clickable { onProfileClick(author) } + Modifier.minimumInteractiveComponentSize() + .clickable { onProfileClick(author) } } else Modifier ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LoadingAsyncImage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LoadingAsyncImage.kt index b64475c0..87337626 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LoadingAsyncImage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/LoadingAsyncImage.kt @@ -62,6 +62,11 @@ internal fun LoadingAsyncImage( CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, + // m3-color-exempt: over a blurhash placeholder the backdrop is an + // arbitrary blurred image, so no role is reliably legible on it. + // White is the conventional choice and holds on all but a white + // photograph. Without a placeholder the surface is known and the + // role is used. color = if (blurPainter != null) Color.White else MaterialTheme.colorScheme.primary ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedAddressableNote.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedAddressableNote.kt index 62e7f568..5bbafff5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedAddressableNote.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedAddressableNote.kt @@ -17,6 +17,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading_note @Composable @@ -48,22 +52,22 @@ internal fun QuotedAddressableNote( color = MaterialTheme.colorScheme.surface, modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp) + .padding(vertical = MaterialTheme.spacing.space75) ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(14.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space175) ) { CircularProgressIndicator( modifier = Modifier .width(14.dp) .height(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space100)) Text( - text = "Loading note...", + text = stringResource(Res.string.loading_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt index c2bb6b9e..597b51ff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/QuotedNote.kt @@ -13,12 +13,17 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading_note +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun QuotedNote( @@ -56,7 +61,7 @@ fun QuotedNote( color = MaterialTheme.colorScheme.surface, modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp) + .padding(vertical = MaterialTheme.spacing.space75) ) { localQuotedNostrEvent.RenderNotePreview( onNavigateToEvent = onNavigateToEvent @@ -72,7 +77,8 @@ fun QuotedNote( modifier = Modifier .fillMaxWidth() .then( - Modifier.clickable { effectiveNoteClick(localQuotedNostrEvent.nostrEvent.id) } + Modifier.minimumInteractiveComponentSize() + .clickable { effectiveNoteClick(localQuotedNostrEvent.nostrEvent.id) } ) ) { localQuotedNostrEvent.RenderNotePreview( @@ -87,22 +93,22 @@ fun QuotedNote( color = MaterialTheme.colorScheme.surface, modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp) + .padding(vertical = MaterialTheme.spacing.space75) ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(14.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space175) ) { CircularProgressIndicator( modifier = Modifier .width(14.dp) .height(14.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space100)) Text( - text = "Loading note...", + text = stringResource(Res.string.loading_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -111,12 +117,12 @@ fun QuotedNote( } } -@Preview +@ConformancePreviews @Composable private fun QuotedNotePreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { QuotedNote( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt index 3f36761b..40f4ab4b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/RichContent.kt @@ -26,11 +26,16 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import co.touchlab.kermit.Logger import coil3.compose.AsyncImage import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.nostr +import mantra.composeapp.generated.resources.nostr_2 +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun RichContent( @@ -283,7 +288,7 @@ fun RichContent( ) } else { Text( - text = "nostr:${segment.eventId.take(8)}..", + text = stringResource(Res.string.nostr, segment.eventId.take(8)), style = style, color = MaterialTheme.colorScheme.primary ) @@ -307,7 +312,7 @@ fun RichContent( ) } else { Text( - text = "nostr:${segment.dTag.take(12)}...", + text = stringResource(Res.string.nostr_2, segment.dTag.take(12)), style = style, color = MaterialTheme.colorScheme.primary ) @@ -393,18 +398,18 @@ fun RichContent( } } -@Preview +@ConformancePreviews @Composable private fun RichContentPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { Card( - modifier = Modifier.padding(10.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space125) ) { RichContent( - modifier = Modifier.padding(10.dp), + modifier = Modifier.padding(MaterialTheme.spacing.space125), nostrEvent = press.mantra.compose.database.model.NostrEvent( id = "e5e3ad0272d1fbeead8e439a4ad3453374ca4969c8d2be84025ba4ef8ae5a520", content = "Something something something nostr:npub1uh366qnj68a7atvwgwdy4569xd6v5jtferftapqztwjwlzh9553q47pqsx\nhttps://www.brainyquote.com/authors/steven-biko-quotes\nnostr:nevent1qqspcusnzxx5u97xht2udvg3yhcq2cvmc0jrxa90lwwdvjplve0atespzpmhxue69uhkummnw3ezuamfdejsyg8h5g639qhuv803hzwexfwhyud6t6suxfu364anddqvrmn9j4cahvxksr8q", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt index 52fc620b..78716cd3 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/content/UnsupportedKindBadge.kt @@ -11,6 +11,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.unsupported_event_kind +import mantra.composeapp.generated.resources.unsupported_event_kind_2 @Composable internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) { @@ -20,13 +25,13 @@ internal fun UnsupportedKindBadge(kind: Int?, style: TextStyle) { color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), modifier = Modifier .fillMaxWidth() - .padding(vertical = 6.dp) + .padding(vertical = MaterialTheme.spacing.space75) ) { Text( - text = if (kind != null) "Unsupported event kind: $kind" else "Unsupported event kind", + text = if (kind != null) stringResource(Res.string.unsupported_event_kind_2, kind) else stringResource(Res.string.unsupported_event_kind), style = style, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(14.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space175) ) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt index 0e233fd7..ccadaa71 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.NostrEvent @@ -57,6 +56,20 @@ import press.mantra.compose.ui.view.model.MetadataEventDetailViewModel import press.mantra.compose.ui.view.state.MetadataEventDetailUIState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.add_to_group +import mantra.composeapp.generated.resources.block_user +import mantra.composeapp.generated.resources.edit_profile +import mantra.composeapp.generated.resources.follow +import mantra.composeapp.generated.resources.follow_back +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -84,6 +97,7 @@ fun MetadataEventDetail( ) Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( @@ -110,11 +124,11 @@ fun MetadataEventDetail( profile?.nip05?.let { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) ) { Icon( Icons.Default.Bolt, - contentDescription = "Lightning Bolt", + contentDescription = "Lightning payment", modifier = Modifier.size(20.dp) ) Text( @@ -133,13 +147,13 @@ fun MetadataEventDetail( } ) { innerPadding -> Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { LazyColumn( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { item { Column( @@ -148,8 +162,8 @@ fun MetadataEventDetail( horizontalAlignment = Alignment.CenterHorizontally ) { Row( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { ProfileAvatar( @@ -180,7 +194,7 @@ fun MetadataEventDetail( onClick = onNavigateToEditProfile ) { Text( - "Edit Profile" + stringResource(Res.string.edit_profile) ) } @@ -194,7 +208,7 @@ fun MetadataEventDetail( } ) { Text( - "Unfollow" + stringResource(Res.string.unfollow) ) } } else { @@ -206,9 +220,9 @@ fun MetadataEventDetail( ) { Text( text = if (metadataEventDetailUIState.followingActiveUserConnection != null) { - "follow back" + stringResource(Res.string.follow_back) } else { - "follow" + stringResource(Res.string.follow) } ) } @@ -224,7 +238,7 @@ fun MetadataEventDetail( } Text( modifier = Modifier.fillMaxWidth().padding( - horizontal = 20.dp + horizontal = MaterialTheme.spacing.space250 ), maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -238,20 +252,20 @@ fun MetadataEventDetail( TextButton( onClick = { onNavigateToEvent.invoke( - ImplementationPendingRoute("Add to Group") + ImplementationPendingRoute("Add to group") ) } ) { Icon( Icons.Default.GroupAdd, - contentDescription = "Add to Group" + contentDescription = "Add to group" ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) - Text("Add To Group") + Text(stringResource(Res.string.add_to_group)) } } @@ -259,7 +273,7 @@ fun MetadataEventDetail( TextButton( onClick = { onNavigateToEvent.invoke( - ImplementationPendingRoute("Block User") + ImplementationPendingRoute("Block user") ) } ) { @@ -269,10 +283,10 @@ fun MetadataEventDetail( ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) - Text("Block User") + Text(stringResource(Res.string.block_user)) } } @@ -290,13 +304,13 @@ fun MetadataEventDetail( ) { Icon( Icons.Default.Mail, - contentDescription = "Direct Message" + contentDescription = "Direct message" ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) - Text("Send Message") + Text(stringResource(Res.string.send_message)) } } } @@ -308,13 +322,13 @@ fun MetadataEventDetail( } } -@Preview +@ConformancePreviews @Composable private fun MetadataEventEventDetailPreview() { TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { MetadataEventDetail( activeUserPublicKey = "", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt index 5aa93d77..34927ce1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/TextNoteEventDetail.kt @@ -35,18 +35,30 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.extensions.toFormattedTimeAndDateString import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.be_the_first_to_comment +import mantra.composeapp.generated.resources.post +import mantra.composeapp.generated.resources.something_went_wrong +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 +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -71,6 +83,7 @@ fun TextNoteEventDetail( ) Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { TopAppBar( @@ -85,7 +98,7 @@ fun TextNoteEventDetail( } }, title = { - Text("Post") + Text(stringResource(Res.string.post)) }, ) }, @@ -93,8 +106,8 @@ fun TextNoteEventDetail( FlexibleBottomAppBar( containerColor = Color.Transparent, contentPadding = PaddingValues( - start = 10.dp, - end = 10.dp + start = MaterialTheme.spacing.space125, + end = MaterialTheme.spacing.space125 ), scrollBehavior = scrollBehavior ) { @@ -102,17 +115,21 @@ fun TextNoteEventDetail( Surface( modifier = Modifier .fillMaxWidth() + .minimumInteractiveComponentSize() .clickable { onNavigateToWriteAReply.invoke( localNostrEvent.nostrEvent.id ) }, - shape = RoundedCornerShape(30.dp), + // The one hand-written corner that was off the shape scale, at 30dp + // against extraLarge's 28. Two units, invisible on its own and exactly + // the drift a scale exists to stop. + shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceVariant ) { Row( - modifier = Modifier.padding(10.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(MaterialTheme.spacing.space125).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), ) { // TODO: Load user profile... Icon( @@ -127,7 +144,7 @@ fun TextNoteEventDetail( Text( modifier = Modifier.weight(1f), - text = "Reply to ${localNostrEvent.profile?.humanReadableNameOrPubkey() ?: "the above"}", + text = stringResource(Res.string.reply_to, localNostrEvent.profile?.humanReadableNameOrPubkey() ?: "the above"), maxLines = 1, ) @@ -181,7 +198,7 @@ fun TextNoteEventDetail( } ) { innerPadding -> Column( - modifier = Modifier.padding(innerPadding).fillMaxSize() + modifier = Modifier.padding(innerPadding).readableContent().fillMaxSize() ) { LazyColumn( @@ -196,8 +213,8 @@ fun TextNoteEventDetail( horizontalAlignment = Alignment.CenterHorizontally ) { Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space150), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), verticalAlignment = Alignment.CenterVertically ) { val profile = localNostrEvent.profile @@ -251,7 +268,7 @@ fun TextNoteEventDetail( press.mantra.compose.ui.composable.widgets.content.RichContent( modifier = Modifier.fillMaxWidth().padding( - horizontal = 20.dp + horizontal = MaterialTheme.spacing.space250 ), nostrEvent = localNostrEvent.nostrEvent, localQuotedNostrEvent = localNostrEvent.localQuotedNostrEvent, @@ -260,7 +277,7 @@ fun TextNoteEventDetail( ) Row( - modifier = Modifier.fillMaxWidth().padding(20.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), horizontalArrangement = Arrangement.Start ) { Text( @@ -281,7 +298,7 @@ fun TextNoteEventDetail( ) { Icon( Icons.Default.AddReaction, - contentDescription = "Add Reaction" + contentDescription = "Add reaction" ) } @@ -340,7 +357,7 @@ fun TextNoteEventDetail( if (inReplyToFeedListUIState.localNostrEvents.isEmpty()) { item { Text( - "Be the first to comment." + stringResource(Res.string.be_the_first_to_comment) ) } } else { @@ -355,11 +372,7 @@ fun TextNoteEventDetail( } } press.mantra.compose.ui.view.state.FeedListUIState.Error -> { - item { - Text( - text = "Something went wrong" - ) - } + item { ErrorState() } } press.mantra.compose.ui.view.state.FeedListUIState.Loading -> { item { @@ -376,13 +389,13 @@ fun TextNoteEventDetail( } } -@Preview +@ConformancePreviews @Composable private fun TextNoteEventDetailPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { TextNoteEventDetail( localNostrEvent = press.mantra.compose.database.model.intermdiate.LocalNostrEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/BottomSheetDialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/BottomSheetDialog.kt index e9270375..904e942f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/BottomSheetDialog.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/BottomSheetDialog.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import press.mantra.compose.ui.theme.spacing /** Provides a Material3 [ModalBottomSheet] with some presets. Content is contained in a [Column] with [internalPadding]. */ @OptIn(ExperimentalMaterial3Api::class) @@ -52,7 +53,7 @@ fun ModalBottomSheet( dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults. DragHandle() }, contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets }, contentHeight: Dp = Dp.Unspecified, - internalPadding: PaddingValues = PaddingValues(top = 0.dp, start = 20.dp, end = 20.dp, bottom = 64.dp), + internalPadding: PaddingValues = PaddingValues(top = MaterialTheme.spacing.space0, start = MaterialTheme.spacing.space250, end = MaterialTheme.spacing.space250, bottom = MaterialTheme.spacing.space800), isContentScrollable: Boolean = true, dismissOnScrimClick: Boolean = true, dismissOnBack: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/NewChatBottomSheetDialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/NewChatBottomSheetDialog.kt index 7f193c47..f2520e36 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/NewChatBottomSheetDialog.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/NewChatBottomSheetDialog.kt @@ -28,6 +28,13 @@ import androidx.compose.ui.unit.dp import press.mantra.compose.ui.composable.navigation.routes.Route import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.close +import mantra.composeapp.generated.resources.create_new_chat +import mantra.composeapp.generated.resources.direct_message_via_npub +import mantra.composeapp.generated.resources.start_a_group_chat @OptIn(ExperimentalMaterial3Api::class) @@ -49,12 +56,12 @@ fun NewChatBottomSheetDialog( ) { // Sheet content Column( - modifier = Modifier.fillMaxWidth().padding(10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space125), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Create new chat", + text = stringResource(Res.string.create_new_chat), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge ) @@ -69,16 +76,16 @@ fun NewChatBottomSheetDialog( leadingContent = { Icon( Icons.Default.Key, - contentDescription = "Direct Message via npub" + contentDescription = "Direct message via npub" ) }, headlineContent = { - Text("Direct Message via npub") + Text(stringResource(Res.string.direct_message_via_npub)) }, trailingContent = { Icon( Icons.Default.ChevronRight, - contentDescription = "Direct Message via npub" + contentDescription = "Direct message via npub" ) } ) @@ -100,7 +107,7 @@ fun NewChatBottomSheetDialog( }, headlineContent = { Text( - text = "Start a group chat" + text = stringResource(Res.string.start_a_group_chat) ) }, trailingContent = { @@ -126,9 +133,9 @@ fun NewChatBottomSheetDialog( contentDescription = "Close new chat" ) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) - Text("Close") + Text(stringResource(Res.string.close)) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt index 7c7b93fc..ca97f935 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import press.mantra.compose.extensions.bech32ToHexOrNull @@ -42,6 +41,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import fr.acinq.phoenix.utils.Parser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.input_npub_or_nip05 +import mantra.composeapp.generated.resources.start_chat +import mantra.composeapp.generated.resources.start_chat_via_npub_or_nip05 +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun StartDirectMessageToNpubOrNip05Dialog( @@ -62,30 +71,40 @@ fun StartDirectMessageToNpubOrNip05Dialog( modifier = Modifier .fillMaxWidth() .wrapContentHeight() - .padding(16.dp), + .padding(MaterialTheme.spacing.containerPadding), shape = RoundedCornerShape(16.dp), ) { val textFieldState = rememberTextFieldState() + + // M3's flow guidance for a dialog: "Focus is set to the dialog component, + // likely to a specific interactive element within the dialog such as a text + // input field." This dialog is one field and two buttons, so the field is + // unambiguous -- and without this the whole dialog opens with nothing + // focused, which for a keyboard or switch user means tabbing in from + // wherever focus happened to be. + val npubFieldFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { npubFieldFocus.requestFocus() } Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { Spacer( - modifier = Modifier.height(4.dp) + modifier = Modifier.height(MaterialTheme.spacing.space50) ) Text( modifier = Modifier.fillMaxWidth().padding( - horizontal = 20.dp, + horizontal = MaterialTheme.spacing.space250, ), - text = "Start chat via npub or nip05", + text = stringResource(Res.string.start_chat_via_npub_or_nip05), style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center ) TextField( + modifier = Modifier.focusRequester(npubFieldFocus), state = textFieldState, placeholder = { - Text("Input npub... or nip05") + Text(stringResource(Res.string.input_npub_or_nip05)) }, trailingIcon = { IconButton( @@ -98,7 +117,7 @@ fun StartDirectMessageToNpubOrNip05Dialog( ) { Icon( Icons.Default.Close, - contentDescription = "Close Start Npub Direct Message" + contentDescription = "Close" ) } }, @@ -152,10 +171,10 @@ fun StartDirectMessageToNpubOrNip05Dialog( } } ) { - Text("Start Chat") + Text(stringResource(Res.string.start_chat)) Spacer( - modifier = Modifier.width(10.dp) + modifier = Modifier.width(MaterialTheme.spacing.space125) ) Icon( @@ -168,7 +187,7 @@ fun StartDirectMessageToNpubOrNip05Dialog( } } -@Preview +@ConformancePreviews @Composable private fun StartDirectMessageToNpubOrNip05DialogPreview() { TorchTheme { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt index aa064946..b74c6f2e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/EventListView.kt @@ -13,16 +13,23 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import press.mantra.compose.extensions.toFormattedTimeAndDateString import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import kotlin.time.Instant +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading_author_information_2 +import mantra.composeapp.generated.resources.malformed_note +import mantra.composeapp.generated.resources.reposted +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( @@ -33,21 +40,22 @@ fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { Row( modifier = Modifier .fillMaxWidth() .padding( - horizontal = 10.dp - ).clickable( + horizontal = MaterialTheme.spacing.space125 + ).minimumInteractiveComponentSize() + .clickable( enabled = true, onClick = { onNavigateToEvent.invoke(profile?.nostrEventId ?: nostrEvent.id) } ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) ) { Icon( modifier = Modifier.size( @@ -63,7 +71,7 @@ fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( color = press.mantra.compose.ui.composable.widgets.profile.ProfileColor.fromPublicKey(profile?.publicKey ?: nostrEvent.pubKey) ) Text( - text = "reposted", + text = stringResource(Res.string.reposted), style = MaterialTheme.typography.labelSmall ) } @@ -89,7 +97,7 @@ fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( ).containedPost() if (containedPost != null) { - Text("Loading author information...") + Text(stringResource(Res.string.loading_author_information_2)) Text( containedPost.content ) @@ -97,7 +105,7 @@ fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( text = Instant.fromEpochSeconds(containedPost.createdAt).toFormattedTimeAndDateString() ) } else { - Text("Malformed Note") + Text(stringResource(Res.string.malformed_note)) } } @@ -118,12 +126,12 @@ fun press.mantra.compose.database.model.intermdiate.LocalNostrEvent.ListView( } -@Preview +@ConformancePreviews @Composable private fun EventListViewRepostedPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { press.mantra.compose.database.model.intermdiate.LocalNostrEvent( nostrEvent = press.mantra.compose.database.model.NostrEvent( @@ -169,12 +177,12 @@ private fun EventListViewRepostedPreview() { } } -@Preview +@ConformancePreviews @Composable private fun EventListViewInReplyPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { press.mantra.compose.database.model.intermdiate.LocalNostrEvent( nostrEvent = press.mantra.compose.database.model.NostrEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt index fa95a173..bbe77197 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/feed/TextNoteFeedItem.kt @@ -15,15 +15,21 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import press.mantra.compose.extensions.toFormattedTimeAndDateString import com.vitorpamplona.quartz.nip01Core.core.HexKey +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading_author_information +import mantra.composeapp.generated.resources.loading_information +import press.mantra.compose.ui.theme.ConformancePreviews @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -42,7 +48,7 @@ internal fun TextNoteFeedItem( if (localInReplyToNostrEvent != null) { Row( modifier = Modifier.fillMaxWidth().padding( - horizontal = 20.dp + horizontal = MaterialTheme.spacing.space250 ), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically @@ -53,39 +59,41 @@ internal fun TextNoteFeedItem( ) Text( - modifier = Modifier.clickable( + modifier = Modifier.minimumInteractiveComponentSize() + .clickable( onClick = { onNavigateToEvent.invoke(localInReplyToNostrEvent.inReplyToRelation.inReplyToNostrEventId) } ), style = MaterialTheme.typography.labelLargeEmphasized, color = press.mantra.compose.ui.composable.widgets.profile.ProfileColor.fromPublicKey(localInReplyToNostrEvent.nostrEvent.pubKey), - text = localInReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: "loading information..." + text = localInReplyToNostrEvent.profile?.humanReadableNameOrPubkey() ?: stringResource(Res.string.loading_information) ) } } ElevatedCard( modifier = Modifier.padding( - horizontal = 10.dp + horizontal = MaterialTheme.spacing.space125 ), onClick = { onNavigateToEvent.invoke(nostrEvent.id) } ) { Column( - modifier = Modifier.padding(10.dp).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(MaterialTheme.spacing.space125).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), horizontalAlignment = Alignment.CenterHorizontally ) { if (profile != null) { Row( - modifier = Modifier.fillMaxWidth().clickable( + modifier = Modifier.fillMaxWidth().minimumInteractiveComponentSize() + .clickable( onClick = { onNavigateToEvent.invoke(profile.nostrEventId) } ), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.Start), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125, Alignment.Start), verticalAlignment = Alignment.CenterVertically ) { press.mantra.compose.ui.composable.widgets.profile.ProfileAvatar( @@ -112,7 +120,7 @@ internal fun TextNoteFeedItem( } } else { Text( - text = "Loading author information" + text = stringResource(Res.string.loading_author_information) ) } @@ -128,7 +136,7 @@ internal fun TextNoteFeedItem( LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy( - 10.dp, + MaterialTheme.spacing.space125, alignment = Alignment.Start ) ) { @@ -176,12 +184,12 @@ internal fun TextNoteFeedItem( } -@Preview +@ConformancePreviews @Composable private fun TextNoteFeedItemPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { TextNoteFeedItem( nostrEvent = press.mantra.compose.database.model.NostrEvent( @@ -207,12 +215,12 @@ private fun TextNoteFeedItemPreview() { } } -@Preview +@ConformancePreviews @Composable private fun TextNoteFeedItemInReplyToPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { TextNoteFeedItem( nostrEvent = press.mantra.compose.database.model.NostrEvent( @@ -261,12 +269,12 @@ private fun TextNoteFeedItemInReplyToPreview() { } } -@Preview +@ConformancePreviews @Composable private fun TextNoteFeedItemQuoteNostrEventPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { TextNoteFeedItem( nostrEvent = press.mantra.compose.database.model.NostrEvent( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt index d47f523c..24cd82b4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/profile/ProfileAvatar.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Face5 import androidx.compose.material.icons.filled.FaceRetouchingOff import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -16,10 +17,11 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.theme.ConformancePreviews @Composable fun ProfileAvatar( @@ -88,12 +90,12 @@ fun ProfileAvatar( // TODO: Show cards... } -@Preview +@ConformancePreviews @Composable fun ProfileAvatarPreview() { press.mantra.compose.ui.theme.TorchTheme { Surface( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(MaterialTheme.spacing.space250) ) { ProfileAvatar( publicKey = "hwgwefwase", diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletAvatar.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletAvatar.kt index 3c90dcbb..e20e6ad4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletAvatar.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletAvatar.kt @@ -44,8 +44,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.machankura.compose.ui.composable.widgets.buttons.Clickable +import press.mantra.compose.ui.composable.widgets.buttons.Clickable import press.mantra.compose.ui.composable.widgets.dialogs.ModalBottomSheet +import press.mantra.compose.ui.theme.spacing object WalletAvatars { val list = listOf( @@ -61,7 +62,7 @@ object WalletAvatars { } @Composable -fun WalletAvatar(avatar: String, fontSize: TextUnit = 28.sp, borderColor: Color = Color.Transparent, backgroundColor: Color = MaterialTheme.colorScheme.surface, internalPadding: PaddingValues = PaddingValues(10.dp)) { +fun WalletAvatar(avatar: String, fontSize: TextUnit = 28.sp, borderColor: Color = Color.Transparent, backgroundColor: Color = MaterialTheme.colorScheme.surface, internalPadding: PaddingValues = PaddingValues(MaterialTheme.spacing.space125)) { Box(modifier = Modifier.clip(CircleShape).background(backgroundColor).border(1.dp, color = borderColor, shape = CircleShape).padding(internalPadding), contentAlignment = Alignment.Center) { Text( text = avatar, @@ -79,20 +80,20 @@ fun ColumnScope.AvatarPicker( var showPickerDialog by remember { mutableStateOf(false) } Clickable(onClick = { showPickerDialog = true }, modifier = Modifier.align(Alignment.CenterHorizontally)) { - WalletAvatar(avatar, fontSize = 48.sp, internalPadding = PaddingValues(16.dp)) + WalletAvatar(avatar, fontSize = 48.sp, internalPadding = PaddingValues(MaterialTheme.spacing.containerPadding)) } if (showPickerDialog) { ModalBottomSheet( onDismiss = { showPickerDialog = false }, horizontalAlignment = Alignment.CenterHorizontally, - internalPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + internalPadding = PaddingValues(horizontal = MaterialTheme.spacing.space300, vertical = MaterialTheme.spacing.space0), isContentScrollable = false, ) { // val s = stringResource(Res.string.wallet_edit_pick_avatar) val s = "Edit avatar" Text(s, style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space300)) LazyVerticalGrid( columns = GridCells.Fixed(6), modifier = Modifier.fillMaxWidth() @@ -102,8 +103,22 @@ fun ColumnScope.AvatarPicker( showPickerDialog = false onAvatarChange(emoji) }) { - val mutedBgColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - WalletAvatar(emoji, backgroundColor = if (emoji == avatar) mutedBgColor else Color.Transparent) + // `secondaryContainer` is M3's role for a selected item, and it + // replaces `onSurface` at 50% -- a content colour used as a + // background, which composited to a mid grey 2.49:1 from the + // unselected cells beside it. + // + // The tonal container is not itself high-contrast against the + // surface in this palette (1.65:1), which M3 accepts because its + // own selected states carry a second cue -- an outline or a + // checkmark. This grid has neither, and adding one is component + // work rather than a colour fix. Recorded in + // docs/material-design-conformance.md. + val selectedBackground = MaterialTheme.colorScheme.secondaryContainer + WalletAvatar( + emoji, + backgroundColor = if (emoji == avatar) selectedBackground else Color.Transparent + ) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt index 245b52af..b21b735d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt @@ -43,12 +43,13 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.machankura.compose.ui.composable.widgets.buttons.Clickable +import press.mantra.compose.ui.composable.widgets.buttons.Clickable import fr.acinq.phoenix.data.UserWallet import fr.acinq.phoenix.data.WalletId import fr.acinq.phoenix.utils.preferences.GlobalPrefs import fr.acinq.phoenix.utils.preferences.UserWalletMetadata import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault +import press.mantra.compose.ui.theme.spacing @Composable fun WalletsSelector( @@ -82,9 +83,9 @@ fun WalletsSelector( canEdit = canEdit, onClick = { onWalletClick(currentWallet) } ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space150)) HorizontalDivider() - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space100)) } } items(items = otherWalletsList) { (walletId, userWallet) -> @@ -96,7 +97,7 @@ fun WalletsSelector( canEdit = canEdit, onClick = { onWalletClick(userWallet) } ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space100)) } bottomContent?.let { item { it.invoke() } @@ -142,22 +143,22 @@ private fun AvailableWalletView( Row(modifier = Modifier.height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) { Row( modifier = Modifier - .padding(horizontal = 12.dp, vertical = 12.dp) + .padding(horizontal = MaterialTheme.spacing.space150, vertical = MaterialTheme.spacing.space150) .weight(1f), verticalAlignment = Alignment.CenterVertically ) { - WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(4.dp)) - Spacer(Modifier.width(12.dp)) + WalletAvatar(avatar = metadata.avatar, backgroundColor = Color.Transparent, internalPadding = PaddingValues(MaterialTheme.spacing.space50)) + Spacer(Modifier.width(MaterialTheme.spacing.space150)) Column { Text(text = metadata.nameOrDefault(), modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.height(2.dp)) + Spacer(Modifier.height(MaterialTheme.spacing.space25)) Text(text = userWallet.nodeId, modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.displayMedium.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)) } } if (isCurrent && canEdit) { - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space100)) // TODO: PhoenixIcon(R.drawable.ic_edit, tint = MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(16.dp)) + Spacer(Modifier.width(MaterialTheme.spacing.space200)) } } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt new file mode 100644 index 00000000..5d210bf2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Breakpoint.kt @@ -0,0 +1,102 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowDpSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * M3's five window width breakpoints. + * + * Renamed from "window size class" in the May 2026 revision, which also grew the set from + * three to five: [Large] and [ExtraLarge] were split off the old expanded class because a + * 1600dp window and an 850dp one want different numbers of panes. Values are from + * m3.material.io/foundations/layout/applying-layout/window-size-classes, read September + * 2026. + * + * | breakpoint | width | panes | navigation | + * |--------------|-------------|------------------------|-------------------------| + * | [Compact] | under 600dp | 1 | navigation bar | + * | [Medium] | 600–839dp | 1 recommended | collapsed rail | + * | [Expanded] | 840–1199dp | 2 recommended | collapsed/expanded rail | + * | [Large] | 1200–1599dp | 2 recommended | expanded rail | + * | [ExtraLarge] | 1600dp+ | up to 3 | expanded rail | + * + * The classification is on the **window**, not on the composable being measured. That is + * the distinction between this and `BoxWithConstraints`: a pane 300dp wide inside a + * 1400dp window is still in a large layout, and should not start behaving like a phone. + * Anything wanting the local constraints should still measure them. + * + * @property minWidth the narrowest window that falls in this breakpoint. + */ +enum class Breakpoint(val minWidth: Dp) { + Compact(0.dp), + Medium(600.dp), + Expanded(840.dp), + Large(1200.dp), + ExtraLarge(1600.dp); + + /** + * `true` when this breakpoint is [other] or anything wider. + * + * The comparison call sites want almost always. Enum ordering already answers it, but + * `breakpoint >= Breakpoint.Expanded` reads as a size comparison on a width and is + * one edit away from being wrong if a breakpoint is ever inserted; this says what it + * means. + */ + fun isAtLeast(other: Breakpoint): Boolean = ordinal >= other.ordinal + + companion object { + /** + * The breakpoint a window of [width] falls in. + * + * Ranges are half-open on the upper bound -- exactly 600dp is [Medium], not + * [Compact] -- which is how M3 states them and how `WindowSizeClass` computes + * them. + * + * Total, including for a width below [Compact.minWidth]. This is called from + * [TorchTheme] on every composition, and a desktop window reports a zero size for + * the frame before its first layout pass; throwing there would take the app down + * on a resize rather than on anything a user did. + */ + fun ofWidth(width: Dp): Breakpoint = + entries.lastOrNull { width >= it.minWidth } ?: Compact + } +} + +/** + * The breakpoint the current window is in. + * + * Provided by [TorchTheme] as [LocalBreakpoint], so screens read + * `MaterialTheme.breakpoint` rather than calling this. It is public because the two + * entry points that compose above the theme -- the desktop passphrase gate, and any + * future splash -- have nowhere else to get it. + * + * **Why the window size and not `currentWindowAdaptiveInfo()`.** The latter also computes + * a [androidx.compose.material3.adaptive.Posture] from the platform's fold state, which + * on android means reaching for `WindowInfoTracker` and an `Activity`. This is called + * from [TorchTheme], which wraps every `@Preview` in the tree, and a preview context is + * not an activity. `currentWindowDpSize()` is `LocalWindowInfo` and `LocalDensity` and + * nothing else, so it answers everywhere. The pane scaffolds ask for posture themselves, + * where a fold genuinely changes the answer. + */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun currentBreakpoint(): Breakpoint = Breakpoint.ofWidth(currentWindowDpSize().width) + +/** + * Defaults to [Breakpoint.Compact] rather than throwing, because that is the layout every + * screen in this app was written against: a composable that never reaches a [TorchTheme] + * should render as it did before breakpoints existed, not fail. + */ +val LocalBreakpoint = staticCompositionLocalOf { Breakpoint.Compact } + +/** `MaterialTheme.breakpoint`, to match `MaterialTheme.spacing` and `MaterialTheme.colorScheme`. */ +val MaterialTheme.breakpoint: Breakpoint + @Composable + @ReadOnlyComposable + get() = LocalBreakpoint.current diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Color.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Color.kt index 809f631c..e70c520d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Color.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Color.kt @@ -2,8 +2,68 @@ package press.mantra.compose.ui.theme import androidx.compose.ui.graphics.Color -val BluePill = Color(0xFF5D8DD6) -val RedPill = Color(230, 32, 32, 191) +// --------------------------------------------------------------------------- +// Extended colour roles: the two pills +// --------------------------------------------------------------------------- +// +// The choice on CreateProfileScreen -- commit to the account, or wipe and leave -- is +// the Matrix pill choice, and the two colours carry that meaning rather than a place in +// the scheme. M3 calls this an extended colour: a brand colour promoted to a full role +// family (`color` / `onColor` / `colorContainer` / `onColorContainer`) so that contrast +// is a property of the family rather than something chosen per call site. +// +// They were `val BluePill = Color(0xFF5D8DD6)` and `val RedPill = Color(230, 32, 32, 191)`, +// paired at the call site with `Color.DarkGray` and `Color.White`. Both pairings were +// below the 4.5:1 floor -- 2.90:1 and, because RedPill carried alpha 0.749 and composited +// over the surface, 3.50:1 -- so this step could not leave the pixels alone. Any correct +// version of these two buttons is a visible change. +// +// **Derivation**, the same rule as the gold palette in the fixed-roles block: maximum +// in-gamut chroma at the source colour's Lab hue, sampled at M3's role tones. BluePill's +// hue is 277.0 and RedPill's is 36.3. +// +// role light dark +// color tone 40 tone 80 +// onColor tone 100 tone 20 +// colorContainer tone 90 tone 30 +// onColorContainer tone 10 tone 90 +// +// A side effect worth having: at tone 40 the two pills are the same lightness, so they +// read as a matched pair. Before, a saturated red sat beside a soft periwinkle and the +// blue looked like the lesser option -- which is not what the screen is asking. +// +// No medium- or high-contrast variants. The whole surface is two buttons on one screen, +// and the light family's weakest pair is 6.44:1 -- clear of the 4.5:1 floor by more than +// the contrast schemes would need to add. Generating 32 more values for that would be +// out of proportion to what they cover. + +val bluePillLight = ColorFamily( + color = Color(0xFF0060AB), // tone 40 + onColor = Color(0xFFFFFFFF), // tone 100 + colorContainer = Color(0xFFD7E2FF), // tone 90 + onColorContainer = Color(0xFF001C39) // tone 10 +) + +val bluePillDark = ColorFamily( + color = Color(0xFFACC7FF), // tone 80 + onColor = Color(0xFF00315C), // tone 20 + colorContainer = Color(0xFF004882), // tone 30 + onColorContainer = Color(0xFFD7E2FF) // tone 90 +) + +val redPillLight = ColorFamily( + color = Color(0xFFC00012), + onColor = Color(0xFFFFFFFF), + colorContainer = Color(0xFFFFDAD3), + onColorContainer = Color(0xFF390C00) +) + +val redPillDark = ColorFamily( + color = Color(0xFFFFB4A5), + onColor = Color(0xFF690000), + colorContainer = Color(0xFF93000B), + onColorContainer = Color(0xFFFFDAD3) +) val primaryLight = Color(0xFF000000) val onPrimaryLight = Color(0xFFFFFFFF) @@ -221,9 +281,74 @@ val surfaceContainerDarkHighContrast = Color(0xFF303030) val surfaceContainerHighDarkHighContrast = Color(0xFF3B3B3B) val surfaceContainerHighestDarkHighContrast = Color(0xFF474747) +// --------------------------------------------------------------------------- +// Fixed colour roles +// --------------------------------------------------------------------------- +// +// The twelve *Fixed* roles are theme-independent by definition: a container that +// stays light in both light and dark, with content that stays dark on it. That is +// why they carry no Light/Dark suffix here. M3 defines them by tone -- +// `xFixed` = tone 90, `xFixedDim` = tone 80, `onXFixed` = tone 10, +// `onXFixedVariant` = tone 30 (ColorLightTokens.kt, material3 1.10.0-alpha05, whose +// light and dark token files carry identical values for all twelve). +// +// Before this block they were simply absent, so `lightColorScheme()` defaulted them +// to `PaletteTokens.Primary90` and friends -- #EADDFF, Material baseline lavender -- +// in a monochrome app, in both themes. +// +// **How these were derived.** Tone is CIE L*, so a tone of a chroma-0 palette is +// exactly the sRGB grey at that L*. Inverting L* -> Y -> sRGB reproduces this +// palette's existing greys to the byte: tone 0 = #000000 (primaryLight), tone 10 = +// #1B1B1B (primaryContainerLight, onSurfaceLight), tone 20 = #303030 (onPrimaryDark), +// tone 40 = #5E5E5E (inversePrimaryDark), tone 80 = #C6C6C6 (primaryDark), tone 90 = +// #E2E2E2 (onSurfaceDark), tone 95 = #F1F1F1 (inverseOnSurfaceLight), tone 100 = +// #FFFFFF. So the neutral family below is computed, not chosen. +// +// The secondary palette is gold at Lab hue 87.5 degrees, and its dark half is +// maximum in-gamut chroma at that hue -- which regenerates onSecondaryDark (#3D2F00, +// tone 20) and secondaryLight (#745B00, tone 40) byte for byte. Tones 10 and 30 below +// come off the same ramp. Its tones 90 and 80 are not regenerated but reused: the +// palette already ships #FFDE82 at tone 90 (secondaryDark) and the brand gold #EFBF04 +// at tone 80 (secondaryContainer, in both themes). Generating them instead would have +// produced #FFDF99 and #F1C100 -- a second, almost identical gold two units away from +// the one already on screen, which is worse than no gold at all. +// +// Sanity check on the derivation: the four ratios these families produce land within +// 0.1 of M3's own baseline fixed family (13.30/7.17/10.08/5.44 here against +// 13.32/7.23/10.08/5.47 for source #6750A4). Tone, not hue, sets the ratio. +val primaryFixed = Color(0xFFE2E2E2) // neutral tone 90 +val primaryFixedDim = Color(0xFFC6C6C6) // neutral tone 80 +val secondaryFixed = Color(0xFFFFDE82) // gold tone 90, = secondaryDark +val secondaryFixedDim = Color(0xFFEFBF04) // gold tone 80, = secondaryContainer +// The tertiary family is a copy of primary throughout this palette -- compare +// tertiaryLight to primaryLight. Regenerate these two with primary's, or give +// tertiary its own hue and regenerate all six. +val tertiaryFixed = Color(0xFFE2E2E2) +val tertiaryFixedDim = Color(0xFFC6C6C6) +// Content on the fixed containers. The containers hold across the contrast setting -- +// they are the brand-visible half -- and the content darkens, which is the same move +// Color.kt already makes for onSurface (#1B1B1B -> #111111 -> #000000) and +// onSurfaceVariant. Tones 10/30, then 5/20, then 0/10. +val onPrimaryFixed = Color(0xFF1B1B1B) // neutral tone 10 +val onPrimaryFixedVariant = Color(0xFF474747) // neutral tone 30 +val onSecondaryFixed = Color(0xFF241A00) // gold tone 10 +val onSecondaryFixedVariant = Color(0xFF584400) // gold tone 30 +val onTertiaryFixed = Color(0xFF1B1B1B) +val onTertiaryFixedVariant = Color(0xFF474747) +val onPrimaryFixedMediumContrast = Color(0xFF111111) // neutral tone 5 +val onPrimaryFixedVariantMediumContrast = Color(0xFF303030) // neutral tone 20 +val onSecondaryFixedMediumContrast = Color(0xFF171000) // gold tone 5 +val onSecondaryFixedVariantMediumContrast = Color(0xFF3D2F00) // gold tone 20 +val onTertiaryFixedMediumContrast = Color(0xFF111111) +val onTertiaryFixedVariantMediumContrast = Color(0xFF303030) - +val onPrimaryFixedHighContrast = Color(0xFF000000) // neutral tone 0 +val onPrimaryFixedVariantHighContrast = Color(0xFF1B1B1B) // neutral tone 10 +val onSecondaryFixedHighContrast = Color(0xFF000000) // gold tone 0 -- no +val onSecondaryFixedVariantHighContrast = Color(0xFF241A00) // chroma survives L*=0 +val onTertiaryFixedHighContrast = Color(0xFF000000) +val onTertiaryFixedVariantHighContrast = Color(0xFF1B1B1B) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt new file mode 100644 index 00000000..d1e56707 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ConformancePreviews.kt @@ -0,0 +1,43 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.ui.tooling.preview.Preview + +/** + * The five conditions every screen has to survive, as one annotation. + * + * A screen renders under a light theme on a phone because that is the only way anybody + * ever looks at it. The other four are where this app's defects have actually been: a + * colour that only fails in dark, a fixed-height container that clips at 200% text, a + * layout that stretches to 1000dp because nothing ever held it, a row that reflows badly + * at 400dp. Each of those is invisible in a diff and obvious in a render. + * + * Replaces a bare `@Preview` at the 51 preview sites in the tree. `@Preview` is + * `@Repeatable`, so this is five renders from one annotation rather than five annotations + * copied onto every preview and drifting apart. + * + * **High contrast is deliberately not here**, and that is an argument rather than an + * omission. Contrast is a property of the *scheme*, not of a screen: the app declares six + * schemes and `ColorSchemeContrastTest` measures every pair in all six, so a screen that + * is right in the default scheme is right in the high-contrast one by construction. A + * per-screen high-contrast preview would be 51 more renders checking something already + * proved, and there is no `@Preview` parameter for it anyway -- it needs + * `TorchTheme(contrast = …)` in the body. [ThemeGallery] covers the schemes once, over + * the components rather than over the screens. + * + * @see ThemeGallery + */ +@Preview(name = "Light", group = "conformance") +@Preview(name = "Dark", group = "conformance", uiMode = UiModeNightYes) +@Preview(name = "200% text", group = "conformance", fontScale = 2f) +@Preview(name = "Compact 400dp", group = "conformance", widthDp = 400, heightDp = 800) +@Preview(name = "Expanded 1000dp", group = "conformance", widthDp = 1000, heightDp = 800) +annotation class ConformancePreviews + +/** + * `Configuration.UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL`. + * + * Written as a literal because `android.content.res.Configuration` is not reachable from + * common code, and as a named constant rather than an inline `0x21` because a bare hex + * number in a preview annotation says nothing about which of the two flags it carries. + */ +private const val UiModeNightYes = 0x21 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt new file mode 100644 index 00000000..6022a524 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Motion.kt @@ -0,0 +1,159 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.IntOffset +import androidx.navigation.NavBackStackEntry + +/** + * Whether the platform has been asked to keep motion to a minimum. + * + * Provided by [TorchTheme] as [LocalReducedMotion]; the transitions below read it, so a + * call site does not have to remember to. Reading it directly is for a screen animating + * something the shared helpers do not cover. + * + * The same expect/actual shape as [platformThemeContrast], for the same reason: this is a + * per-platform accessibility setting, it can change while the app is running, and somebody + * who turns it on because motion makes them ill should not have to restart the app. + */ +@Composable +expect fun platformReducedMotion(): Boolean + +/** Defaults to `false`, which is the app's behaviour anywhere the theme is not reached. */ +val LocalReducedMotion = staticCompositionLocalOf { false } + +/** `MaterialTheme.reducedMotion`, to match `MaterialTheme.breakpoint` and `MaterialTheme.spacing`. */ +val MaterialTheme.reducedMotion: Boolean + @Composable + @ReadOnlyComposable + get() = LocalReducedMotion.current + +/** + * The transitions between navigation destinations. + * + * **What this replaces.** All 43 routes took navigation-compose's default, which on + * android and desktop is `fadeIn(tween(700))` / `fadeOut(tween(700))`. That is not the + * hard cut the plan expected -- but 700 milliseconds is roughly three times M3's own + * duration for a full-screen transition, and a `tween` written into a library's internals + * is not a decision this app made. Both directions now come from the theme's + * [androidx.compose.material3.MotionScheme], which is where phase 1 put the one decision + * about how this app moves. + * + * **Shape.** M3's shared-axis transition for forward and backward navigation: the arriving + * screen slides in from the trailing edge while the leaving one slides out toward the + * leading edge, both fading. Going back mirrors it, so the direction of travel is legible + * rather than a dissolve that looks the same either way. `slideIntoContainer` is + * layout-direction aware, so an RTL locale gets the mirror image for free. + * + * Spatial specs for the slide and effects specs for the fade, which is the distinction the + * scheme draws: spatial motion is springy because it moves something, effects motion is not + * because a fading colour that overshoots looks like a fault. + * + * Typed for `NavBackStackEntry` rather than star-projected. These are only ever handed to + * a `NavHost`, and a star projection would make every call site cast. + */ +object NavigationMotion { + + /** The distance a sliding screen travels, as a fraction of the container. */ + private const val SlideFraction = 0.25f + + @Composable + fun enter(): AnimatedContentTransitionScope.() -> EnterTransition { + val reduced = MaterialTheme.reducedMotion + val spatial = MaterialTheme.motionSchemeSpatial() + val effects = MaterialTheme.motionSchemeEffects() + return { + if (reduced) { + fadeIn(effects) + } else { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = spatial, + initialOffset = { (it * SlideFraction).toInt() }, + ) + fadeIn(effects) + } + } + } + + @Composable + fun exit(): AnimatedContentTransitionScope.() -> ExitTransition { + val reduced = MaterialTheme.reducedMotion + val spatial = MaterialTheme.motionSchemeSpatial() + val effects = MaterialTheme.motionSchemeEffects() + return { + if (reduced) { + fadeOut(effects) + } else { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = spatial, + targetOffset = { (it * SlideFraction).toInt() }, + ) + fadeOut(effects) + } + } + } + + @Composable + fun popEnter(): AnimatedContentTransitionScope.() -> EnterTransition { + val reduced = MaterialTheme.reducedMotion + val spatial = MaterialTheme.motionSchemeSpatial() + val effects = MaterialTheme.motionSchemeEffects() + return { + if (reduced) { + fadeIn(effects) + } else { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = spatial, + initialOffset = { (it * SlideFraction).toInt() }, + ) + fadeIn(effects) + } + } + } + + @Composable + fun popExit(): AnimatedContentTransitionScope.() -> ExitTransition { + val reduced = MaterialTheme.reducedMotion + val spatial = MaterialTheme.motionSchemeSpatial() + val effects = MaterialTheme.motionSchemeEffects() + return { + if (reduced) { + fadeOut(effects) + } else { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = spatial, + targetOffset = { (it * SlideFraction).toInt() }, + ) + fadeOut(effects) + } + } + } +} + +/** + * The scheme's default spatial spec, typed for a slide. + * + * `MotionSchemeKeyTokens` -- which the plan named -- is `internal` to material3, so an app + * cannot reach the tokens by name. `MaterialTheme.motionScheme` is the public surface and + * gives the same six specs; these two helpers exist only to name which of them this app + * uses for what, so that the choice is made once rather than at every call site. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MaterialTheme.motionSchemeSpatial(): FiniteAnimationSpec = + motionScheme.defaultSpatialSpec() + +/** The scheme's default effects spec, typed for a fade. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MaterialTheme.motionSchemeEffects(): FiniteAnimationSpec = + motionScheme.defaultEffectsSpec() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Panes.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Panes.kt new file mode 100644 index 00000000..e32278c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Panes.kt @@ -0,0 +1,30 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * How wide a list pane should be at [breakpoint], or `null` where M3 asks for one pane. + * + * **`null` below expanded is the spec, not caution.** The breakpoints page says not to put + * two dense panes in a medium window, and `calculatePaneScaffoldDirective` in + * `material3-adaptive` says the same thing in code: `maxHorizontalPartitions = 1` for both + * compact and medium. A chat transcript is exactly the dense content that rule is about. + * + * The two widths are that same function's, so a hand-built pair of panes measures the same + * as a `ListDetailPaneScaffold` would: `DefaultPreferredWidth` at expanded, and + * `DefaultPreferredWidthXL` from large upward, where the directive also allows a third + * partition this app has no content for. + * + * **Why not the scaffold itself.** `ListDetailPaneScaffold` earns its API surface by + * owning the single-pane case too -- showing the detail *instead of* the list on a phone, + * and animating between them. This app cannot hand it that: the chat room is a navigation + * destination reached from eleven places, so on a compact window the detail has to stay a + * pushed route. A scaffold permanently in its two-pane state would be a `Row` with more + * words, and a navigator whose history nothing reads. + */ +fun listPaneWidthFor(breakpoint: Breakpoint): Dp? = when (breakpoint) { + Breakpoint.Compact, Breakpoint.Medium -> null + Breakpoint.Expanded -> 360.dp + Breakpoint.Large, Breakpoint.ExtraLarge -> 412.dp +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ReadableMeasure.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ReadableMeasure.kt new file mode 100644 index 00000000..95bae745 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ReadableMeasure.kt @@ -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)) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Shape.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Shape.kt new file mode 100644 index 00000000..80eb881e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Shape.kt @@ -0,0 +1,41 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.material3.Shapes + +/** + * The shape scale, and which corner belongs to what. + * + * Baseline M3, unmodified, for the same reason [AuxTypography] is: the values are already + * right for this app. The corners hand-written across the tree land on the scale almost + * exactly — + * + * RoundedCornerShape(4.dp) x3 = extraSmall + * RoundedCornerShape(12.dp) x11 = medium + * RoundedCornerShape(16.dp) x2 = large + * RoundedCornerShape(30.dp) x1 ~ extraLarge (28dp) + * + * — so nothing needs restyling. What is missing is that they are literals, which is why the + * 30dp one drifted two units from the scale and why nothing can be adjusted per breakpoint + * or per density later. Migrating those seventeen call sites onto `MaterialTheme.shapes` is + * a later phase; this file is what they migrate onto. + * + * **The scale, and what each step is for.** Under `MaterialExpressiveTheme` there are eight + * steps rather than five: + * + * - `extraSmall` (4dp) — small containers whose corners should barely read: text field + * indicators, small badges, snackbar edges. + * - `small` (8dp) — chips and other compact controls. + * - `medium` (12dp) — cards, and the default for a container holding a group of content. + * - `large` (16dp) — bigger surfaces: bottom sheet tops, large cards, dialogs. + * - `largeIncreased` (20dp) — expressive only. `large` where the container is prominent + * enough to want the extra roundness. + * - `extraLarge` (28dp) — extended FABs, prominent containers. + * - `extraLargeIncreased` (32dp) — expressive only. + * - `extraExtraLarge` (48dp) — expressive only. Very large surfaces, hero containers. + * + * A fully round shape is `CircleShape`, not a step on this scale. + * + * Declared explicitly rather than left to `MaterialTheme`'s default so that every slot of + * the theme has a named home, and so this note has somewhere to live. + */ +val MantraShapes = Shapes() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt new file mode 100644 index 00000000..f333eeda --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Spacing.kt @@ -0,0 +1,150 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * M3's spacing scale, and the semantic names layered over it. + * + * `MaterialTheme` has no spacing slot, so this rides a composition local beside it, the + * same way [ExtendedColors] does. [TorchTheme] provides it. + * + * **The scale** is M3's own, an 8dp system where `space100 = 8dp`, including the sub-8 + * nested units Material defines because its components need them. Numbers are from + * m3.material.io/m3/pages/spacing/tokens, read September 2026; see + * docs/material-design-conformance.md for the full table. + * + * **Why a `data class` and not constants.** So that a different instance can be provided + * without touching a call site, which is what [spacingFor] now does per [Breakpoint] and + * what M3's density setting for data-heavy views would do next. A file of top-level + * `val`s would read the same at the call site and adapt to nothing. + */ +@Immutable +data class Spacing( + val space0: Dp = 0.dp, + val space25: Dp = 2.dp, + val space50: Dp = 4.dp, + val space75: Dp = 6.dp, + val space100: Dp = 8.dp, + val space125: Dp = 10.dp, + val space150: Dp = 12.dp, + val space175: Dp = 14.dp, + val space200: Dp = 16.dp, + val space250: Dp = 20.dp, + val space300: Dp = 24.dp, + val space400: Dp = 32.dp, + val space450: Dp = 36.dp, + val space500: Dp = 40.dp, + val space600: Dp = 48.dp, + val space700: Dp = 56.dp, + val space800: Dp = 64.dp, + val space900: Dp = 72.dp, + + // ----------------------------------------------------------------------- + // Semantic names + // ----------------------------------------------------------------------- + // + // `space125` at a call site is no more readable than `10.dp` -- it says the size and + // not the job. These say the job, and they are what call sites should reach for; the + // raw scale is for the cases none of them fits. + // + // The distinction M3 draws, and the reason the names are split this way: + // + // padding space inside an element, between its edge and its content + // gap space between elements in a row, column or grid + // margin space outside an element, between it and its parent or the screen + // + // The spec is explicit that margins are a last resort -- "define padding and gaps on + // the parent container", "avoid defining margins on child elements as they usually + // aren't uniform, and require more tokens" -- so there is exactly one margin here, + // for the screen edge, and everything else is padding or a gap. + + // They are constructor parameters rather than `get()`s over the scale so that a + // breakpoint can reassign one without moving the stop underneath it. That direction + // matters: M3's spacing tokens are absolute values that do not change with window + // width -- `space200` is 16dp on a phone and 16dp on a desktop -- and what adapts is + // which token a given job reaches for. A wider window takes a wider screen margin, it + // does not take a wider 16. + // + // Kotlin resolves a default expression against the parameters before it, so each of + // these still reads its stop by name and follows it when the scale itself is + // overridden. `Spacing(space200 = 24.dp)` still moves `screenMargin`. + + /** Screen edge to content. The one margin; everything inside a screen is padding or a gap. */ + val screenMargin: Dp = space200, + + /** Inside a card, dialog, sheet or list row: container edge to its content. */ + val containerPadding: Dp = space200, + + /** Inside a compact container -- a chip, a badge, a dense row. */ + val compactPadding: Dp = space100, + + /** Between two elements that belong to the same thought: a label and its value. */ + val relatedGap: Dp = space50, + + /** The default gap between items in a list or column. */ + val itemGap: Dp = space100, + + /** Between one group of content and the next within a screen. */ + val sectionGap: Dp = space300, + + /** Around a lone element that needs to stand apart -- an empty state, a hero action. */ + val emphasisGap: Dp = space500, + + /** Between adjacent touch targets, which M3 asks to be at least 8dp apart. */ + val targetGap: Dp = space100, + + /** + * Between two panes. M3's own `PaneScaffoldDirective` uses 24dp at every breakpoint + * that has a second pane, which is why this does not vary with the window either. + */ + val paneGap: Dp = space300, +) + +/** + * The spacing a window at [breakpoint] should use. + * + * Exactly one value moves, and that is not an oversight. M3 publishes a margin per + * breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else that + * varies with window width: padding inside a card and the gap between two list rows are + * component decisions, and a card does not become a different component because the + * window grew. Widening them all would be the "everything breathes on a big screen" + * instinct, which reads as a zoomed phone rather than as a layout. + * + * What actually fills a wide window is a second pane and a bounded measure, not fatter + * gaps. Those are layout, and they live in `AdaptiveContent` rather than here. + */ +fun spacingFor(breakpoint: Breakpoint): Spacing = + if (breakpoint == Breakpoint.Compact) CompactSpacing else MediumAndWiderSpacing + +// Held as singletons rather than built per call. `LocalSpacing` is a +// `staticCompositionLocalOf`, so a provider that hands it a fresh but equal instance on +// every recomposition would restart every composition reading it; `Spacing` is a data +// class, but static locals compare by identity when deciding whether to invalidate. +private val CompactSpacing = Spacing() +private val MediumAndWiderSpacing = Spacing(screenMargin = 24.dp) + +val LocalSpacing = staticCompositionLocalOf { Spacing() } + +/** + * `MaterialTheme.spacing.containerPadding`, to match `MaterialTheme.colorScheme.primary`. + * + * The alternative is `LocalSpacing.current`, which has to be read into a local before it + * can be used and so cannot appear inline in a `Modifier` chain. That difference decides + * whether a call site reads + * + * Modifier.padding(MaterialTheme.spacing.containerPadding) + * + * or acquires a `val spacing = LocalSpacing.current` several lines above, in whichever + * composable happens to enclose it. Over 500 call sites the second shape is what stops + * people using the scale. + */ +val MaterialTheme.spacing: Spacing + @Composable + @ReadOnlyComposable + get() = LocalSpacing.current diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt index 804c46b0..d5c2c66f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Theme.kt @@ -2,15 +2,24 @@ package press.mantra.compose.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MotionScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -import com.example.ui.theme.AuxTypography -private val lightScheme = lightColorScheme( +// The six schemes are `internal` rather than `private` so ColorSchemeContrastTest can +// walk the real objects. Testing a copy rebuilt in the test from Color.kt would assert +// the palette and miss the wiring, which is the half that has gone wrong before -- a +// role pointed at the neighbouring value reads fine in isolation. +internal val lightScheme = lightColorScheme( primary = primaryLight, onPrimary = onPrimaryLight, primaryContainer = primaryContainerLight, @@ -46,9 +55,30 @@ private val lightScheme = lightColorScheme( surfaceContainer = surfaceContainerLight, surfaceContainerHigh = surfaceContainerHighLight, surfaceContainerHighest = surfaceContainerHighestLight, + // Explicit only so that no role is left to a default. This is the value + // lightColorScheme()/darkColorScheme() would have supplied anyway, and it is + // right: surfaceColorAtElevation composites surfaceTint over surface at 2-8% + // alpha, so an elevated light surface darkens toward primary and an elevated + // dark one lightens toward it, which is M3's own behaviour. Nothing reads it + // today -- the app sets no elevations anywhere. + surfaceTint = primaryLight, + // Theme-independent by definition -- these carry no Light/Dark variant. See the + // "Fixed colour roles" block in Color.kt for how the tones were derived. + primaryFixed = primaryFixed, + primaryFixedDim = primaryFixedDim, + onPrimaryFixed = onPrimaryFixed, + onPrimaryFixedVariant = onPrimaryFixedVariant, + secondaryFixed = secondaryFixed, + secondaryFixedDim = secondaryFixedDim, + onSecondaryFixed = onSecondaryFixed, + onSecondaryFixedVariant = onSecondaryFixedVariant, + tertiaryFixed = tertiaryFixed, + tertiaryFixedDim = tertiaryFixedDim, + onTertiaryFixed = onTertiaryFixed, + onTertiaryFixedVariant = onTertiaryFixedVariant, ) -private val darkScheme = darkColorScheme( +internal val darkScheme = darkColorScheme( primary = primaryDark, onPrimary = onPrimaryDark, primaryContainer = primaryContainerDark, @@ -84,9 +114,30 @@ private val darkScheme = darkColorScheme( surfaceContainer = surfaceContainerDark, surfaceContainerHigh = surfaceContainerHighDark, surfaceContainerHighest = surfaceContainerHighestDark, + // Explicit only so that no role is left to a default. This is the value + // lightColorScheme()/darkColorScheme() would have supplied anyway, and it is + // right: surfaceColorAtElevation composites surfaceTint over surface at 2-8% + // alpha, so an elevated light surface darkens toward primary and an elevated + // dark one lightens toward it, which is M3's own behaviour. Nothing reads it + // today -- the app sets no elevations anywhere. + surfaceTint = primaryDark, + // Theme-independent by definition -- these carry no Light/Dark variant. See the + // "Fixed colour roles" block in Color.kt for how the tones were derived. + primaryFixed = primaryFixed, + primaryFixedDim = primaryFixedDim, + onPrimaryFixed = onPrimaryFixed, + onPrimaryFixedVariant = onPrimaryFixedVariant, + secondaryFixed = secondaryFixed, + secondaryFixedDim = secondaryFixedDim, + onSecondaryFixed = onSecondaryFixed, + onSecondaryFixedVariant = onSecondaryFixedVariant, + tertiaryFixed = tertiaryFixed, + tertiaryFixedDim = tertiaryFixedDim, + onTertiaryFixed = onTertiaryFixed, + onTertiaryFixedVariant = onTertiaryFixedVariant, ) -private val mediumContrastLightColorScheme = lightColorScheme( +internal val mediumContrastLightColorScheme = lightColorScheme( primary = primaryLightMediumContrast, onPrimary = onPrimaryLightMediumContrast, primaryContainer = primaryContainerLightMediumContrast, @@ -122,9 +173,31 @@ private val mediumContrastLightColorScheme = lightColorScheme( surfaceContainer = surfaceContainerLightMediumContrast, surfaceContainerHigh = surfaceContainerHighLightMediumContrast, surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, + // Explicit only so that no role is left to a default. This is the value + // lightColorScheme()/darkColorScheme() would have supplied anyway, and it is + // right: surfaceColorAtElevation composites surfaceTint over surface at 2-8% + // alpha, so an elevated light surface darkens toward primary and an elevated + // dark one lightens toward it, which is M3's own behaviour. Nothing reads it + // today -- the app sets no elevations anywhere. + surfaceTint = primaryLightMediumContrast, + // Theme-independent by definition -- these carry no Light/Dark variant. See the + // "Fixed colour roles" block in Color.kt for how the tones were derived. + // Content darkens with the contrast setting; the containers hold. + primaryFixed = primaryFixed, + primaryFixedDim = primaryFixedDim, + onPrimaryFixed = onPrimaryFixedMediumContrast, + onPrimaryFixedVariant = onPrimaryFixedVariantMediumContrast, + secondaryFixed = secondaryFixed, + secondaryFixedDim = secondaryFixedDim, + onSecondaryFixed = onSecondaryFixedMediumContrast, + onSecondaryFixedVariant = onSecondaryFixedVariantMediumContrast, + tertiaryFixed = tertiaryFixed, + tertiaryFixedDim = tertiaryFixedDim, + onTertiaryFixed = onTertiaryFixedMediumContrast, + onTertiaryFixedVariant = onTertiaryFixedVariantMediumContrast, ) -private val highContrastLightColorScheme = lightColorScheme( +internal val highContrastLightColorScheme = lightColorScheme( primary = primaryLightHighContrast, onPrimary = onPrimaryLightHighContrast, primaryContainer = primaryContainerLightHighContrast, @@ -160,9 +233,31 @@ private val highContrastLightColorScheme = lightColorScheme( surfaceContainer = surfaceContainerLightHighContrast, surfaceContainerHigh = surfaceContainerHighLightHighContrast, surfaceContainerHighest = surfaceContainerHighestLightHighContrast, + // Explicit only so that no role is left to a default. This is the value + // lightColorScheme()/darkColorScheme() would have supplied anyway, and it is + // right: surfaceColorAtElevation composites surfaceTint over surface at 2-8% + // alpha, so an elevated light surface darkens toward primary and an elevated + // dark one lightens toward it, which is M3's own behaviour. Nothing reads it + // today -- the app sets no elevations anywhere. + surfaceTint = primaryLightHighContrast, + // Theme-independent by definition -- these carry no Light/Dark variant. See the + // "Fixed colour roles" block in Color.kt for how the tones were derived. + // Content darkens with the contrast setting; the containers hold. + primaryFixed = primaryFixed, + primaryFixedDim = primaryFixedDim, + onPrimaryFixed = onPrimaryFixedHighContrast, + onPrimaryFixedVariant = onPrimaryFixedVariantHighContrast, + secondaryFixed = secondaryFixed, + secondaryFixedDim = secondaryFixedDim, + onSecondaryFixed = onSecondaryFixedHighContrast, + onSecondaryFixedVariant = onSecondaryFixedVariantHighContrast, + tertiaryFixed = tertiaryFixed, + tertiaryFixedDim = tertiaryFixedDim, + onTertiaryFixed = onTertiaryFixedHighContrast, + onTertiaryFixedVariant = onTertiaryFixedVariantHighContrast, ) -private val mediumContrastDarkColorScheme = darkColorScheme( +internal val mediumContrastDarkColorScheme = darkColorScheme( primary = primaryDarkMediumContrast, onPrimary = onPrimaryDarkMediumContrast, primaryContainer = primaryContainerDarkMediumContrast, @@ -198,9 +293,31 @@ private val mediumContrastDarkColorScheme = darkColorScheme( surfaceContainer = surfaceContainerDarkMediumContrast, surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, + // Explicit only so that no role is left to a default. This is the value + // lightColorScheme()/darkColorScheme() would have supplied anyway, and it is + // right: surfaceColorAtElevation composites surfaceTint over surface at 2-8% + // alpha, so an elevated light surface darkens toward primary and an elevated + // dark one lightens toward it, which is M3's own behaviour. Nothing reads it + // today -- the app sets no elevations anywhere. + surfaceTint = primaryDarkMediumContrast, + // Theme-independent by definition -- these carry no Light/Dark variant. See the + // "Fixed colour roles" block in Color.kt for how the tones were derived. + // Content darkens with the contrast setting; the containers hold. + primaryFixed = primaryFixed, + primaryFixedDim = primaryFixedDim, + onPrimaryFixed = onPrimaryFixedMediumContrast, + onPrimaryFixedVariant = onPrimaryFixedVariantMediumContrast, + secondaryFixed = secondaryFixed, + secondaryFixedDim = secondaryFixedDim, + onSecondaryFixed = onSecondaryFixedMediumContrast, + onSecondaryFixedVariant = onSecondaryFixedVariantMediumContrast, + tertiaryFixed = tertiaryFixed, + tertiaryFixedDim = tertiaryFixedDim, + onTertiaryFixed = onTertiaryFixedMediumContrast, + onTertiaryFixedVariant = onTertiaryFixedVariantMediumContrast, ) -private val highContrastDarkColorScheme = darkColorScheme( +internal val highContrastDarkColorScheme = darkColorScheme( primary = primaryDarkHighContrast, onPrimary = onPrimaryDarkHighContrast, primaryContainer = primaryContainerDarkHighContrast, @@ -236,6 +353,28 @@ private val highContrastDarkColorScheme = darkColorScheme( surfaceContainer = surfaceContainerDarkHighContrast, surfaceContainerHigh = surfaceContainerHighDarkHighContrast, surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, + // Explicit only so that no role is left to a default. This is the value + // lightColorScheme()/darkColorScheme() would have supplied anyway, and it is + // right: surfaceColorAtElevation composites surfaceTint over surface at 2-8% + // alpha, so an elevated light surface darkens toward primary and an elevated + // dark one lightens toward it, which is M3's own behaviour. Nothing reads it + // today -- the app sets no elevations anywhere. + surfaceTint = primaryDarkHighContrast, + // Theme-independent by definition -- these carry no Light/Dark variant. See the + // "Fixed colour roles" block in Color.kt for how the tones were derived. + // Content darkens with the contrast setting; the containers hold. + primaryFixed = primaryFixed, + primaryFixedDim = primaryFixedDim, + onPrimaryFixed = onPrimaryFixedHighContrast, + onPrimaryFixedVariant = onPrimaryFixedVariantHighContrast, + secondaryFixed = secondaryFixed, + secondaryFixedDim = secondaryFixedDim, + onSecondaryFixed = onSecondaryFixedHighContrast, + onSecondaryFixedVariant = onSecondaryFixedVariantHighContrast, + tertiaryFixed = tertiaryFixed, + tertiaryFixedDim = tertiaryFixedDim, + onTertiaryFixed = onTertiaryFixedHighContrast, + onTertiaryFixedVariant = onTertiaryFixedVariantHighContrast, ) @Immutable @@ -250,31 +389,170 @@ val unspecified_scheme = ColorFamily( Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified ) +/** + * Brand colours that are not part of the generated scheme. + * + * `ColorScheme` has no slot for them -- M3 calls them extended colours and expects them to + * ride alongside -- so they travel on their own composition local, provided by [TorchTheme] + * from the same `darkTheme` the scheme is chosen with. Reading `isSystemInDarkTheme()` at + * the call site instead would be subtly wrong: it would ignore a caller who passed + * `darkTheme` explicitly, and a preview forcing dark would show light pills. + */ +@Immutable +data class ExtendedColors( + val bluePill: ColorFamily, + val redPill: ColorFamily, +) + +/** + * The extended colours for the current theme. + * + * Defaults to the light families rather than to `unspecified_scheme` so that anything + * composing outside [TorchTheme] renders a real colour instead of nothing. That should not + * happen -- as of this commit nothing in the tree does -- but an invisible button is a worse + * way to find out than a light-themed one. + */ +val LocalExtendedColors = staticCompositionLocalOf { + ExtendedColors(bluePill = bluePillLight, redPill = redPillLight) +} + +/** `MaterialTheme.extendedColors.redPill`, matching the [spacing] accessor. */ +val MaterialTheme.extendedColors: ExtendedColors + @Composable + @ReadOnlyComposable + get() = LocalExtendedColors.current + +internal fun extendedColorsFor(darkTheme: Boolean): ExtendedColors = + if (darkTheme) { + ExtendedColors(bluePill = bluePillDark, redPill = redPillDark) + } else { + ExtendedColors(bluePill = bluePillLight, redPill = redPillLight) + } + +/** + * How much contrast the person using the device has asked for. + * + * Not a preference this app invents -- every platform that has the setting owns it, and + * `platformThemeContrast` reports it. M3's accessibility foundation puts *honour + * individuals* first: "supporting varying preferences and choices that allow individuals + * to address how their changing conditions, individual knowledge, and varying needs are + * met." The four schemes to answer with were already written out in Color.kt and were, + * until this type existed, unreachable. + */ +enum class ThemeContrast { + /** The app's default schemes. */ + Standard, + + /** Android's 0.5 contrast step. No iOS or desktop equivalent, so never reported there. */ + Medium, + + /** Android's 1.0 step; iOS "Increase Contrast"; Windows high contrast mode. */ + High, +} + +/** + * The scheme to use, before dynamic colour gets a say. + * + * Kept in common code rather than behind the platform boundary so that all six schemes + * are selected from one table. The platform actuals answer two narrow questions instead + * -- what contrast was asked for, and whether there is a wallpaper palette to prefer -- + * which is the part that genuinely differs. + */ +internal fun appColorScheme(darkTheme: Boolean, contrast: ThemeContrast): ColorScheme = + when (contrast) { + ThemeContrast.Standard -> if (darkTheme) darkScheme else lightScheme + ThemeContrast.Medium -> + if (darkTheme) mediumContrastDarkColorScheme else mediumContrastLightColorScheme + ThemeContrast.High -> + if (darkTheme) highContrastDarkColorScheme else highContrastLightColorScheme + } + +/** + * The app's theme: colour, shape, type and motion, in one place. + * + * **Expressive, deliberately.** `MaterialExpressiveTheme` rather than `MaterialTheme` is a + * product decision taken 2026-09-08. The pinned material3 1.10.0-alpha05 ships the whole + * expressive set -- button groups, split buttons, floating toolbars, wide navigation rails, + * loading indicators, the eight-step shape scale, thirty type roles -- and the tree already + * opts into `ExperimentalMaterial3ExpressiveApi` in 66 places. The visible effect is that + * components take their expressive defaults: rounder, larger, more motion. It is one + * function name to change back. + * + * All four slots are passed explicitly. `MaterialExpressiveTheme` would otherwise supply + * `expressiveLightColorScheme()` and friends, which are Material's palette rather than this + * app's -- the same class of accident as the twelve unassigned fixed roles. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun TorchTheme( darkTheme: Boolean = isSystemInDarkTheme(), + contrast: ThemeContrast = platformThemeContrast(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, - content: @Composable() () -> Unit + // A parameter with a platform default, the same shape as `contrast` and for the same + // reason: the platform's answer is the right starting point and the wrong final word. + // A desktop cannot see what its desktop environment was told, and a settings screen + // will eventually want to override this on every platform. + reducedMotion: Boolean = platformReducedMotion(), + content: @Composable () -> Unit ) { - val colorScheme = themeColorScheme( - darkTheme, - dynamicColor, - darkScheme, - lightScheme - ) + // Dynamic colour wins when the platform offers it, because it is the user's own + // choice and already carries their contrast setting -- on Android 14+ the + // `system_*` palette resources shift with it, so `dynamicLightColorScheme` needs + // no help from `contrast`. Everywhere else the app's schemes answer, and that is + // where `contrast` decides which of the six. + // + // Note what this means in practice: on Android 12+ the six schemes above are not + // used at all, and the app takes the wallpaper palette. That is the intended + // behaviour -- see "What this plan does not cover" in + // docs/material-design-conformance.md -- but it is why a change to Color.kt shows + // up on desktop and ios and not on a modern phone. + val colorScheme = dynamicColorScheme(darkTheme, dynamicColor) + ?: appColorScheme(darkTheme, contrast) - MaterialTheme( - colorScheme = colorScheme, - typography = AuxTypography, - content = content - ) + // Classified once, here, so that every screen below reads the same answer. Doing it + // per screen would let two of them disagree about the window they are both in, which + // is the failure mode of `BoxWithConstraints`-per-screen adaptivity. + val breakpoint = currentBreakpoint() + + CompositionLocalProvider( + LocalExtendedColors provides extendedColorsFor(darkTheme), + LocalBreakpoint provides breakpoint, + // Provided once here rather than read per animation, so a screen cannot animate + // against a setting a neighbouring screen is honouring. + LocalReducedMotion provides reducedMotion, + // The payoff for tokenising spacing in phase 2: the screen margin widens from + // 16dp to 24dp at medium and above without a single call site changing. + LocalSpacing provides spacingFor(breakpoint), + ) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + // Every animation in the app should come from here rather than from a literal + // `tween`, so that the whole app's feel is one decision. Nothing reads it yet; + // the motion phase is what puts it to work. + motionScheme = MotionScheme.expressive(), + shapes = MantraShapes, + typography = AuxTypography, + content = content + ) + } } +/** + * The platform's contrast setting, recomposing when it changes. + * + * Reading it once at startup would be a worse version of honouring it: someone who turns + * contrast up because they cannot read the screen in front of them should not have to + * find out that the app needs restarting. + */ @Composable -expect fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme +expect fun platformThemeContrast(): ThemeContrast + +/** + * A wallpaper-derived palette, or `null` where the platform has none. + * + * Returns `null` rather than falling back internally so that the choice of app scheme -- + * which now depends on contrast as well as darkness -- stays in one place. + */ +@Composable +expect fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt new file mode 100644 index 00000000..e5e393a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/ThemeGallery.kt @@ -0,0 +1,116 @@ +// m3-string-exempt: the words in this file are sample text for looking at colour +// pairings, not UI text. Nobody navigates here and nothing here is translated; +// putting these eight strings in the catalogue would add eight entries that no +// screen shows and that a translator would have to be told to ignore. +package press.mantra.compose.ui.theme + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import press.mantra.compose.ui.composable.widgets.Decorative + +/** + * All six schemes, side by side, over the components that carry text. + * + * The one place the medium and high contrast schemes can be looked at. They are reachable + * in the app only through a platform setting -- and on android 12 and up, only with + * dynamic colour switched off -- so without this they are values in `Color.kt` that + * nobody has seen. + * + * Over components rather than over screens, and that is the point of it. Contrast is a + * property of the scheme: `ColorSchemeContrastTest` measures every pair in all six, so + * what a render adds is not proof but judgement -- whether `secondaryContainer` still + * reads as a container in high contrast, whether the outline is doing its job. Six renders + * of this answer that for the whole app; six renders of each of 51 screens would answer it + * 51 times. + */ +@Preview(name = "Six schemes", group = "schemes", widthDp = 1200, heightDp = 900) +@Composable +private fun ThemeGalleryPreview() { + // One render holding all six, not six renders. The contrast step is a `TorchTheme` + // parameter and no preview annotation can set it, so the combinations have to be laid + // out in the body -- and laying them out side by side is what makes them comparable, + // which is the only reason to look at them at all. + Column { + listOf(false, true).forEach { dark -> + Row(modifier = Modifier.fillMaxWidth()) { + ThemeContrast.entries.forEach { contrast -> + Column(modifier = Modifier.weight(1f)) { + ThemeGallery(darkTheme = dark, contrast = contrast) + } + } + } + } + } +} + +/** + * One scheme's worth of components. Public so a screen's own preview can borrow it. + * + * `dynamicColor = false` on purpose: with it left on, an android 12+ preview renders the + * wallpaper palette and every column here comes out identical. That is correct app + * behaviour and useless as a gallery of the six schemes. + */ +@Composable +fun ThemeGallery(darkTheme: Boolean, contrast: ThemeContrast) { + TorchTheme(darkTheme = darkTheme, contrast = contrast, dynamicColor = false) { + Surface { + Column( + modifier = Modifier.padding(MaterialTheme.spacing.compactPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap), + ) { + Text( + text = "${if (darkTheme) "Dark" else "Light"} · ${contrast.name}", + style = MaterialTheme.typography.labelSmall, + ) + + // Body copy on the plain surface: the pairing every screen uses most, and + // the one that has to hold 4.5:1. + Text("Body on surface", style = MaterialTheme.typography.bodyMedium) + Text( + text = "Secondary text", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + HorizontalDivider() + + // A card with a container role and a list item inside it -- the exact + // arrangement that rendered a headline at 1.00:1 before phase 3, because + // ListItem does not read LocalContentColor. + Card { + ListItem( + headlineContent = { Text("Awaiting your signature") }, + supportingContent = { Text("Two events") }, + leadingContent = { Icon(Icons.Default.Star, contentDescription = Decorative) }, + ) + } + + // The three button emphases, which is where the extended brand colours and + // the error role are seen. + Button(onClick = {}) { Text("Primary") } + FilledTonalButton(onClick = {}) { Text("Tonal") } + TextButton(onClick = {}) { + Text("Sign out", color = MaterialTheme.colorScheme.error) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Type.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Type.kt index bc939ab7..21409f9b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Type.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/theme/Type.kt @@ -1,5 +1,42 @@ -package com.example.ui.theme +package press.mantra.compose.ui.theme import androidx.compose.material3.Typography +/** + * The type scale, and which role carries what. + * + * The values are M3's baseline -- `Typography()` with nothing overridden -- which is a + * deliberate starting point rather than an omission: the scale itself is well judged, and + * this app has no brand typeface to substitute. What it lacks is not different numbers but + * a rule about which of the roles to reach for, and the audit says so plainly. + * + * **Roles, and what belongs in each.** M3 gives fifteen roles in three sizes across five + * families, and material3 1.10 adds an `…Emphasized` variant of every one, so thirty in + * total. They are not interchangeable sizes; each family has a job: + * + * - `display*` — a screen's own identity. A number or a word that *is* the screen. + * Sparing: at most one per screen, often none. + * - `headline*` — the top of a screen or a major section. What a heading level 1 or 2 + * would be. + * - `title*` — section headers, card headers, app bar titles, list item headlines. + * - `body*` — prose. Anything the user reads a sentence of. **This is the default**; when + * in doubt about a run of text, it is `bodyMedium` or `bodyLarge`. + * - `label*` — **component text only**. Button labels, tab labels, chip labels, field + * labels, badges, timestamps. Never a sentence. + * + * The `…Emphasized` variants are the same size at heavier weight, for the one line in a + * block that carries the weight -- a sender's name above their message, the active item in + * a list. They are not a second, bolder scale to reach for freely. + * + * **Why this is written down.** 92 of the app's 240 typography reads are `label*`, which is + * the smallest and tightest family and is meant for component text, while `display*` and + * `headline*` together carry 9 uses across 43 screens. The effect is a UI at one pitch: + * body copy set in a label role reads as cramped, and nothing establishes a hierarchy + * because the roles that would are unused. That is a call-site problem, not a scale + * problem, which is why this file stays baseline while the rule lives here for the sweep + * that fixes them. + * + * Until this commit this file declared `package com.example.ui.theme`, one of three package + * namespaces holding live UI code in this tree. + */ val AuxTypography = Typography() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index c8cc6793..3b036c8a 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -1,78 +1,23 @@ package press.mantra.compose.ui.view.model -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccessTime -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.ChevronRight -import androidx.compose.material.icons.filled.ErrorOutline -import androidx.compose.material.icons.filled.CallMerge -import androidx.compose.material.icons.filled.FactCheck -import androidx.compose.material.icons.filled.Draw -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.History -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.PanTool -import androidx.compose.material.icons.filled.PersonAdd -import androidx.compose.material.icons.filled.Upload -import androidx.compose.material.icons.filled.WorkspacePremium -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.KeyOff -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Pending -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import press.mantra.compose.database.model.ChatMessage import press.mantra.compose.database.model.NegentropySynchronizeRequest import press.mantra.compose.database.model.Participant -import press.mantra.compose.database.model.intermdiate.LocalChatMessage import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.extensions.shortened -import press.mantra.compose.extensions.toFormattedTimeAndDateString import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.nostr.MemberProfileSync import press.mantra.compose.nostr.Nip17Filters @@ -81,7 +26,6 @@ import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.text.ProposedEvent -import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.view.state.ChatMessageListUIState import co.touchlab.kermit.Logger import com.vitorpamplona.quartz.nip01Core.core.Event @@ -368,398 +312,6 @@ class ChatMessageListViewModel( } } - @OptIn(ExperimentalMaterial3ExpressiveApi::class) - @Composable - fun RenderMessages( - onOpenSharedKey: () -> Unit, - /** - * Opens the signing this line is about, by session id -- or the room's - * whole list of proposals when the line predates [ChatMessage.frostSigningSessionId] - * and cannot say which one it meant. - */ - onOpenSigning: (sessionId: String?) -> Unit, - /** Opens the room's proposals, all of them, whatever their state. */ - onOpenProposals: () -> Unit, - ) { - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (val chatRoomDetailMessageListUIState = this@ChatMessageListViewModel.chatMessageListUIState) { - ChatMessageListUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "Something went wrong", - ) - } - } - is ChatMessageListUIState.Loaded -> { - Spacer( - modifier = Modifier.weight(1f) - ) - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - - if (chatRoomDetailMessageListUIState.chatMessageList.isEmpty()) { - Spacer( - modifier = Modifier.height(20.dp) - ) - Text( - modifier = Modifier.padding(20.dp), - text = "Currently no messages have been shared.\nBreak the ice.", - textAlign = TextAlign.Center - ) - - Spacer( - modifier = Modifier.height(20.dp) - ) - } else { - - // Which request lines are still asking something of the - // reader. Read off the transcript rather than the session - // -- the rows are what a line rendered days later has -- - // and both rules live on ChatMessage, where they can be - // stated once and tested. - val messages = chatRoomDetailMessageListUIState - .chatMessageList - .map { it.chatMessage } - - val answeredRequests = ChatMessage.answeredRequests(messages) - val settledRequests = ChatMessage.settledRequests(messages) - - // Read out here rather than inside the list, so the - // notice appearing and disappearing is a recomposition - // of this function and not of a lazy item that may not - // be composed at the time. - val awaitingYou = proposalsAwaitingYou - - LazyColumn( - modifier = Modifier.fillMaxWidth().padding(5.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - reverseLayout = true - ) { - // First item, and the layout is reversed, so this - // sits under the newest message and above the - // composer -- where the reader already is. - if (awaitingYou.isNotEmpty()) { - item { - ProposalsAwaitingYouNotice( - proposals = awaitingYou, - onClick = onOpenProposals - ) - } - } - - item { - if (isReceiverChatMessageRelayListMissing.value) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center - ) { - Card( - onClick = { - // TODO: Open description for chatMessageRelayList - }, - modifier = Modifier.fillMaxWidth(0.79f), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - - Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = "No Chat Message Relays were found for this user.", - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelSmall - ) - } - - Icon( - Icons.Default.Info, - contentDescription = "Warning description" - ) - } - - - } - } - } - } - items( - items = chatRoomDetailMessageListUIState.chatMessageList, - key = { it.chatMessage.id } - ) { localChatMessage -> - // Not somebody's words -- see ChatMessage.DKG_TYPES. - // A bubble would attribute "a shared key ceremony - // started" to the coordinator as if they had said it. - if (localChatMessage.chatMessage.messageType in ChatMessage.DKG_TYPES) { - RitualNotice( - localChatMessage = localChatMessage, - isAnswered = localChatMessage.chatMessage.id in answeredRequests, - isSettled = localChatMessage.chatMessage.id in settledRequests, - onClick = onOpenSharedKey - ) - return@items - } - - // A private message this device cannot open. Everything - // about it is known except the one thing that matters, - // so it is a notice rather than an empty bubble -- - // which would read as the sender having said nothing. - if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE && - localChatMessage.chatMessage.content.isBlank() - ) { - PrivateMessageNotice( - sender = nameFor(localChatMessage.chatMessage.senderPublicKey), - recipient = nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey) - ) - return@items - } - - // Catching a member up is nobody's words either, - // and it leads nowhere: the work it delivered is - // in the artifact list, not behind this line. - // Passed as answered and settled because those - // are about requests and this asks nothing -- - // which is what keeps it in the quiet tint. - if (localChatMessage.chatMessage.messageType in ChatMessage.CHRONICLE_TYPES) { - RitualNotice( - localChatMessage = localChatMessage, - isAnswered = true, - isSettled = true, - onClick = {} - ) - return@items - } - - // Inviting somebody is nobody's words either, and - // for a while it was not in the room at all: the - // line was written when the Welcome went out, which - // on the deferred path is a relay round trip away - // and may never happen. Passed as answered and - // settled for the same reason the chronicle's are - // -- these report rather than ask, so they stay in - // the quiet tint and offer nothing to review. - if (localChatMessage.chatMessage.messageType in ChatMessage.MEMBERSHIP_TYPES) { - RitualNotice( - localChatMessage = localChatMessage, - isAnswered = true, - isSettled = true, - onClick = {} - ) - return@items - } - - // Signing lines are the same kind of thing and get - // the same treatment -- nobody said them either -- - // but they lead somewhere else, because what a - // reader needs from one is the event being signed - // rather than the state of the key. - if (localChatMessage.chatMessage.messageType in ChatMessage.FROST_TYPES) { - RitualNotice( - localChatMessage = localChatMessage, - isAnswered = localChatMessage.chatMessage.id in answeredRequests, - isSettled = localChatMessage.chatMessage.id in settledRequests, - onClick = { - onOpenSigning( - localChatMessage.chatMessage.frostSigningSessionId - ) - } - ) - return@items - } - - BoxWithConstraints( - modifier = Modifier.fillMaxWidth() - ) { - val screenWidth = maxWidth - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (localChatMessage.chatMessage.isUserMessage) { - Arrangement.End - } else { - Arrangement.Start - } - ) { - // Only somebody else's message, and only in a - // group -- a NIP-17 room has no audience for a - // message to be private from. - val canReplyPrivately = - localChatRoom.chatRoom.mlsGroupState != null && - !localChatMessage.chatMessage.isUserMessage && - participantFor(localChatMessage.chatMessage.senderPublicKey) != null - - Card( - modifier = Modifier.widthIn( - max = screenWidth * 0.8f - ).wrapContentWidth(), - onClick = { - if (canReplyPrivately) { - openMessageActions(localChatMessage.chatMessage.id) - } - }, - ) { - Column( - modifier = Modifier.padding(10.dp), - horizontalAlignment = if (localChatMessage.chatMessage.isUserMessage) { - Alignment.End - } else { - Alignment.Start - }, - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - if (localChatMessage.chatMessage.isUserMessage.not()) { - Text( - text = localChatMessage.profile?.humanReadableNameOrPubkey() ?: localChatMessage.chatMessage.senderPublicKey, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - color = ProfileColor.fromPublicKey(localChatMessage.chatMessage.senderPublicKey), - style = MaterialTheme.typography.labelSmall - ) - } - - // A readable direct message must never - // pass for a public one. The label says - // who the other party is, since that is - // the thing a reader would otherwise - // assume was the whole room. - if (localChatMessage.chatMessage.messageType == ChatMessage.TYPE_DIRECT_MESSAGE) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = if (localChatMessage.chatMessage.isUserMessage) { - "Private to ${nameFor(localChatMessage.chatMessage.directMessageRecipientPublicKey)}" - } else { - "Private to you" - }, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelSmall - ) - } - } - - SelectionContainer { - - Text( - text = localChatMessage.chatMessage.content, - style = MaterialTheme.typography.bodyMedium - ) - - } - - Row( - horizontalArrangement = Arrangement.spacedBy( - 10.dp, - Alignment.End - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = localChatMessage.chatMessage.createdAt.toFormattedTimeAndDateString(), - style = MaterialTheme.typography.labelSmall - ) - if (localChatMessage.chatMessage.isUserMessage) { - if (localChatMessage.chatMessageBroadcastNostrEventReceiptRelation != null) { - Icon( - Icons.Default.Check, - contentDescription = "Message sent" - ) - } else if (localChatMessage.chatMessageBroadcastNostrEventRequestRelation != null) { - Icon( - Icons.Default.AccessTime, - contentDescription = "Message sent" - ) - } else if (localChatMessage.chatMessageNostrEventRelation != null) { - Icon( - Icons.Default.Pending, - contentDescription = "Message signed and sealed status" - ) - } else { - Icon( - Icons.Default.KeyOff, - contentDescription = "Unsealed message status" - ) - } - - } - - } - } - } - - DropdownMenu( - expanded = openMessageActionsFor == localChatMessage.chatMessage.id, - onDismissRequest = { openMessageActions(null) } - ) { - DropdownMenuItem( - text = { - Text("Reply privately to ${nameFor(localChatMessage.chatMessage.senderPublicKey)}") - }, - leadingIcon = { - Icon(Icons.Default.Lock, contentDescription = null) - }, - onClick = { - participantFor(localChatMessage.chatMessage.senderPublicKey) - ?.let { startDirectMessage(it) } - } - ) - } - } - } - } - } - } - } - } - ChatMessageListUIState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "Loading", - textAlign = TextAlign.Center - ) - Spacer( - modifier = Modifier.height(50.dp) - ) - - LoadingIndicator() - } - } - } - } - } fun sendMessage(textFieldState: TextFieldState) { if (textFieldState.text.isNotBlank()) { @@ -802,303 +354,3 @@ class ChatMessageListViewModel( } } } - -/** - * What the group is waiting on this member to sign, standing at the foot of the - * transcript. - * - * A proposal announces itself as a line and then the conversation carries it - * upward, but the decision it asks for does not expire with the scroll -- and a - * member who has not answered is what the whole room is waiting on. So the ask - * is restated where the reader already is, under the newest message, and is gone - * the moment nothing is owed. Nothing to dismiss: there is no state here beyond - * whether the group still needs an answer. - * - * Named after what it signs when there is one of them, because "a proposal is - * waiting" is not something anybody can decide about. With several, the count is - * the honest summary -- naming one of several here would say the others were not - * there. - * - * It opens the room's proposals rather than the one it names, in every case. The - * transcript's own lines are the way to one proposal; this is the standing count - * of what is owed, and the queue is the screen that answers the question it - * raises -- including for the one it could not name. - */ -@Composable -private fun ProposalsAwaitingYouNotice( - proposals: List, - onClick: () -> Unit, -) { - val single = proposals.singleOrNull() - - val summary = single?.lead - ?.let { lead -> - listOfNotNull(lead.label, lead.detail.takeIf { it.isNotBlank() }) - .joinToString(" · ") - } - // Two ways for a proposal to have no lead, and they are different - // situations: a session can exist before its proposal has arrived, and a - // proposal can arrive holding events this build cannot read. Said the - // same way the proposal list says it. - ?: single?.let { - if (it.eventCount == 0) { - "Nothing has arrived to sign yet" - } else { - "None of its events could be read" - } - } - - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Draw, - contentDescription = null - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = if (single != null) { - "Waiting for your signature" - } else { - "${proposals.size} proposals are waiting for your signature" - }, - style = MaterialTheme.typography.labelMedium - ) - - if (summary != null) { - Text( - text = summary, - style = MaterialTheme.typography.bodySmall, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - } - - // The same word the transcript's own request lines use for the same - // thing, so a member reading down the room is not asked twice in two - // vocabularies. - Text( - text = "Review", - style = MaterialTheme.typography.labelLarge - ) - - Icon( - Icons.Default.ChevronRight, - contentDescription = "Open the group's proposals" - ) - } - } -} - -/** - * A private message this device cannot read, as a system line. - * - * Deliberately not a bubble. The group is meant to know that a private message was sent - * and to whom -- that is the honest half of the feature -- but an empty bubble attributed - * to the sender would read as them having said nothing, and a bubble with placeholder text - * would read as them having said the placeholder. - * - * Not tappable: there is nothing behind it to open. See docs/marmot-direct-messages.md. - */ -@Composable -private fun PrivateMessageNotice( - sender: String, - recipient: String, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Lock, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = "$sender sent a private message to $recipient", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelSmall - ) - } -} - -/** - * A ChillDKG milestone, as a system line across the transcript. - * - * Deliberately not a bubble: nobody said this, and giving it a sender and a side - * would make the coordinator appear to have announced it. It is tappable because - * the point of telling the group is to give them somewhere to go — a ritual only - * finishes once every member's device has taken part, and the ladder that shows - * who it is waiting on lives on the shared-key screen. - */ -@Composable -private fun RitualNotice( - localChatMessage: LocalChatMessage, - isAnswered: Boolean, - isSettled: Boolean, - onClick: () -> Unit, -) { - val chatMessage = localChatMessage.chatMessage - - // One per stage. A ceremony puts a dozen-odd lines in a row into the transcript, - // and with a single icon on all of them the reader has to actually read each to - // tell "somebody joined" from "somebody contributed" from "you are being asked - // for something". A request shares its stage's icon rather than getting a - // distinct one: it is the same step, before rather than after, and the primary - // tint and the Review affordance already say which. - val icon = when (chatMessage.messageType) { - ChatMessage.TYPE_DKG_STARTED -> Icons.Default.Key - ChatMessage.TYPE_DKG_HOST_KEY -> Icons.Default.PersonAdd - ChatMessage.TYPE_DKG_ROUND_1 -> Icons.Default.Upload - ChatMessage.TYPE_DKG_COORDINATOR_ROUND_1 -> Icons.Default.CallMerge - ChatMessage.TYPE_DKG_ROUND_2 -> Icons.Default.FactCheck - ChatMessage.TYPE_DKG_CERTIFICATE -> Icons.Default.WorkspacePremium - ChatMessage.TYPE_DKG_COMPLETE -> Icons.Default.CheckCircle - ChatMessage.TYPE_DKG_FAILED -> Icons.Default.ErrorOutline - - ChatMessage.TYPE_DKG_APPROVAL_NEEDED_HOST_KEY -> Icons.Default.PersonAdd - ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_1 -> Icons.Default.Upload - ChatMessage.TYPE_DKG_APPROVAL_NEEDED_ROUND_2 -> Icons.Default.FactCheck - - ChatMessage.TYPE_FROST_STARTED -> Icons.Default.Draw - ChatMessage.TYPE_FROST_NONCE -> Icons.Default.Upload - ChatMessage.TYPE_FROST_SIGNER_SET -> Icons.Default.Groups - ChatMessage.TYPE_FROST_PARTIAL_SIGNATURE -> Icons.Default.Draw - ChatMessage.TYPE_FROST_SIGNATURE -> Icons.Default.WorkspacePremium - ChatMessage.TYPE_FROST_COMPLETE -> Icons.Default.CheckCircle - ChatMessage.TYPE_FROST_FAILED -> Icons.Default.ErrorOutline - ChatMessage.TYPE_FROST_APPROVAL_NEEDED -> Icons.Default.Draw - - ChatMessage.TYPE_CHRONICLE_REQUESTED -> Icons.Default.History - ChatMessage.TYPE_CHRONICLE_SENT -> Icons.Default.Upload - ChatMessage.TYPE_CHRONICLE_RECEIVED -> Icons.Default.Download - - // An invite made and an invite sent are two separate steps on the deferred - // path, so they get separate icons -- the whole reason both lines exist is - // to be able to see that the first happened and the second did not. - ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd - ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload - ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline - - else -> Icons.Default.PanTool - } - - // The requests are the ritual lines that ask rather than report, and the ones - // the ceremony cannot get past on its own. Everything else here is deliberately - // quiet; these are not. - // An answered request is history, not a summons: it keeps its stage's icon so - // the step is still recognisable, but drops the colour and the call to action. - // So is a settled one -- declined, or signed by a quorum that did not need this - // member. Nothing was answered there, so it gets no tick, but offering to - // review it would be offering a decision that has already gone by. - val isRequest = ( - chatMessage.messageType in ChatMessage.DKG_REQUEST_TYPES || - chatMessage.messageType in ChatMessage.FROST_REQUEST_TYPES - ) && !isAnswered && !isSettled - - val tint = when { - chatMessage.messageType == ChatMessage.TYPE_DKG_FAILED || - chatMessage.messageType == ChatMessage.TYPE_FROST_FAILED || - chatMessage.messageType == ChatMessage.TYPE_MEMBER_INVITE_FAILED -> - MaterialTheme.colorScheme.error - isRequest -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tint - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = buildAnnotatedString { - // Somebody opened this ceremony, or somebody walked away from it, - // and which member that was is the point of the line. Resolved - // from the joined profile rather than written into the content, - // so it follows a rename and is not stuck on the "LOADING..." - // placeholder a member is given the moment they are first seen. - if (chatMessage.messageType in ChatMessage.DKG_AUTHORED_TYPES || - chatMessage.messageType in ChatMessage.FROST_AUTHORED_TYPES - ) { - withStyle( - SpanStyle(color = ProfileColor.fromPublicKey(chatMessage.senderPublicKey)) - ) { - append( - if (chatMessage.isUserMessage) { - "You" - } else { - localChatMessage.profile?.humanReadableNameOrPubkey() - ?: chatMessage.senderPublicKey.shortened() - } - ) - } - append(" ") - } - - append(chatMessage.content) - }, - style = MaterialTheme.typography.bodySmall, - color = tint - ) - - Text( - text = chatMessage.createdAt.toFormattedTimeAndDateString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (isRequest) { - Text( - text = "Review", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - } else if (isAnswered) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "You approved this", - tint = MaterialTheme.colorScheme.primary - ) - } - - Icon( - imageVector = Icons.Default.ChevronRight, - contentDescription = "Open the shared key ceremony", - tint = tint - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt index bf36213c..e3a988f9 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomListViewModel.kt @@ -40,6 +40,13 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading +import mantra.composeapp.generated.resources.no_messages_go_to_a_profile_and_send_them_a +import mantra.composeapp.generated.resources.something_went_wrong +import press.mantra.compose.ui.composable.widgets.ErrorState class ChatRoomListViewModel( initialChatRoomListUIState: ChatRoomListUIState, @@ -145,33 +152,23 @@ class ChatRoomListViewModel( ) { when (val chatRoomListUIState = chatRoomListUIState) { ChatRoomListUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "Something went wrong", - ) - } + ErrorState() } is ChatRoomListUIState.Loaded -> { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { if (chatRoomListUIState.chatRoomList.isEmpty()) { Text( - text = "No messages. Go to a profile and send them a message." + text = stringResource(Res.string.no_messages_go_to_a_profile_and_send_them_a) ) } else { LazyColumn( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { items( items = chatRoomListUIState.chatRoomList, @@ -190,8 +187,8 @@ class ChatRoomListViewModel( } ) { Row( - modifier = Modifier.fillMaxWidth().padding(20.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(MaterialTheme.spacing.space250), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125), verticalAlignment = Alignment.CenterVertically ) { // Both lines are clipped to one, so the name @@ -200,7 +197,7 @@ class ChatRoomListViewModel( // the clock off the row. Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.relatedGap) ) { localChatRoom.RenderChatRoomTitleText() localChatRoom.RenderChatRoomLastMessageText() @@ -232,14 +229,14 @@ class ChatRoomListViewModel( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) Text( - text = "Loading", + text = stringResource(Res.string.loading), textAlign = TextAlign.Center ) Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) LoadingIndicator() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FeedListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FeedListViewModel.kt index bc84b164..80ae5e61 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FeedListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FeedListViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -33,6 +34,14 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.nothing_in_this_feed_yet +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.EmptyState class FeedListViewModel( initialFeedListUIState: FeedListUIState, @@ -111,36 +120,16 @@ class FeedListViewModel( ) { when (val feedListUIState = feedListUIState) { FeedListUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "Something went wrong", - ) - } + ErrorState() } is FeedListUIState.Loaded -> { if (feedListUIState.localNostrEvents.isEmpty()) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "No events were found", - ) - } + EmptyState(message = stringResource(Res.string.nothing_in_this_feed_yet)) } else { LazyColumn( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { items( items = feedListUIState.localNostrEvents, @@ -167,14 +156,14 @@ class FeedListViewModel( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) Text( - text = "Loading", + text = stringResource(Res.string.loading), textAlign = TextAlign.Center ) Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) LoadingIndicator() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FollowersListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FollowersListViewModel.kt index 82e3100d..c25c443f 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FollowersListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FollowersListViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -29,6 +30,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.nobody_following_you_yet +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.EmptyState class FollowersListViewModel( initialFollowingListUIState: press.mantra.compose.ui.view.state.FollowersListUIState, @@ -105,36 +114,16 @@ class FollowersListViewModel( ) { when (val followersListUIState = followersListUIState) { press.mantra.compose.ui.view.state.FollowersListUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "Something went wrong", - ) - } + ErrorState() } is press.mantra.compose.ui.view.state.FollowersListUIState.Loaded -> { if (followersListUIState.followers.isEmpty()) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "No events were found", - ) - } + EmptyState(message = stringResource(Res.string.nobody_following_you_yet)) } else { LazyColumn( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { items( items = followersListUIState.followers, @@ -155,14 +144,14 @@ class FollowersListViewModel( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) Text( - text = "Loading", + text = stringResource(Res.string.loading), textAlign = TextAlign.Center ) Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) LoadingIndicator() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FollowingListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FollowingListViewModel.kt index 51d5d92d..4b77fd33 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FollowingListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/FollowingListViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -29,6 +30,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.not_following_anyone_yet +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.EmptyState class FollowingListViewModel( initialFollowingListUIState: press.mantra.compose.ui.view.state.FollowingListUIState, @@ -104,36 +113,16 @@ class FollowingListViewModel( ) { when (val feedListUIState = followingListUIState) { press.mantra.compose.ui.view.state.FollowingListUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "Something went wrong", - ) - } + ErrorState() } is press.mantra.compose.ui.view.state.FollowingListUIState.Loaded -> { if (feedListUIState.following.isEmpty()) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "No events were found", - ) - } + EmptyState(message = stringResource(Res.string.not_following_anyone_yet)) } else { LazyColumn( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { items( items = feedListUIState.following, @@ -154,14 +143,14 @@ class FollowingListViewModel( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) Text( - text = "Loading", + text = stringResource(Res.string.loading), textAlign = TextAlign.Center ) Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) LoadingIndicator() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/InReplyToViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/InReplyToViewModel.kt index f03cd197..a71c922e 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/InReplyToViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/InReplyToViewModel.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -34,6 +35,14 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlin.time.Instant +import press.mantra.compose.ui.theme.spacing +import mantra.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.loading +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.no_replies_to_this_yet +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.EmptyState class InReplyToViewModel( val nostrEvent: press.mantra.compose.database.model.NostrEvent, @@ -115,36 +124,16 @@ class InReplyToViewModel( ) { when (val feedListUIState = inReplyToFeedListUIState) { press.mantra.compose.ui.view.state.FeedListUIState.Error -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "Something went wrong", - ) - } + ErrorState() } is press.mantra.compose.ui.view.state.FeedListUIState.Loaded -> { if (feedListUIState.localNostrEvents.isEmpty()) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer( - modifier = Modifier.height(50.dp) - ) - Text( - text = "No events were found", - ) - } + EmptyState(message = stringResource(Res.string.no_replies_to_this_yet)) } else { LazyColumn( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) ) { items( items = feedListUIState.localNostrEvents, @@ -171,14 +160,14 @@ class InReplyToViewModel( horizontalAlignment = Alignment.CenterHorizontally ) { Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) Text( - text = "Loading", + text = stringResource(Res.string.loading), textAlign = TextAlign.Center ) Spacer( - modifier = Modifier.height(50.dp) + modifier = Modifier.height(MaterialTheme.spacing.space600) ) LoadingIndicator() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/KeyPackageManagementViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/KeyPackageManagementViewModel.kt index 38e11c5f..bbd7570a 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/KeyPackageManagementViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/KeyPackageManagementViewModel.kt @@ -40,19 +40,29 @@ class KeyPackageManagementViewModel( } } - fun publishNewKeyPackage() { + /** + * @param onDone called on completion so the screen can report it. Both of these were + * fire and forget: you tapped, a coroutine ran, and nothing on screen changed -- + * which is indistinguishable from a tap that missed. + */ + fun publishNewKeyPackage(onDone: () -> Unit = {}) { viewModelScope.launch(Dispatchers.IO) { // TODO: Rotate all other keys... marmotRepository.publishMarmotKeyPackageBundle( publicKey = activeUserPublicKey, nsecPassword = "" // TODO: Implement nsecPassword logic... ) + onDone() } } - fun rotateKeyPackage(marmotKeyPackageBundle: MarmotKeyPackageBundle) { + fun rotateKeyPackage( + marmotKeyPackageBundle: MarmotKeyPackageBundle, + onDone: () -> Unit = {}, + ) { viewModelScope.launch(Dispatchers.IO) { marmotRepository.rotateMarmotKeyPackageBundle(marmotKeyPackageBundle) + onDone() } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt new file mode 100644 index 00000000..f899c5cc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteTest.kt @@ -0,0 +1,92 @@ +package press.mantra.compose.ui.composable.navigation + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType +import press.mantra.compose.ui.composable.navigation.routes.ActiveProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.HomeRoute +import press.mantra.compose.ui.composable.navigation.routes.SearchRoute +import press.mantra.compose.ui.theme.Breakpoint +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The navigation component per breakpoint, and the routes its three items lead to. + * + * The mapping is the kind of thing that is wrong in one row and right in every screenshot + * anybody takes: the collapsed and expanded rails differ only in whether labels are drawn, + * and nobody opens a 1200dp window on purpose. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +class NavigationSuiteTest { + + private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3" + private val profileEventId = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899" + + @Test + fun `each breakpoint takes the component the layout foundation gives it`() { + // m3.material.io/foundations/layout/applying-layout/window-size-classes: navigation + // bar under 600dp, collapsed rail through medium and expanded, expanded rail from + // 1200dp. The library's own `NavigationSuiteScaffoldDefaults.navigationSuiteType` + // cannot produce the last row -- it classifies with the three-value window size + // class -- which is the whole reason this function exists. + val expected = mapOf( + Breakpoint.Compact to NavigationSuiteType.ShortNavigationBarCompact, + Breakpoint.Medium to NavigationSuiteType.WideNavigationRailCollapsed, + Breakpoint.Expanded to NavigationSuiteType.WideNavigationRailCollapsed, + Breakpoint.Large to NavigationSuiteType.WideNavigationRailExpanded, + Breakpoint.ExtraLarge to NavigationSuiteType.WideNavigationRailExpanded, + ) + + assertEquals(Breakpoint.entries.size, expected.size, "a breakpoint has no component") + expected.forEach { (breakpoint, component) -> + assertEquals(component, navigationSuiteTypeFor(breakpoint, isTopLevel = true), "$breakpoint") + } + } + + @Test + fun `nothing is drawn off a top-level destination, at any width`() { + // The component belongs on the destinations it switches between. On a chat room, a + // signing screen or an onboarding step -- reached by pushing a route and left by + // coming back -- a persistent bar is an invitation to lose your place. + Breakpoint.entries.forEach { breakpoint -> + assertEquals( + NavigationSuiteType.None, + navigationSuiteTypeFor(breakpoint, isTopLevel = false), + "$breakpoint drew a navigation component off a top-level destination", + ) + } + } + + @Test + fun `every destination leads to the route it names`() { + assertEquals( + HomeRoute(activeUserPublicKey = publicKey), + TopLevelDestination.Messages.route(publicKey, profileEventId), + ) + assertEquals( + SearchRoute(activeUserPublicKey = publicKey), + TopLevelDestination.Search.route(publicKey, profileEventId), + ) + assertEquals( + ActiveProfileRoute(activeUserPublicKey = publicKey, nostrEventId = profileEventId), + TopLevelDestination.Profile.route(publicKey, profileEventId), + ) + } + + @Test + fun `only the profile item is unavailable before its event has been read`() { + // `ActiveProfileRoute` is addressed by metadata event id rather than by public key, + // so between signing in and that row arriving there is no route to build. The item + // is disabled for those moments; the other two are never affected, which is what + // stops a "wait for everything" guard being put around the whole component. + assertNull(TopLevelDestination.Profile.route(publicKey, activeProfileNostrEventId = null)) + listOf(TopLevelDestination.Messages, TopLevelDestination.Search).forEach { + assertEquals( + it.route(publicKey, profileEventId), + it.route(publicKey, activeProfileNostrEventId = null), + "$it changed with the profile event id, which it does not use", + ) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt new file mode 100644 index 00000000..900cac4a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/AppColorSchemeSelectionTest.kt @@ -0,0 +1,83 @@ +package press.mantra.compose.ui.theme + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame + +/** + * The six-way table `appColorScheme` picks from. + * + * Worth a test because the failure mode is silent and specific: a scheme wired to the + * wrong cell still renders a complete, plausible UI. Somebody who turns contrast up and + * gets the medium scheme back has no way to tell it apart from a high-contrast scheme + * that is simply not very high, and `Color.kt`'s six schemes are 468 near-identical + * lines in which a transposed row reads as a typo you would have to be looking for. + * + * Until this table existed, four of the six schemes were unreachable -- declared in full + * and never selected. + */ +class AppColorSchemeSelectionTest { + + @Test + fun `each darkness and contrast pair selects its own scheme`() { + assertSame(lightScheme, appColorScheme(darkTheme = false, contrast = ThemeContrast.Standard)) + assertSame(darkScheme, appColorScheme(darkTheme = true, contrast = ThemeContrast.Standard)) + + assertSame( + mediumContrastLightColorScheme, + appColorScheme(darkTheme = false, contrast = ThemeContrast.Medium), + ) + assertSame( + mediumContrastDarkColorScheme, + appColorScheme(darkTheme = true, contrast = ThemeContrast.Medium), + ) + + assertSame( + highContrastLightColorScheme, + appColorScheme(darkTheme = false, contrast = ThemeContrast.High), + ) + assertSame( + highContrastDarkColorScheme, + appColorScheme(darkTheme = true, contrast = ThemeContrast.High), + ) + } + + @Test + fun `all six schemes are distinct`() { + // A copy-paste that left two cells pointing at the same object would satisfy the + // table test above only if it also mislabelled one, so this catches the other + // half: six declarations that are not six schemes. + val schemes = ThemeContrast.entries.flatMap { contrast -> + listOf(false, true).map { dark -> "$contrast/${if (dark) "dark" else "light"}" to appColorScheme(dark, contrast) } + } + + schemes.forEachIndexed { i, (nameA, a) -> + schemes.drop(i + 1).forEach { (nameB, b) -> + assertNotEquals(a, b, "$nameA and $nameB are the same scheme") + } + } + } + + @Test + fun `raising contrast never lowers the contrast of body text`() { + // The one direction that must hold. Individual tonal surfaces legitimately move + // the other way -- see ColorSchemeContrastTest for why a full monotonicity + // assertion is wrong -- but onSurface against surface is the pair the setting + // exists for, and it going backwards would be indefensible. + listOf(false, true).forEach { dark -> + val standard = appColorScheme(dark, ThemeContrast.Standard) + val medium = appColorScheme(dark, ThemeContrast.Medium) + val high = appColorScheme(dark, ThemeContrast.High) + + val ratios = listOf(standard, medium, high).map { contrastRatio(it.surface, it.onSurface) } + val theme = if (dark) "dark" else "light" + + assertEquals( + ratios.sorted(), + ratios, + "$theme: onSurface on surface does not rise with contrast — $ratios", + ) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/BreakpointTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/BreakpointTest.kt new file mode 100644 index 00000000..58fce9da --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/BreakpointTest.kt @@ -0,0 +1,123 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The breakpoint thresholds against M3's published values, and the spacing they select. + * + * The thresholds are the kind of number that is wrong silently: a layout that switches to + * two panes at 640dp instead of 600dp still looks like a working layout on every device + * anybody happens to test, and only misbehaves in the 40dp nobody opens. Asserting the + * boundary from both sides is the only way that shows up. + */ +class BreakpointTest { + + /** m3.material.io/foundations/layout/applying-layout/window-size-classes, September 2026. */ + private val published = listOf( + Breakpoint.Compact to 0, + Breakpoint.Medium to 600, + Breakpoint.Expanded to 840, + Breakpoint.Large to 1200, + Breakpoint.ExtraLarge to 1600, + ) + + @Test + fun `each breakpoint starts at its published width`() { + assertEquals(published.size, Breakpoint.entries.size, "a breakpoint was added or removed") + published.forEach { (breakpoint, lowerBound) -> + assertEquals( + lowerBound.dp, + breakpoint.minWidth, + "$breakpoint starts at ${breakpoint.minWidth}, M3 says $lowerBound.dp", + ) + } + } + + @Test + fun `the lower bound belongs to the breakpoint it opens`() { + // Half-open on the upper bound: exactly 600dp is Medium, and 599dp is Compact. + // Off by one here and a phone in landscape gets the tablet layout, or does not. + published.forEach { (breakpoint, lowerBound) -> + assertEquals( + breakpoint, + Breakpoint.ofWidth(lowerBound.dp), + "${lowerBound}dp should be exactly at the bottom of $breakpoint", + ) + } + } + + @Test + fun `one dp below a lower bound is the breakpoint beneath it`() { + published.drop(1).forEachIndexed { indexBefore, (breakpoint, lowerBound) -> + val beneath = published[indexBefore].first + assertEquals( + beneath, + Breakpoint.ofWidth((lowerBound - 1).dp), + "${lowerBound - 1}dp fell in $breakpoint rather than $beneath", + ) + } + } + + @Test + fun `the widths the phase is meant to be checked at land where the plan says`() { + // The five window widths phase 6's acceptance criterion names. If one of these + // ever moves, the manual check and the code have stopped talking about the same + // thing. + assertEquals(Breakpoint.Compact, Breakpoint.ofWidth(400.dp)) + assertEquals(Breakpoint.Medium, Breakpoint.ofWidth(700.dp)) + assertEquals(Breakpoint.Expanded, Breakpoint.ofWidth(1000.dp)) + assertEquals(Breakpoint.Large, Breakpoint.ofWidth(1400.dp)) + assertEquals(Breakpoint.ExtraLarge, Breakpoint.ofWidth(1800.dp)) + } + + @Test + fun `a zero or negative width is compact rather than an error`() { + // A window has zero width for one frame on desktop, before the first layout pass, + // and `ofWidth` is called from the theme every composition. Throwing there would + // take the app down on a resize. + assertEquals(Breakpoint.Compact, Breakpoint.ofWidth(0.dp)) + assertEquals(Breakpoint.Compact, Breakpoint.ofWidth((-1).dp)) + } + + @Test + fun `isAtLeast reads up the scale and not down it`() { + assertTrue(Breakpoint.Large.isAtLeast(Breakpoint.Expanded)) + assertTrue(Breakpoint.Expanded.isAtLeast(Breakpoint.Expanded)) + assertTrue(!Breakpoint.Medium.isAtLeast(Breakpoint.Expanded)) + } + + @Test + fun `the screen margin widens at medium and holds there`() { + // M3 publishes 16dp compact, 24dp for every wider breakpoint -- it does not keep + // growing. A margin that scaled with the window would push a bounded column of + // text further from the edge for no reason at 1800dp. + assertEquals(16.dp, spacingFor(Breakpoint.Compact).screenMargin) + listOf(Breakpoint.Medium, Breakpoint.Expanded, Breakpoint.Large, Breakpoint.ExtraLarge) + .forEach { assertEquals(24.dp, spacingFor(it).screenMargin, "$it") } + } + + @Test + fun `nothing but the screen margin moves with the breakpoint`() { + // The scale itself is absolute -- space200 is 16dp in every window -- and the + // other semantic names are component decisions. This is the assertion that stops + // a later "make it breathe on desktop" edit from quietly turning the whole scale + // into a zoom factor. + val compact = spacingFor(Breakpoint.Compact) + val wide = spacingFor(Breakpoint.ExtraLarge) + + assertEquals(compact, wide.copy(screenMargin = compact.screenMargin)) + } + + @Test + fun `every breakpoint gets one of two shared instances`() { + // LocalSpacing is a static composition local: it invalidates on identity, not on + // equality, so handing it a freshly built but equal Spacing every recomposition + // would restart every composition that reads spacing. + assertSame(spacingFor(Breakpoint.Medium), spacingFor(Breakpoint.ExtraLarge)) + assertSame(spacingFor(Breakpoint.Compact), spacingFor(Breakpoint.Compact)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt new file mode 100644 index 00000000..e81928c1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ColorSchemeContrastTest.kt @@ -0,0 +1,393 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import kotlin.math.pow +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Every colour pair the six declared schemes promise, measured. + * + * M3's accessibility foundation gives two thresholds: small text needs 4.5:1 against + * its background, and large text or a meaningful non-text boundary needs 3:1. Disabled + * states are exempt. See docs/material-design-conformance.md, "The numbers". + * + * This passes as written -- the generated palette is sound, and the tightest pair in + * the tree is `onPrimaryContainer` on `primaryContainer` at 4.61:1 light. It exists so + * that a later edit to Color.kt or to the wiring in Theme.kt cannot quietly break one, + * because a broken pair is invisible in review: the two hex values look unrelated, and + * the failure only appears on a device, in one theme, to someone who cannot read it. + * + * It walks the real `ColorScheme` objects rather than rebuilding them from Color.kt, so + * it also covers the wiring. A role pointed at its neighbour's value -- `surfaceContainerHigh + * = surfaceContainerHighestLight` -- is a plausible slip that reads fine in isolation. + * + * What is deliberately *not* asserted here: + * + * - **Monotonicity across the contrast ladder.** The obvious invariant, that + * high-contrast beats medium beats default for every pair, is false and correctly + * so. In the light high-contrast scheme `surfaceContainerHighest` goes darker to + * separate it from `surface`, which lowers its ratio against `onSurface` (13.30 -> + * 12.29) while raising the one that matters. Ten pairs move that way. The floor is + * the invariant; the ladder is not. + * - **`outlineVariant`.** It reads 1.61:1 against surface, which looks alarming and is + * not a defect: M3's own baseline sits in the same range, and outlineVariant is a + * decorative divider. `outline`, the meaningful-boundary role, is asserted at 3:1. + * - **Call sites that pair two roles the scheme already covers.** Once + * `ProposalListScreen` derives its `ListItem` colours from its `Card`, the pairing it + * produces is `onPrimaryContainer` on `primaryContainer`, which the first assertion + * already walks. Restating it here would double the maintenance and catch nothing. + * What *is* asserted per call site is the composited case, below, because a + * translucent container has no ratio until it is put over something. + */ +class ColorSchemeContrastTest { + + private val schemes: List> = listOf( + "light" to lightScheme, + "dark" to darkScheme, + "light medium-contrast" to mediumContrastLightColorScheme, + "dark medium-contrast" to mediumContrastDarkColorScheme, + "light high-contrast" to highContrastLightColorScheme, + "dark high-contrast" to highContrastDarkColorScheme, + ) + + /** + * Container role paired with the content role M3 assigns to it. Text drawn on the + * first is drawn in the second, so each of these is a small-text pairing. + */ + private val textPairs: List Color, (ColorScheme) -> Color>> = + listOf( + Triple("onPrimary on primary", { s: ColorScheme -> s.primary }, { s: ColorScheme -> s.onPrimary }), + Triple("onPrimaryContainer on primaryContainer", { s: ColorScheme -> s.primaryContainer }, { s: ColorScheme -> s.onPrimaryContainer }), + Triple("onSecondary on secondary", { s: ColorScheme -> s.secondary }, { s: ColorScheme -> s.onSecondary }), + Triple("onSecondaryContainer on secondaryContainer", { s: ColorScheme -> s.secondaryContainer }, { s: ColorScheme -> s.onSecondaryContainer }), + Triple("onTertiary on tertiary", { s: ColorScheme -> s.tertiary }, { s: ColorScheme -> s.onTertiary }), + Triple("onTertiaryContainer on tertiaryContainer", { s: ColorScheme -> s.tertiaryContainer }, { s: ColorScheme -> s.onTertiaryContainer }), + Triple("onError on error", { s: ColorScheme -> s.error }, { s: ColorScheme -> s.onError }), + Triple("onErrorContainer on errorContainer", { s: ColorScheme -> s.errorContainer }, { s: ColorScheme -> s.onErrorContainer }), + Triple("onBackground on background", { s: ColorScheme -> s.background }, { s: ColorScheme -> s.onBackground }), + Triple("onSurface on surface", { s: ColorScheme -> s.surface }, { s: ColorScheme -> s.onSurface }), + Triple("onSurfaceVariant on surfaceVariant", { s: ColorScheme -> s.surfaceVariant }, { s: ColorScheme -> s.onSurfaceVariant }), + Triple("inverseOnSurface on inverseSurface", { s: ColorScheme -> s.inverseSurface }, { s: ColorScheme -> s.inverseOnSurface }), + ) + + /** + * The tonal surfaces. All eight carry `onSurface` content -- there is no + * `onSurfaceContainer` role -- so every one of them is a text background, and a + * scheme that darkens one of them without checking is how this breaks. + */ + private val tonalSurfaces: List Color>> = listOf( + "surfaceDim" to { s: ColorScheme -> s.surfaceDim }, + "surfaceBright" to { s: ColorScheme -> s.surfaceBright }, + "surfaceContainerLowest" to { s: ColorScheme -> s.surfaceContainerLowest }, + "surfaceContainerLow" to { s: ColorScheme -> s.surfaceContainerLow }, + "surfaceContainer" to { s: ColorScheme -> s.surfaceContainer }, + "surfaceContainerHigh" to { s: ColorScheme -> s.surfaceContainerHigh }, + "surfaceContainerHighest" to { s: ColorScheme -> s.surfaceContainerHighest }, + ) + + @Test + fun `every content role reads at 4_5 to 1 on its container`() { + val failures = mutableListOf() + + schemes.forEach { (schemeName, scheme) -> + textPairs.forEach { (pairName, container, content) -> + val ratio = contrastRatio(container(scheme), content(scheme)) + if (ratio < SMALL_TEXT_MINIMUM) { + failures += "$schemeName: $pairName is ${ratio.format()}:1" + } + } + } + + assertTrue(failures.isEmpty(), "below 4.5:1 —\n" + failures.joinToString("\n")) + } + + @Test + fun `onSurface reads at 4_5 to 1 on every tonal surface`() { + val failures = mutableListOf() + + schemes.forEach { (schemeName, scheme) -> + tonalSurfaces.forEach { (surfaceName, surface) -> + val ratio = contrastRatio(surface(scheme), scheme.onSurface) + if (ratio < SMALL_TEXT_MINIMUM) { + failures += "$schemeName: onSurface on $surfaceName is ${ratio.format()}:1" + } + } + } + + assertTrue(failures.isEmpty(), "below 4.5:1 —\n" + failures.joinToString("\n")) + } + + @Test + fun `both content roles read on both fixed containers at 4_5 to 1`() { + // `onXFixedVariant` is the lower-emphasis of the two and `xFixedDim` the darker + // container, so onVariant-on-Dim is the corner that decides the family. It is + // 5.44:1 in the default schemes -- real headroom, but not much, and a tone + // chosen by eye rather than computed would land under it. + val failures = mutableListOf() + + schemes.forEach { (schemeName, scheme) -> + listOf( + "primary" to Quad(scheme.primaryFixed, scheme.primaryFixedDim, scheme.onPrimaryFixed, scheme.onPrimaryFixedVariant), + "secondary" to Quad(scheme.secondaryFixed, scheme.secondaryFixedDim, scheme.onSecondaryFixed, scheme.onSecondaryFixedVariant), + "tertiary" to Quad(scheme.tertiaryFixed, scheme.tertiaryFixedDim, scheme.onTertiaryFixed, scheme.onTertiaryFixedVariant), + ).forEach { (family, roles) -> + listOf( + "on${family}Fixed on ${family}Fixed" to contrastRatio(roles.fixed, roles.on), + "on${family}FixedVariant on ${family}Fixed" to contrastRatio(roles.fixed, roles.onVariant), + "on${family}Fixed on ${family}FixedDim" to contrastRatio(roles.dim, roles.on), + "on${family}FixedVariant on ${family}FixedDim" to contrastRatio(roles.dim, roles.onVariant), + ).forEach { (pairName, ratio) -> + if (ratio < SMALL_TEXT_MINIMUM) { + failures += "$schemeName: $pairName is ${ratio.format()}:1" + } + } + } + } + + assertTrue(failures.isEmpty(), "below 4.5:1 —\n" + failures.joinToString("\n")) + } + + @Test + fun `the fixed roles are the same colour in light and dark`() { + // What "fixed" means. A fixed container that shifted with the theme would be an + // ordinary container with a misleading name, and the bug would only show on a + // screen that puts one beside a themed surface. + val failures = mutableListOf() + + listOf( + "default" to (lightScheme to darkScheme), + "medium-contrast" to (mediumContrastLightColorScheme to mediumContrastDarkColorScheme), + "high-contrast" to (highContrastLightColorScheme to highContrastDarkColorScheme), + ).forEach { (level, pair) -> + val (light, dark) = pair + fixedRoles.forEach { (roleName, role) -> + if (role(light) != role(dark)) { + failures += "$level: $roleName is ${role(light)} light, ${role(dark)} dark" + } + } + } + + assertTrue(failures.isEmpty(), "fixed roles differ across themes —\n" + failures.joinToString("\n")) + } + + @Test + fun `no role is left at the Material baseline palette`() { + // A role omitted from lightColorScheme() takes its baseline default, which for + // the twelve *Fixed* roles is PaletteTokens.Primary90 and friends -- lavender, + // in an app whose primary is pure black. Nothing in the tree reads a fixed role + // yet, so this cannot be caught by looking at the app; it has to be asserted. + val baselineFixed = setOf( + Color(0xFFEADDFF), Color(0xFFD0BCFF), Color(0xFF21005D), Color(0xFF4F378B), // primary + Color(0xFFE8DEF8), Color(0xFFCCC2DC), Color(0xFF1D192B), Color(0xFF4A4458), // secondary + Color(0xFFFFD8E4), Color(0xFFEFB8C8), Color(0xFF31111D), Color(0xFF633B48), // tertiary + ) + val failures = mutableListOf() + + schemes.forEach { (schemeName, scheme) -> + fixedRoles.forEach { (roleName, role) -> + if (role(scheme) in baselineFixed) { + failures += "$schemeName: $roleName is still the baseline ${role(scheme)}" + } + } + } + + assertTrue(failures.isEmpty(), "unassigned roles —\n" + failures.joinToString("\n")) + } + + @Test + fun `the extended brand families read at 4_5 to 1`() { + // The two pills on CreateProfileScreen. They are not part of any ColorScheme, so + // nothing else in this file reaches them, and their previous incarnation is + // exactly why they need asserting: `BluePill` paired with `Color.DarkGray` by eye + // was 2.90:1, and `RedPill` carried alpha 0.749 so its white label was 3.50:1 once + // composited. Both looked fine to whoever wrote them. + val failures = mutableListOf() + + listOf( + "bluePill light" to bluePillLight, + "bluePill dark" to bluePillDark, + "redPill light" to redPillLight, + "redPill dark" to redPillDark, + ).forEach { (name, family) -> + listOf( + "onColor on color" to contrastRatio(family.color, family.onColor), + "onColorContainer on colorContainer" to + contrastRatio(family.colorContainer, family.onColorContainer), + ).forEach { (pairName, ratio) -> + if (ratio < SMALL_TEXT_MINIMUM) { + failures += "$name: $pairName is ${ratio.format()}:1" + } + } + } + + assertTrue(failures.isEmpty(), "below 4.5:1 —\n" + failures.joinToString("\n")) + } + + @Test + fun `the extended brand families are opaque`() { + // `RedPill` was `Color(230, 32, 32, 191)` -- the four-Int constructor, whose last + // argument is alpha. A translucent container has no contrast ratio of its own; it + // has one only once composited, and the test above would have measured a colour + // the user never sees. Roles are opaque. + val translucent = listOf( + "bluePillLight" to bluePillLight, + "bluePillDark" to bluePillDark, + "redPillLight" to redPillLight, + "redPillDark" to redPillDark, + ).flatMap { (name, family) -> + listOf( + "$name.color" to family.color, + "$name.onColor" to family.onColor, + "$name.colorContainer" to family.colorContainer, + "$name.onColorContainer" to family.onColorContainer, + ) + }.filter { (_, color) -> color.alpha != 1f } + + assertTrue( + translucent.isEmpty(), + "translucent roles —\n" + translucent.joinToString("\n") { "${it.first} alpha ${it.second.alpha}" }, + ) + } + + @Test + fun `translucent containers still carry their content at 4_5 to 1 once composited`() { + // Call sites that tint a container with `.copy(alpha = …)`. A translucent colour + // has no ratio of its own, so the only way to check one is to composite it over + // what is actually behind it and measure that -- which is why these could not be + // caught by the role-pair assertions above, and why one of them was missed for + // as long as it was. + // + // Each row names the call site so a failure says where to go. `surface` is the + // backdrop in every case; a screen that puts one of these on a tonal surface + // instead would need its own row. + val failures = mutableListOf() + + schemes.forEach { (schemeName, scheme) -> + listOf( + Triple( + "ChatRoomMessagingScreen: private-message field", + scheme.primaryContainer.copy(alpha = 0.3f), + scheme.onSurface, + ), + Triple( + "UnsupportedKindBadge / LinkPreview: tinted badge", + scheme.surfaceVariant.copy(alpha = 0.3f), + scheme.onSurfaceVariant, + ), + ).forEach { (site, container, content) -> + val ratio = contrastRatio(container.compositeOver(scheme.surface), content) + if (ratio < SMALL_TEXT_MINIMUM) { + failures += "$schemeName: $site is ${ratio.format()}:1" + } + } + } + + assertTrue(failures.isEmpty(), "below 4.5:1 once composited —\n" + failures.joinToString("\n")) + } + + @Test + fun `outline separates from every surface it is drawn on at 3 to 1`() { + val failures = mutableListOf() + + schemes.forEach { (schemeName, scheme) -> + (listOf("surface" to { s: ColorScheme -> s.surface }) + tonalSurfaces) + .forEach { (surfaceName, surface) -> + val ratio = contrastRatio(surface(scheme), scheme.outline) + if (ratio < NON_TEXT_MINIMUM) { + failures += "$schemeName: outline on $surfaceName is ${ratio.format()}:1" + } + } + } + + assertTrue(failures.isEmpty(), "below 3:1 —\n" + failures.joinToString("\n")) + } + + @Test + fun `a filled container stands off the surface behind it at 3 to 1`() { + // M3 asks for 3:1 between a clustered interactive container and its background. + // A standalone element such as a FAB is exempt by prominence, but `primary` and + // `error` are both used for buttons that sit beside other buttons. + val failures = mutableListOf() + + schemes.forEach { (schemeName, scheme) -> + listOf( + "primary" to scheme.primary, + "error" to scheme.error, + ).forEach { (roleName, role) -> + val ratio = contrastRatio(scheme.surface, role) + if (ratio < NON_TEXT_MINIMUM) { + failures += "$schemeName: $roleName on surface is ${ratio.format()}:1" + } + } + } + + assertTrue(failures.isEmpty(), "below 3:1 —\n" + failures.joinToString("\n")) + } + + /** The twelve theme-independent roles, by name, for the two tests that walk them all. */ + private val fixedRoles: List Color>> = listOf( + "primaryFixed" to { s: ColorScheme -> s.primaryFixed }, + "primaryFixedDim" to { s: ColorScheme -> s.primaryFixedDim }, + "onPrimaryFixed" to { s: ColorScheme -> s.onPrimaryFixed }, + "onPrimaryFixedVariant" to { s: ColorScheme -> s.onPrimaryFixedVariant }, + "secondaryFixed" to { s: ColorScheme -> s.secondaryFixed }, + "secondaryFixedDim" to { s: ColorScheme -> s.secondaryFixedDim }, + "onSecondaryFixed" to { s: ColorScheme -> s.onSecondaryFixed }, + "onSecondaryFixedVariant" to { s: ColorScheme -> s.onSecondaryFixedVariant }, + "tertiaryFixed" to { s: ColorScheme -> s.tertiaryFixed }, + "tertiaryFixedDim" to { s: ColorScheme -> s.tertiaryFixedDim }, + "onTertiaryFixed" to { s: ColorScheme -> s.onTertiaryFixed }, + "onTertiaryFixedVariant" to { s: ColorScheme -> s.onTertiaryFixedVariant }, + ) + + /** One fixed family: the two containers and the two content roles that sit on them. */ + private data class Quad(val fixed: Color, val dim: Color, val on: Color, val onVariant: Color) + + private companion object { + /** WCAG 2.x, small text. */ + const val SMALL_TEXT_MINIMUM = 4.5 + + /** WCAG 2.x, large text and meaningful non-text elements. */ + const val NON_TEXT_MINIMUM = 3.0 + } +} + +/** + * WCAG relative luminance of one sRGB channel. + * + * The 0.03928 knee and the 2.4 exponent are the specification's, not an approximation + * of gamma 2.2 -- swapping in the simpler curve moves borderline pairs by enough to + * change a verdict, which is the whole point of this file. + */ +private fun channelLuminance(component: Float): Double { + val c = component.toDouble() + return if (c <= 0.03928) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4) +} + +private fun Color.relativeLuminance(): Double = + 0.2126 * channelLuminance(red) + + 0.7152 * channelLuminance(green) + + 0.0722 * channelLuminance(blue) + +/** + * Contrast ratio between two opaque colours, 1.0 to 21.0. + * + * Both arguments must be opaque. A translucent colour has no ratio of its own -- it has + * one only once composited over something -- so composite it first and pass the result. + * Phase 3 needs that for the `.copy(alpha = 0.5f)` call sites; the schemes here are all + * fully opaque. + */ +internal fun contrastRatio(a: Color, b: Color): Double { + val la = a.relativeLuminance() + val lb = b.relativeLuminance() + return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05) +} + +/** Two decimal places, without pulling in a platform formatter. */ +private fun Double.format(): String { + val scaled = (this * 100).toInt() + return "${scaled / 100}.${(scaled % 100).toString().padStart(2, '0')}" +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/PanesTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/PanesTest.kt new file mode 100644 index 00000000..c1824a40 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/PanesTest.kt @@ -0,0 +1,75 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * When a second pane appears, and how wide the first one is. + * + * The numbers are `material3-adaptive`'s own `calculatePaneScaffoldDirective`, transcribed + * so that a hand-built pair of panes measures the same as a `ListDetailPaneScaffold` would. + * A transcription error here is the kind that looks fine on the one desktop anybody opens. + */ +class PanesTest { + + @Test + fun `no second pane below expanded`() { + // M3: "don't use two panes in medium layouts with high information density", and + // `calculatePaneScaffoldDirective` agrees in code -- `maxHorizontalPartitions = 1` + // for compact and medium alike. A chat transcript is that dense content. + assertNull(listPaneWidthFor(Breakpoint.Compact)) + assertNull(listPaneWidthFor(Breakpoint.Medium)) + } + + @Test + fun `the list pane takes the directive's two widths`() { + assertEquals(360.dp, listPaneWidthFor(Breakpoint.Expanded)) + assertEquals(412.dp, listPaneWidthFor(Breakpoint.Large)) + assertEquals(412.dp, listPaneWidthFor(Breakpoint.ExtraLarge)) + } + + @Test + fun `the list pane never widens past the window that first allowed it`() { + // The failure a fixed width invites: a pane wider than the breakpoint that opens + // it leaves the detail pane with nothing, or negative space. 360dp of list plus + // 24dp of gap inside the 840dp window that first allows two panes leaves 456dp for + // the transcript, which is above the 40-character floor. + Breakpoint.entries.forEach { breakpoint -> + val width = listPaneWidthFor(breakpoint) ?: return@forEach + val gap = Spacing().paneGap + val detail = breakpoint.minWidth - width - gap + + assertTrue( + detail >= readableWidthFor(16.dp, charactersPerLine = 40), + "at $breakpoint the detail pane starts at $detail, under a 40-character line", + ) + } + } + + @Test + fun `the gap between panes is the directive's 24dp at every width that has one`() { + // `PaneScaffoldDirective` uses 24dp for `horizontalPartitionSpacerSize` at every + // breakpoint with a second pane, which is why `paneGap` does not vary either. + assertEquals(24.dp, Spacing().paneGap) + assertEquals( + spacingFor(Breakpoint.Compact).paneGap, + spacingFor(Breakpoint.ExtraLarge).paneGap, + "the pane gap moved with the breakpoint, which the directive does not do", + ) + } + + @Test + fun `two panes begin exactly where the breakpoint says`() { + // The pairing that has to hold: the first breakpoint with a list pane is the first + // one M3 recommends two panes in. If a breakpoint were inserted, or `Expanded` + // renumbered, this is what notices. + assertNotNull(listPaneWidthFor(Breakpoint.Expanded)) + assertEquals(840.dp, Breakpoint.Expanded.minWidth) + assertNull(listPaneWidthFor(Breakpoint.ofWidth(839.dp))) + assertNotNull(listPaneWidthFor(Breakpoint.ofWidth(840.dp))) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ReadableMeasureTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ReadableMeasureTest.kt new file mode 100644 index 00000000..d5c51979 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/ReadableMeasureTest.kt @@ -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) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/SpacingScaleTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/SpacingScaleTest.kt new file mode 100644 index 00000000..e72dbb57 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/ui/theme/SpacingScaleTest.kt @@ -0,0 +1,141 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The spacing scale against M3's published token values. + * + * A scale is only worth having if it is the same scale everyone else is using. These are + * transcribed from m3.material.io/m3/pages/spacing/tokens, and the point of asserting them + * is that a transcription error is invisible: `space175 = 15.dp` would look entirely + * plausible in the source, would compile, and would put every call site that reached for + * it one unit off the grid. + */ +class SpacingScaleTest { + + private val spacing = Spacing() + + /** M3's system spacing tokens, token name to value. */ + private val published: List> = listOf( + "space0" to 0.dp, + "space25" to 2.dp, + "space50" to 4.dp, + "space75" to 6.dp, + "space100" to 8.dp, + "space125" to 10.dp, + "space150" to 12.dp, + "space175" to 14.dp, + "space200" to 16.dp, + "space250" to 20.dp, + "space300" to 24.dp, + "space400" to 32.dp, + "space450" to 36.dp, + "space500" to 40.dp, + "space600" to 48.dp, + "space700" to 56.dp, + "space800" to 64.dp, + "space900" to 72.dp, + ) + + private val declared: List> = listOf( + "space0" to spacing.space0, + "space25" to spacing.space25, + "space50" to spacing.space50, + "space75" to spacing.space75, + "space100" to spacing.space100, + "space125" to spacing.space125, + "space150" to spacing.space150, + "space175" to spacing.space175, + "space200" to spacing.space200, + "space250" to spacing.space250, + "space300" to spacing.space300, + "space400" to spacing.space400, + "space450" to spacing.space450, + "space500" to spacing.space500, + "space600" to spacing.space600, + "space700" to spacing.space700, + "space800" to spacing.space800, + "space900" to spacing.space900, + ) + + @Test + fun `every stop matches its published value`() { + declared.zip(published).forEach { (mine, theirs) -> + assertEquals(theirs.second, mine.second, "${mine.first} is ${mine.second}, M3 says ${theirs.second}") + } + } + + @Test + fun `the token name is the value's relation to space100`() { + // The naming rule, which is what makes the scale readable: the number after + // "space" is the value as a percentage of the 8dp base. space250 is 20dp because + // 20 is 250% of 8. A stop that does not obey it is a stop nobody can predict. + val base = 8.0 + declared.forEach { (name, value) -> + val percent = name.removePrefix("space").toInt() + assertEquals( + base * percent / 100.0, + value.value.toDouble(), + absoluteTolerance = 0.001, + message = "$name should be ${base * percent / 100.0}dp to match its name, is $value", + ) + } + } + + @Test + fun `the scale rises`() { + declared.zipWithNext().forEach { (a, b) -> + assertTrue(b.second > a.second, "${b.first} (${b.second}) is not greater than ${a.first} (${a.second})") + } + } + + @Test + fun `every semantic name resolves to a stop on the scale`() { + // The semantic layer exists so call sites say the job rather than the size. It + // stops being a scale the moment one of them is given a literal instead, which is + // an easy thing to do and an invisible thing to review. + val stops = declared.map { it.second }.toSet() + val semantic = listOf( + "screenMargin" to spacing.screenMargin, + "containerPadding" to spacing.containerPadding, + "compactPadding" to spacing.compactPadding, + "relatedGap" to spacing.relatedGap, + "itemGap" to spacing.itemGap, + "sectionGap" to spacing.sectionGap, + "emphasisGap" to spacing.emphasisGap, + "targetGap" to spacing.targetGap, + ) + + semantic.forEach { (name, value) -> + assertTrue(value in stops, "$name is $value, which is not on the scale") + } + } + + @Test + fun `adjacent touch targets are held at least 8dp apart`() { + // M3: "targets separated by 8dp of space or more promote balanced information + // density and usability." targetGap is what the phase 3 sweep applies between + // icon buttons, so it is the one semantic name with an external floor. + assertTrue( + spacing.targetGap >= 8.dp, + "targetGap is ${spacing.targetGap}, below M3's 8dp minimum separation", + ) + } + + @Test + fun `a scaled instance moves every stop and every semantic name with it`() { + // What the data class is for. The breakpoint phase provides a wider Spacing at + // larger windows; if a semantic name were a hardcoded Dp rather than a reference + // to a stop, it would stay behind and the layout would half-adapt. + val wide = Spacing(space200 = 24.dp, space300 = 32.dp) + + assertEquals(24.dp, wide.screenMargin, "screenMargin did not follow space200") + assertEquals(24.dp, wide.containerPadding, "containerPadding did not follow space200") + assertEquals(32.dp, wide.sectionGap, "sectionGap did not follow space300") + assertEquals(spacing.itemGap, wide.itemGap, "itemGap moved without space100 moving") + } +} diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Motion.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Motion.ios.kt new file mode 100644 index 00000000..b53fb3dc --- /dev/null +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Motion.ios.kt @@ -0,0 +1,38 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import platform.UIKit.UIAccessibilityIsReduceMotionEnabled +import platform.UIKit.UIAccessibilityReduceMotionStatusDidChangeNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue + +/** + * iOS names the setting exactly: Settings > Accessibility > Motion > Reduce Motion. + * + * The one platform of the three where the answer is a single documented call. The + * notification is the same shape as the darker-system-colours one + * [platformThemeContrast] observes on this platform, and for the same reason: it can be + * turned on from Control Center without the app being backgrounded. + */ +@Composable +actual fun platformReducedMotion(): Boolean { + var reduced by remember { mutableStateOf(UIAccessibilityIsReduceMotionEnabled()) } + + DisposableEffect(Unit) { + val observer = NSNotificationCenter.defaultCenter.addObserverForName( + name = UIAccessibilityReduceMotionStatusDidChangeNotification, + `object` = null, + queue = NSOperationQueue.mainQueue, + ) { _ -> + reduced = UIAccessibilityIsReduceMotionEnabled() + } + onDispose { NSNotificationCenter.defaultCenter.removeObserver(observer) } + } + + return reduced +} diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt index 1760ce45..edd9e0d8 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/theme/Theme.ios.kt @@ -2,16 +2,55 @@ package press.mantra.compose.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import platform.UIKit.UIAccessibilityDarkerSystemColorsEnabled +import platform.UIKit.UIAccessibilityDarkerSystemColorsStatusDidChangeNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +/** + * iOS Settings > Accessibility > Display & Text Size > Increase Contrast. + * + * A boolean, not a three-step slider, so this answers [ThemeContrast.High] or + * [ThemeContrast.Standard] and never [ThemeContrast.Medium]. Apple's own contrast work is + * mostly done inside its system colours rather than exposed as a level; the one bit it + * does expose is `UIAccessibilityDarkerSystemColorsEnabled`. + * + * **Not compiled on this machine.** The ios targets are declared only on macos (see + * docs/jvm-target.md for why the composite build forces that), so this file has been + * written against the UIKit and Foundation bindings rather than checked by a compiler. + * The Android and jvm actuals of the same two functions are compiled and tested. + */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme { - return when { - darkTheme -> darkScheme - else -> lightScheme +actual fun platformThemeContrast(): ThemeContrast { + var contrast by remember { mutableStateOf(currentContrast()) } + + // The switch can be flipped while the app is foregrounded, and iOS announces it + // rather than restarting anything -- so without this observer the new setting would + // wait for the next cold start. + DisposableEffect(Unit) { + val observer = NSNotificationCenter.defaultCenter.addObserverForName( + name = UIAccessibilityDarkerSystemColorsStatusDidChangeNotification, + `object` = null, + queue = NSOperationQueue.mainQueue, + ) { _ -> contrast = currentContrast() } + + onDispose { NSNotificationCenter.defaultCenter.removeObserver(observer) } } -} \ No newline at end of file + + return contrast +} + +private fun currentContrast(): ThemeContrast = + if (UIAccessibilityDarkerSystemColorsEnabled()) ThemeContrast.High else ThemeContrast.Standard + +/** + * Always `null`: dynamic colour means Material You, an android wallpaper-derived palette + * with no iOS counterpart. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? = null diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Motion.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Motion.jvm.kt new file mode 100644 index 00000000..4606bc93 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Motion.jvm.kt @@ -0,0 +1,21 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.runtime.Composable + +/** + * Always `false`, and this is the honest answer rather than a stub. + * + * The three desktop platforms each have a reduced-motion setting -- Windows' "Show + * animations in Windows", macos' "Reduce motion", and the freedesktop + * `gtk-enable-animations` / `org.gnome.desktop.interface enable-animations` -- and **none + * of them reaches AWT or the jvm at all**. Reading any one means a native call per + * platform, which is the same wall `platformThemeContrast` hits on linux and macos. + * + * Recorded here rather than papered over, because the alternative shape -- guessing, or + * quietly disabling motion on desktop -- would be worse than a documented gap. When the + * app grows a settings screen, this becomes a preference with the platform as its default, + * which is where a desktop app should have ended up regardless: it cannot always see what + * the desktop was told. + */ +@Composable +actual fun platformReducedMotion(): Boolean = false diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt index c5d5e191..db5010e2 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/theme/Theme.jvm.kt @@ -2,16 +2,63 @@ package press.mantra.compose.ui.theme import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import java.awt.Toolkit /** - * `dynamicColor` is ignored: it means Material You, which reads a wallpaper-derived palette - * from the android system and has no desktop counterpart. The app's own schemes are used - * whatever the caller asks for. + * Desktop has no portable contrast setting, and this reads the one platform that exposes + * a usable signal through AWT. + * + * Windows publishes its high contrast mode as the `win.highContrast.on` desktop property, + * which AWT surfaces on that platform and reports as `null` everywhere else. There is no + * medium step -- Windows high contrast is a boolean -- so this answers + * [ThemeContrast.High] or [ThemeContrast.Standard] and never [ThemeContrast.Medium]. + * + * On linux and macos the answer is always [ThemeContrast.Standard]. macos does have + * "Increase contrast" and linux desktops have their own equivalents, but neither reaches + * AWT, and reading them means a native call per platform. **Until the app has a settings + * screen this is simply unhonoured there**, which is worth knowing rather than papering + * over: the setting should ultimately be a preference the user can override anyway, + * since a desktop app cannot always see what the desktop was told. */ @Composable -actual fun themeColorScheme( - darkTheme: Boolean, - dynamicColor: Boolean, - darkScheme: ColorScheme, - lightScheme: ColorScheme -): ColorScheme = if (darkTheme) darkScheme else lightScheme +actual fun platformThemeContrast(): ThemeContrast { + val toolkit = remember { runCatching { Toolkit.getDefaultToolkit() }.getOrNull() } + ?: return ThemeContrast.Standard + + var contrast by remember(toolkit) { mutableStateOf(windowsHighContrast(toolkit)) } + + // Windows fires a property change when the user toggles high contrast, so the app + // does not need restarting. The listener is a no-op on platforms that never publish + // the property. + DisposableEffect(toolkit) { + val listener = java.beans.PropertyChangeListener { + contrast = windowsHighContrast(toolkit) + } + toolkit.addPropertyChangeListener(HIGH_CONTRAST_PROPERTY, listener) + onDispose { toolkit.removePropertyChangeListener(HIGH_CONTRAST_PROPERTY, listener) } + } + + return contrast +} + +private const val HIGH_CONTRAST_PROPERTY = "win.highContrast.on" + +private fun windowsHighContrast(toolkit: Toolkit): ThemeContrast = + if (toolkit.getDesktopProperty(HIGH_CONTRAST_PROPERTY) == true) { + ThemeContrast.High + } else { + ThemeContrast.Standard + } + +/** + * Always `null`: dynamic colour means Material You, which reads a wallpaper-derived + * palette from the android system and has no desktop counterpart. The app's own schemes + * answer whatever the caller asks for. + */ +@Composable +actual fun dynamicColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme? = null diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt index 81e4ded9..76885170 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt @@ -28,6 +28,9 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import androidx.navigation.compose.rememberNavController +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import fr.acinq.phoenix.PhoenixGlobal import fr.acinq.phoenix.security.JvmKeyStore import kotlinx.coroutines.Dispatchers @@ -37,6 +40,8 @@ import press.mantra.compose.MantraApp import press.mantra.compose.MantraGlobal import press.mantra.compose.PlatformContext import press.mantra.compose.defaultMantraDir +import press.mantra.compose.ui.theme.TorchTheme +import java.awt.Dimension import java.io.File /** @@ -56,23 +61,47 @@ fun main() = application { Window( onCloseRequest = ::exitApplication, title = "Mantra", - // The layouts have only ever been exercised at phone widths. This is a starting size - // that does not immediately misrepresent them, not a considered desktop layout. - state = rememberWindowState(width = 480.dp, height = 900.dp), + // 1100dp is the expanded breakpoint -- 840 to 1199 -- which is the narrowest window + // M3 recommends two panes in, and so the smallest opening size at which a desktop + // user sees a desktop layout rather than a phone one stretched. It used to open at + // 480dp with a comment apologising for it; that was honest while the layouts had + // only ever been exercised at phone widths, and is no longer true. + // + // The content does not stretch to fill it. Screens are held to a readable measure + // and centred (see `readableContent`), so the extra width is margin, which is what + // M3's single-pane canonical layout does with a window wider than its content. + state = rememberWindowState(width = 1100.dp, height = 800.dp), ) { var unlocked by remember { mutableStateOf(false) } - if (unlocked) { - val navController = rememberNavController() - MantraApp( - navController = navController, - mantraGlobal = MantraGlobal(platformContext = PlatformContext(appDir)), - phoenixGlobal = PhoenixGlobal( - ctx = fr.acinq.phoenix.utils.PlatformContext(applicationDir = appDir), - ), - ) - } else { - PassphraseGate(appDir = appDir, onUnlocked = { unlocked = true }) + // A floor rather than a preference. Compose Desktop has no minimum in WindowState, + // so without this the window can be dragged narrower than any layout in the app was + // written for -- and the compact breakpoint's own floor is a phone, not nothing. + // 400x600 is the narrowest window the phase's acceptance widths name. + LaunchedEffect(window) { window.minimumSize = Dimension(400, 600) } + + // TorchTheme wraps both branches, not just the app. PassphraseGate used to sit + // beside MantraApp -- which applies the theme itself -- so it composed under the + // default MaterialTheme and its colorScheme and typography were baseline M3 + // rather than this app's. It is the first screen a desktop user sees. + // + // MantraApp still applies TorchTheme, so the unlocked branch is wrapped twice. + // That is deliberate: android and ios enter through MantraApp and would lose the + // theme entirely if it moved out, and a second application of the same values + // costs one composition of a CompositionLocalProvider. + TorchTheme { + if (unlocked) { + val navController = rememberNavController() + MantraApp( + navController = navController, + mantraGlobal = MantraGlobal(platformContext = PlatformContext(appDir)), + phoenixGlobal = PhoenixGlobal( + ctx = fr.acinq.phoenix.utils.PlatformContext(applicationDir = appDir), + ), + ) + } else { + PassphraseGate(appDir = appDir, onUnlocked = { unlocked = true }) + } } } } @@ -96,6 +125,13 @@ private fun PassphraseGate(appDir: File, onUnlocked: () -> Unit) { var error by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + // The first screen of a desktop app, whose entire content is one field. M3 asks for + // an initial focus to be defined per screen; here there is only one candidate, and on + // a desktop there is no tap to give it focus -- somebody who opens the app and starts + // typing their passphrase should not have to reach for the mouse first. + val passphraseFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { passphraseFocus.requestFocus() } + fun submit() { if (busy || passphrase.isEmpty()) return busy = true @@ -144,7 +180,7 @@ private fun PassphraseGate(appDir: File, onUnlocked: () -> Unit) { visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), keyboardActions = KeyboardActions(onGo = { submit() }), - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().focusRequester(passphraseFocus), ) error?.let { Text( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt new file mode 100644 index 00000000..f9d32e02 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/StringCatalogueJvmTest.kt @@ -0,0 +1,78 @@ +package press.mantra.compose.ui + +import kotlinx.coroutines.runBlocking +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.currently_no_messages_have_been_shared +import mantra.composeapp.generated.resources.don_t_sign +import mantra.composeapp.generated.resources.something_went_wrong +import mantra.composeapp.generated.resources.add_chapter_to +import mantra.composeapp.generated.resources.chapter_words_characters +import mantra.composeapp.generated.resources.sent_a_private_message_to +import org.jetbrains.compose.resources.getString +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The string catalogue reads back what the source used to say. + * + * Moving 315 literals out of composables and into `strings.xml` is a rename that a + * compiler cannot check: every call site still compiles if a resource holds the wrong + * text, and the mistake surfaces as wrong words on a screen nobody has opened. + * + * The escapes are the part worth a test rather than an assumption. A string going into an + * XML resource needs `'` escaped as `\'` and `%` doubled, and the generated `.cvr` stores + * the escaped form verbatim -- base64 of `We couldn\'t find...`. Whether the backslash is + * still there when `stringResource` hands the text to a `Text` is a property of the + * resources library, not of the extraction, and the failure mode is an app that literally + * displays `Don\'t sign`. + * + * `getString` is the non-composable reader for the same resources, so this can assert it + * without a composition. + */ +class StringCatalogueJvmTest { + + @Test + fun `an apostrophe survives the round trip through the catalogue`() = runBlocking { + assertEquals("Don't sign", getString(Res.string.don_t_sign)) + } + + @Test + fun `an escaped newline comes back as a newline`() = runBlocking { + assertEquals( + "Currently no messages have been shared.\nBreak the ice.", + getString(Res.string.currently_no_messages_have_been_shared), + ) + } + + @Test + fun `a plain string comes back unchanged`() = runBlocking { + assertEquals("Something went wrong", getString(Res.string.something_went_wrong)) + } + + @Test + fun `a format string substitutes its arguments in order`() = runBlocking { + // The interpolated strings became `%1$s` format strings, and the argument order + // is decided by where each `${…}` sat in the template. A transposition compiles + // and reads plausibly -- "Recovered 3 of 12" against "Recovered 12 of 3" -- so + // the ordering is worth asserting on a multi-argument one. + assertEquals( + "Add chapter to Genesis", + getString(Res.string.add_chapter_to, "Genesis"), + ) + assertEquals( + "Ada sent a private message to Grace", + getString(Res.string.sent_a_private_message_to, "Ada", "Grace"), + ) + } + + @Test + fun `a unicode escape was decoded into the catalogue`() = runBlocking { + // `·` is Kotlin source syntax with no meaning in XML, so the extractor + // decodes it and the resource holds the character. If it had been left alone the + // app would render the six characters of the escape. + assertEquals( + "Chapter 3 · 900 words · 4800 characters", + getString(Res.string.chapter_words_characters, "3", "900", "4800"), + ) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt new file mode 100644 index 00000000..fa4a4c38 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/ChatPaneLayoutJvmTest.kt @@ -0,0 +1,134 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import press.mantra.compose.database.model.NostrEvent +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalProfileWithFollowing +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.TorchTheme +import press.mantra.compose.ui.view.state.HomeScreenUIState +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlin.test.Test + +/** + * The chat list-detail split, measured in windows of the widths the phase names. + * + * Three claims, and none of them can be read off the source: + * + * - below expanded the home screen is unchanged. That is the promise that let the split + * land as one commit -- the chat room is a navigation destination reached from eleven + * places, and on a phone it stays one; + * - at expanded and above the list pane is exactly the width + * `calculatePaneScaffoldDirective` gives it, so a hand-built pair measures the same as + * a `ListDetailPaneScaffold` would; + * - the detail pane says what it is for while nothing is selected, rather than being an + * unexplained empty half of the window. + * + * The repositories are the no-op ones with the two reads this screen makes delegated to a + * fixed answer. Kotlin's interface delegation makes that ten lines rather than a + * reimplementation of two large interfaces, and it keeps the test about layout: the screen + * asks for a profile and a room list, and what matters here is where they are drawn. + */ +@OptIn(ExperimentalTestApi::class) +class ChatPaneLayoutJvmTest { + + private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3" + private val emptyDetail = "Pick a conversation to read it here." + + @Test + fun `a phone window is one pane, as it has always been`() = runDesktopComposeUiTest(400, 900) { + setContent { Home() } + + onNodeWithText(emptyDetail).assertDoesNotExist() + } + + @Test + fun `a medium window is still one pane, because a transcript is dense`() = + runDesktopComposeUiTest(700, 900) { + setContent { Home() } + + // M3: no two panes in a medium window with high information density. + onNodeWithText(emptyDetail).assertDoesNotExist() + } + + @Test + fun `an expanded window splits, with the directive's 360dp list pane`() = + runDesktopComposeUiTest(1000, 900) { + setContent { Home() } + + onNodeWithText(emptyDetail).assertIsDisplayed() + onNodeWithTag(ChatListPaneTag).assertWidthIsEqualTo(360.dp) + } + + @Test + fun `a large window widens the list pane to 412dp`() = runDesktopComposeUiTest(1400, 900) { + setContent { Home() } + + onNodeWithText(emptyDetail).assertIsDisplayed() + onNodeWithTag(ChatListPaneTag).assertWidthIsEqualTo(412.dp) + } + + @androidx.compose.runtime.Composable + private fun Home() { + TorchTheme { + // Required, and deliberately so: `LocalSnackbarHostState` throws rather than + // defaulting, because a default would make every `notify` on a screen that + // forgot the host a silent no-op. + ProvideSnackbarHost { + HomeScreen( + activeUserPublicKey = publicKey, + initialHomeScreenUIState = HomeScreenUIState.Loaded(profileWithFollowing = profile), + onNavigateToRoute = {}, + onNavigateToDirectMessageDetail = {}, + onNavigateToChatRoomCreation = {}, + nostrRepository = FixedProfile, + chatRepository = NoRooms, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + ) + } + } + } + + private val profile = LocalProfileWithFollowing( + nostrEvent = NostrEvent( + id = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899", + pubKey = publicKey, + content = "", + tags = emptyArray(), + sig = "", + kind = TextNoteEvent.KIND, + ), + profile = Profile( + publicKey = publicKey, + displayName = "Reader", + nostrEventId = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899", + ), + following = emptyList(), + ) + + /** The no-op repository, plus the one read the home screen makes of it. */ + private val FixedProfile = object : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY { + override suspend fun observeProfileWithFollowing( + publicKey: String, + ): Flow = flowOf(profile) + } + + /** The no-op repository, plus the one read the room list makes of it. */ + private val NoRooms = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun observeChatRoomListByPublicKey( + publicKey: String, + ): Flow> = flowOf(emptyList()) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt new file mode 100644 index 00000000..4082b769 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/navigation/NavigationSuiteRenderJvmTest.kt @@ -0,0 +1,150 @@ +package press.mantra.compose.ui.composable.navigation + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import press.mantra.compose.ui.composable.navigation.routes.ActiveProfileRoute +import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.ui.composable.navigation.routes.HomeRoute +import press.mantra.compose.ui.composable.navigation.routes.SearchRoute +import press.mantra.compose.ui.theme.Breakpoint +import press.mantra.compose.ui.theme.TorchTheme +import kotlin.test.Test + +/** + * The navigation component composed around a real nav graph. + * + * [NavigationSuiteTest] asserts the mapping; this asserts that the pieces meet. Three + * things here can only go wrong in a composition, and each of them fails silently: + * + * - `TopLevelDestination.of` matches by `NavDestination.hasRoute`, which is reflection + * over the serialized route. A route renamed, or a `@Serializable` dropped, stops + * matching, and the consequence is a navigation component that is simply never shown; + * - which item reads as selected follows the same `hasRoute` match, so a graph whose + * routes stopped matching would show a component with nothing selected in it; + * - the profile item is disabled until its metadata event id arrives, and "disabled" + * is a state that reads identically to "enabled" in source. + * + * The window is 1400 pixels at density 1, so the component is the expanded rail and the + * three labels are drawn. In the compact bar they are drawn too, so the assertions hold + * either way; the width is fixed so the test is not about the mapping. + * + * **Navigation is driven through the controller rather than by tapping an item.** Not a + * preference: a click handler that navigates trips navigation-compose's own main-thread + * assertion under `runDesktopComposeUiTest`, and it does so with twenty lines containing + * no app code -- `NavHost`, two routes, and a `TextButton` that navigates. The harness + * dispatches the click off the main thread and `LifecycleRegistry` refuses it. So what an + * item's `onClick` builds is asserted in [NavigationSuiteTest], where it is a pure + * function, and what the component does with a destination is asserted here. + */ +@OptIn(ExperimentalTestApi::class) +class NavigationSuiteRenderJvmTest { + + private val publicKey = "de1a1e64d1c4e0d6bd97b0e73d4dfd0e1ec1cdd1e6d8d0e2b9a7f3c5d0e1a2b3" + private val profileEventId = "aa11bb22cc33dd44ee55ff6600778899aabbccddeeff00112233445566778899" + + @Test + fun `the three items are shown on a top-level destination`() = runDesktopComposeUiTest(1400, 900) { + setContent { Harness(profileNostrEventId = profileEventId) } + + onNodeWithText("Messages").assertIsDisplayed() + onNodeWithText("Search").assertIsDisplayed() + onNodeWithText("Profile").assertIsDisplayed() + } + + @Test + fun `the item matching the current destination is the selected one`() = + runDesktopComposeUiTest(1400, 900) { + lateinit var controller: NavHostController + setContent { + controller = rememberNavController() + Harness(profileNostrEventId = profileEventId, navController = controller) + } + + onNodeWithText("Messages").assertIsSelected() + onNodeWithText("Search").assertIsNotSelected() + + runOnIdle { controller.navigate(SearchRoute(publicKey)) } + waitForIdle() + + onNodeWithText("search screen").assertIsDisplayed() + onNodeWithText("Search").assertIsSelected() + onNodeWithText("Messages").assertIsNotSelected() + } + + @Test + fun `nothing is drawn once a pushed route is on top`() = runDesktopComposeUiTest(1400, 900) { + lateinit var controller: NavHostController + setContent { + controller = rememberNavController() + Harness(profileNostrEventId = profileEventId, navController = controller) + } + + onNodeWithText("Messages").assertIsDisplayed() + + runOnIdle { + controller.navigate( + ChatRoomMessagingRoute( + activeUserPublicKey = publicKey, + chatRoomId = "room", + relayHint = null, + ) + ) + } + waitForIdle() + + onNodeWithText("chat room").assertIsDisplayed() + onNodeWithText("Messages").assertDoesNotExist() + onNodeWithText("Profile").assertDoesNotExist() + } + + @Test + fun `the profile item waits for its event id, and the other two do not`() = + runDesktopComposeUiTest(1400, 900) { + setContent { Harness(profileNostrEventId = null) } + + onNodeWithText("Profile").assertIsNotEnabled() + onNodeWithText("Messages").assertIsEnabled() + onNodeWithText("Search").assertIsEnabled() + } + + /** + * The three top-level routes and one pushed route, each rendering only its own name. + * + * A stand-in for the app's graph rather than the graph itself: `MantraNavHost` builds + * a database, six repositories and four view models on the way to its first screen, + * and none of that decides whether the navigation component appears. + */ + @Composable + private fun Harness( + profileNostrEventId: String?, + navController: NavHostController = rememberNavController(), + ) { + TorchTheme { + MantraNavigationSuite( + navController = navController, + breakpoint = Breakpoint.Large, + activeUserPublicKey = publicKey, + activeProfileNostrEventId = profileNostrEventId, + ) { + NavHost(navController, startDestination = HomeRoute(publicKey)) { + composable { Text("home screen") } + composable { Text("search screen") } + composable { Text("profile screen") } + composable { Text("chat room") } + } + } + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt new file mode 100644 index 00000000..c00c6add --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/ScreenStateTransitionJvmTest.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.ui.composable.widgets + +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.ui.theme.TorchTheme +import kotlin.test.Test + +/** Stand-ins for a screen's UI state: two classes, and data that changes within one. */ +private sealed interface Fixture { + data object Loading : Fixture + data class Loaded(val text: String) : Fixture +} + +/** + * That a screen's state change is a transition rather than a cut, and that ordinary data + * changes are not. + * + * Both halves matter and the second is the one that is easy to get wrong. `AnimatedContent` + * keyed on the state *value* would re-run the whole fade every time a loaded list grew by + * one row -- a screen that flickers whenever a message arrives -- and it would look + * entirely correct in any screenshot. Keying on the state's class is what separates "the + * screen is now answering a different question" from "the answer got longer". + * + * The clock is held still so a frame in the middle of a transition can be inspected. That + * frame is the whole test: during a crossfade both states are composed, and during a cut + * only ever one is. + */ +@OptIn(ExperimentalTestApi::class) +class ScreenStateTransitionJvmTest { + + @Test + fun `both states are on screen while one replaces the other`() = + runDesktopComposeUiTest(400, 600) { + var state by mutableStateOf(Fixture.Loading) + mainClock.autoAdvance = false + setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } } + + mainClock.advanceTimeBy(16) + onNodeWithText("loading").assertIsDisplayed() + + runOnUiThread { state = Fixture.Loaded("first") } + mainClock.advanceTimeBy(48) + + // The frame that distinguishes a transition from a cut. + onNodeWithText("loading").assertExists() + onNodeWithText("first").assertExists() + + mainClock.autoAdvance = true + waitForIdle() + onNodeWithText("loading").assertDoesNotExist() + onNodeWithText("first").assertIsDisplayed() + } + + @Test + fun `data changing inside one state does not animate`() = runDesktopComposeUiTest(400, 600) { + var state by mutableStateOf(Fixture.Loaded("first")) + mainClock.autoAdvance = false + setContent { TorchTheme { ScreenStateTransition(state) { Render(it) } } } + + mainClock.advanceTimeBy(16) + onNodeWithText("first").assertIsDisplayed() + + runOnUiThread { state = Fixture.Loaded("second") } + mainClock.advanceTimeBy(48) + + // Same class, so `contentKey` is unchanged and nothing crossfades: the old text is + // gone at once rather than lingering through a fade nobody asked for. + onNodeWithText("first").assertDoesNotExist() + onNodeWithText("second").assertIsDisplayed() + + mainClock.autoAdvance = true + waitForIdle() + } + + @androidx.compose.runtime.Composable + private fun Render(state: Fixture) = when (state) { + Fixture.Loading -> Text("loading") + is Fixture.Loaded -> Text(state.text) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt new file mode 100644 index 00000000..961150be --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/NavigationMotionJvmTest.kt @@ -0,0 +1,118 @@ +package press.mantra.compose.ui.theme + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runDesktopComposeUiTest +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertTrue + +/** Two destinations, so a transition has something to run between. */ +@Serializable +object First + +@Serializable +object Second + +/** + * That the reduced-motion setting reaches the navigation transitions, measured mid-flight. + * + * The claim cannot be read off the source and cannot be asserted on the values: + * `EnterTransition` has no public shape to inspect, so "this one slides and that one does + * not" is only answerable by looking at where the arriving screen *is* part-way through. + * + * The clock is held still and advanced by hand. At a third of the way in, a sliding screen + * is still some distance from its resting place and a fading one is already at it. + */ +@OptIn(ExperimentalTestApi::class) +class NavigationMotionJvmTest { + + private val window = 800 + + @Test + fun `the arriving screen slides when motion is not reduced`() { + assertTrue( + arrivingOffsetMidTransition(reducedMotion = false) > 1.dp.value, + "the second screen was already in place, so nothing slid", + ) + } + + @Test + fun `it arrives in place when the platform asks for reduced motion`() { + // Not "no transition": the screen still fades, which M3 and WCAG 2.3.3 both allow. + // What goes is the movement. + val offset = arrivingOffsetMidTransition(reducedMotion = true) + assertTrue( + offset <= 1.dp.value, + "the second screen was $offset dp from its resting place, so it still slid", + ) + } + + /** + * How far the arriving screen's leading edge is from zero, a third of the way through. + * + * A third rather than a frame or two: the scheme's spatial spec is a spring, and a + * spring's first frames are slow enough that a fade and a slide are hard to tell apart + * there. + */ + private fun arrivingOffsetMidTransition(reducedMotion: Boolean): Float { + var offset = 0f + runDesktopComposeUiTest(width = window, height = 600) { + lateinit var controller: NavHostController + mainClock.autoAdvance = false + + setContent { + controller = rememberNavController() + // Through the theme's parameter rather than by providing the local + // around it. Providing it outside is what this test did first, and + // `TorchTheme` silently overwrote it -- the reduced case read 54dp of + // slide. That is also why `reducedMotion` is a `TorchTheme` parameter + // rather than something the theme only reads from the platform: the + // desktop actual is a hardcoded `false`, and a value nothing can override + // is a value nothing can test either. + TorchTheme(reducedMotion = reducedMotion) { + NavHost( + navController = controller, + startDestination = First, + enterTransition = NavigationMotion.enter(), + exitTransition = NavigationMotion.exit(), + popEnterTransition = NavigationMotion.popEnter(), + popExitTransition = NavigationMotion.popExit(), + ) { + composable { Box(Modifier.fillMaxSize().testTag("first")) } + composable { Box(Modifier.fillMaxSize().testTag("second")) } + } + } + } + + mainClock.advanceTimeBy(16) + // On the main thread, and not through `runOnIdle`: navigation-compose asserts + // the thread, and nothing is idle while the clock is held. + runOnUiThread { controller.navigate(Second) } + mainClock.advanceTimeBy(16) + + // A third of the scheme's default spatial duration. Long enough that a spring + // has visibly moved, short enough that it has not settled. + mainClock.advanceTimeBy(120) + + offset = onNodeWithTag("second").getUnclippedBoundsInRoot().left.value + + // Let the transition finish before the composition is torn down. A back stack + // entry caught mid-transition is still below CREATED, and navigation-compose + // throws trying to move it to DESTROYED. + mainClock.autoAdvance = true + waitForIdle() + } + return offset + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt new file mode 100644 index 00000000..647c55af --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/theme/ReadableContentLayoutJvmTest.kt @@ -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" + } +} diff --git a/docs/README.md b/docs/README.md index 905be8b1..77ff07d0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ silent, or a decision that looked arbitrary and was not. | [long-running-sync.md](./long-running-sync.md) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list | | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | | [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | +| [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme | Start with the ceremony if you are new to this area; the Marmot notes all assume it. Read the skipped-keys note before debugging any "the other device never got it" @@ -27,3 +28,6 @@ chronicle note is a phased plan that has not been built, and reads as the membership note's unanswered half: what a member who joins late can be given, and the one thing they cannot. The jvm-target note is unrelated to all of them: it is a build and packaging story. +The Material Design note is a phased plan that has not been built, and is the +only one about what the app looks like rather than what it does; read the +jvm-target note first if you want to know why its adaptive-layout phase exists. diff --git a/docs/material-design-conformance.md b/docs/material-design-conformance.md new file mode 100644 index 00000000..b8709b3a --- /dev/null +++ b/docs/material-design-conformance.md @@ -0,0 +1,1133 @@ +# Bringing the UI in line with Material Design 3 + +What the M3 foundations actually require, where this app's 43 screens stand +against them today, and a phased order of work that makes each phase mechanical +by the time it starts. + +The app already builds on Material 3 — `compose-material3:1.10.0-alpha05`, a +generated `ColorScheme`, `Scaffold`/`TopAppBar`/`ListItem` throughout. What is +missing is not the library. It is that **the design decisions live at the call +site rather than in the theme**, so there is no single place to change and no +way to check whether a screen conforms. Every phase below moves one class of +decision out of 18,000 lines of screen code and into something a test can read. + +Sources are the current foundations pages on , +read September 2026 — after the May 2026 revision that renamed window size +classes to breakpoints and published the spacing system as tokens. Numbers +quoted here are from those pages, not from memory of older M3. + +## What the spec asks for + +### The seven foundations + +`/foundations` lists seven areas. Each maps onto something concrete in this +codebase: + +| foundation | what it governs | where it lands here | +|---|---|---| +| Accessibility | contrast, target size, labels, focus order, structure | `Icon` labels, `.clickable` targets, colour pairings, keyboard flow on desktop | +| Content design | UX writing, sentence case, alt text, global writing | 334 string literals in composables | +| Customizing Material | brand colour through the role system, dynamic colour | `Color.kt`, `Theme.kt`, `BluePill`/`RedPill` | +| Design tokens | style values named by role, never hardcoded | `10.dp` × 132, `20.dp` × 115 | +| Interaction states | enabled/disabled/hover/focus/press/drag, state layers | `Clickable.kt`, custom `Card` colours | +| Layout | breakpoints, panes, grids, spacing, bidirectionality | one phone layout on three platforms | +| Material A-Z | shared vocabulary | naming in this document | + +### The numbers + +**Breakpoints** (renamed from window size classes; apply to Android and web, +and are the right vocabulary for the desktop target too): + +| breakpoint | width | panes | navigation | +|---|---|---|---| +| Compact | under 600dp | 1 | navigation bar, modal expanded rail | +| Medium | 600–839dp | 1 recommended, 2 possible | collapsed navigation rail | +| Expanded | 840–1199dp | 2 recommended | collapsed or standard expanded rail | +| Large | 1200–1599dp | 2 recommended | standard expanded rail | +| Extra-large | 1600dp+ | up to 3 | standard expanded rail | + +Two rules from the same page are easy to miss and both bite here: *"across all +breakpoints, adjust margins and type styles to keep text between 40–60 +characters per line"*, and *"don't use two panes in medium layouts with high +information density"*. + +**Spacing.** The system is an 8dp scale where `space100 = 8dp`. Material defines +only the recommended stops, including sub-8 nested units: + +| token | dp | | token | dp | +|---|---|---|---|---| +| space0 | 0 | | space250 | 20 | +| space25 | 2 | | space300 | 24 | +| space50 | 4 | | space400 | 32 | +| space75 | 6 | | space450 | 36 | +| space100 | 8 | | space500 | 40 | +| space125 | 10 | | space600 | 48 | +| space150 | 12 | | space700 | 56 | +| space175 | 14 | | space800 | 64 | +| space200 | 16 | | space900 | 72 | + +Spacing has three categories with different rules: **padding** (inside an +element), **gap** (between elements in a container), **margin** (outside an +element). The spec is explicit that margins are a last resort — *"define padding +and gaps on the parent container"*, *"avoid defining margins on child +elements"*. The note that these tokens are Compose-only is in our favour: this +is a Compose app. + +**Targets.** Touch targets at least 48×48dp; pointer targets at least 44×44dp; +targets separated by 8dp or more. An icon may be 24dp while its target is 48dp — +the padding is part of the target, not decoration. + +**Contrast.** Small text at least 4.5:1 against its background; large text +(14pt bold / 18pt regular and up) and graphics at least 3:1. Disabled states are +exempt. Clustered non-text elements — a group of buttons — need 3:1 between +container and background; a standalone element such as a FAB does not. + +**State layers.** A fixed overlay in the content colour: hover 8%, focus 10%, +press 10%, drag 16%, disabled 38%. State layer 40dp, interactive target 48dp. + +### What the May 2026 revision changed + +Worth knowing before reading older guidance or older code: + +- window size class → **breakpoint**, and there are now five, not three; +- responsive design → **adaptive design**; +- the spacing system is published as tokens, on an 8dp scale, Compose-first; +- the **layout scaffold** (bars, rails, panes, rulers) is the recommended + structure, replacing hand-rolled adaptive branches; +- there are now expressive spacing guidelines, and a stated position that + spacing carries product personality rather than being neutral. + +## Where this app stands + +Counts below are over `composeApp/src/commonMain/kotlin/press/mantra/compose/ui` +unless stated. Phase 0 turns them into a script so they can be re-run. + +### The theme is incomplete, and two entry points bypass it + +`Color.kt` defines six schemes — light, dark, and medium/high contrast variants +of each — and `Theme.kt` wires four of them into `lightColorScheme`/ +`darkColorScheme`. That is more than most apps do, and the generated pairs hold +up: every `onX`-on-`X` pair in every scheme clears 4.5:1, the tightest being +`onPrimaryContainer` on `primaryContainer` at 4.61:1 (light) and 4.56:1 (dark). + +Three gaps: + +1. **Twelve roles are never set.** `primaryFixed`, `primaryFixedDim`, + `onPrimaryFixed`, `onPrimaryFixedVariant` and the secondary/tertiary + equivalents are absent from both schemes, so they fall through to + `ColorLightTokens.PrimaryFixed` — `PaletteTokens.Primary90`, which is + `#EADDFF`. Any component reaching for a fixed role paints **Material baseline + lavender** into a monochrome app, in light and dark alike. Nothing uses them + today; the trap springs the first time an expressive component does. + +2. **The medium and high contrast schemes are dead code.** All four are declared + `private val`; `TorchTheme` only ever selects `darkScheme` or `lightScheme`. + There is no plumbing to the platform's contrast setting, so the two schemes + that would honour it are unreachable. The accessibility foundation's first + principle is *honour individuals* — *"supporting varying preferences and + choices"* — and the values to do it are already sitting in the file. + +3. **`AuxTypography` is `Typography()`** — the baseline, in package + `com.example.ui.theme`, in a file otherwise unused. No shapes and no motion + scheme are passed to `MaterialTheme` at all. + +Two entry points render outside the theme: + +- `composeApp/src/jvmMain/kotlin/press/mantra/desktop/Main.kt:126` — + `PassphraseGate` sits in the `else` branch beside `MantraApp`, so it composes + under the **default** `MaterialTheme`. Its `MaterialTheme.colorScheme.error` + and `typography.headlineSmall` are baseline M3, not this app's. It is the + first screen a desktop user sees. +- `Profile.kt:183` and 50 other `@Preview` bodies wrap in `TorchTheme` by hand, + which is correct, but there is no preview that exercises dark, high-contrast + or a wide window — so nothing catches the two problems below. + +### Colour is decided at the call site, and one pairing is invisible + +Eleven sites hardcode a `Color`, and 14 more derive one with `.copy(alpha = …)`. +Measured against the light scheme: + +| site | pairing | ratio | needs | +|---|---|---|---| +| `ProposalListScreen.kt:228` | `ListItem` headline (`onSurface`) on a `Card` of `primaryContainer` | **1.00:1** | 4.5:1 | +| `HomeScreen.kt:113` | `titleContentColor = primary` on `containerColor = primaryContainer` | **1.22:1** | 4.5:1 | +| `ArticleCard.kt:67`, `QuotedNote.kt:101`, `QuotedAddressableNote.kt:62`, `LiveStreamCardContent.kt:42` | `onSurfaceVariant.copy(alpha = 0.5f)` on surface — these four are `CircularProgressIndicator` colours, not text, so the threshold is 3:1 rather than 4.5:1. Still under it. | 2.49:1 | 3:1 | +| `CreateProfileScreen.kt:329` | `Color.DarkGray` on `BluePill` | 2.90:1 | 4.5:1 | +| `ArticleCard.kt:154` | `onSurfaceVariant.copy(alpha = 0.7f)` on surface | 3.96:1 | 4.5:1 | +| `CreateProfileScreen.kt:311` | `Color.White` on `RedPill` (75% alpha over surface) | 3.50:1 | 4.5:1 | +| `LiveStreamCardContent.kt:86` | `Color.White` on `#E53935` | 4.23:1 | 4.5:1 | + +The first is the worst and is worth spelling out, because it is not obvious from +reading the call: + +```kotlin +Card( + onClick = onClick, + colors = if (proposal.awaitsYou) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else { + CardDefaults.cardColors() + } +) { + ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), +``` + +`cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`, +so `LocalContentColor` inside the card is right. But `ListItem` does not read +`LocalContentColor` — its headline colour comes from `ListTokens.ItemLabelTextColor`, +which is `onSurface`, and the call site overrides only `containerColor`. In the +light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. **The +proposals that await your signature are the ones rendered invisible.** + +`BluePill` and `RedPill` are brand colours held as raw `Color` values outside the +role system. The customization foundation's whole argument is that a brand colour +becomes a *source colour* generating a role family — `container`/`on`/ +`onContainer` — so contrast is handled and dynamic colour still works. + +Note what is *not* a finding: `outlineVariant` at 1.61:1 against surface, and +`secondaryContainer` at 1.65:1. M3's own baseline is in the same range, and the +3:1 rule is for clustered interactive containers, not dividers or tonal +surfaces. `onSurface.copy(alpha = 0.38f)` in `TranslationChapterScreen.kt:203` +is the specified disabled opacity and is exempt. + +### Spacing is a habit, not a system + +527 `.dp` literals. 419 land on a defined spacing stop, 19 are dimensions rather +than spacing (a hairline border, an avatar, an image height), and 89 are off-scale: + +``` + 5dp × 10 15dp × 14 18dp × 1 22dp × 1 30dp × 2 + 35dp × 3 50dp × 53 55dp × 2 70dp × 2 75dp × 1 +``` + +The off-scale values are the smaller half of the problem. `10.dp` (132 uses) and +`20.dp` (115) — the two dominant values — *are* on the scale, as `space125` and +`space250`, so a sweep for off-grid numbers would change almost nothing. What is +wrong is that **nothing records which of padding, gap or margin any of these +is**, so there is no way to adapt them per breakpoint or density later, and no +way to tell a deliberate 15dp from a typo. + +`Modifier.height(50.dp)` appears 49 times, almost always as a `Spacer` pushing +an empty or error message down the screen. It is the same three lines copied +into 16 files. + +### Typography uses half the scale, weighted small + +240 `MaterialTheme.typography.*` reads, which is good — only two `fontSize` +literals in the whole tree. The distribution is the problem: + +``` +labelMedium 49 bodySmall 46 bodyLarge 41 bodyMedium 30 labelSmall 28 +labelLarge 13 titleSmall 12 titleMedium 6 titleLarge 4 headlineSmall 4 +headlineMedium 2 headlineLarge 2 displayMedium 1 +``` + +`label*` roles are 92 of 240 uses. Labels are for component text — buttons, +tabs, chips — not for body copy or list content, and they are the smallest and +tightest roles in the scale. Reading a screen where `labelMedium` carries the +prose is the visual equivalent of everything being at the same pitch. Meanwhile +the three `display*` and three `headline*` roles carry 9 uses between them +across 43 screens, so almost nothing establishes hierarchy. + +The pinned material3 ships **30** type roles, not 15 — every role has an +`…Emphasized` variant. Two are used (`labelSmallEmphasized`, +`labelLargeEmphasized`). + +### Targets and labels are mostly right, with a countable set of exceptions + +- 153 `Icon(` calls; 18 pass `contentDescription = null`. Some of those are + correct (a decorative icon beside its own label should be null), but they have + not been triaged. +- 45 `IconButton` and 5 `FilledIconButton` — these enforce 48dp themselves. +- 33 bare `Modifier.clickable`, which does not. Two are text-sized: + `ArticleCard.kt:143` makes an author name clickable, and `LinkPreview.kt:122` + a `Text` with 2dp vertical padding. Both are around 20dp tall. +- `Clickable.kt` — a vendored ACINQ helper, still in package + `com.machankura.compose.ui.composable.widgets.buttons` — defaults to + `internalPadding = PaddingValues(0.dp)` and `RectangleShape`, so every use + starts below the minimum. +- Zero uses of `minimumInteractiveComponentSize()`. + +91 `TextAlign.Center` and 128 `Alignment.CenterHorizontally`. Centring is the +default posture of this UI. The grids-and-spacing page asks the opposite for +lists and content: *"leading elements like thumbnails, avatars, or icons should +always be aligned"*, and the rulers section builds hierarchy from a shared +leading edge. Centred body text also loses the 40–60 character line the +breakpoints page asks for, because there is no ruler to hold it to. + +Bidirectionality is, unexpectedly, in decent shape: 23 uses of +`padding(horizontal =`/`vertical =`, 6 of `start`/`end`, and no `left`/`right` +anywhere. + +### Text cannot be translated + +Two `stringResource` calls. 334 literal strings inside composables — 247 +`text = "…"` and 87 `Text("…")`. `strings.xml` exists but holds Phoenix wallet +strings inherited from the fork, under `app_name` = "Machankura". + +Capitalisation is title case throughout — roughly 45 distinct strings including +`"Edit Profile"`, `"Create Profile"`, `"New Chat"`, `"Sign In"`, `"Leave Group"`, +`"Key Package Management"`, `"Publish New Key Package"`. The style guide is +unambiguous: *"All text, including titles, headings, labels, menu items, +navigation components, app bars, and buttons should use sentence-style +capitalization."* + +The app's top bar reads `"Torch"` while the window title reads `"Mantra"` and +`app_name` reads `"Machankura"`. Three names for one product. + +### There is no feedback surface, and empty states are bare + +Zero `Snackbar`, zero `SnackbarHost`, zero `rememberSnackbarHostState` — across +26 `Scaffold`s. There is one `AlertDialog` in the whole app. Every transient +outcome — a message sent, an invite failing, a key package published — has +nowhere to be reported. + +The error state is `Text("Something went wrong")`, duplicated at 16 sites, and +`Text("No events were found")` at 5. **None of the 16 offers a retry.** No icon, +no explanation, no action. The content design foundation asks for the opposite: +*"emphasize the results of the user's potential action"*, *"tell users what will +happen … and how they can undo it"*. + +Interaction states are the library's defaults, which is mostly correct — ripple, +hover and focus come free with `Button`, `Card`, `ListItem`. But the 33 bare +`.clickable` sites and `Clickable.kt` opt out of the shape and padding that make +a state layer legible, and nothing anywhere handles keyboard focus explicitly, +which the desktop target needs. + +### One layout, three platforms + +Two `BoxWithConstraints` in the entire tree, both inside +`ChatMessageListViewModel.kt`. No `WindowSizeClass`, no +`currentWindowAdaptiveInfo`, no `NavigationSuiteScaffold`, no +`ListDetailPaneScaffold`, no `NavigationBar`, no `NavigationRail`. The desktop +entry point says so itself: + +```kotlin +// The layouts have only ever been exercised at phone widths. This is a starting size +// that does not immediately misrepresent them, not a considered desktop layout. +state = rememberWindowState(width = 480.dp, height = 900.dp), +``` + +Every one of the components needed is present in the pinned material3 — +`NavigationRailKt`, `ShortNavigationBarKt`, `WideNavigationRail`, +`AppBarRowKt`/`AppBarColumnKt`, `FloatingToolbarKt`, `ButtonGroupKt`, +`SplitButtonKt`, `LoadingIndicatorKt`, `MaterialShapesKt`, `MotionSchemeKt`, +`MaterialExpressiveTheme`. What is missing is the adaptive layer +(`material3-adaptive`), which is not declared in `libs.versions.toml`. + +Insets are thin but not absent: `enableEdgeToEdge()` is called in +`MainActivity.kt:22`, and `Scaffold` consumes `ScaffoldDefaults.contentWindowInsets` +by default, so most screens are covered. Nine explicit inset uses exist — +`imePadding` appears once, in `SovereignWalletStartupScreen.kt`, though nine +screens and a dialog carry text fields. + +### Nothing moves + +One animation API in use, `animateScrollToPage`, twice. No `AnimatedVisibility`, +no `AnimatedContent`, no `Crossfade`, no `MotionScheme`, and no +`enterTransition`/`exitTransition` on any of the 43 navigation routes. Every +state change in the app is a hard cut. + +### Summary + +| area | state | phase | +|---|---|---| +| Theme completeness | 12 roles unset, 4 schemes unreachable, no shapes/motion | 1 | +| Colour at call sites | 7 pairings under threshold, one at 1.00:1 | 1, 3 | +| Spacing | 527 literals, no role recorded | 2 | +| Typography | 90/240 uses on `label*`, 2/30 roles emphasized | 1 | +| Targets & labels | 33 unguarded `.clickable`, 18 untriaged nulls | 3 | +| Content | 334 literals, title case throughout | 4 | +| Feedback | 0 snackbars, 16 duplicated error states, 0 retries | 5 | +| Adaptive | 1 layout, 5 breakpoints | 6 | +| Motion | 1 API, 0 transitions | 7 | + +## The phases + +Each phase lands on its own and leaves the app shippable. The order is chosen so +that each phase makes the next one mechanical rather than judgemental: tokens +before the call sites that consume them, the accessibility floor before the +adaptive work that would otherwise double the surface to fix, guard rails last +so they lock in real state rather than aspiration. + +--- + +### Phase 0 — a baseline that can be re-measured + +**Why first.** Every count in this document was produced by hand. If they cannot +be regenerated, the phases below have no acceptance criteria — only opinions. + +**Built.** + +1. **`docs/scripts/m3-audit.sh`** regenerates every count in "Where this app + stands": the dp histogram split three ways, the typography role + distribution, hardcoded colour sites, `.clickable` sites, + `contentDescription = null`, string literals, snackbars, adaptive APIs. Each + number that a phase is meant to move carries a **budget** at the top of the + file, and `--check` exits 1 when one is exceeded. Budgets ratchet down in the + same commit that earns the reduction; Phase 8 wires `--check` into CI, at + which point raising one is the thing a reviewer looks for. + + Two counts it reports separately, because conflating them would overstate the + problem: the twelve `*Fixed*` roles that fall through to Material baseline + lavender, and `surfaceTint`, which is also unassigned but defaults to + `primary` and is therefore correct. The `.dp` histogram likewise splits + dimensions (a hairline border, an avatar) out of the off-scale count. + +2. **`ColorSchemeContrastTest`** in `commonTest` — WCAG relative luminance from + first principles, no Compose runtime, four assertions over all six schemes: + every content role on its container at 4.5:1, `onSurface` on each of the + seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on + at 3:1, and `primary`/`error` containers against `surface` at 3:1. 4 tests, + green. + + It walks the real `ColorScheme` objects, which is why `Theme.kt`'s six + schemes moved from `private` to `internal`: rebuilding them in the test from + `Color.kt` would assert the palette and miss the wiring, and a role pointed at + its neighbour's value is exactly the slip that reads fine in review. + + Verified to bite. Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` + — a plausible "soften the secondary text" edit — fails it with + `light: onSurfaceVariant on surfaceVariant is 2.29:1`. + +**Deliberately not asserted:** monotonicity across the contrast ladder. The +obvious invariant — high-contrast beats medium beats default, for every pair — +is false, and correctly so: ten pairs move the other way because a high-contrast +scheme darkens `surfaceContainerHighest` to separate it from `surface`, trading +ratio against `onSurface` for the separation that matters. The floor is the +invariant; the ladder is not. + +**Risk:** none to the app. The only production change is a visibility keyword. + +--- + +### Phase 1 — one theme, complete + +**Why here.** Six of the nine problem areas are call sites reaching past a theme +that has nothing to offer them. Fill the theme and most later phases become +find-and-replace. + +**Work.** + +1. **Regenerate the scheme with all 49 roles.** Feed the existing source colours + through Material Theme Builder and take the full export — the twelve + `*Fixed*` roles and `surfaceTint` included. This removes the latent lavender. + Keep the existing hex values for the roles already defined so nothing shifts + visually; this phase adds, it does not restyle. + +2. **Reach the contrast schemes.** *Built.* `TorchTheme` takes a `ThemeContrast` + defaulted from a new `platformThemeContrast()` expect/actual, and the six-way + selection table lives in common code as `appColorScheme`. The old + `themeColorScheme` expect took four arguments and did both jobs; it splits into + `platformThemeContrast()` and `dynamicColorScheme()`, each answering one narrow + question, so the scheme table is in one place rather than three. + + Android reads `UiModeManager.getContrast()` (API 34+) and registers a + `ContrastChangeListener`, because a contrast change does not restart the + activity or arrive as a `Configuration` update — without the listener the new + setting would wait for the next cold start, which is the case the setting + exists for. iOS reads `UIAccessibilityDarkerSystemColorsEnabled` and observes + `…StatusDidChangeNotification`; it is a boolean, so iOS never reports Medium. + Desktop reads Windows' `win.highContrast.on` AWT desktop property and answers + Standard on linux and macos, which is honest rather than complete — see + "What this plan does not cover". + + Verified on an API 36 emulator with dynamic colour off: the `onPrimaryContainer` + pixel of the "Skip for now" label reads `#848484` → `#A7A7A7` → `#D0D0D0` as the + setting moves, the three declared values exactly, **without the app restarting**. + +3. **Give the theme its other three slots.** *Built.* The expressive-vs-standard + question was put to the product owner on 2026-09-08 and answered + **expressive**, so `TorchTheme` calls `MaterialExpressiveTheme` with all four + slots passed explicitly — colour, `MotionScheme.expressive()`, `MantraShapes`, + `AuxTypography`. Explicitly, because `MaterialExpressiveTheme` would otherwise + default them to `expressiveLightColorScheme()` and friends, which is Material's + palette rather than this app's: the same class of accident as the twelve + unassigned fixed roles. + + No visual change on the screens checked. The "Invite a Friend" button measures + byte-identical before and after — same fill, same 357×… box — because the + expressive default for a `Button` at default size matches the baseline in this + version. What expressive buys is elsewhere: `LocalUsingExpressiveTheme`, the + three increased shape steps, the fifteen `…Emphasized` type roles, and the + components later phases need (button groups, split buttons, floating toolbars, + wide navigation rails, `LoadingIndicator`). + + `MantraShapes` is baseline `Shapes()`, and `Shape.kt` records why: the corners + hand-written across the tree already land on the scale — 4dp ×3 = `extraSmall`, + 12dp ×11 = `medium`, 16dp ×2 = `large`, and one 30dp that drifted two units off + `extraLarge`. Nothing needs restyling; seventeen literals need migrating, later. + +4. **Move and fill typography.** `Type.kt` moves from `com.example.ui.theme` to + `press.mantra.compose.ui.theme`. It stays baseline-derived, but it becomes a + real file with a stated font stack and a comment recording which roles carry + what: `display*`/`headline*` for screen identity, `title*` for section and + card headers, `body*` for prose, `label*` for component text only. + +5. **Brand colours become roles.** `BluePill` and `RedPill` leave `Color.kt` as + raw values and enter the scheme as extended colour families with their own + `container`/`on`/`onContainer`. `CreateProfileScreen.kt:311` and `:329` then + stop pairing them with `Color.White`/`Color.DarkGray` by eye. + +6. **Wrap the desktop gate.** *Built.* `Main.kt` moves `TorchTheme` outside the + `unlocked` branch, so `PassphraseGate` — the first screen a desktop user sees — + stops composing under the default `MaterialTheme`. `MantraApp` keeps its own + `TorchTheme`, so the unlocked branch is wrapped twice: android and ios enter + through `MantraApp` and would lose the theme entirely if it moved out, and a + second application of the same values costs one `CompositionLocalProvider`. + + `Type.kt` also left `com.example.ui.theme` for `press.mantra.compose.ui.theme`, + which removes one of the three package namespaces the UI was living across. + +**Done when** every role in `ColorScheme` is explicitly assigned in both +schemes; the contrast test still passes and now covers six schemes; `Type.kt` +lives under `press.mantra`; no composable in the tree renders under a default +`MaterialTheme`. + +**Risk:** medium. Adding the fixed roles cannot regress anything (nothing reads +them), but switching to `MaterialExpressiveTheme` changes default component +shapes and sizes app-wide. Land it as its own commit so it can be reverted +alone. + +--- + +### Phase 2 — spacing becomes a token + +**Why here.** Phase 6 has to adapt spacing per breakpoint. It cannot adapt 527 literals. + +**Built.** Three commits. + +1. **The scale**, `Spacing.kt` — M3's eighteen stops on a composition local, with eight + semantic names over them (`screenMargin`, `containerPadding`, `compactPadding`, + `relatedGap`, `itemGap`, `sectionGap`, `emphasisGap`, `targetGap`) split along the + spec's padding / gap / margin distinction, with exactly one margin. A `data class` + rather than constants so that a wider instance at a larger breakpoint moves every + value, including the semantic ones — asserted, because a semantic name holding a + literal would stay behind and the layout would half-adapt. + + Reached through `MaterialTheme.spacing.containerPadding`, matching + `MaterialTheme.colorScheme.primary`. `LocalSpacing.current` would have to be read into + a local first and so could not appear inline in a `Modifier` chain; over 500 call sites + that difference is what decides whether the scale gets used. + +2. **The 89 off-grid values**, to their nearest stop — 5→4, 15→16, 30→32, 50→48. Largest + move 2dp. Plus the one hand-written corner that was off the *shape* scale, 30dp against + `extraLarge`'s 28, which is the drift a scale exists to stop. + +3. **The remaining 353**, shape-aware: `padding + 8dp → compactPadding`, + `padding + 16dp → containerPadding`, `gap + 4dp → relatedGap`, `gap + 8dp → itemGap`, + everything else to the raw stop. 38 of 353 take a semantic name; the rest do not, + because assigning one needs somebody to have read what the container is, and a name + asserting a meaning the code lacks is worse than a stop asserting none. + +**The audit was measuring the wrong thing, and this is where it showed.** It split +literals by *value* against an exemption list, and the split is not a property of the +value: 16dp is a spacing stop and a plausible icon size, and 50dp was a `Spacer` height in +52 places and a divider width in one. `docs/scripts/m3-spacing-positions.py` classifies by +**call shape** instead — brace-matching `padding(…)`, `PaddingValues(…)`, +`Arrangement.spacedBy(…)`, and a `.height()`/`.width()` whose enclosing call is `Spacer(` +— and `docs/scripts/m3-migrate-spacing.py` rewrites using the same parse, so the audit and +the migration agree by construction. + +One value is exempt and says so at the site, through an inline `// m3-spacing-exempt: +` comment the classifier honours: a 128dp spacer reserving room to scroll the last +wallet clear of the window. Exemptions belong at the call site rather than in a list of +numbers in the tool. + +**Done:** spacing positions 527 → **0**, 431 token reads, one reasoned exemption. +Verified on-device that nothing moved — the landing screen differs in 47 of 162,000 +sampled pixels, 0.03%, all of them the status bar clock. + +**Left for later:** 76 `.dp` in dimension positions — avatar sizes at 35/55/70/75dp, icon +sizes at 18/22dp. Sizing is a per-component question, and the plan puts component specs +after the adaptive phase. They are reported rather than exempted so the number stays +visible. + +--- + +### Phase 3 — the accessibility floor + +**Why here.** These are defects, not polish, and one of them hides the app's most +important rows. It goes before the adaptive work so the fixes are made once rather than +per breakpoint. + +**Built.** Four commits. + +1. **Nine contrast failures**, all of them colour reached for at the call site rather + than derived from what it sits on. The worst was `ProposalListScreen`: a `ListItem` + inside a `Card` with only the container overridden, so the headline took + `ListTokens.ItemLabelTextColor` (`onSurface`) against `primaryContainer` — both + `#1B1B1B` in the light scheme, **1.00:1**, on exactly the proposals awaiting your + signature. Four of five colours on that card were under the floor. + + Also `HomeScreen`'s top bar at 1.22:1 (override removed), five + `onSurfaceVariant.copy(alpha = …)` sites, the LIVE badge's hand-mixed red, and the + avatar picker's selection tint. Three colours stay hardcoded behind a + `// m3-color-exempt:` marker with a reason at the site: a QR code's modules, a + control over an arbitrary photograph, a spinner over a blurhash. + +2. **Every `.clickable` chain got `minimumInteractiveComponentSize()`** — a no-op above + 48dp, so applying it everywhere makes the rule checkable rather than measurable. Three + of the nineteen were text-sized. `Clickable.kt` got it built in and left + `com.machankura`. + +3. **Eighteen `contentDescription = null` became a decision.** Fifteen say `Decorative` + — same null, but recording that somebody looked. Three carried state the text did not + repeat and got real descriptions, including `ProposalListScreen`'s stage icon, which + the previous commit had made the *only* cue for a failed proposal on the highlighted + card. + +4. **Eight text-field screens lift above the keyboard.** `Scaffold`'s + `contentWindowInsets` is `systemBars` and excludes the ime, so a Scaffold alone does + nothing about it. `ChatRoomMessagingScreen` is deliberately excluded — its composer + already reserves the bottom inset by hand, and combining the two needs a device. + +5. **Initial focus** on the npub dialog's field and the desktop passphrase field — M3's + flow rule that a dialog takes focus on open, and that every screen defines one. Those + are the two places where a single field *is* the screen; auto-focusing elsewhere would + pop the keyboard over content somebody wants to read first. + +**Font scale verified, not assumed.** A static pass found no fixed-height container +holding text — all 23 fixed vertical dimensions are icons, images and progress +indicators. Then at 200% text size on an API 36 emulator: onboarding, the message list +and a chat room all reflow without clipping, system messages wrapping to two lines with +their timestamps and chevrons still aligned. + +**Left undone, and why.** Full keyboard traversal on desktop — tab order across the four +`ModalBottomSheet`s and the `AlertDialog`, and focus returning to what opened them — is +not verified. Compose restores focus on dismissal by default, so the gap is evidence +rather than known breakage, and checking it means driving the desktop build by keyboard. +The avatar picker's selected state is `secondaryContainer` at 1.65:1 against the surface, +which M3 accepts only because its own selected states carry a second cue; this grid has +neither an outline nor a checkmark, and adding one is component work. + +--- + +### Phase 4 — text the system can translate + +**Why here.** It is the last phase that touches every file, and doing it after phase 2 +means one pass over each file instead of two. It must precede phase 6: RTL is a breakpoint +concern too, and there is no point testing a mirrored layout against 334 English literals. + +**Built.** Three commits. + +1. **Sentence case, 100 occurrences across 60 strings.** Two passes, and the second is + the instructive one: the first pattern required every word after the first to be + capitalised, so anything with an article survived — "Invite a Friend" was still on the + app's first screen after the audit reported zero — and it read one line at a time, so a + `Text(` whose literal sat on the next line was invisible. + + Sample data is deliberately left in title case: "Steve Biko", "To Kill a Mockingbird". + Those are a person and a book. + +2. **251 plain strings into the catalogue**, 315 call sites. The extractor took four + attempts and each failure is recorded in `docs/scripts/m3-extract-strings.py`: a bare + `text = "…"` is not a Compose string (it rewrote a data class), a regex over quote + pairs is not a Kotlin lexer (it lifted `"chunk"` out of `"${if (n == 1) "chunk" …}"`), + and a fragment of a `+` concatenation is not a translatable unit. + +3. **40 interpolated strings as format strings**, 49 call sites — `${expr}` to `%1$s`, + passed as arguments. + +**Compose Resources is not aapt, and a test caught that before a device did.** The first +extraction escaped apostrophes as `\'` and doubled `%`, which is what android's resource +compiler requires. Compose Resources does neither: `getString` returned `Don\'t sign`, +backslash included, across 30-odd strings. Escape handling is *partial* rather than +absent — `\n` **is** processed — so there is no family rule to lean on. +`StringCatalogueJvmTest` asserts each escape it depends on. + +**Done:** literals in composables 334 → 39, `stringResource` 0 → 424, title case 0. Plus +1101 inherited phoenix strings deleted (nothing referenced them), the product settled on +one name, and the two destructive actions now state their consequences — read out of the +repository rather than guessed, because "Delete group" with no qualifier invites the +belief that the messages are gone from the relays, which is the opposite of true. + +**Left for later, and why it is not a script's job.** 83 literals that are terms of a `+` +concatenation. Reassembling `"a " + x + " b"` into one format string means deciding what +the whole sentence is, and several of them are pluralisations — +`(if (n == 2) "event" else "events")` — which want a real plural resource rather than a +format argument. `m3-extract-formatted.py --remaining` lists them. + +`UserAgent.APP_NAME` still says "Torch". It goes on the wire to relay operators, so it is +a network identity question rather than a content one. + +--- + +### Phase 5 — every screen has four states + +**Why here.** It needs the tokens from phase 2 and the strings from phase 4, and it +produces the components phase 6 will lay out. + +**Built.** Two commits. + +1. **`ErrorState` and `EmptyState`** replace 21 hand-copied blocks — 16 saying "Something + went wrong", five saying "No events were found", **none of the sixteen with a retry**. + `EmptyState`'s message is required with no default, because that one sentence was shown + for five different absences and a shared default would have preserved exactly that. + +2. **A snackbar host**, where there had been none across 43 `Scaffold`s. On a composition + local rather than a parameter, because a view model coroutine reporting an outcome sits + several composables below the `Scaffold` that owns the host. It throws rather than + defaulting to a detached state: a default would make `notify(…)` a silent no-op on any + screen that forgot the host, which is the failure the file exists to end. Wired to + `publishNewKeyPackage` and `rotateKeyPackage`, both of which were fire-and-forget, and + verified on a device. + +3. **`LoadingDataIndicator` draws `LoadingIndicator`** — the expressive component for an + indeterminate wait — instead of a `CircularProgressIndicator` hardcoded to 80dp in the + brand gold, which read as a warning rather than as a wait. One wrapper, 41 call sites. + +4. **The profile screen's hierarchy.** Seven actions in one list, five of them + `TextButton`s and two filled `Button`s — M3's highest emphasis, meant for one action per + screen. One of the two was **Sign out**: the most prominent control on the screen given + to its most destructive action. Sharing is now `FilledTonalButton`; signing out is a + `TextButton` in the error colour, matching how leaving and deleting a group are already + treated elsewhere in this app. + +**The plan was wrong about disabled FABs.** It said five screens should pass `enabled` and +let the component apply the 38% state layer. No `FloatingActionButton` overload in +material3 1.10 takes `enabled` — the spec's position is that an unavailable FAB should not +appear at all — so hand-computing the colours is the only way to show one, and the existing +code already pairs it with `Modifier.semantics { disabled() }` so a screen reader does not +announce a button it is happy to press. Left alone. + +**Left for a person.** Eight more screens have two or more filled buttons competing: +LandingScreen's "Sign in" beside "Create profile", SocialPreconditionScreen's "Invite a +friend" beside "View invites", and six others. Which of a pair is primary is a product +decision about what the screen is for, not something to infer from the source, and getting +it wrong quietly weights a choice the user is supposed to make freely. + +--- + +### Phase 6 — layouts that survive a wide window + +**Why here.** It is the largest phase and the only one that cannot be done +mechanically. Everything above reduces its surface: tokenised spacing can be +swapped per breakpoint, and the states from Phase 5 are what fills a second +pane. + +**Built.** Six commits, in the order the dependencies fall rather than the order +listed above: the view model split first because the pane work needs it, the +measure before the panes because it decides what a pane holds, navigation and +panes last because both were product decisions. + +1. **The dependency question, settled.** `material3-adaptive` publishes + multiplatform under `org.jetbrains.compose.material3.adaptive`, with android, + desktop and ios variants — the ios ones carry `ios_arm64` and + `ios_simulator_arm64` attributes despite their `uikit*` artifact names, so the + targets declared on a mac resolve. Version **1.2.0**, not the newer + 1.3.0-beta02, because it is what the pinned material3 itself resolves: + `material3-adaptive-navigation-suite:1.10.0-alpha05` names `adaptive:1.2.0` in + its pom, and 1.3.0 would pull window-core 1.5.0 in beside the 1.4.0 the pinned + material3 compiled against. Nothing is lost by staying — 1.2.0 already computes + the large and extra-large breakpoints through `supportLargeAndXLargeWidth`. + + So the library scaffolds were available. Step 3 uses one and step 4 does not; + see below. + +2. **`Breakpoint`**, the five-value enum with `ofWidth` as a pure function so the + thresholds are assertable without a Compose runtime. `TorchTheme` classifies + once and provides `LocalBreakpoint`, so no two screens can disagree about the + window they are both in. + + It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`, + because the latter also computes a `Posture` from the platform's fold state — + on android, `WindowInfoTracker` and an activity. This call sits in `TorchTheme`, + which wraps all 51 `@Preview` bodies in the tree, and a preview context is not + an activity. + + **Spacing adapts, and exactly one value moves.** M3 publishes a margin per + breakpoint — 16dp compact, 24dp wider — and publishes nothing else that varies + with window width. The scale itself is absolute: `space200` is 16dp on a phone + and 16dp on a desktop, and what adapts is which token a job reaches for. So + `screenMargin` goes 16 → 24 at medium and holds; `containerPadding`, `itemGap` + and the rest do not move. A test asserts the non-movement, because "make it + breathe on a big screen" is the edit a reviewer waves through. + + A test found a real defect while being written: `ofWidth` threw below 0dp, and + a desktop window reports a zero size for the frame before its first layout pass. + +3. **The chat transcript left the view model.** `ChatMessageListViewModel` was + 1,113 lines, of which 380 were a `@Composable` member holding a `LazyColumn`, a + `DropdownMenu` and both of the app's only two `BoxWithConstraints`. It is now + 356 lines of state, and `ChatTranscript.kt` is 779 of layout. The move is + verbatim — the three helper composables are byte-identical, and the transcript + differs by its signature and fourteen references that had been resolving + against the enclosing class. + +4. **A readable measure on all 49 screen roots.** `readableContentWidth()` is + `bodyLarge`'s size through the current density, times half an em per character, + times sixty: 480dp at the default text size. Derived rather than written down, + because a hardcoded `480.dp` holds thirty characters at 200% text scale — + silently, since the text still fits. Only the ceiling is enforced: a 400dp + window less its margins holds about 46 characters, and no cap can add any. + + The *column* is centred; the text is not. Those are opposite things, and a + centred column still has one straight leading edge for every row, avatar and + icon to align to. + + Of the 91 `TextAlign.Center` uses, **84 are right** and were left. Centring is + correct for a block that is the only thing on a screen — an empty state, a + loading message, an onboarding status screen, a placeholder, a hero. Seven + were wrong and shared one shape: text in a column *beside a full-width + element*, so there was a leading edge and it was being ignored — four helper + lines under `fillMaxWidth()` text fields, one above three full-width cards, and + a confirmation list where "Name" and the name below it each floated at their + own width. + +5. **A navigation component, and an IA decision to make one possible.** The app + had none: 43 screens reached by route, and one home screen whose app bar + carried the only two peer surfaces. With a single top-level destination a + navigation bar would hold one item and be strictly worse than the app bar it + replaced, so the question — promote search and profile to peer destinations, or + record the finding and wait — was put to the product owner on 2026-09-08 and + answered **promote**. `HomeScreen`'s app bar now carries a title and nothing + else: two routes to one destination is what M3's "swap only functionally + equivalent components" caution is about. + + Compact takes a navigation bar, medium and expanded a collapsed rail, large and + extra-large an expanded rail. `NavigationSuiteScaffoldDefaults.navigationSuiteType` + is not used, and the difference is the last row — it stops at the collapsed rail, + because it classifies with the three-value window size class rather than the five + breakpoints. `NavigationSuiteType.None` on every other screen. + + Two things the wiring needed. `ActiveProfileRoute` is addressed by metadata event + id rather than by public key, and only the home screen ever had one, so the nav + host observes it and the profile item is *disabled* until it arrives rather than + absent. And the item click pops to `HomeRoute`, not to the graph's start + destination: the android docs give the second shape and it would be wrong here, + because this graph starts at `LoadingRoute` and onboarding clears the stack on + its way to home. + +6. **The chat list beside the conversation, from expanded up.** Below that it is + one pane, and that is the spec rather than caution: the breakpoints page says no + two dense panes in a medium window, and `calculatePaneScaffoldDirective` says the + same in code — `maxHorizontalPartitions = 1` for compact and medium alike. + + `ListDetailPaneScaffold` was available and was **not** used. It earns its API + surface — a navigator, a destination history, an `AnimatedPane` per pane, three + experimental opt-ins — by owning the single-pane case as well, showing the detail + *instead of* the list on a phone. This app cannot hand it that: + `ChatRoomMessagingRoute` is navigated to from eleven places, so the conversation + stays a pushed destination whatever the window is doing, and a scaffold + permanently in its two-pane state would be a `Row` with more words. Its *numbers* + are taken instead — 360dp of list at expanded, 412dp from large up, 24dp between + — so a hand-built pair measures the same as the scaffold would. + + The floating action button moves into the list pane when there are two, because + the `Scaffold`'s slot is the bottom-right of the window, which with two panes is + on top of the transcript's send button. + +7. **The desktop window opens at 1100×800** — inside the expanded breakpoint, the + narrowest window M3 recommends two panes in — with a 400×600 minimum it never + had. The comment apologising for the old 480dp size is gone because it has + stopped being true. + +**Verified by measuring compositions, not by reading code.** This phase added +`compose.desktop.uiTestJUnit4` to `jvmTest`, pinned to the same 1.11.1 as the rest +of Compose Multiplatform, and `runDesktopComposeUiTest(width = …)` gives a window +that genuinely is that many pixels across at density 1. Thirteen tests run at the +five widths the phase names: + +- the measure caps at 480dp and centres at 1400, and is a no-op at 400. Swapping + the last two modifiers in `readableContent()` reports `Actual width is 1400.0.dp, + expected 480.0.dp` — the "centred but never capped" failure, which no phone-width + preview would show; +- `currentBreakpoint()` answers Compact / Medium / Expanded / Large / ExtraLarge at + 400 / 700 / 1000 / 1400 / 1800, with the screen margin following. A version + measuring the parent's constraints rather than the window would answer `Compact` + everywhere and pass every unit test in the suite; +- the navigation component appears on the three top-level destinations, disappears + under a pushed route, and marks the right item selected; +- the chat panes split at 1000 and 1400 and do not at 400 or 700, with the list + pane exactly 360dp and 412dp. + +**One test could not be written, and the reason is recorded at the site.** A click +handler that navigates trips navigation-compose's own main-thread assertion under +`runDesktopComposeUiTest` — reproducible in twenty lines containing no app code, a +`NavHost`, two routes and a `TextButton`. What an item's `onClick` builds is +asserted where it is a pure function instead. + +**The audit grew a floor.** Every other budget in `m3-audit.sh` is a ceiling that +ratchets down; the adaptive work is the one thing in this document that a later +edit removes rather than adds — a screen that stops reading the breakpoint still +compiles and still renders — so `--check` now also fails when the adaptive API and +navigation component counts drop *below* their floors. + +**Left for a person, and for a later commit.** + +Proposals → signing and artifacts → chapters are the two remaining list-detail +families the plan names. Chat was done first and alone, deliberately: it is the +one the spec is most careful about, and it is the one whose eleven entry points +made the shape of the answer clear. The other two are the same shape with +different content. + +"Every screen renders correctly at five widths" is verified structurally rather +than screen by screen — the measure is applied at all 49 roots and asserted at +those widths, and every screen compiles under it. What a person still has to look +at is whether a 480dp column of a *particular* screen reads well, which is a +judgement no assertion makes. + +**Risk:** high, and realised in one place — the navigation bar changes what every +screen's app bar offers. It is its own commit and reverts alone. + +--- + +### Phase 7 — motion + +**Why last of the build phases.** Motion describes relationships between +layouts. Animating the current layouts and then changing them in Phase 6 is +work done twice. + +**Built.** Two commits. + +1. **Navigation transitions on all 43 routes at once**, from the theme's + `MotionScheme` rather than from a literal. The plan expected a hard cut and + found something else: navigation-compose's default on android and desktop is + `fadeIn(tween(700))` / `fadeOut(tween(700))`, written into the library's own + internals. Both halves are worth changing — 700ms is roughly three times M3's + duration for a full-screen change, and a literal inside a dependency is not a + decision this app made. + + The shape is M3's shared axis: the arriving screen slides in from the trailing + edge while the leaving one slides out toward the leading edge, both fading, and + going back mirrors it so the direction of travel is legible. + `slideIntoContainer` is layout-direction aware, so an RTL locale gets the + mirror for free. + + **The plan named an API an app cannot reach.** `MotionSchemeKeyTokens` is + `internal` to material3, so the tokens are not addressable by name from outside. + `MaterialTheme.motionScheme` is the public surface and offers the same six + specs; two private helpers name which of them this app uses for what — spatial + for the slide, effects for the fade, which is the distinction the scheme draws. + +2. **`ScreenStateTransition` on 20 screens**, M3's fade-through between a screen's + loading, error, empty and loaded states. The outgoing state fades out, the + incoming one fades in and grows the last 8% into place, with `SizeTransform` + off so a tall loaded state does not stretch a short spinner on its way in. + + **The content key is the state's class, not the state**, and that is the half + that is easy to get wrong and impossible to see. Keyed on the value, a screen + re-runs the whole fade every time its loaded data changes — a message arriving, + a list growing by one — so it flickers whenever anything happens, and every + screenshot looks perfect. + + Not applied to 15 other `when`s, by a mechanical rule: `AnimatedContent` is a + layout node, so it can only wrap a `when` that is a composable's whole body. + Where the `when` sits inside a `Column` whose branches use `Modifier.weight` — + the sign-in and create-profile flows, frost signing, the proposal list, the two + feed detail widgets, the four render helpers still on view models — wrapping it + would take those branches out of `ColumnScope`. + +**Reduced motion, in the shape phase 1 established.** `platformReducedMotion()` is +an expect/actual beside `platformThemeContrast()`, observed rather than read once. +It does not mean *no* transition: the screen still fades and what goes is the +movement, which is what M3 and WCAG 2.3.3 are both about. + +- **Android has no "reduce motion" switch.** It has **Remove animations**, which + sets the animation duration scales to zero — and the platform applies that scale + to `ValueAnimator` and **not to Compose**, which runs on its own clock and + ignores it entirely. An app that draws its own transitions has to read the + setting itself. A `ContentObserver` on `ANIMATOR_DURATION_SCALE` catches changes + without a restart. +- **iOS** is the one platform where it is a single documented call, + `UIAccessibilityIsReduceMotionEnabled`, with the same notification shape as the + darker-system-colours one already observed there. +- **Desktop answers `false`**, and says at the site why that is honest rather than + a stub: Windows, macos and the freedesktop desktops each have the setting and + none of the three reaches AWT. The same wall `platformThemeContrast` hits on + linux and macos, and the same eventual answer — a preference with the platform + as its default. + +**Verified by holding the clock still**, because neither claim can be asserted on +a value. `EnterTransition` has no public shape to inspect, so "this one slides and +that one does not" is measured: navigate, advance a third of the way, and read +where the arriving screen is — 54dp from home when sliding, already there when +reduced. And a crossfade is told from a cut by the one frame in which *both* +states are composed. + +**A test found a design flaw rather than a bug.** The reduced case read 54dp of +slide at first, because the test provided `LocalReducedMotion` around `TorchTheme` +and the theme overwrote it. The fix is not in the test: `reducedMotion` is now a +`TorchTheme` parameter defaulted to the platform, exactly as `contrast` is, +because a value nothing can override is a value nothing can test — and because the +desktop actual is a hardcoded `false` that a settings screen will need to override +anyway. + +**Left undone.** The container transform between a list item and its detail +screen, which the plan names as the transition carrying the most meaning. It is +`SharedTransitionLayout` work and it wants the pane split to settle first: on a +wide window the detail is already beside the list, so there is no container to +transform, and the animation only applies below the expanded breakpoint. Doing it +now would mean writing it twice. + +**Risk:** low. Visible, easily tuned, easily reverted. + +--- + +### Phase 8 — guard rails + +**Why last.** These lock in what the previous phases achieved. Written earlier, +they would only fail. + +**Built.** Three commits over the four items: the second needed nothing, and +finding that out is most of what it was worth. + +1. **`:composeApp:m3Audit`, wired into `check`.** The audit has existed since + phase 0 and has been run by hand at the end of every phase since, which is the + arrangement it was written to end: a budget nobody checks at the moment the + number moves is a number that drifts. It shells out to + `docs/scripts/m3-audit.sh --check` and fails the build on a budget exceeded or + a floor undercut, declares the script and the ui source tree as inputs so it is + up-to-date-able, and skips loudly rather than failing where there is no bash. + + Verified to bite: one `Color(0xFFAABBCC)` added to `LoadingScreen.kt` reports + `hardcoded Color outside theme/ 1 over budget 0` and takes the build with it. + + **A Gitea Actions workflow** beside it, since the remote is a Gitea 1.25 + instance. Two jobs on purpose: `budgets` is grep over the source tree with no + gradle, no SDK, no submodules and no network — which is why the audit is a + shell script rather than a gradle plugin — and `tests` needs the whole + composite chain and a cold cross-compile of secp256k1, so it is split out for a + runner that has the capacity. **The workflow is unverified**: this repository + has had no CI of any kind, so there is no runner to try it against. The gradle + task is the half that is proven, and it is the half that runs on every + developer machine regardless. + +2. **The contrast test was already there.** Phases 0 and 3 built it out to ten + assertions over all six schemes — every content role on its container, every + tonal surface, the twelve fixed roles, the extended brand families, the + composited translucent containers, `outline` at 3:1 — and it runs in + `:composeApp:jvmTest`, which is now a CI job. Nothing was added, and the reason + is recorded in the test itself: a call site that pairs two roles the scheme + already covers produces a pairing the first assertion already walks, so + restating it per site would double the maintenance and catch nothing. + +3. **`@ConformancePreviews` on all 53 previews.** Every one of them rendered a + light theme at 100% text at whatever width the pane happened to be — the only + condition under which this app has never had a defect. They now render under + five: light, dark, 200% text, compact 400dp, expanded 1000dp. Each of the four + new ones is where a defect has actually been. It also gives phase 6 the check + it could not make: two of its five acceptance widths are now one click away on + every screen. + + High contrast is deliberately not in the annotation. Contrast is a property of + the scheme rather than of a screen, `ColorSchemeContrastTest` measures every + pair in all six, and there is no `@Preview` parameter for it — it needs + `TorchTheme(contrast = …)` in the body. `ThemeGallery` covers the six once, + over components rather than screens, with `dynamicColor = false` because an + android 12+ preview would otherwise paint all six columns from the wallpaper. + It is the only place the medium and high contrast schemes can be seen at all. + +4. **`CLAUDE.md`**, which this repository did not have. Seven rules, each with the + shape to copy, the shape not to, and the budget the audit holds it to. Where a + rule has a trap that has already caught somebody, the trap is named rather than + the rule restated — `.copy(alpha = …)` on a content role is how nine contrast + failures got in, Compose Resources unescapes `\n` but not `\'`, + `AnimatedContent` takes a `when`'s branches out of `ColumnScope`, + `MotionSchemeKeyTokens` is internal. + +**Risk:** none to the app; some friction for contributors, which is the point. + +--- + +## Where this leaves the app + +All nine phases are built. The counts the audit was written to move, from the +state recorded in "Where this app stands" to the state `docs/scripts/m3-audit.sh` +reports today: + +| area | was | is | budget | +|---|---|---|---| +| roles falling to the baseline palette | 12 | 0 | 0 | +| hardcoded `Color` outside `theme/` | 11 | 0 | 0 | +| dp literals in spacing positions | 527 | 0 | 0 | +| `.clickable` with no minimum target | 33 | 0 | 0 | +| untriaged `contentDescription = null` | 18 | 0 | 0 | +| title case in UI strings | ~45 | 0 | 0 | +| string literals in composables | 334 | 39 | reported | +| `stringResource` call sites | 2 | 422 | — | +| snackbar hosts | 0 | 137 | — | +| adaptive API uses | 2 | 12 | floor 12 | +| navigation components | 0 | 2 | floor 2 | +| motion API uses | 1 | 13 | — | +| navigation transitions | 0 | 3 | — | + +The 39 remaining literals are all terms of a `+` concatenation, several of them +pluralisations that want a real plural resource rather than a format argument; +`m3-extract-formatted.py --remaining` lists them. + +What a person still has to look at, gathered from the phases that said so: + +- **which of a competing pair of filled buttons is primary**, on eight screens. + That is a product decision about what each screen is for, and getting it wrong + quietly weights a choice the user is supposed to make freely (phase 5). +- **whether a 480dp column of a particular screen reads well.** The measure is + applied at every root and asserted at five widths, but "renders correctly" is a + judgement no assertion makes (phase 6). +- **proposals → signing and artifacts → chapters**, the two list-detail families + the pane work did not reach. Same shape as chat, different content (phase 6). +- **the container transform** between a list item and its detail screen, which + wants `SharedTransitionLayout` and only applies below the expanded breakpoint, + where the detail is not already beside the list (phase 7). +- **full keyboard traversal on desktop** — tab order across the modal sheets and + the dialog, and focus returning to what opened them. Compose restores focus by + default, so the gap is evidence rather than known breakage (phase 3). +- **the avatar picker's selected state**, `secondaryContainer` at 1.65:1 against + the surface, which M3 accepts only where a second cue carries the selection. + This grid has neither an outline nor a checkmark (phase 3). + +## Order and dependencies + +| phase | depends on | touches | reversible alone | +|---|---|---|---| +| 0 — baseline | — | new files only | n/a | +| 1 — theme | 0 | `ui/theme/`, 2 entry points | yes | +| 2 — spacing | 1 | ~every file | yes, mechanically | +| 3 — a11y floor | 1 | ~25 sites | yes | +| 4 — content | 2 | ~every file | yes, mechanically | +| 5 — states | 2, 4 | 26 scaffolds, 21 sites | yes | +| 6 — adaptive | 2, 3, 5 | navigation, ~15 screens | no — plan per family | +| 7 — motion | 1, 5, 6 | navigation, state switches | yes | +| 8 — guard rails | all | CI, `CLAUDE.md` | yes | + +Phases 1–5 can be worked in parallel by different people if 1 lands first; +6 cannot start until 3 and 5 are done, or the same screens get touched twice. + +## What this plan does not cover + +- **Which M3 is the target.** Phase 1 asks the expressive-vs-standard question + and this document does not answer it. The 66 existing + `ExperimentalMaterial3ExpressiveApi` opt-ins and the pinned alpha both point + expressive, but it is a product decision with a visible outcome, and it should + be made deliberately rather than inherited from an import. +- **Whether dynamic colour should keep overriding the brand.** `TorchTheme` defaults + `dynamicColor = true`, and on Android 12+ that wins unconditionally — so on + essentially every current Android device, none of the six schemes below is used + and the app renders in whatever the user's wallpaper produced. Verified on an API + 36 emulator: the app paints Material lavender until dynamic colour is switched + off, at which point the black-and-gold brand appears. M3's customization + foundation treats this as a developer choice, and applying it selectively — a + profile screen, say — is the usual answer for an app with a brand. Deciding it is + a product call, not a conformance one, and it is why phase 1's contrast work is + reachable today only on Android below 12, on iOS and on desktop. On Android 14+ + with dynamic colour on, the platform honours contrast itself. +- **Whether the monochrome palette is right.** The scheme's `primary` is pure + black in light and near-white in dark, with the entire tertiary family a copy + of primary. That is a defensible choice for this product and it passes + contrast. It is also why `HomeScreen`'s app bar override collapsed to 1.22:1 — + a monochrome scheme has no slack. This plan fixes the call sites; it does not + propose a repalette. +- **Component-level specs.** The foundations are the scope. The per-component + pages — button sizes, card variants, list item densities — are a second pass, + best taken after Phase 6 settles which components are used where. +- **iOS.** The ios targets only build on a mac (see `jvm-target.md`), so nothing + here has been verified on the platform whose HIG asks for 44dp targets rather + than 48dp. M3 notes the difference; this plan assumes 48dp everywhere. +- **The three package namespaces.** `press.mantra`, `com.example.ui.theme` and + `com.machankura.compose` all hold live UI code. Phase 1 moves `Type.kt` and + Phase 3 moves `Clickable.kt` because both are in the way; the rest of the + `com.machankura` tree — NFC widgets among it — is out of scope. diff --git a/docs/scripts/m3-audit.sh b/docs/scripts/m3-audit.sh new file mode 100755 index 00000000..a5e0e35e --- /dev/null +++ b/docs/scripts/m3-audit.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +# +# Material Design 3 conformance audit. +# +# Regenerates every count quoted in docs/material-design-conformance.md. The plan +# in that document has acceptance criteria per phase; this is what checks them. +# +# Usage: +# docs/scripts/m3-audit.sh report, always exit 0 +# docs/scripts/m3-audit.sh --check report, exit 1 if any budget is exceeded +# +# The budgets at the top are the state of the tree at the phase named beside each +# one. They ratchet down as phases land: lower the number in the same commit that +# earns it, never raise one. Phase 8 wires --check into CI, at which point raising +# a budget is what a reviewer looks for. + +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." || exit 1 + +UI=composeApp/src/commonMain/kotlin/press/mantra/compose/ui +THEME="$UI/theme" + +# --------------------------------------------------------------------------- +# Budgets. "-1" means not yet budgeted -- reported, but never fails --check. +# --------------------------------------------------------------------------- +BUDGET_HARDCODED_COLOR=0 # phase 3: reached 2026-09-08 +BUDGET_SPACING_LITERALS=0 # phase 2: reached 2026-09-08 +BUDGET_BARE_CLICKABLE=0 # phase 3: reached 2026-09-08 +BUDGET_NULL_DESCRIPTION=0 # phase 3: reached 2026-09-08 +BUDGET_STRING_LITERALS=-1 # phase 4 drives to <10 +BUDGET_TITLE_CASE=0 # phase 4: reached 2026-09-08 +BUDGET_UNSET_COLOR_ROLES=0 # phase 1: reached 2026-09-07 + +# A floor rather than a ceiling: --check fails when the count drops *below* it. The +# adaptive work is the one thing in this document that a later edit removes rather +# than adds -- a screen that stops reading the breakpoint still compiles and still +# renders -- so the budget that protects it has to point the other way. +FLOOR_ADAPTIVE_APIS=12 # phase 6: reached 2026-09-08 +FLOOR_NAVIGATION_COMPONENTS=2 # phase 6: reached 2026-09-08 + +fail_count=0 + +hdr() { printf '\n\033[1m== %s\033[0m\n' "$1"; } +note() { printf ' %s\n' "$1"; } + +# floor