11 KiB
11 KiB
Flow Patterns in Amethyst
StateFlow and SharedFlow usage patterns from the codebase.
Table of Contents
StateFlow for State Management
AccountManager Pattern
File: commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-115
sealed class AccountState {
data object LoggedOut : AccountState()
data class LoggedIn(
val signer: NostrSigner,
val pubKeyHex: String,
val npub: String,
val nsec: String?,
val isReadOnly: Boolean,
) : AccountState()
}
class AccountManager {
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
val accountState: StateFlow<AccountState> = _accountState.asStateFlow()
fun generateNewAccount(): AccountState.LoggedIn {
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val state = AccountState.LoggedIn(
signer = signer,
pubKeyHex = keyPair.pubKey.toHexKey(),
npub = keyPair.pubKey.toNpub(),
nsec = keyPair.privKey?.toNsec(),
isReadOnly = false
)
_accountState.value = state // Update state
return state
}
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
// ... validation ...
val state = AccountState.LoggedIn(...)
_accountState.value = state
return Result.success(state)
}
fun logout() {
_accountState.value = AccountState.LoggedOut
}
}
Pattern highlights:
- Private
MutableStateFlowfor internal mutations - Public
StateFlowvia.asStateFlow()for read-only access - Sealed class for type-safe state variants
- Initial value required (
AccountState.LoggedOut)
RelayConnectionManager Pattern
File: commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt:44-80
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val error: String? = null,
val messageCount: Int = 0
)
open class RelayConnectionManager(
websocketBuilder: WebsocketBuilder
) : IRelayClientListener {
private val client = NostrClient(websocketBuilder)
// Map of relay URLs to their status
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
// Delegated StateFlows from client
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
fun addRelay(url: String): NormalizedRelayUrl? {
val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
return normalized
}
fun removeRelay(url: NormalizedRelayUrl) {
_relayStatuses.value = _relayStatuses.value - url // Immutable update (remove from map)
}
private fun updateRelayStatus(
relay: NormalizedRelayUrl,
update: (RelayStatus) -> RelayStatus
) {
_relayStatuses.value = _relayStatuses.value.toMutableMap().apply {
val current = get(relay) ?: RelayStatus(relay, false)
put(relay, update(current))
}
}
// IRelayClientListener implementation
override fun onConnect(relay: NormalizedRelayUrl) {
updateRelayStatus(relay) { it.copy(connected = true, error = null) }
}
override fun onError(relay: NormalizedRelayUrl, error: String) {
updateRelayStatus(relay) { it.copy(connected = false, error = error) }
}
}
Pattern highlights:
Mapas state value for collection tracking- Immutable map updates (copy with modifications)
- Helper function
updateRelayStatusfor consistent updates - Delegation pattern (client exposes its own StateFlows)
Flow Composition
Multiple StateFlows in UI
Pattern:
@Composable
fun LoginScreen(accountManager: AccountManager) {
val accountState by accountManager.accountState.collectAsState()
when (accountState) {
is AccountState.LoggedOut -> {
LoginForm(onLogin = { key -> accountManager.loginWithKey(key) })
}
is AccountState.LoggedIn -> {
MainApp(account = accountState as AccountState.LoggedIn)
}
}
}
Observing Multiple Flows
Pattern:
@Composable
fun RelayStatusCard(relayManager: RelayConnectionManager) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
Column {
Text("${connectedRelays.size} of ${relayStatuses.size} relays connected")
relayStatuses.forEach { (url, status) ->
RelayRow(
url = url,
connected = status.connected,
error = status.error
)
}
}
}
Common Patterns
Pattern: Immutable State Updates
// Map updates
_relayStatuses.value = _relayStatuses.value + (url to newStatus) // Add
_relayStatuses.value = _relayStatuses.value - url // Remove
_relayStatuses.value = _relayStatuses.value.mapValues { (key, value) ->
if (key == targetUrl) value.copy(connected = true) else value
}
// List updates
_items.value = _items.value + newItem // Append
_items.value = _items.value.filter { it.id != removedId } // Remove
_items.value = _items.value.map { if (it.id == id) it.copy(name = newName) else it } // Update
// Object updates
_user.value = _user.value.copy(name = newName)
Pattern: Conditional State Transitions
fun attemptLogin(credentials: Credentials) {
if (_loginState.value is LoginState.LoggingIn) {
return // Already logging in, ignore
}
_loginState.value = LoginState.LoggingIn
viewModelScope.launch {
try {
val user = repository.login(credentials)
_loginState.value = LoginState.Success(user)
} catch (e: Exception) {
_loginState.value = LoginState.Error(e.message ?: "Login failed")
}
}
}
Pattern: Derived State
class MyViewModel {
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items.asStateFlow()
// Derived state (computed from items)
val itemCount: StateFlow<Int> = items.map { it.size }
.stateIn(viewModelScope, SharingStarted.Lazily, 0)
val hasItems: StateFlow<Boolean> = items.map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
}
// Usage in Compose
@Composable
fun ItemList(viewModel: MyViewModel) {
val itemCount by viewModel.itemCount.collectAsState()
val hasItems by viewModel.hasItems.collectAsState()
if (hasItems) {
Text("$itemCount items")
} else {
Text("No items")
}
}
Pattern: State with Loading/Error
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String) : UiState<Nothing>()
}
class FeedViewModel {
private val _feedState = MutableStateFlow<UiState<List<Event>>>(UiState.Loading)
val feedState: StateFlow<UiState<List<Event>>> = _feedState.asStateFlow()
fun loadFeed() {
viewModelScope.launch {
_feedState.value = UiState.Loading
try {
val events = repository.getEvents()
_feedState.value = UiState.Success(events)
} catch (e: Exception) {
_feedState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
// UI
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val state by viewModel.feedState.collectAsState()
when (state) {
is UiState.Loading -> LoadingSpinner()
is UiState.Success -> EventList((state as UiState.Success).data)
is UiState.Error -> ErrorMessage((state as UiState.Error).message)
}
}
Anti-Patterns
❌ Exposing Mutable State
// BAD: External code can mutate
class BadViewModel {
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
}
// Caller can do:
viewModel.state.value = State.Hacked // Bypass internal logic!
✅ Expose Immutable
// GOOD: Only ViewModel can mutate
class GoodViewModel {
private val _state = MutableStateFlow(State.Initial)
val state: StateFlow<State> = _state.asStateFlow()
fun updateState(newState: State) {
// Controlled mutation with validation
_state.value = newState
}
}
❌ Not Using Immutable Updates
// BAD: Mutating collection doesn't trigger StateFlow update
val list = mutableListOf<Item>()
list.add(newItem)
_items.value = list // Same reference, no update emitted!
✅ Create New Instance
// GOOD: New list instance
_items.value = _items.value + newItem // New list created, update emitted
❌ StateFlow for Events
// BAD: Events get lost if no collector
class BadViewModel {
val navigationEvent: StateFlow<NavEvent?> = MutableStateFlow(null)
fun navigate(event: NavEvent) {
_navigationEvent.value = event // Lost if UI not observing!
}
}
✅ SharedFlow for Events
// GOOD: Events queued
class GoodViewModel {
private val _navigationEvent = MutableSharedFlow<NavEvent>(replay = 0)
val navigationEvent: SharedFlow<NavEvent> = _navigationEvent.asSharedFlow()
fun navigate(event: NavEvent) {
viewModelScope.launch {
_navigationEvent.emit(event) // Queued for collector
}
}
}
❌ Blocking Operations in State Update
// BAD: Blocking main thread
fun loadData() {
_state.value = fetchDataFromNetwork() // Blocks!
}
✅ Async Updates
// GOOD: Use coroutines
fun loadData() {
viewModelScope.launch {
_state.value = UiState.Loading
val data = withContext(Dispatchers.IO) {
fetchDataFromNetwork()
}
_state.value = UiState.Success(data)
}
}
References
- AccountManager.kt:36-115
- RelayConnectionManager.kt:44-80
- StateFlow and SharedFlow | Android Developers
- Hot vs Cold Flows