feat: give the app somewhere to report an outcome, and every dead-end error a way out

Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.

**Sixteen copies of the same dead end.** The tree held sixteen instances 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.** Every failure in this app named no cause and had no way forward but the back
button.

`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.

`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.

**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.

`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.

It **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 precisely
the failure this file exists to end.

**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.

**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)

**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.

**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso NgakoandClaude Opus 5 committed 2026-09-08 01:57:18 +02:00
1 parent 2117e22d48
commit 44bf2a01f0
54 files changed
+457 -214

No files matched your search

@@ -93,7 +93,7 @@
<string name="has_not_taken_part_yet">Has not taken part yet</string>
<string name="how_many_admins_have_to_approve_a_change">How many admins have to approve a change?</string>
<string name="how_many_members_will_it_take_to_sign">How many members will it take to sign?</string>
<string name="if_you_are_new_to_torch_or_just_want_to">If you are new to Torch or just want to create a fresh profile</string>
<string name="if_you_are_new_to_torch_or_just_want_to">If you are new to Mantra or just want to create a fresh profile</string>
<string name="initial_version_label">Initial version label</string>
<string name="input_npub_or_nip05">Input npub... or nip05</string>
<string name="introduce_yourself">Introduce yourself</string>
@@ -138,7 +138,6 @@
<string name="no_chunks">No chunks.</string>
<string name="no_dialects_have_been_defined_in_this_group">No dialects have been defined in this group yet. Add one from the group's detail screen first.</string>
<string name="no_dialects_have_been_defined_in_this_group_2">No dialects have been defined in this group.</string>
<string name="no_events_were_found">No events were found</string>
<string name="no_messages_go_to_a_profile_and_send_them_a">No messages. Go to a profile and send them a message.</string>
<string name="no_one_selected_yet">No one selected yet</string>
<string name="no_one_to_add_yet">No one to add yet.</string>
@@ -195,7 +194,7 @@
<string name="sign">Sign</string>
<string name="sign_in">Sign in</string>
<string name="sign_in_to_nsec">Sign in to nsec</string>
<string name="sign_in_to_torch_via_nsec_or_remote_signer">Sign in to Torch via nsec, or remote signer</string>
<string name="sign_in_to_torch_via_nsec_or_remote_signer">Sign in to Mantra via nsec, or remote signer</string>
<string name="sign_in_with_an_npub">Sign in with an npub</string>
<string name="sign_out">Sign out</string>
<string name="sign_with_the_group_s_key">Sign with the group's key</string>
@@ -234,7 +233,7 @@
<string name="this_will_be_the_display_name_for_your">This will be the display name for your profile and also important for search.</string>
<string name="this_will_give_you_read_only_access_to_the">This will give you read only access to the profile.</string>
<string name="this_will_give_you_write_access_to_the">This will give you write access to the profile.</string>
<string name="torch_will_be_broadcast_what_you_publish_to">Torch will be broadcast what you publish to a distributed set of relays so that it's decentralized.</string>
<string name="torch_will_be_broadcast_what_you_publish_to">Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised.</string>
<string name="translate_chunk">Translate chunk</string>
<string name="translate_into_which_dialect">Translate into which dialect?</string>
<string name="translated_text">Translated text</string>
@@ -286,7 +285,7 @@
<string name="functionality_coming_soon_2">"%1$s" functionality coming soon</string>
<string name="how_should_be_run">How should %1$s be run?</string>
<string name="invite_2">Invite %1$s</string>
<string name="key_packages">%1$s Key Packages</string>
<string name="key_packages">%1$s key packages</string>
<string name="next_with">Next with %1$s</string>
<string name="nostr">nostr:%1$s..</string>
<string name="nostr_2">nostr:%1$s...</string>
@@ -301,7 +300,7 @@
<string name="recovered_of_still_unreadable">Recovered %1$s of %2$s · %3$s still unreadable%4$s</string>
<string name="reply_privately_to">Reply privately to %1$s</string>
<string name="reply_to">Reply to %1$s</string>
<string name="replying_to">replying To %1$s</string>
<string name="replying_to">Replying to %1$s</string>
<string name="searching_for_on">Searching for %1$s on "%2$s"</string>
<string name="selected">%1$s selected</string>
<string name="sent_a_private_message_to">%1$s sent a private message to %2$s</string>
@@ -309,8 +308,15 @@
<string name="to_join_the_chat_room">to join the %1$s chat room.</string>
<string name="translate">Translate %1$s</string>
<string name="unsupported_event_kind_2">Unsupported event kind: %1$s</string>
<string name="was_created_but_couldn_t_be_added_yet_invite">%1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Torch.</string>
<string name="was_created_but_couldn_t_be_added_yet_invite">%1$s was created, but %2$s couldn't be added yet. Invite them again from the chat once they're on Mantra.</string>
<string name="was_created_but_its_shared_key_ceremony">%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.</string>
<string name="words_characters">%1$s words · %2$s characters</string>
<string name="you_and_others">You and %1$s others</string>
<string name="not_following_anyone_yet">You aren't following anyone yet.</string>
<string name="nobody_following_you_yet">Nobody is following you yet.</string>
<string name="nothing_in_this_feed_yet">Nothing in this feed yet.</string>
<string name="no_replies_to_this_yet">No replies to this yet.</string>
<string name="nothing_matched_that_search">Nothing matched that search.</string>
<string name="key_package_published">Key package published</string>
<string name="key_package_rotated">Key package rotated</string>
</resources>
@@ -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
)
}
}
}
}
@@ -64,6 +64,9 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -87,16 +90,11 @@ fun ActiveProfileScreen(
when(val activeProfileUIState = activeProfileViewModel.activeProfileUIState) {
ActiveProfileUIState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.something_went_wrong))
}
ErrorState()
}
is ActiveProfileUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -83,6 +83,8 @@ import mantra.composeapp.generated.resources.no_dialects_have_been_defined_in_th
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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -148,6 +150,7 @@ fun AddArtifactScreen(
// 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(
@@ -64,6 +64,8 @@ import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -136,6 +138,7 @@ fun AddChapterScreen(
// 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(
@@ -67,6 +67,8 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -119,6 +121,7 @@ fun AddDialectScreen(
// 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(
@@ -46,6 +46,8 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -88,6 +90,7 @@ fun AddMemberToChatRoomConfirmationScreen(
}
is AddMemberToChatRoomConfirmationUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
// topBar = {
// TopAppBar(
// title = {
@@ -65,6 +65,8 @@ 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
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -136,6 +138,7 @@ fun AddTranslationArtifactVersionScreen(
val buttonColors = ButtonDefaults.buttonColors()
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.translate, addTranslationUIState.artifact.name)) },
@@ -64,6 +64,8 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -102,6 +104,7 @@ fun ArtifactDetailScreen(
is ArtifactDetailUIState.Loaded -> {
val artifact = artifactDetailUIState.artifact
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(artifact.name) },
@@ -48,6 +48,8 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -85,6 +87,7 @@ fun ChapterDetailScreen(
is ChapterDetailUIState.Loaded -> {
val chapter = chapterDetailUIState.chapter
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(chapter.name) },
@@ -39,6 +39,8 @@ import mantra.composeapp.generated.resources.this_will_be_shown_when_people_open
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
@Composable
fun ChatRoomCreationScreen(
@@ -51,7 +53,9 @@ fun ChatRoomCreationScreen(
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(modifier = Modifier.imePadding()) { innerPadding ->
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth()
) {
@@ -92,6 +92,8 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -134,6 +136,7 @@ fun ChatRoomDetailScreen(
}
is ChatRoomDetailUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -66,6 +66,8 @@ import mantra.composeapp.generated.resources.direct_message_functionality_will_b
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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -118,6 +120,7 @@ fun ChatRoomMessagingScreen(
)
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -59,6 +59,8 @@ import mantra.composeapp.generated.resources.this_will_be_the_display_name_for_y
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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -78,7 +80,9 @@ fun CreateProfileScreen(
)
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(modifier = Modifier.imePadding()) { innerPadding ->
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -43,6 +43,8 @@ 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
/**
* The frame every ChillDKG approval screen sits in: the view model, the states
@@ -84,6 +86,7 @@ internal fun DkgApprovalScaffold(
}
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
@@ -99,6 +99,8 @@ import mantra.composeapp.generated.resources.everyone_has_to_be_online_at_the_sa
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
/**
* The shared-key ceremony: a ChillDKG ritual run across the group's NIP-17
@@ -148,6 +150,7 @@ fun DkgRitualScreen(
val isActionPending = dkgRitualViewModel.isActionPending.value
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -69,6 +69,8 @@ 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
/**
* One signing session, and the member's decision about it.
@@ -112,6 +114,7 @@ fun FrostSigningScreen(
}
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -62,6 +62,9 @@ import org.jetbrains.compose.resources.stringResource
import mantra.composeapp.generated.resources.mantra
import mantra.composeapp.generated.resources.new_chat
import mantra.composeapp.generated.resources.something_went_wrong
import press.mantra.compose.ui.composable.widgets.ErrorState
import androidx.compose.material3.SnackbarHost
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -91,28 +94,19 @@ fun HomeScreen(
when(val homeScreenUIState = homeScreenViewModel.homeScreenUIState) {
HomeScreenUIState.Error -> {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = stringResource(Res.string.something_went_wrong)
)
Spacer(
modifier = Modifier.weight(2f)
)
ErrorState()
}
}
}
is HomeScreenUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier,
topBar = {
// No colour override. It was `containerColor = primaryContainer` with
@@ -17,13 +17,15 @@ 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
@Composable
fun ImplementationPendingScreen(
text: String
) {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
@@ -51,6 +51,13 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -63,6 +70,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,
@@ -74,16 +87,11 @@ fun KeyPackageManagementScreen(
when(val keyPackageManagementUIState = keyPackageManagementViewModel.keyPackageManagementUIState) {
KeyPackageManagementUIState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.something_went_wrong))
}
ErrorState()
}
is KeyPackageManagementUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -117,7 +125,9 @@ fun KeyPackageManagementScreen(
item {
Button(
onClick = {
keyPackageManagementViewModel.publishNewKeyPackage()
keyPackageManagementViewModel.publishNewKeyPackage {
notify(publishedMessage)
}
}
) {
Icon(
@@ -193,7 +203,9 @@ fun KeyPackageManagementScreen(
onClick = {
keyPackageManagementViewModel.rotateKeyPackage(
marmotKeyPackageBundle
)
) {
notify(rotatedMessage)
}
}
) {
Icon(
@@ -29,6 +29,8 @@ 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
@Composable
fun LandingScreen(
@@ -36,7 +38,7 @@ fun LandingScreen(
onNavigateToSignIn: () -> Unit,
onNavigateToCreateProfile: () -> Unit
) {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -8,12 +8,14 @@ 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
@Composable
fun LoadingScreen(
text: String? = null
) {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -38,6 +38,9 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -64,13 +67,7 @@ fun NostrEventDetailScreen(
when(val feedListUIState = nostrEventDetailViewModel.nostrEventDetailUIState) {
NostrEventDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.something_went_wrong))
}
ErrorState()
}
is NostrEventDetailUIState.Loaded -> {
@@ -108,7 +105,7 @@ fun NostrEventDetailScreen(
}
}
else -> {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
@@ -63,6 +63,8 @@ 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
/**
* Everything the group has asked its shared key to sign.
@@ -106,6 +108,7 @@ fun ProposalListScreen(
}
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -46,6 +46,8 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -85,6 +87,7 @@ fun SearchMemberToAddToChatRoomScreen(
}
is SearchMemberToAddToChatRoomUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -57,11 +57,15 @@ 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.no_events_were_found
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
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -89,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,
@@ -213,32 +217,12 @@ fun SearchResultScreen(
when (val searchResultListUIState = searchViewModel.searchResultListUIState) {
SearchResultListUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.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(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.no_events_were_found),
)
}
EmptyState(message = stringResource(Res.string.nothing_matched_that_search))
} else {
LazyColumn(
@@ -70,6 +70,8 @@ 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
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -92,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,
@@ -53,6 +53,8 @@ import mantra.composeapp.generated.resources.you_can_still_carry_on_and_invite_p
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
/**
* Second step of group creation: pick who is in the group.
@@ -100,6 +102,7 @@ fun SelectChatRoomMembersScreen(
val selectedCount = selectChatRoomMembersViewModel.selectedPublicKeys.size
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -70,6 +70,8 @@ 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
/**
* Last step of group creation: convenient (one admin) or robust (everyone
@@ -128,6 +130,7 @@ fun SelectChatRoomTypeScreen(
val isRobustSelected = selectedChatRoomType == ChatRoomType.ROBUST
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -51,6 +51,9 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -74,16 +77,11 @@ fun ShareProfileScreen(
when(val shareProfileUIState = shareProfileViewModel.shareProfileUIState) {
ShareProfileUIState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(Res.string.something_went_wrong))
}
ErrorState()
}
is ShareProfileUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = {
@@ -44,6 +44,8 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -59,7 +61,9 @@ fun SignInToProfileScreen(
)
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(modifier = Modifier.imePadding()) { innerPadding ->
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -31,6 +31,8 @@ import mantra.composeapp.generated.resources.tell_friends_to_join_you_so_your_fe
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
@Composable
fun SocialPreconditionScreen(
@@ -39,6 +41,7 @@ fun SocialPreconditionScreen(
onNavigateToViewInvites: () -> Unit,
) {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
bottomBar = {
BottomAppBar(
modifier = Modifier,
@@ -62,6 +62,8 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -114,6 +116,7 @@ fun TranslateChunkScreen(
// 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(
@@ -57,6 +57,8 @@ import mantra.composeapp.generated.resources.this_group_has_no_shared_key_so_it_
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -99,6 +101,7 @@ fun TranslationArtifactVersionDetailScreen(
is TranslationArtifactVersionDetailUIState.Loaded -> {
val translation = translationDetailUIState.translation
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(translation.name) },
@@ -52,6 +52,8 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -89,6 +91,7 @@ fun TranslationChapterScreen(
is TranslationChapterUIState.Loaded -> {
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.chapter_translation)) },
@@ -22,11 +22,13 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun UnannouncedProfileScreen() {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -22,11 +22,13 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun UnindexedProfileScreen() {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -22,11 +22,13 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun UnqueuedProfileScreen() {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -24,6 +24,8 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -40,7 +42,7 @@ fun UnqueuedProfileSynchronizationScreen(
)
)
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -22,11 +22,13 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun UnsignedProfileScreen() {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -22,13 +22,15 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun UnsyncedProfileScreen(
unsyncedProfilePublicKey: String
) {
Scaffold { innerPadding ->
Scaffold(snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -56,6 +56,8 @@ 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
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@Composable
@@ -79,7 +81,9 @@ fun WriteNewNoteScreen(
)
// imePadding: this screen has a text field, and without it the software
// keyboard covers whatever is being typed into.
Scaffold(modifier = Modifier.imePadding()) { innerPadding ->
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.imePadding()) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
@@ -0,0 +1,127 @@
package press.mantra.compose.ui.composable.widgets
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.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.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()
}
}
@@ -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<SnackbarHostState> =
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)
}
}
}
}
@@ -67,6 +67,8 @@ 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
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -94,6 +96,7 @@ fun MetadataEventDetail(
)
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
@@ -55,6 +55,9 @@ 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
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -79,6 +82,7 @@ fun TextNoteEventDetail(
)
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
@@ -367,11 +371,7 @@ fun TextNoteEventDetail(
}
}
press.mantra.compose.ui.view.state.FeedListUIState.Error -> {
item {
Text(
text = stringResource(Res.string.something_went_wrong)
)
}
item { ErrorState() }
}
press.mantra.compose.ui.view.state.FeedListUIState.Loading -> {
item {
@@ -108,6 +108,7 @@ 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
class ChatMessageListViewModel(
initialChatMessageListUIState: ChatMessageListUIState,
@@ -405,17 +406,7 @@ class ChatMessageListViewModel(
) {
when (val chatRoomDetailMessageListUIState = this@ChatMessageListViewModel.chatMessageListUIState) {
ChatMessageListUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.something_went_wrong),
)
}
ErrorState()
}
is ChatMessageListUIState.Loaded -> {
Spacer(
@@ -46,6 +46,7 @@ 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,
@@ -151,17 +152,7 @@ class ChatRoomListViewModel(
) {
when (val chatRoomListUIState = chatRoomListUIState) {
ChatRoomListUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.something_went_wrong),
)
}
ErrorState()
}
is ChatRoomListUIState.Loaded -> {
Column(
@@ -38,8 +38,10 @@ 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_events_were_found
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,
@@ -118,31 +120,11 @@ class FeedListViewModel(
) {
when (val feedListUIState = feedListUIState) {
FeedListUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.something_went_wrong),
)
}
ErrorState()
}
is FeedListUIState.Loaded -> {
if (feedListUIState.localNostrEvents.isEmpty()) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.no_events_were_found),
)
}
EmptyState(message = stringResource(Res.string.nothing_in_this_feed_yet))
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
@@ -34,8 +34,10 @@ 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_events_were_found
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,
@@ -112,31 +114,11 @@ 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(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.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(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.no_events_were_found),
)
}
EmptyState(message = stringResource(Res.string.nobody_following_you_yet))
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
@@ -34,8 +34,10 @@ 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_events_were_found
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,
@@ -111,31 +113,11 @@ 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(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.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(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.no_events_were_found),
)
}
EmptyState(message = stringResource(Res.string.not_following_anyone_yet))
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
@@ -39,8 +39,10 @@ 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_events_were_found
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,
@@ -122,31 +124,11 @@ 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(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.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(MaterialTheme.spacing.space600)
)
Text(
text = stringResource(Res.string.no_events_were_found),
)
}
EmptyState(message = stringResource(Res.string.no_replies_to_this_yet))
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
@@ -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()
}
}