From 44bf2a01f0e2855f809618aff0d41a4173d381bc Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Tue, 8 Sep 2026 01:57:18 +0200 Subject: [PATCH] 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 --- .../composeResources/values/strings.xml | 20 ++- .../kotlin/press/mantra/compose/MantraApp.kt | 18 ++- .../ui/composable/ActiveProfileScreen.kt | 12 +- .../ui/composable/AddArtifactScreen.kt | 3 + .../compose/ui/composable/AddChapterScreen.kt | 3 + .../compose/ui/composable/AddDialectScreen.kt | 3 + .../AddMemberToChatRoomConfirmationScreen.kt | 3 + .../AddTranslationArtifactVersionScreen.kt | 3 + .../ui/composable/ArtifactDetailScreen.kt | 3 + .../ui/composable/ChapterDetailScreen.kt | 3 + .../ui/composable/ChatRoomCreationScreen.kt | 6 +- .../ui/composable/ChatRoomDetailScreen.kt | 3 + .../ui/composable/ChatRoomMessagingScreen.kt | 3 + .../ui/composable/CreateProfileScreen.kt | 6 +- .../ui/composable/DkgApprovalScaffold.kt | 3 + .../compose/ui/composable/DkgRitualScreen.kt | 3 + .../ui/composable/FrostSigningScreen.kt | 3 + .../compose/ui/composable/HomeScreen.kt | 18 +-- .../composable/ImplementationPendingScreen.kt | 4 +- .../composable/KeyPackageManagementScreen.kt | 30 +++-- .../compose/ui/composable/LandingScreen.kt | 4 +- .../compose/ui/composable/LoadingScreen.kt | 4 +- .../ui/composable/NostrEventDetailScreen.kt | 13 +- .../ui/composable/ProposalListScreen.kt | 3 + .../SearchMemberToAddToChatRoomScreen.kt | 3 + .../ui/composable/SearchResultScreen.kt | 32 ++--- .../compose/ui/composable/SearchScreen.kt | 4 +- .../composable/SelectChatRoomMembersScreen.kt | 3 + .../ui/composable/SelectChatRoomTypeScreen.kt | 3 + .../ui/composable/ShareProfileScreen.kt | 12 +- .../compose/ui/composable/SignInScreen.kt | 6 +- .../ui/composable/SocialPreconditionScreen.kt | 3 + .../ui/composable/TranslateChunkScreen.kt | 3 + .../TranslationArtifactVersionDetailScreen.kt | 3 + .../ui/composable/TranslationChapterScreen.kt | 3 + .../ui/composable/UnannouncedProfileScreen.kt | 4 +- .../ui/composable/UnindexedProfileScreen.kt | 4 +- .../ui/composable/UnqueuedProfileScreen.kt | 4 +- .../UnqueuedProfileSynchronizationScreen.kt | 4 +- .../ui/composable/UnsignedProfileScreen.kt | 4 +- .../ui/composable/UnsyncedProfileScreen.kt | 4 +- .../ui/composable/WriteNewNoteScreen.kt | 6 +- .../ui/composable/widgets/ScreenState.kt | 127 ++++++++++++++++++ .../ui/composable/widgets/Snackbars.kt | 79 +++++++++++ .../widgets/detail/MetadataEventDetail.kt | 3 + .../widgets/detail/TextNoteEventDetail.kt | 10 +- .../ui/view/model/ChatMessageListViewModel.kt | 13 +- .../ui/view/model/ChatRoomListViewModel.kt | 13 +- .../ui/view/model/FeedListViewModel.kt | 28 +--- .../ui/view/model/FollowersListViewModel.kt | 28 +--- .../ui/view/model/FollowingListViewModel.kt | 28 +--- .../ui/view/model/InReplyToViewModel.kt | 28 +--- .../model/KeyPackageManagementViewModel.kt | 14 +- docs/scripts/m3-title-case.py | 21 +++ 54 files changed, 457 insertions(+), 214 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/Snackbars.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ed48d520..3f4ced0e 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -93,7 +93,7 @@ Has not taken part yet How many admins have to approve a change? How many members will it take to sign? - If you are new to Torch or just want to create a fresh profile + If you are new to Mantra or just want to create a fresh profile Initial version label Input npub... or nip05 Introduce yourself @@ -138,7 +138,6 @@ 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 events were found No messages. Go to a profile and send them a message. No one selected yet No one to add yet. @@ -195,7 +194,7 @@ Sign Sign in Sign in to nsec - Sign in to Torch via nsec, or remote signer + Sign in to Mantra via nsec, or remote signer Sign in with an npub Sign out Sign with the group's key @@ -234,7 +233,7 @@ 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. - Torch will be broadcast what you publish to a distributed set of relays so that it's decentralized. + Mantra broadcasts what you publish to a distributed set of relays, so it stays decentralised. Translate chunk Translate into which dialect? Translated text @@ -286,7 +285,7 @@ "%1$s" functionality coming soon How should %1$s be run? Invite %1$s - %1$s Key Packages + %1$s key packages Next with %1$s nostr:%1$s.. nostr:%1$s... @@ -301,7 +300,7 @@ 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 + Replying to %1$s Searching for %1$s on "%2$s" %1$s selected %1$s sent a private message to %2$s @@ -309,8 +308,15 @@ 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 Torch. + %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 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/ui/composable/ActiveProfileScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt index 5157d5a8..86e48564 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 @@ -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 = { 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 c029fb2c..d4b2b660 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 @@ -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( 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 fe355e7a..a74454bb 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 @@ -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( 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 64d28e0d..b1806d33 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 @@ -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( 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 32cee011..92486b24 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 @@ -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 = { 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 96eb42d8..f4da3638 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 @@ -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)) }, 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 96de40d5..79598cf3 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 @@ -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) }, 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 44994bc7..7de99e55 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 @@ -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) }, 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 9be67274..2b97f9d4 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 @@ -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() ) { 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 24e11a6a..63ab31bc 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 @@ -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 = { 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 f4bcda21..7606c2d6 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 @@ -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 = { 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 035310fa..f7055ee7 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 @@ -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) ) { 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 96ea36e4..f88e94e8 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 @@ -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) } 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 6973c43d..fa98cf63 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 @@ -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 = { 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 3bff2f5b..b6a6bbbe 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 @@ -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 = { 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 e1c08bd1..2c5360ca 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 @@ -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 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 f9102c41..3b853dd4 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 @@ -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() ) { 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 ba863c18..3b2b2f2e 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 @@ -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( 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 7aecc3fa..34dbc60d 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 @@ -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) ) { 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..093059b2 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 @@ -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) ) { 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 269968af..d3c66669 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 @@ -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() ) { 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 d3795056..ca2daee6 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 @@ -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 = { 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 1fbcfcbd..3659bc27 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 @@ -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 = { 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 4b80749c..4eded6bc 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 @@ -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( 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 93afe8c1..6b187100 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 @@ -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, 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 58bcbc7b..53f43bb3 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 @@ -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 = { 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 db33c18d..fe835df7 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 @@ -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 = { 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 b596258a..a6ee9dcf 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 @@ -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 = { 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 534e803c..ae7ae4f8 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 @@ -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) ) { 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 6f2f75a1..d18b92d5 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 @@ -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, 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 5d05cf60..62124519 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 @@ -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( 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 6b10e683..f3919405 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 @@ -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) }, 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 6f74e5e7..971fab63 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 @@ -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)) }, 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 408d55bd..ce8714fd 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 @@ -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) ) { 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 ef3d18cf..b48eca3b 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 @@ -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) ) { 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 92e99bb6..aff957c4 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 @@ -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) ) { 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 832ae60b..b2b4e960 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 @@ -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) ) { 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 a25a79d4..6bf8c904 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 @@ -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) ) { 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 2ad93668..6ca4efb3 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 @@ -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) ) { 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 86313d71..f95a776b 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 @@ -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) ) { 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..7dd74acc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/ScreenState.kt @@ -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() + } +} 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/detail/MetadataEventDetail.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/detail/MetadataEventDetail.kt index 9952f93f..704f211b 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 @@ -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( 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 757bba37..4874b5c6 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 @@ -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 { 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 5198ce29..01000b42 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 @@ -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( 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 a407647b..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 @@ -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( 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 beb7a10b..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 @@ -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(), 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 04e246dd..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 @@ -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(), 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 345ecdef..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 @@ -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(), 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 a6223751..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 @@ -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(), 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/docs/scripts/m3-title-case.py b/docs/scripts/m3-title-case.py index 9f6335a8..02a4c11a 100755 --- a/docs/scripts/m3-title-case.py +++ b/docs/scripts/m3-title-case.py @@ -20,6 +20,7 @@ Usage: m3-title-case.py [--list] import os, re, sys UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui' +CATALOGUE = 'composeApp/src/commonMain/composeResources/values/strings.xml' SMALL = {'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'via', 'in', 'on', 'at', 'with', 'from', 'by'} @@ -33,6 +34,26 @@ SAMPLE = { def offenders(): found = [] + # The catalogue, first. When phase 4 moved 364 strings out of composables it moved + # them out of this checker's reach at the same time -- it scanned .kt files only, so + # it went on reporting zero while "%1$s Key Packages" and three surviving mentions of + # the old product name sat in strings.xml. Externalising narrows what a source scan + # can see; the check has to follow. + if os.path.exists(CATALOGUE): + for m in re.finditer(r'([^<]*)', + open(CATALOGUE, encoding='utf-8').read()): + phrase = m.group(2) + if phrase in SAMPLE: + continue + words = [w for w in re.sub(r'%\d+\$s', '', phrase).split() if w] + if len(words) < 2 or not words[0][:1].isupper(): + continue + later = [w for w in words[1:] if w.lower() not in SMALL] + if not later: + continue + if all(w[:1].isupper() for w in later) and \ + any(w[:1].isupper() and w[1:].islower() for w in later): + found.append((CATALOGUE, m.group(1), phrase)) for root, _, files in os.walk(UI): for f in sorted(files): if not f.endswith('.kt'):