initial skills
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
# 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/)
|
||||
Reference in New Issue
Block a user