Files
amethyst/commons/plans/2026-10-09-headless-account-viewmodel.md
Claude 9a7aa30df0 refactor: split AccountViewModel into a headless base and typed notices
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
2026-10-09 14:51:31 +00:00

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 Res strings in AccountViewModel itself (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, plus HttpStatusMessages and NwcResponseMessages.
  • ~34 headless helpers sit in commonsUI only 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 in commons.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 BaseAccountViewModel holds the whole headless body. It takes a UserNoticeSink and a UserNoticeResolver and never sees Res.
  • commonsUI: class AccountViewModel : BaseAccountViewModel adds only what needs the UI module: its ToastManager (passed down as the sink), the Res resolver, and the CallSessionBridge registration (Android's call activity reads the UI type back out). Every member the UI calls is inherited, so the hundreds of accountViewModel.x call sites don't change.
  • ViewModels that need launchSigner/notices take BaseAccountViewModel; ones that only need the account take Account.

Steps

  1. Move the ViewModels that only read accountViewModel.account (switch them to Account).
  2. Add commons.notices; convert the payment helpers and move them to commons.
  3. Extract BaseAccountViewModel with its headless helpers; move the ViewModels it unblocks.

Outcome

  • Step 1: 7 ViewModels switched to Account and moved.
  • Step 2: commons.notices added; LightningInvoiceResolver, ZapPaymentHandler, V4VPaymentHandler and MeltProcessor report notices and moved to commons. TopNavFilterState labels became a TopNavLabel enum; NotificationSummaryState publishes plain chart data and commonsUI builds the Vico model.
  • Step 3: BaseAccountViewModel in commons with 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/Res in the ViewModel itself: podcast and music composers.
  • CountFilter/RelayCountResult carry StringResource labels: the relay-list ViewModels (BasicRelaySetupInfoModel and 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.