406 lines
11 KiB
Markdown
406 lines
11 KiB
Markdown
# Flow Patterns in Amethyst
|
|
|
|
StateFlow and SharedFlow usage patterns from the codebase.
|
|
|
|
## Table of Contents
|
|
- [StateFlow for State Management](#stateflow-for-state-management)
|
|
- [Flow Composition](#flow-composition)
|
|
- [Common Patterns](#common-patterns)
|
|
- [Anti-Patterns](#anti-patterns)
|
|
|
|
---
|
|
|
|
## StateFlow for State Management
|
|
|
|
### AccountManager Pattern
|
|
|
|
**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-115`
|
|
|
|
```kotlin
|
|
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 `MutableStateFlow` for internal mutations
|
|
- Public `StateFlow` via `.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`
|
|
|
|
```kotlin
|
|
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:**
|
|
- `Map` as state value for collection tracking
|
|
- Immutable map updates (copy with modifications)
|
|
- Helper function `updateRelayStatus` for consistent updates
|
|
- Delegation pattern (client exposes its own StateFlows)
|
|
|
|
---
|
|
|
|
## Flow Composition
|
|
|
|
### Multiple StateFlows in UI
|
|
|
|
**Pattern:**
|
|
|
|
```kotlin
|
|
@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:**
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
// GOOD: New list instance
|
|
_items.value = _items.value + newItem // New list created, update emitted
|
|
```
|
|
|
|
---
|
|
|
|
### ❌ StateFlow for Events
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
// BAD: Blocking main thread
|
|
fun loadData() {
|
|
_state.value = fetchDataFromNetwork() // Blocks!
|
|
}
|
|
```
|
|
|
|
### ✅ Async Updates
|
|
|
|
```kotlin
|
|
// 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](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow)
|
|
- [Hot vs Cold Flows](https://carrion.dev/en/posts/kotlin-flows-hot-cold/)
|