finish skills
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
# Custom Icon Assets and ImageVector Patterns
|
||||
|
||||
Guide to creating and using custom ImageVector icons in Compose Multiplatform.
|
||||
|
||||
## Why ImageVector?
|
||||
|
||||
ImageVector is the native Compose format for vector graphics:
|
||||
- **Pure Kotlin**: No XML, no asset files
|
||||
- **Multiplatform**: Works on Android, Desktop, iOS without conversion
|
||||
- **Performant**: Lightweight, composable, GPU-accelerated
|
||||
- **Type-safe**: Compile-time checking, no resource IDs
|
||||
|
||||
## Amethyst Pattern: Robohash
|
||||
|
||||
Amethyst generates deterministic avatars using ImageVector builders.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
commons/robohash/
|
||||
├── RobohashAssembler.kt # Main assembly logic
|
||||
├── CachedRobohash.kt # Caching layer
|
||||
└── parts/
|
||||
├── Face0C3po.kt # Face variants (0-9)
|
||||
├── Eyes2Single.kt # Eye variants (0-9)
|
||||
├── Mouth3Grid.kt # Mouth variants (0-9)
|
||||
├── Body2Thinnest.kt # Body variants (0-9)
|
||||
└── Accessory7Antenna.kt # Accessory variants (0-9)
|
||||
```
|
||||
|
||||
**Pattern**: 10 variants per feature × 5 features = 100,000+ unique combinations
|
||||
|
||||
### roboBuilder DSL
|
||||
|
||||
Custom ImageVector builder with sensible defaults:
|
||||
|
||||
```kotlin
|
||||
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
|
||||
return ImageVector.Builder(
|
||||
name = "Robohash",
|
||||
defaultWidth = 300.dp,
|
||||
defaultHeight = 300.dp,
|
||||
viewportWidth = 300f,
|
||||
viewportHeight = 300f
|
||||
).apply(block).build()
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun CustomIcon() {
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
// Add paths here
|
||||
}
|
||||
),
|
||||
contentDescription = "Custom icon"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Path Building Pattern
|
||||
|
||||
```kotlin
|
||||
fun face0C3po(fgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
|
||||
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
|
||||
builder.addPath(pathData5, fill = Black, fillAlpha = 0.2f)
|
||||
builder.addPath(pathData6, stroke = Black, strokeLineWidth = 1.0f)
|
||||
builder.addPath(pathData7, fill = Black, stroke = Black, fillAlpha = 0.2f, strokeLineWidth = 0.75f)
|
||||
}
|
||||
|
||||
private val pathData1 = PathData {
|
||||
moveTo(144.5f, 87.5f)
|
||||
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
|
||||
curveToRelative(0.0f, 0.0f, 0.0f, 27.0f, 5.0f, 42.0f)
|
||||
reflectiveCurveToRelative(10.0f, 38.0f, 10.0f, 38.0f)
|
||||
lineToRelative(16.0f, 16.0f)
|
||||
// ...
|
||||
close()
|
||||
}
|
||||
```
|
||||
|
||||
**Key elements**:
|
||||
- `pathData` variables for path commands
|
||||
- `addPath()` for each layer
|
||||
- Parameterized colors (`fgColor`)
|
||||
- Constant colors (`Black`)
|
||||
- Alpha for shadows/highlights
|
||||
|
||||
### PathData DSL
|
||||
|
||||
Compose's PathData builder provides SVG-like commands:
|
||||
|
||||
| Command | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| `moveTo(x, y)` | Move pen without drawing | `moveTo(100f, 100f)` |
|
||||
| `lineTo(x, y)` | Draw line to point | `lineTo(200f, 150f)` |
|
||||
| `curveToRelative(...)` | Relative cubic Bézier | `curveToRelative(10f, 20f, 30f, 40f, 50f, 60f)` |
|
||||
| `reflectiveCurveToRelative(...)` | Smooth curve | `reflectiveCurveToRelative(-51f, 3f, -53f, 55f)` |
|
||||
| `horizontalLineTo(x)` | Horizontal line | `horizontalLineTo(250f)` |
|
||||
| `verticalLineTo(y)` | Vertical line | `verticalLineTo(300f)` |
|
||||
| `close()` | Close path | `close()` |
|
||||
|
||||
**Relative vs Absolute**:
|
||||
- `moveTo` / `lineTo` - Absolute coordinates
|
||||
- `moveToRelative` / `lineToRelative` - Relative to current position
|
||||
|
||||
## Creating Custom Icons
|
||||
|
||||
### Method 1: From SVG (Recommended)
|
||||
|
||||
1. **Export SVG** from design tool (Figma, Illustrator)
|
||||
2. **Convert to ImageVector** using Android Studio's Vector Asset tool
|
||||
3. **Extract path data** and adapt to roboBuilder pattern
|
||||
|
||||
```kotlin
|
||||
// SVG path: M 10 10 L 20 20 ...
|
||||
// Becomes:
|
||||
private val myIconPath = PathData {
|
||||
moveTo(10f, 10f)
|
||||
lineTo(20f, 20f)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Programmatic
|
||||
|
||||
Build paths programmatically for simple shapes:
|
||||
|
||||
```kotlin
|
||||
fun simpleIcon(): ImageVector = roboBuilder {
|
||||
addPath(
|
||||
pathData = PathData {
|
||||
moveTo(50f, 50f)
|
||||
lineTo(150f, 50f)
|
||||
lineTo(150f, 150f)
|
||||
lineTo(50f, 150f)
|
||||
close()
|
||||
},
|
||||
fill = SolidColor(Color.Blue),
|
||||
stroke = SolidColor(Color.Black),
|
||||
strokeLineWidth = 2f
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Method 3: Material Icons Extensions
|
||||
|
||||
Extend Material Icons when you need platform-consistent icons:
|
||||
|
||||
```kotlin
|
||||
// For standard icons, use Material Icons
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
Icon(Icons.Default.Check, contentDescription = "Success")
|
||||
Icon(Icons.Default.Close, contentDescription = "Error")
|
||||
```
|
||||
|
||||
## CachedRobohash Pattern
|
||||
|
||||
Performance optimization for generated icons:
|
||||
|
||||
```kotlin
|
||||
object CachedRobohash {
|
||||
private val cache = mutableMapOf<Pair<String, Boolean>, ImageVector>()
|
||||
|
||||
fun get(seed: String, isLight: Boolean): ImageVector {
|
||||
return cache.getOrPut(seed to isLight) {
|
||||
RobohashAssembler.assemble(seed, isLight)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**:
|
||||
- Key: `(seed, theme)` pair
|
||||
- Value: Assembled ImageVector
|
||||
- Lifecycle: Application lifetime (never cleared)
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RobohashImage(robot: String) {
|
||||
Image(
|
||||
imageVector = CachedRobohash.get(robot, isLightTheme()),
|
||||
contentDescription = "Avatar for $robot"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Color Management
|
||||
|
||||
### Dynamic Colors
|
||||
|
||||
Pass colors as parameters for theme adaptation:
|
||||
|
||||
```kotlin
|
||||
fun themedIcon(fgColor: SolidColor, bgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = bgColor)
|
||||
builder.addPath(pathData2, fill = fgColor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedIcon() {
|
||||
val fg = MaterialTheme.colorScheme.primary
|
||||
val bg = MaterialTheme.colorScheme.surface
|
||||
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
themedIcon(SolidColor(fg), SolidColor(bg), this)
|
||||
}
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Static Colors
|
||||
|
||||
Define constants for colors that don't change:
|
||||
|
||||
```kotlin
|
||||
val Black = SolidColor(Color.Black)
|
||||
val White = SolidColor(Color.White)
|
||||
val Transparent = SolidColor(Color.Transparent)
|
||||
```
|
||||
|
||||
## Advanced Techniques
|
||||
|
||||
### Layering
|
||||
|
||||
Build complex icons with multiple layers:
|
||||
|
||||
```kotlin
|
||||
fun complexIcon(builder: Builder) {
|
||||
// Layer 1: Background
|
||||
builder.addPath(bgPath, fill = SolidColor(Color.White))
|
||||
|
||||
// Layer 2: Shadow
|
||||
builder.addPath(shadowPath, fill = SolidColor(Color.Black), fillAlpha = 0.2f)
|
||||
|
||||
// Layer 3: Main shape
|
||||
builder.addPath(mainPath, fill = SolidColor(Color.Blue))
|
||||
|
||||
// Layer 4: Highlight
|
||||
builder.addPath(highlightPath, fill = SolidColor(Color.White), fillAlpha = 0.3f)
|
||||
|
||||
// Layer 5: Stroke
|
||||
builder.addPath(outlinePath, stroke = SolidColor(Color.Black), strokeLineWidth = 1f)
|
||||
}
|
||||
```
|
||||
|
||||
**Render order**: Bottom to top (first addPath = bottom layer)
|
||||
|
||||
### Alpha for Visual Effects
|
||||
|
||||
```kotlin
|
||||
// Shadow
|
||||
builder.addPath(shadowPath, fill = Black, fillAlpha = 0.4f)
|
||||
|
||||
// Highlight
|
||||
builder.addPath(highlightPath, fill = White, fillAlpha = 0.2f)
|
||||
|
||||
// Glass effect
|
||||
builder.addPath(glassPath, fill = White, fillAlpha = 0.1f)
|
||||
```
|
||||
|
||||
### Stroke Styles
|
||||
|
||||
```kotlin
|
||||
// Outline only
|
||||
builder.addPath(path, stroke = Black, strokeLineWidth = 1.5f)
|
||||
|
||||
// Fill + outline
|
||||
builder.addPath(path, fill = fgColor, stroke = Black, strokeLineWidth = 1f)
|
||||
|
||||
// Dashed (not supported directly, use multiple segments)
|
||||
```
|
||||
|
||||
## Composable Icon Pattern
|
||||
|
||||
Wrap ImageVector in a Composable for reusability:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun MyCustomIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = Color.Unspecified
|
||||
) {
|
||||
Image(
|
||||
painter = rememberVectorPainter(myIconVector()),
|
||||
contentDescription = "My custom icon",
|
||||
modifier = modifier,
|
||||
colorFilter = if (tint != Color.Unspecified) {
|
||||
ColorFilter.tint(tint)
|
||||
} else null
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```kotlin
|
||||
MyCustomIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### DO
|
||||
✅ Cache generated ImageVectors for performance
|
||||
✅ Use PathData DSL for readability
|
||||
✅ Parameterize colors for theme support
|
||||
✅ Use Material Icons for standard icons
|
||||
✅ Keep viewport size consistent (e.g., 300×300)
|
||||
✅ Layer paths from back to front
|
||||
✅ Use alpha for shadows and highlights
|
||||
|
||||
### DON'T
|
||||
❌ Generate ImageVectors in @Composable without caching
|
||||
❌ Hardcode theme-specific colors
|
||||
❌ Create custom icons for standard Material icons
|
||||
❌ Use extreme viewport sizes (stay 24-1000dp)
|
||||
❌ Mix absolute and relative coordinates unnecessarily
|
||||
❌ Forget to close() paths
|
||||
|
||||
## Icon Organization
|
||||
|
||||
### Structure
|
||||
```
|
||||
commons/icons/
|
||||
├── CustomIcons.kt # Icon collection object
|
||||
├── icons/
|
||||
│ ├── Zap.kt # Lightning bolt
|
||||
│ ├── Relay.kt # Relay indicator
|
||||
│ └── Bitcoin.kt # Bitcoin symbol
|
||||
└── builders/
|
||||
└── IconBuilder.kt # Shared builder utilities
|
||||
```
|
||||
|
||||
### Collection Object
|
||||
```kotlin
|
||||
object CustomIcons {
|
||||
val Zap: ImageVector by lazy { ZapIcon.create() }
|
||||
val Relay: ImageVector by lazy { RelayIcon.create() }
|
||||
val Bitcoin: ImageVector by lazy { BitcoinIcon.create() }
|
||||
}
|
||||
|
||||
// Usage
|
||||
Icon(CustomIcons.Zap, contentDescription = "Zap")
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Compose ImageVector API](https://developer.android.com/reference/kotlin/androidx/compose/ui/graphics/vector/ImageVector)
|
||||
- [SVG Path Commands](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths)
|
||||
- [Material Icons](https://fonts.google.com/icons)
|
||||
- Robohash implementation: `commons/robohash/` in AmethystMultiplatform
|
||||
@@ -0,0 +1,281 @@
|
||||
# Shared Composables Catalog
|
||||
|
||||
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
commons/src/commonMain/kotlin/.../commons/ui/
|
||||
├── components/ # Reusable UI components
|
||||
├── screens/ # Screen-level composables
|
||||
├── theme/ # Theming and styling
|
||||
└── feed/ # Feed-specific components
|
||||
```
|
||||
|
||||
## Components (`ui/components/`)
|
||||
|
||||
### State Visualization
|
||||
|
||||
**LoadingState** - Centered loading indicator with message
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadingState(message: String, modifier: Modifier = Modifier)
|
||||
```
|
||||
- Use for: Async operations, data fetching
|
||||
- Pattern: fillMaxSize, centered Column, CircularProgressIndicator
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**EmptyState** - Centered empty state with optional refresh
|
||||
```kotlin
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
description: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
refreshLabel: String = "Refresh"
|
||||
)
|
||||
```
|
||||
- Use for: Empty lists, no data scenarios
|
||||
- Pattern: Centered Column, optional OutlinedButton
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**ErrorState** - Centered error message with retry
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
retryLabel: String = "Try Again"
|
||||
)
|
||||
```
|
||||
- Use for: Error handling, failed operations
|
||||
- Pattern: error color, optional Button
|
||||
- Works on: Android, Desktop
|
||||
|
||||
### Feed-Specific States
|
||||
|
||||
**FeedEmptyState** - Pre-configured empty state for feeds
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedEmptyState(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "Feed is empty",
|
||||
onRefresh: (() -> Unit)? = null
|
||||
)
|
||||
```
|
||||
|
||||
**FeedErrorState** - Pre-configured error state for feeds
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedErrorState(
|
||||
errorMessage: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null
|
||||
)
|
||||
```
|
||||
|
||||
### Action Buttons
|
||||
|
||||
**Shared Constants**:
|
||||
```kotlin
|
||||
val ActionButtonShape = RoundedCornerShape(20.dp)
|
||||
val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
|
||||
```
|
||||
|
||||
**AddButton** - Consistent "Add" action button
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Add",
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
- Pattern: OutlinedButton with consistent shape/padding
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**RemoveButton** - Consistent "Remove" action button
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RemoveButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String = "Remove",
|
||||
enabled: Boolean = true
|
||||
)
|
||||
```
|
||||
|
||||
### Custom Images
|
||||
|
||||
**RobohashImage** - Deterministic avatar generation
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RobohashImage(
|
||||
robot: String, // Seed (e.g., pubkey)
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
loadRobohash: Boolean = true
|
||||
)
|
||||
|
||||
// Overload with more options
|
||||
@Composable
|
||||
fun RobohashImage(
|
||||
robot: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
colorFilter: ColorFilter? = null,
|
||||
loadRobohash: Boolean = true
|
||||
)
|
||||
```
|
||||
- Use for: User avatars, deterministic graphics
|
||||
- Pattern: Uses CachedRobohash.get(), isLightTheme() detection
|
||||
- Fallback: Icons.Default.Face
|
||||
- Works on: Android, Desktop (pure ImageVector)
|
||||
|
||||
**Theme Detection Helper**:
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
```
|
||||
|
||||
## Feed Components (`ui/feed/`)
|
||||
|
||||
### FeedHeader
|
||||
|
||||
**FeedHeader** - Screen header with title and relay status
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedHeader(
|
||||
title: String,
|
||||
connectedRelayCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Row with SpaceBetween, title + RelayStatusIndicator
|
||||
- Works on: Android, Desktop
|
||||
|
||||
**RelayStatusIndicator** - Compact relay connection indicator
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Status icon + count text + refresh button
|
||||
- Colors: RelayStatusColors.{Disconnected, Connecting, Connected}
|
||||
- Visual cues: Check icon (connected), Close icon (disconnected)
|
||||
|
||||
## Screens (`ui/screens/`)
|
||||
|
||||
### Placeholder Pattern
|
||||
|
||||
**PlaceholderScreen** - Generic placeholder
|
||||
```kotlin
|
||||
@Composable
|
||||
fun PlaceholderScreen(
|
||||
title: String,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
```
|
||||
- Pattern: Column with title (headlineMedium) + description
|
||||
- Use for: Unimplemented screens, coming soon features
|
||||
|
||||
**Specific Placeholders**:
|
||||
- `SearchPlaceholder()` - Search screen
|
||||
- `MessagesPlaceholder()` - DMs screen
|
||||
- `NotificationsPlaceholder()` - Notifications screen
|
||||
|
||||
Pattern: Specific implementations wrap PlaceholderScreen with preset text.
|
||||
|
||||
## Custom Icons (`robohash/parts/`)
|
||||
|
||||
### ImageVector Builder Pattern
|
||||
|
||||
Amethyst uses a custom DSL for building ImageVector assets:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Face0C3po() {
|
||||
Image(
|
||||
painter = rememberVectorPainter(
|
||||
roboBuilder {
|
||||
face0C3po(SolidColor(Color.Blue), this)
|
||||
}
|
||||
),
|
||||
contentDescription = ""
|
||||
)
|
||||
}
|
||||
|
||||
fun face0C3po(fgColor: SolidColor, builder: Builder) {
|
||||
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
|
||||
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
|
||||
// ...
|
||||
}
|
||||
|
||||
private val pathData1 = PathData {
|
||||
moveTo(144.5f, 87.5f)
|
||||
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
|
||||
// ... path commands
|
||||
}
|
||||
```
|
||||
|
||||
**roboBuilder** - Custom ImageVector.Builder DSL
|
||||
- Located in: `commons/robohash/`
|
||||
- Pattern: Builder-based, composable paths
|
||||
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
|
||||
- Colors: Dynamic (fgColor parameter) + Black constants
|
||||
|
||||
### CachedRobohash
|
||||
|
||||
```kotlin
|
||||
CachedRobohash.get(seed: String, isLight: Boolean): ImageVector
|
||||
```
|
||||
- Deterministic: Same seed → same avatar
|
||||
- Theme-aware: Different colors for light/dark
|
||||
- Cached: Performance optimization
|
||||
- Pure ImageVector: Works on all platforms
|
||||
|
||||
## Sharing Guidelines
|
||||
|
||||
### Always Share
|
||||
- State visualization (Loading, Empty, Error)
|
||||
- Action buttons with consistent styling
|
||||
- Generic placeholders
|
||||
- Custom ImageVector icons
|
||||
- Material3 themed components
|
||||
- Theme utilities (isLightTheme)
|
||||
|
||||
### Platform-Specific (Delegate to Experts)
|
||||
- Navigation structure (android-expert, desktop-expert)
|
||||
- Screen layouts and scaffolds
|
||||
- Platform system integrations
|
||||
- Gesture handling specifics
|
||||
|
||||
### Decision Framework
|
||||
1. **Can it use Material3 primitives?** → Share
|
||||
2. **Does it need platform system APIs?** → Platform-specific
|
||||
3. **Is it a visual component without navigation?** → Share
|
||||
4. **Does it require platform UX patterns?** → Ask platform expert
|
||||
|
||||
## Material3 Usage
|
||||
|
||||
All shared composables use Material3:
|
||||
- `MaterialTheme.colorScheme.*` for colors
|
||||
- `MaterialTheme.typography.*` for text styles
|
||||
- `OutlinedButton`, `Button`, `IconButton` for actions
|
||||
- `CircularProgressIndicator` for loading
|
||||
- `Icon`, `Image` for visuals
|
||||
|
||||
This ensures consistent theming across Android and Desktop.
|
||||
@@ -0,0 +1,334 @@
|
||||
# Compose State Management Patterns
|
||||
|
||||
Visual guide to state management in Compose Multiplatform. For Kotlin-specific patterns (StateFlow, sealed classes), see `kotlin-expert` skill.
|
||||
|
||||
## Core State Functions
|
||||
|
||||
### remember
|
||||
|
||||
Cache values across recompositions:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun Counter() {
|
||||
var count by remember { mutableStateOf(0) }
|
||||
|
||||
Button(onClick = { count++ }) {
|
||||
Text("Clicked $count times")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Simple UI state (toggles, counters, text input)
|
||||
**Visual pattern**: Button press → state changes → UI updates
|
||||
|
||||
### derivedStateOf
|
||||
|
||||
Compute state from other state, recompose only when result changes:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ScrollToTopButton(listState: LazyListState) {
|
||||
// Only recomposes when showButton value changes (not every scroll pixel)
|
||||
val showButton by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
FloatingActionButton(onClick = { /* scroll to top */ }) {
|
||||
Icon(Icons.Default.ArrowUpward, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Input state changes frequently, but derived result changes rarely
|
||||
**Visual pattern**: Scroll position (0, 1, 2...) → boolean (show/hide) → FAB visibility
|
||||
**Performance**: Prevents recomposition on every scroll event
|
||||
|
||||
### produceState
|
||||
|
||||
Convert non-Compose state into Compose state:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoadUserProfile(userId: String): State<User?> {
|
||||
return produceState<User?>(initialValue = null, userId) {
|
||||
value = repository.fetchUser(userId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(userId: String) {
|
||||
val user by LoadUserProfile(userId)
|
||||
|
||||
when (user) {
|
||||
null -> LoadingState("Loading profile...")
|
||||
else -> ProfileCard(user!!)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**: Convert Flow, LiveData, callbacks into Compose state
|
||||
**Visual pattern**: Async operation → state updates → UI reflects changes
|
||||
**Lifecycle**: Coroutine cancelled when composable leaves composition
|
||||
|
||||
## State Hoisting Pattern
|
||||
|
||||
Move state up to make composables reusable and testable:
|
||||
|
||||
### Before (Stateful)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchBar() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Search...") }
|
||||
)
|
||||
}
|
||||
```
|
||||
❌ Hard to test, can't control state externally
|
||||
|
||||
### After (Stateless)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text("Search...") },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchScreen() {
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
Column {
|
||||
SearchBar(query = query, onQueryChange = { query = it })
|
||||
SearchResults(query = query)
|
||||
}
|
||||
}
|
||||
```
|
||||
✅ Reusable, testable, state controlled by parent
|
||||
|
||||
**Hoisting principle**: State goes up, events go down
|
||||
- State: `query: String` (read-only)
|
||||
- Events: `onQueryChange: (String) -> Unit` (write-only)
|
||||
|
||||
## Amethyst State Patterns
|
||||
|
||||
### Theme-Aware State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
private fun isLightTheme(): Boolean {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
return (background.red + background.green + background.blue) / 3 > 0.5f
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThemedContent() {
|
||||
val isDark = !isLightTheme()
|
||||
// Adjust visuals based on theme
|
||||
val iconTint = if (isDark) Color.White else Color.Black
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Derive state from MaterialTheme
|
||||
**Visual**: Component adapts to light/dark theme automatically
|
||||
|
||||
### Relay Status State
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun RelayStatusIndicator(
|
||||
connectedCount: Int,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val statusColor = when {
|
||||
connectedCount == 0 -> RelayStatusColors.Disconnected
|
||||
connectedCount < 3 -> RelayStatusColors.Connecting
|
||||
else -> RelayStatusColors.Connected
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close,
|
||||
tint = statusColor
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Visual state derived from domain state
|
||||
**Visual mapping**:
|
||||
- 0 relays → Red + X icon
|
||||
- 1-2 relays → Yellow + Check icon
|
||||
- 3+ relays → Green + Check icon
|
||||
|
||||
### Loading/Empty/Error States
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
when (uiState) {
|
||||
is UiState.Loading -> LoadingState("Loading feed...")
|
||||
is UiState.Empty -> FeedEmptyState(onRefresh = { viewModel.refresh() })
|
||||
is UiState.Error -> FeedErrorState(
|
||||
errorMessage = uiState.message,
|
||||
onRetry = { viewModel.retry() }
|
||||
)
|
||||
is UiState.Success -> LazyColumn {
|
||||
items(uiState.items) { FeedItem(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern**: Sealed class → visual state component
|
||||
**Components**:
|
||||
- `LoadingState` - Progress indicator
|
||||
- `EmptyState` - Empty message + refresh
|
||||
- `ErrorState` - Error message + retry
|
||||
- Success - Actual content
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Toggle State
|
||||
```kotlin
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = { isExpanded = !isExpanded }) {
|
||||
Icon(
|
||||
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand"
|
||||
)
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
Text("Expanded content...")
|
||||
}
|
||||
```
|
||||
|
||||
### List State with Actions
|
||||
```kotlin
|
||||
var items by remember { mutableStateOf(listOf("Item 1", "Item 2")) }
|
||||
|
||||
Column {
|
||||
AddButton(onClick = {
|
||||
items = items + "Item ${items.size + 1}"
|
||||
})
|
||||
|
||||
items.forEachIndexed { index, item ->
|
||||
Row {
|
||||
Text(item)
|
||||
RemoveButton(onClick = {
|
||||
items = items.filterIndexed { i, _ -> i != index }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TextField State
|
||||
```kotlin
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text("Enter text") }
|
||||
)
|
||||
```
|
||||
|
||||
## Performance Patterns
|
||||
|
||||
### Avoid Unnecessary Recomposition
|
||||
```kotlin
|
||||
// ❌ Bad: Recomposes on every scroll position change
|
||||
@Composable
|
||||
fun BadScrollButton(scrollState: ScrollState) {
|
||||
if (scrollState.value > 100) { // scrollState.value changes constantly
|
||||
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Good: Only recomposes when visibility changes
|
||||
@Composable
|
||||
fun GoodScrollButton(scrollState: ScrollState) {
|
||||
val showButton by remember {
|
||||
derivedStateOf { scrollState.value > 100 }
|
||||
}
|
||||
|
||||
if (showButton) {
|
||||
Button(onClick = { /* ... */ }) { Text("Scroll to Top") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Stable Parameters
|
||||
Use `@Immutable` data classes (see `kotlin-expert`) to prevent recomposition:
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class UserProfile(val name: String, val avatar: String)
|
||||
|
||||
@Composable
|
||||
fun ProfileCard(profile: UserProfile) {
|
||||
// Only recomposes when profile instance changes
|
||||
Row {
|
||||
RobohashImage(robot = profile.avatar)
|
||||
Text(profile.name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Kotlin State
|
||||
|
||||
For ViewModel state, Flow, StateFlow → See `kotlin-expert` skill
|
||||
|
||||
Common integration pattern:
|
||||
```kotlin
|
||||
// ViewModel (Kotlin state)
|
||||
class FeedViewModel {
|
||||
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
}
|
||||
|
||||
// Composable (Compose state)
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
// Use uiState to render UI
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Function | Use Case | Recomposes When |
|
||||
|----------|----------|----------------|
|
||||
| `remember { mutableStateOf() }` | Local UI state | State value changes |
|
||||
| `derivedStateOf { }` | Computed state | Derived result changes |
|
||||
| `produceState { }` | Async/Flow → State | Async operation updates value |
|
||||
| `collectAsState()` | Flow → State | Flow emits new value |
|
||||
| State hoisting | Reusable components | Parent passes new state |
|
||||
|
||||
## Sources
|
||||
|
||||
State management patterns based on:
|
||||
- [State and Jetpack Compose - Android Developers](https://developer.android.com/develop/ui/compose/state)
|
||||
- [When should I use derivedStateOf?](https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b)
|
||||
- [Advanced State and Side Effects](https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects)
|
||||
- AmethystMultiplatform codebase patterns (2025)
|
||||
Reference in New Issue
Block a user