AccountViewModel imported no Compose UI; it was pinned to commonsUI only because commons had no headless way to tell the user something, so every message was a compose-resources string. - commons.notices: UserNotice is a sealed catalog of what headless code tells the user (SignerNotice, InvoiceNotice, ZapNotice, ...), reported to a UserNoticeSink or worded through a UserNoticeResolver. commonsUI words every notice in notices/ui/UserNoticeText.kt with one exhaustive `when`; ToastManager is the sink and renders notices like any toast. - The payment helpers report notices instead of Res text and moved to commons: LightningInvoiceResolver, ZapPaymentHandler (its callers keep their (title, message, user) callbacks), V4VPaymentHandler and MeltProcessor. TopNavFilterState labels became a TopNavLabel enum, and NotificationSummaryState publishes plain chart data that commonsUI turns into the Vico model. - BaseAccountViewModel (commons) is the whole ViewModel minus its toast manager and the call/nest bridges, which stay in commonsUI's thin AccountViewModel subclass, so the screens' call sites are unchanged. 28 helpers it needed moved with it (relay filter assemblers, feed states, URI routing, the LRU caches), and 9 more ViewModels now take it from commons (Bolt12 offers, payment targets, Blossom blobs, lists, geohash chat, Cashu wallet, top-up and wizard). One behavior change: an LNURL server that answers with an error status is reported as "service unavailable" with its message, instead of being re-wrapped as "could not resolve". Covered by new LightningInvoiceResolver tests; ResUserNoticeResolverTest checks the wording resolves. Plan: commons/plans/2026-10-09-headless-account-viewmodel.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019F7k9DtPmTVJCPRTBZpJHe
4.6 KiB
Headless AccountViewModel and typed user notices
Status: shipped (steps 1-3); follow-ups below
Problem
AccountViewModel (3.2k lines) lives in commonsUI, so every ViewModel that
takes it is stuck there too, out of reach of cli. It imports no Compose UI. What
pins it is that commons has no headless way to say "tell the user this": every
user-facing message is a compose-resources StringResource.
- 46
Resstrings inAccountViewModelitself (PoW publish failures, Concord kicks and pin failures, signer errors, Bolt12, nutzap, Cashu redemption). - The payment helpers it calls build their error text the same way
(
loadStringRes(Res.string.x, args)then a(title, message)callback):ZapPaymentHandler,V4VPaymentHandler,LightningInvoiceResolver,MeltProcessor, plusHttpStatusMessagesandNwcResponseMessages. - ~34 headless helpers sit in
commonsUIonly because their users did (relay filter assemblers,AccountFeedContentStates,UriToRoute,CachedState).
Design
Typed notices (commons.notices)
sealed interface UserNotice: a typed fact about what happened (SignerNotice.ReadOnly,InvoiceNotice.CallbackNotFound(user), …), never text. All subtypes live incommons.notices(a sealed hierarchy must share a package), which doubles as the catalog of everything the app tells the user from headless code.fun interface UserNoticeSink { fun notify(notice: UserNotice) }: where headless code reports a notice (a toast in the GUI apps).fun interface UserNoticeResolver { suspend fun resolve(notice): ResolvedNotice }: for headless code whose API already hands text to a caller (the payment helpers'onError(title, message, user)callbacks keep their signatures, so the zap buttons and dialogs don't change).
commonsUI implements both: one exhaustive when maps each notice to its
Res title/message (commons.notices.ui), ToastManager implements
UserNoticeSink, and DisplayErrorMessages renders a notice toast in
composition like any resource toast. Adding a notice without a rendering does
not compile. cli can supply its own resolver.
Rejected: a generic MessageKey that commonsUI maps to Res. It only moves
string keys around instead of naming what failed, and loses the exhaustiveness.
Split the ViewModel
commons:open class BaseAccountViewModelholds the whole headless body. It takes aUserNoticeSinkand aUserNoticeResolverand never seesRes.commonsUI:class AccountViewModel : BaseAccountViewModeladds only what needs the UI module: itsToastManager(passed down as the sink), the Res resolver, and theCallSessionBridgeregistration (Android's call activity reads the UI type back out). Every member the UI calls is inherited, so the hundreds ofaccountViewModel.xcall sites don't change.- ViewModels that need
launchSigner/notices takeBaseAccountViewModel; ones that only need the account takeAccount.
Steps
- Move the ViewModels that only read
accountViewModel.account(switch them toAccount). - Add
commons.notices; convert the payment helpers and move them tocommons. - Extract
BaseAccountViewModelwith its headless helpers; move the ViewModels it unblocks.
Outcome
- Step 1: 7 ViewModels switched to
Accountand moved. - Step 2:
commons.noticesadded;LightningInvoiceResolver,ZapPaymentHandler,V4VPaymentHandlerandMeltProcessorreport notices and moved tocommons.TopNavFilterStatelabels became aTopNavLabelenum;NotificationSummaryStatepublishes plain chart data andcommonsUIbuilds the Vico model. - Step 3:
BaseAccountViewModelincommonswith 28 helpers (relay filter assemblers, feed states, URI routing, caches); 9 more ViewModels moved onto it.
One behavior change: an LNURL server that answers with an error status is now reported as "service unavailable" directly, instead of being re-wrapped as "could not resolve" with the first message inside.
Follow-ups
Still in commonsUI, by blocker:
toastManager/Resin the ViewModel itself: podcast and music composers.CountFilter/RelayCountResultcarryStringResourcelabels: the relay-list ViewModels (BasicRelaySetupInfoModeland its 7 subclasses).- UI-module helpers:
uploadToDefaultServer*(emoji packs, new user metadata),UserSuggestionState(list display),ChannelNewMessageViewModel/LevelFeedViewModel(Compose UI state),TransactionFilter,distanceUnit,EditNestSheet, and the Blossom/Nests server lists.
ToastManager's ~150 existing Res call sites stay: UI code may keep using Res.