Merge pull request #1625 from nrobi144/feat/desktop-multiplatform

Create desktopApp module
This commit is contained in:
Vitor Pamplona
2025-12-29 10:41:58 -05:00
committed by GitHub
155 changed files with 5209 additions and 277 deletions
+174
View File
@@ -0,0 +1,174 @@
# Amethyst Desktop Fork
## Project Overview
Fork of [Amethyst](https://github.com/vitorpamplona/amethyst) adding Compose Multiplatform Desktop support. Quartz library converted to full KMP for code sharing between Android and Desktop JVM.
## Architecture
```
amethyst/
├── quartz/ # Nostr KMP library (protocol only, no UI)
│ └── src/
│ ├── commonMain/ # Shared Nostr protocol, data models
│ ├── androidMain/ # Android-specific (crypto, storage)
│ └── jvmMain/ # Desktop JVM-specific
├── commons/ # Shared UI components (convert to KMP)
│ └── src/
│ ├── commonMain/ # Shared composables, icons, state
│ ├── androidMain/ # Android-specific UI utilities
│ └── jvmMain/ # Desktop-specific UI utilities
├── desktopApp/ # Desktop JVM application (layouts, navigation)
├── amethyst/ # Android app (layouts, navigation)
└── ammolite/ # Support module
```
**Sharing Philosophy:**
- `quartz/` = Business logic, protocol, data (no UI)
- `commons/` = Shared UI components, icons, composables
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Core** | Quartz (Nostr KMP) |
| **UI** | Compose Multiplatform 1.7.x |
| **Async** | kotlinx.coroutines + Flow |
| **Network** | OkHttp (JVM) |
| **Serialization** | Jackson |
| **DI** | Manual / Koin |
| **Build** | Gradle 8.x, Kotlin 2.1.0 |
## Agents
Use these specialized agents for domain expertise:
| Agent | Expertise | When to Use |
|-------|-----------|-------------|
| `nostr-protocol` | NIPs, events, relays, crypto | Protocol questions, NIP implementation |
| `kotlin-multiplatform` | KMP, source sets, expect/actual | Project structure, code sharing |
| `compose-ui` | Composables, Desktop features | UI components, navigation |
| `kotlin-coroutines` | Flows, async, concurrency | Data streams, async operations |
## Commands
- `/desktop-run` - Build and run desktop app
- `/extract <component>` - Move composable to shared code
- `/nip <number>` - Get NIP implementation guidance
## Feature Workflow
When picking up a new task or feature, follow this process:
### Step 1: Analyze Android Implementation
Start by examining the existing Android Amethyst codebase:
1. Find the relevant feature/component in `amethyst/` module
2. Understand the current implementation patterns
3. Identify dependencies and integrations
### Step 2: Create Implementation Plan
Before coding, create a plan that categorizes work into three buckets:
| Category | Description | Location |
|----------|-------------|----------|
| **Android-Specific** | Platform APIs, navigation, layouts | `amethyst/`, `androidMain/` |
| **Reusable (Shared)** | Business logic, UI components, state | `quartz/commonMain/`, `commons/` (convert to KMP) |
| **Desktop-Specific** | Desktop navigation, layouts, platform APIs | `desktopApp/`, `jvmMain/` |
### Step 3: Code Sharing Strategy
**Share:**
- Business logic and data models → `quartz/commonMain/`
- Major UI components (cards, lists, dialogs) → `commons/` (convert to KMP as needed)
- State management and ViewModels → shared
- Icons and visual assets → `commons/commonMain/`
**Keep Platform-Native:**
- Navigation patterns (sidebar vs bottom nav)
- Screen layouts and scaffolding
- Platform-specific interactions (gestures, keyboard shortcuts)
- System integrations (notifications, file pickers)
### Step 4: Extract Shared Components
When extracting UI components:
1. Identify reusable composables in Android code
2. Use `/extract <component>` to move to `commons/commonMain/`
3. Create expect/actual declarations for platform-specific behavior
4. Update both Android and Desktop to use shared component
**Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP.
## Build Commands
```bash
# Run desktop app
./gradlew :desktopApp:run
# Run Android app
./gradlew :amethyst:installDebug
# Build Quartz for all targets
./gradlew :quartz:build
# Run tests
./gradlew test
# Format code
./gradlew spotlessApply
```
## Quartz KMP Structure
The Quartz library uses expect/actual for platform-specific implementations:
```kotlin
// commonMain - shared protocol logic
expect class CryptoProvider {
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean
}
// androidMain - uses secp256k1-kmp-jni-android
actual class CryptoProvider { /* Android implementation */ }
// jvmMain - uses secp256k1-kmp-jni-jvm
actual class CryptoProvider { /* JVM implementation */ }
```
## Key Patterns
### Platform Abstraction
```kotlin
// commonMain
expect fun openExternalUrl(url: String)
// androidMain
actual fun openExternalUrl(url: String) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
// jvmMain (Desktop)
actual fun openExternalUrl(url: String) {
Desktop.getDesktop().browse(URI(url))
}
```
### Navigation Shell
- **Desktop**: Sidebar + main content area
- **Android**: Bottom navigation
## Git Workflow
- Branch: `feat/desktop-<feature>` or `fix/desktop-<issue>`
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
- Never use `--no-verify`
## Resources
- [Nostr NIPs](https://github.com/nostr-protocol/nips)
- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html)
+169
View File
@@ -0,0 +1,169 @@
---
name: compose-ui
description: Automatically invoked when working with Compose Multiplatform UI code, @Composable functions, desktop Window/MenuBar/Tray, navigation patterns, or UI components in desktopApp/ or shared UI modules.
tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch
model: sonnet
---
# Compose Multiplatform UI Agent
You are a Compose Multiplatform UI expert specializing in shared composables and desktop-specific features.
## Auto-Trigger Contexts
Activate when user works with:
- `@Composable` functions
- `desktopApp/` module files
- `Window`, `MenuBar`, `Tray` components
- Navigation patterns (NavigationRail, screens)
- Material3 theming
- Keyboard shortcuts, context menus
## Core Knowledge
### Desktop Entry Point
```kotlin
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
state = rememberWindowState(width = 1200.dp, height = 800.dp),
title = "Amethyst Desktop"
) {
MenuBar {
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
Item("Quit", onClick = ::exitApplication)
}
}
App()
}
}
```
### Desktop-Specific Features
**Menu Bar**
```kotlin
MenuBar {
Menu("File") {
Item("New", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
Separator()
Item("Quit", onClick = ::exitApplication)
}
}
```
**System Tray**
```kotlin
Tray(
icon = painterResource("icon.png"),
menu = {
Item("Show", onClick = { windowVisible = true })
Item("Exit", onClick = ::exitApplication)
}
)
```
**Context Menus**
```kotlin
ContextMenuArea(items = {
listOf(
ContextMenuItem("Copy") { copyToClipboard(text) },
ContextMenuItem("Reply") { openReply() }
)
}) {
Text(content)
}
```
**Keyboard Shortcuts**
```kotlin
Modifier.onKeyEvent { event ->
when {
event.isCtrlPressed && event.key == Key.Enter -> { send(); true }
event.key == Key.Escape -> { close(); true }
else -> false
}
}
```
### Navigation Pattern
```kotlin
@Composable
fun DesktopLayout(currentScreen: Screen, onNavigate: (Screen) -> Unit) {
Row(Modifier.fillMaxSize()) {
NavigationRail {
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
selected = currentScreen == Screen.Feed,
onClick = { onNavigate(Screen.Feed) }
)
// More items...
}
Box(Modifier.weight(1f)) {
when (currentScreen) {
Screen.Feed -> FeedScreen()
Screen.Messages -> MessagesScreen()
}
}
}
}
```
### Platform Differences
| Aspect | Android | Desktop |
|--------|---------|---------|
| **Entry** | Activity | main() + Window |
| **Navigation** | Bottom nav | Sidebar / MenuBar |
| **Input** | Touch | Mouse + Keyboard |
| **Windows** | Single | Multi-window |
| **Menus** | Overflow | MenuBar |
## Workflow
### 1. Assess Task
- Shared composable or desktop-specific?
- Navigation change or component work?
- State management needs?
### 2. Investigate
```bash
# Find existing composables
grep -r "@Composable" desktopApp/src/
# Check navigation structure
grep -r "Screen\|navigate" desktopApp/src/
```
### 3. Implement
- Shared composables go in shared UI module
- Desktop-specific (Window, MenuBar) in desktopApp
- Use Material3 components
- Handle keyboard/mouse input for desktop
### 4. Verify
```bash
./gradlew :desktopApp:run
```
## State Management
```kotlin
class FeedViewModel {
private val _state = MutableStateFlow(FeedState())
val state: StateFlow<FeedState> = _state.asStateFlow()
}
@Composable
fun FeedScreen(viewModel: FeedViewModel) {
val state by viewModel.state.collectAsState()
// UI based on state
}
```
## Constraints
- Prefer shared composables in commonMain when possible
- Desktop-specific features only in desktopApp
- Follow Material3 design guidelines
- Support keyboard navigation for accessibility
- Test on macOS, Windows, Linux when possible
+187
View File
@@ -0,0 +1,187 @@
---
name: kotlin-coroutines
description: Automatically invoked when working with coroutines, Flow, StateFlow, SharedFlow, suspend functions, CoroutineScope, channels, or async patterns in Kotlin code.
tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch
model: sonnet
---
# Kotlin Coroutines Agent
You are a Kotlin Coroutines expert specializing in async patterns, Flow, and structured concurrency.
## Auto-Trigger Contexts
Activate when user works with:
- `suspend` functions
- `Flow`, `StateFlow`, `SharedFlow`
- `CoroutineScope`, `launch`, `async`
- `Channel`, `channelFlow`
- Dispatchers configuration
- Exception handling in coroutines
## Core Knowledge
### Coroutine Builders
```kotlin
// launch: fire-and-forget
val job = launch { delay(1000); println("Done") }
// async: returns result
val deferred = async { computeValue() }
val result = deferred.await()
// Structured concurrency
suspend fun loadProfile(userId: String) = coroutineScope {
val user = async { fetchUser(userId) }
val notes = async { fetchNotes(userId) }
Profile(user.await(), notes.await())
}
```
### Dispatchers
| Dispatcher | Use Case |
|------------|----------|
| `Dispatchers.Main` | UI updates |
| `Dispatchers.IO` | Network, disk I/O |
| `Dispatchers.Default` | CPU-intensive |
### Flow (Cold Streams)
```kotlin
fun observeNotes(): Flow<List<Note>> = flow {
while (true) {
emit(repository.getNotes())
delay(30_000)
}
}
// Operators
repository.observeNotes()
.map { it.filter { note -> note.isVisible } }
.distinctUntilChanged()
.catch { emit(emptyList()) }
.flowOn(Dispatchers.IO)
.collect { updateUI(it) }
```
### StateFlow (Hot, Always Has Value)
```kotlin
class FeedViewModel {
private val _state = MutableStateFlow(FeedState())
val state: StateFlow<FeedState> = _state.asStateFlow()
fun updateFilter(filter: Filter) {
_state.update { it.copy(filter = filter) }
}
}
```
### SharedFlow (Hot, Configurable Replay)
```kotlin
private val _events = MutableSharedFlow<AppEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<AppEvent> = _events.asSharedFlow()
```
### Channels
```kotlin
fun relayEvents(relay: Relay): Flow<Event> = channelFlow {
relay.connect()
relay.onEvent { event -> trySend(event) }
awaitClose { relay.disconnect() }
}
```
### Exception Handling
```kotlin
val handler = CoroutineExceptionHandler { _, e ->
log.error("Coroutine failed", e)
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)
// supervisorScope: child failures don't cancel siblings
supervisorScope {
launch { task1() } // Can fail independently
launch { task2() } // Continues if task1 fails
}
```
## Nostr-Specific Patterns
### Relay Connection Pool
```kotlin
class RelayPool(private val scope: CoroutineScope) {
fun connect(url: String) {
scope.launch {
supervisorScope {
launch { connection.receiveLoop() }
launch { connection.sendLoop() }
}
}
}
fun observeEvents(): Flow<Event> = relays.values
.map { it.events }
.merge()
.distinctBy { it.id }
}
```
### Subscription Management
```kotlin
fun subscribe(filters: List<Filter>): Flow<Event> = channelFlow {
val subId = UUID.randomUUID().toString()
try {
relayPool.activeRelays.collect { relays ->
relays.forEach { relay ->
launch { relay.subscribe(subId, filters).collect { send(it) } }
}
}
} finally {
relayPool.unsubscribe(subId)
}
}
```
## Workflow
### 1. Assess Task
- Cold stream (Flow) or hot stream (StateFlow/SharedFlow)?
- Need structured concurrency?
- Error handling strategy?
### 2. Investigate
```bash
# Find coroutine usage
grep -r "suspend \|launch\|async\|Flow<" quartz/src/
# Check existing patterns
grep -r "CoroutineScope\|StateFlow" quartz/src/
```
### 3. Implement
- Use appropriate dispatcher
- Implement proper cancellation
- Handle exceptions at right level
- Use structured concurrency
### 4. Test
```kotlin
@Test
fun `test async operation`() = runTest {
val result = viewModel.loadData()
advanceUntilIdle()
assertEquals(expected, result)
}
```
## Constraints
- Always use structured concurrency
- Never use `GlobalScope`
- Handle cancellation cooperatively (`ensureActive()`, `yield()`)
- Use `SupervisorJob` when children should fail independently
- Prefer Flow over callbacks
- Use `flowOn` to switch dispatchers, not `withContext` in flow
+127
View File
@@ -0,0 +1,127 @@
---
name: kotlin-multiplatform
description: Automatically invoked when working with KMP project structure, build.gradle.kts files, expect/actual declarations, source sets (commonMain, androidMain, jvmMain), or multiplatform migration tasks. Expert in code sharing across Android and Desktop JVM.
tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch
model: sonnet
---
# Kotlin Multiplatform Agent
You are a Kotlin Multiplatform expert specializing in KMP architecture for Android and Desktop JVM targets.
## Auto-Trigger Contexts
Activate when user works with:
- `build.gradle.kts` files with multiplatform plugin
- Files in `commonMain/`, `androidMain/`, `jvmMain/` source sets
- `expect` or `actual` declarations
- Cross-platform library selection
- Migration from Android-only to multiplatform
## Core Knowledge
### Source Set Hierarchy
```
commonMain
┌───────────┼───────────┐
│ │ │
jvmMain nativeMain jsMain
│ │
┌──────┴───┐ ┌───┴───┐
│ │ │ │
androidMain desktopMain iosMain
```
### expect/actual Pattern
```kotlin
// commonMain - Declaration
expect class PlatformContext
expect fun getPlatform(): Platform
// androidMain
actual class PlatformContext(val context: Context)
actual fun getPlatform(): Platform = Platform.Android
// jvmMain (Desktop)
actual class PlatformContext
actual fun getPlatform(): Platform = Platform.Desktop
```
### Platform Dependencies
| Concern | Android | Desktop JVM |
|---------|---------|-------------|
| **Crypto** | secp256k1-kmp-jni-android | secp256k1-kmp-jni-jvm |
| **Storage** | Android KeyStore | Java KeyStore / File |
| **Sodium** | lazysodium-android | lazysodium-java |
| **UI** | Jetpack Compose | Compose Desktop |
## Workflow
### 1. Assess Task
- Identify if task involves shared vs platform-specific code
- Check existing source set structure
- Understand dependency requirements
### 2. Investigate
```bash
# Check existing structure
ls -la quartz/src/
# Find expect declarations
grep -r "expect " quartz/src/commonMain/
# Find actual implementations
grep -r "actual " quartz/src/androidMain/ quartz/src/jvmMain/
```
### 3. Implement
- Place shared code in `commonMain`
- Create `expect` declarations for platform APIs
- Implement `actual` in each target source set
- Update `build.gradle.kts` dependencies per source set
### 4. Verify
```bash
./gradlew :quartz:build
./gradlew :desktopApp:compileKotlinJvm
```
## Quartz KMP Structure
```
quartz/src/
├── commonMain/kotlin/ # Shared Nostr protocol
├── commonTest/kotlin/ # Shared tests
├── androidMain/kotlin/ # Android crypto, storage
└── jvmMain/kotlin/ # Desktop crypto, storage
```
## Key Abstractions for This Project
- `CryptoProvider` - secp256k1 signing/verification
- `SodiumProvider` - NIP-44 encryption
- `SecureStorage` - Key storage
- `PlatformContext` - Platform-specific context
## Constraints
- Maximize code in `commonMain` (target 70-80%)
- Use KMP-compatible libraries only in commonMain
- Platform implementations must have identical signatures
- Run tests on all targets before completing
## Resources
Reference these GitHub repositories for KMP patterns and libraries:
| Repository | Focus | Key Examples |
|------------|-------|--------------|
| [joreilly](https://github.com/joreilly) | KMP samples | PeopleInSpace, Confetti, GeminiKMP |
| [touchlab](https://github.com/touchlab) | KMP tooling | Kermit (logging), Stately (state), SKIE |
| [cashapp](https://github.com/cashapp) | KMP libraries | SQLDelight, Turbine, Molecule |
**Useful libraries from these sources:**
- `SQLDelight` - Type-safe SQL for all platforms
- `Kermit` - Multiplatform logging
- `Turbine` - Testing Kotlin Flows
- `Molecule` - Build UI state with Compose
+168
View File
@@ -0,0 +1,168 @@
---
name: nostr-protocol
description: Automatically invoked when working with Nostr events, NIPs, relay communication, cryptographic operations (signing, encryption), or Quartz library code for protocol implementation.
tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch
model: sonnet
---
# Nostr Protocol Agent
You are a Nostr Protocol expert specializing in NIPs, events, relays, and cryptographic operations.
## Auto-Trigger Contexts
Activate when user works with:
- Event classes (kind, tags, content, sig)
- NIP implementations
- Relay WebSocket communication
- Cryptographic operations (secp256k1, NIP-44)
- Quartz library protocol code
- Filters and subscriptions
## Core Knowledge
### Event Structure
```kotlin
data class Event(
val id: String, // SHA256 of serialized event
val pubkey: String, // 32-byte hex public key
val created_at: Long, // Unix timestamp
val kind: Int, // Event type
val tags: List<List<String>>,
val content: String,
val sig: String // 64-byte Schnorr signature
)
// Event ID = SHA256([0, pubkey, created_at, kind, tags, content])
```
### NIP Categories
| Category | NIPs | Scope |
|----------|------|-------|
| **Core** | 01, 02, 10, 11 | Basic events, follows, threads, relay info |
| **Messaging** | 04, 17, 44 | DMs, encrypted messaging |
| **Social** | 18, 25, 32, 51 | Reactions, reports, lists |
| **Identity** | 05, 19, 39, 46 | DNS, bech32, bunker, signer |
| **Media** | 23, 30, 54, 94 | Long-form, audio, video, blobs |
| **Payments** | 47, 57, 60 | Wallet Connect, zaps, cashu |
### Relay Messages
```
Client -> Relay:
["REQ", <sub_id>, <filter>...] # Subscribe
["EVENT", <event>] # Publish
["CLOSE", <sub_id>] # Unsubscribe
Relay -> Client:
["EVENT", <sub_id>, <event>] # Event received
["EOSE", <sub_id>] # End of stored events
["OK", <event_id>, <bool>, <msg>] # Publish result
["NOTICE", <message>] # Info message
```
### Cryptographic Operations
**Signing (Schnorr/BIP-340)**
```kotlin
val signature = Secp256k1.sign(
data = eventHash,
privateKey = privateKeyBytes
)
```
**NIP-44 Encryption (Modern)**
```kotlin
val ciphertext = Nip44.encrypt(
plaintext = message,
sharedSecret = computeSharedSecret(myPrivKey, theirPubKey)
)
```
**NIP-04 Encryption (Deprecated)**
```kotlin
// AES-256-CBC - only for legacy compatibility
val ciphertext = Nip04.encrypt(message, sharedSecret)
```
### Common Event Kinds
| Kind | NIP | Description |
|------|-----|-------------|
| 0 | 01 | Metadata (profile) |
| 1 | 01 | Short text note |
| 3 | 02 | Follows list |
| 4 | 04 | Encrypted DM (deprecated) |
| 7 | 25 | Reaction |
| 1984 | 32 | Report |
| 30023 | 23 | Long-form content |
### Tag Patterns
```kotlin
// Reply threading (NIP-10)
tags = listOf(
listOf("e", rootEventId, relayUrl, "root"),
listOf("e", replyToId, relayUrl, "reply"),
listOf("p", authorPubkey)
)
// Mentions
tags = listOf(
listOf("p", mentionedPubkey),
listOf("t", "hashtag")
)
```
## Workflow
### 1. Assess Task
- Which NIP(s) are involved?
- Event creation or parsing?
- Relay communication?
- Crypto operations needed?
### 2. Investigate
```bash
# Check existing NIP implementations
grep -r "kind.*=" quartz/src/
# Find event classes
grep -r "class.*Event" quartz/src/
# Check crypto usage
grep -r "Secp256k1\|Nip44\|sign\|verify" quartz/src/
```
### 3. Reference NIP Spec
```bash
# Fetch NIP specification
curl https://raw.githubusercontent.com/nostr-protocol/nips/master/XX.md
```
### 4. Implement
- Follow NIP spec exactly
- Use existing Quartz patterns
- Validate event structure
- Test signature verification
### 5. Verify
```bash
./gradlew :quartz:test
```
## Quartz Library Structure
```
quartz/src/commonMain/kotlin/
├── events/ # Event types per NIP
├── encoders/ # Bech32, hex encoding
├── crypto/ # Signing, encryption
├── relay/ # WebSocket communication
└── filters/ # Subscription filters
```
## Constraints
- Always follow NIP specifications exactly
- Use NIP-44 for new encryption (not NIP-04)
- Validate all incoming events (sig, id)
- Never log private keys or decrypted content
- Use existing Quartz classes when available
- Test with real relay responses when possible
+45
View File
@@ -0,0 +1,45 @@
---
description: Build and run the desktop app
---
Build and run the Amethyst Desktop application:
```bash
./gradlew :desktopApp:run
```
## Troubleshooting
If the build fails, check:
1. **JDK Version**: Requires JDK 17+
```bash
java -version
```
2. **Compose Multiplatform Plugin**: Verify version in `gradle/libs.versions.toml`
3. **Quartz Build**: Ensure Quartz compiles first
```bash
./gradlew :quartz:build
```
4. **Desktop Dependencies**: Check `desktopApp/build.gradle.kts` has:
```kotlin
implementation(compose.desktop.currentOs)
```
## Creating Distributable
```bash
# macOS
./gradlew :desktopApp:packageDmg
# Windows
./gradlew :desktopApp:packageMsi
# Linux
./gradlew :desktopApp:packageDeb
```
Outputs will be in `desktopApp/build/compose/binaries/`
+50
View File
@@ -0,0 +1,50 @@
---
description: Extract a composable from amethyst to shared code
---
Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
## Process
1. **Locate the component** in the amethyst module:
```bash
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
```
2. **Analyze dependencies**:
- Android-specific imports (Context, Intent, etc.)
- Platform APIs (Camera, MediaStore, etc.)
- Android Compose specifics vs standard Compose
3. **Identify what can be shared**:
- Pure Composable functions → `shared-ui/commonMain/`
- Business logic → `quartz/commonMain/`
- Platform-specific → create expect/actual
4. **Create shared version**:
- Move to appropriate shared module
- Replace Android imports with multiplatform alternatives
- Add expect declarations for platform-specific parts
5. **Update references**:
- Change imports in amethyst module
- Add implementations in desktopApp if needed
## Common Replacements
| Android | Multiplatform |
|---------|---------------|
| `LocalContext.current` | expect/actual or parameter |
| `stringResource()` | `Res.string.*` |
| `painterResource()` | `painterResource(Res.drawable.*)` |
| `Toast.makeText()` | Custom snackbar/notification |
| `Intent` | expect/actual for navigation |
## Example
```
/extract NoteCard
```
This will find NoteCard, analyze its dependencies, and guide you through extracting it to shared code.
+32
View File
@@ -0,0 +1,32 @@
---
description: Get NIP specification and implementation guidance
---
Fetch and explain NIP-$ARGUMENTS from the Nostr protocol:
1. **Get the specification** from https://github.com/nostr-protocol/nips/blob/master/$ARGUMENTS.md
2. **Show key details**:
- Event kind(s) used
- Required and optional fields
- Tag structure
- Message flow between client and relay
3. **Check implementation status** in Quartz:
```bash
grep -r "NIP-$ARGUMENTS\|nip$ARGUMENTS\|kind.*=" quartz/src/
```
4. **Provide implementation guidance**:
- Which Quartz classes to use or create
- Event construction example
- Relay subscription filters
- Verification/validation logic
## Example Usage
```
/nip 01 # Basic protocol
/nip 44 # Versioned encryption
/nip 57 # Zaps
```
+339
View File
@@ -0,0 +1,339 @@
# Compose Desktop Skill
## Desktop Application Entry Point
```kotlin
// desktopApp/src/jvmMain/kotlin/Main.kt
package com.vitorpamplona.amethyst.desktop
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
height = 800.dp,
position = WindowPosition.Aligned(Alignment.Center)
)
// System tray
Tray(
icon = painterResource("icon.png"),
tooltip = "Amethyst",
menu = {
Item("Show", onClick = { windowState.isMinimized = false })
Separator()
Item("Exit", onClick = ::exitApplication)
}
)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Amethyst",
icon = painterResource("icon.png")
) {
MenuBar {
Menu("File") {
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
Separator()
Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { }
Separator()
Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication)
}
Menu("Edit") {
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { }
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { }
}
Menu("View") {
Item("Feed") { }
Item("Messages") { }
Item("Notifications") { }
}
Menu("Help") {
Item("About Amethyst") { }
}
}
App()
}
}
```
## Desktop-Specific Components
### File Dialog
```kotlin
@Composable
fun rememberFileDialog(): FileDialogState {
return remember { FileDialogState() }
}
class FileDialogState {
var isOpen by mutableStateOf(false)
var result by mutableStateOf<File?>(null)
fun open() { isOpen = true }
@Composable
fun Dialog(
title: String = "Select File",
allowedExtensions: List<String> = emptyList()
) {
if (isOpen) {
DisposableEffect(Unit) {
val dialog = java.awt.FileDialog(null as java.awt.Frame?, title)
if (allowedExtensions.isNotEmpty()) {
dialog.setFilenameFilter { _, name ->
allowedExtensions.any { name.endsWith(it) }
}
}
dialog.isVisible = true
result = dialog.file?.let { File(dialog.directory, it) }
isOpen = false
onDispose { }
}
}
}
}
```
### Scroll Behavior
```kotlin
@Composable
fun DesktopScrollableColumn(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val scrollState = rememberScrollState()
Box(modifier) {
Column(
modifier = Modifier
.verticalScroll(scrollState)
.fillMaxSize()
) {
content()
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd),
adapter = rememberScrollbarAdapter(scrollState)
)
}
}
```
### Keyboard Navigation
```kotlin
@Composable
fun KeyboardNavigableList(
items: List<Note>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
onActivate: (Note) -> Unit
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
LazyColumn(
modifier = Modifier
.focusRequester(focusRequester)
.focusable()
.onKeyEvent { event ->
when {
event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex))
true
}
event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> {
onSelect((selectedIndex - 1).coerceAtLeast(0))
true
}
event.key == Key.Enter && event.type == KeyEventType.KeyDown -> {
items.getOrNull(selectedIndex)?.let { onActivate(it) }
true
}
else -> false
}
}
) {
itemsIndexed(items) { index, note ->
NoteCard(
note = note,
isSelected = index == selectedIndex,
modifier = Modifier.clickable { onSelect(index) }
)
}
}
}
```
### Multi-Window Support
```kotlin
@Composable
fun ApplicationScope.NoteDetailWindow(
note: Note,
onClose: () -> Unit
) {
Window(
onCloseRequest = onClose,
title = "Note by ${note.author.name}",
state = rememberWindowState(width = 600.dp, height = 400.dp)
) {
NoteDetailScreen(note)
}
}
// Usage in main application
var openNotes by remember { mutableStateOf<List<Note>>(emptyList()) }
openNotes.forEach { note ->
key(note.id) {
NoteDetailWindow(
note = note,
onClose = { openNotes = openNotes - note }
)
}
}
```
### Tooltips
```kotlin
@Composable
fun TooltipButton(
tooltip: String,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
TooltipArea(
tooltip = {
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.inverseSurface
) {
Text(
text = tooltip,
modifier = Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
) {
IconButton(onClick = onClick) {
content()
}
}
}
```
## Desktop Layout Pattern
```kotlin
@Composable
fun DesktopAppLayout(
currentScreen: Screen,
onNavigate: (Screen) -> Unit,
content: @Composable () -> Unit
) {
Row(Modifier.fillMaxSize()) {
// Sidebar navigation
NavigationRail(
modifier = Modifier.width(72.dp),
containerColor = MaterialTheme.colorScheme.surfaceVariant
) {
Spacer(Modifier.height(16.dp))
NavigationRailItem(
icon = { Icon(Icons.Default.Home, "Feed") },
label = { Text("Feed") },
selected = currentScreen == Screen.Feed,
onClick = { onNavigate(Screen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, "Messages") },
label = { Text("DMs") },
selected = currentScreen == Screen.Messages,
onClick = { onNavigate(Screen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == Screen.Notifications,
onClick = { onNavigate(Screen.Notifications) }
)
Spacer(Modifier.weight(1f))
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, "Settings") },
label = { Text("Settings") },
selected = currentScreen == Screen.Settings,
onClick = { onNavigate(Screen.Settings) }
)
}
// Divider
VerticalDivider()
// Main content
Box(Modifier.weight(1f).fillMaxHeight()) {
content()
}
}
}
```
## Build Configuration
```kotlin
// desktopApp/build.gradle.kts
plugins {
kotlin("jvm")
id("org.jetbrains.compose")
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.material3)
implementation(project(":quartz"))
}
compose.desktop {
application {
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
nativeDistributions {
targetFormats(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb
)
packageName = "Amethyst"
packageVersion = "1.0.0"
macOS {
bundleID = "com.vitorpamplona.amethyst.desktop"
iconFile.set(project.file("icons/icon.icns"))
}
windows {
iconFile.set(project.file("icons/icon.ico"))
menuGroup = "Amethyst"
}
linux {
iconFile.set(project.file("icons/icon.png"))
}
}
}
}
```
+165
View File
@@ -0,0 +1,165 @@
# Quartz KMP Conversion Skill
When working with Quartz library conversion to Kotlin Multiplatform:
## Current Structure (Android-only)
```
quartz/
├── build.gradle
└── src/
├── main/kotlin/ # All Nostr code here
├── test/
└── androidTest/
```
## Target Structure (KMP)
```
quartz/
├── build.gradle.kts
└── src/
├── commonMain/kotlin/ # Shared protocol code
├── commonTest/kotlin/ # Shared tests
├── androidMain/kotlin/ # Android crypto, storage
├── androidTest/kotlin/
├── jvmMain/kotlin/ # Desktop crypto, storage
└── jvmTest/kotlin/
```
## Platform Abstractions Required
### 1. Cryptography (expect/actual)
```kotlin
// commonMain
expect object Secp256k1 {
fun sign(data: ByteArray, privateKey: ByteArray): ByteArray
fun verify(data: ByteArray, signature: ByteArray, pubKey: ByteArray): Boolean
fun pubKeyCreate(privateKey: ByteArray): ByteArray
}
// androidMain - uses secp256k1-kmp-jni-android
actual object Secp256k1 {
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
}
// ...
}
// jvmMain - uses secp256k1-kmp-jni-jvm
actual object Secp256k1 {
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
}
// ...
}
```
### 2. NIP-44 Encryption (Sodium)
```kotlin
// commonMain
expect object Nip44 {
fun encrypt(plaintext: String, sharedSecret: ByteArray): String
fun decrypt(ciphertext: String, sharedSecret: ByteArray): String
}
// androidMain - lazysodium-android
// jvmMain - lazysodium-java or libsodium-jni
```
### 3. Secure Random
```kotlin
// commonMain
expect fun secureRandomBytes(size: Int): ByteArray
// androidMain
actual fun secureRandomBytes(size: Int): ByteArray {
return SecureRandom().let { random ->
ByteArray(size).also { random.nextBytes(it) }
}
}
// jvmMain
actual fun secureRandomBytes(size: Int): ByteArray {
return java.security.SecureRandom().let { random ->
ByteArray(size).also { random.nextBytes(it) }
}
}
```
## Build Configuration
```kotlin
// quartz/build.gradle.kts
plugins {
kotlin("multiplatform")
id("com.android.library")
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
jvm("desktop") {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.collections.immutable)
}
}
val androidMain by getting {
dependencies {
implementation(libs.secp256k1.kmp.jni.android)
implementation(libs.lazysodium.android)
}
}
val desktopMain by getting {
dependencies {
implementation(libs.secp256k1.kmp.jni.jvm)
// lazysodium-java or alternative
}
}
}
}
android {
namespace = "com.vitorpamplona.quartz"
compileSdk = 35
defaultConfig.minSdk = 26
}
```
## Migration Steps
1. **Convert build.gradle to build.gradle.kts** with KMP plugin
2. **Move pure Kotlin code** to `commonMain/`
3. **Identify platform dependencies** (crypto, JNA, Android APIs)
4. **Create expect declarations** for platform-specific APIs
5. **Implement actuals** in androidMain and jvmMain
6. **Update imports** in amethyst module
7. **Test on both platforms**
## Files to Move to commonMain
Most of Quartz can be shared:
- Event classes and parsing
- Filter definitions
- Relay message types
- NIP implementations (logic only)
- Utilities (hex encoding, bech32)
## Files Needing expect/actual
- `Secp256k1.kt` - Signature operations
- `Nip04.kt` - Legacy encryption (uses AES)
- `Nip44.kt` - Modern encryption (uses ChaCha)
- `KeyPair.kt` - Key generation
+3
View File
@@ -138,3 +138,6 @@ lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Local task tracking
TASKS.md
+12 -4
View File
@@ -153,9 +153,11 @@ Information shared on Nostr can be re-broadcasted to other servers and should be
# Development Overview
This repository is split between Amethyst and Quartz:
- Amethyst is a native Android app made with Kotlin and Jetpack Compose.
- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
This repository is split between Amethyst, Quartz, Commons, and DesktopApp:
- **Amethyst** - Native Android app with Kotlin and Jetpack Compose
- **Quartz** - Nostr-commons KMP library for protocol classes shared across platforms
- **Commons** - Kotlin Multiplatform module with shared UI components (icons, robohash, blurhash, composables)
- **DesktopApp** - Compose Multiplatform Desktop application reusing commons and quartz
The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
@@ -187,11 +189,17 @@ git clone https://github.com/vitorpamplona/amethyst.git
Use an Android Studio build action to install and run the app on your device or a simulator.
## Building
Build the app:
Build the Android app:
```bash
./gradlew assembleDebug
```
Build and run the Desktop app (requires Java 21+):
```bash
./gradlew :desktopApp:run
```
## Testing
```bash
./gradlew test
@@ -31,6 +31,7 @@ import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.base64Image.Base64Image
import com.vitorpamplona.amethyst.commons.base64Image.toBitmap
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
@@ -42,7 +43,7 @@ class Base64Fetcher(
override suspend fun fetch(): FetchResult? =
runCatching {
ImageFetchResult(
image = Base64Image.Companion.toBitmap(data.toString()).asImage(true),
image = Base64Image.toBitmap(data.toString()).asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
@@ -30,6 +30,7 @@ import coil3.fetch.ImageFetchResult
import coil3.key.Keyer
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
import com.vitorpamplona.amethyst.commons.blurhash.toAndroidBitmap
data class BlurhashWrapper(
val blurhash: String,
@@ -43,10 +44,10 @@ class BlurHashFetcher(
override suspend fun fetch(): FetchResult? {
val hash = data.blurhash
val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
return ImageFetchResult(
image = bitmap.asImage(true),
image = platformImage.toAndroidBitmap().asImage(true),
isSampled = false,
dataSource = DataSource.MEMORY,
)
+2 -1
View File
@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.diffplugSpotless)
alias(libs.plugins.googleServices) apply false
alias(libs.plugins.jetbrainsComposeCompiler) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
alias(libs.plugins.serialization)
@@ -64,4 +65,4 @@ tasks.register('installGitHook', Copy) {
into { new File(rootProject.rootDir, '.git/hooks') }
filePermissions { unix(0777) }
}
tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook
tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook
-69
View File
@@ -1,69 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.jetbrainsComposeCompiler)
}
android {
namespace = 'com.vitorpamplona.amethyst.commons'
compileSdk = libs.versions.android.compileSdk.get().toInteger()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInteger()
targetSdk = libs.versions.android.targetSdk.get().toInteger()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.debug
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
buildFeatures {
compose = true
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
freeCompilerArgs.add("-Xstring-concat=inline")
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
}
dependencies {
implementation project(path: ':quartz')
// Import @Immutable and @Stable
implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.ui
implementation libs.androidx.compose.foundation
debugImplementation libs.androidx.ui.tooling
implementation libs.androidx.ui.tooling.preview
// immutable collections to avoid recomposition
api libs.kotlinx.collections.immutable
testImplementation libs.junit
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
}
+121
View File
@@ -0,0 +1,121 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.composeMultiplatform)
}
android {
namespace = "com.vitorpamplona.amethyst.commons"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xstring-concat=inline")
freeCompilerArgs.add("-Xexpect-actual-classes")
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets {
commonMain {
dependencies {
implementation(project(":quartz"))
// Compose Multiplatform
implementation(compose.ui)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
// LruCache (KMP-ready)
implementation(libs.androidx.collection)
// Immutable collections
api(libs.kotlinx.collections.immutable)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
}
}
// Shared JVM code for both Android and Desktop
val jvmAndroid = create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// URL detection (JVM library, works on both)
implementation(libs.url.detector)
}
}
jvmMain {
dependsOn(jvmAndroid)
dependencies {
// Desktop-specific Compose
implementation(compose.desktop.currentOs)
implementation(compose.uiTooling)
}
}
androidMain {
dependsOn(jvmAndroid)
dependencies {
// Android-specific Compose tooling
implementation(libs.androidx.ui.tooling.preview)
}
}
androidUnitTest {
dependencies {
implementation(libs.junit)
}
}
androidInstrumentedTest {
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.espresso.core)
}
}
}
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.base64Image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
import java.util.Base64
fun Base64Image.toBitmap(content: String): Bitmap {
val matcher = pattern.matcher(content)
if (matcher.find()) {
val base64String = matcher.group(2)
val byteArray = Base64.getDecoder().decode(base64String)
return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
}
throw Exception("Unable to convert base64 to image $content")
}
/**
* Converts a base64 image data URI to a PlatformImage.
* Delegates to toBitmap and wraps the result.
*/
fun Base64Image.toPlatformImage(content: String): PlatformImage = toBitmap(content).toPlatformImage()
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
/**
* Encodes this Android Bitmap to a blurhash string.
* Delegates to PlatformImage.toBlurhash() for the actual encoding.
*/
fun Bitmap.toBlurhash(): String = this.toPlatformImage().toBlurhash()
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
actual class PlatformImage(
val bitmap: Bitmap,
) {
actual val width: Int get() = bitmap.width
actual val height: Int get() = bitmap.height
actual fun getPixels(
pixels: IntArray,
offset: Int,
stride: Int,
x: Int,
y: Int,
width: Int,
height: Int,
) {
bitmap.getPixels(pixels, offset, stride, x, y, width, height)
}
actual fun scale(
width: Int,
height: Int,
): PlatformImage = PlatformImage(Bitmap.createScaledBitmap(bitmap, width, height, false))
actual companion object {
actual fun create(
pixels: IntArray,
width: Int,
height: Int,
): PlatformImage = PlatformImage(Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888))
}
}
/**
* Extension to convert Android Bitmap to PlatformImage.
*/
fun Bitmap.toPlatformImage(): PlatformImage = PlatformImage(this)
/**
* Extension to get the underlying Android Bitmap.
*/
fun PlatformImage.toAndroidBitmap(): Bitmap = this.bitmap
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb
import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.srgbToLinear
import kotlin.math.pow
@@ -66,7 +65,7 @@ object BlurHashDecoder {
}
/**
* Decode a blur hash into a new bitmap.
* Decode a blur hash into a new PlatformImage.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations. By
@@ -76,7 +75,7 @@ object BlurHashDecoder {
blurHash: String?,
width: Int,
useCache: Boolean = true,
): Bitmap? {
): PlatformImage? {
if (blurHash == null || blurHash.length < 6) {
return null
}
@@ -87,7 +86,7 @@ object BlurHashDecoder {
val height = (width * (1 / (numCompX.toFloat() / numCompY.toFloat()))).roundToInt()
val colors = computeColors(numCompX, numCompY, blurHash)
val imageArray = composeImageArray(width, height, numCompX, numCompY, colors, useCache)
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
return PlatformImage.create(imageArray, width, height)
}
private fun decodeDc(colorEnc: Int): FloatArray {
@@ -20,14 +20,17 @@
*/
package com.vitorpamplona.amethyst.commons.blurhash
import android.graphics.Bitmap
import kotlin.math.roundToInt
fun Bitmap.toBlurhash(): String {
/**
* Encodes this PlatformImage to a blurhash string.
* The image will be scaled down if larger than 100x100 for performance.
*/
fun PlatformImage.toBlurhash(): String {
val aspectRatio = this.width.toFloat() / this.height.toFloat()
if (this.width > 100 && this.height > 100) {
return Bitmap.createScaledBitmap(this, 100, (100 / aspectRatio).toInt(), false).toBlurhash()
return this.scale(100, (100 / aspectRatio).toInt()).toBlurhash()
}
val intArray = IntArray(width * height)
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.commons.blurhash
import androidx.collection.LruCache
import kotlin.math.PI
import kotlin.math.cos
object CosineCache {
@@ -54,7 +55,7 @@ object CosineCache {
DoubleArray(height * numCompY) {
val y = it / numCompY
val j = it % numCompY
cos(Math.PI * y * j / height)
cos(PI * y * j / height)
}.also {
cacheCosinesY.put(height * numCompY, it)
}
@@ -73,7 +74,7 @@ object CosineCache {
DoubleArray(width * numCompX) {
val x = it / numCompX
val i = it % numCompX
cos(Math.PI * x * i / width)
cos(PI * x * i / width)
}.also { cacheCosinesX.put(width * numCompX, it) }
}
else -> cacheCosinesX[width * numCompX]!!
@@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.blurhash
/**
* Platform-agnostic image wrapper for blurhash encoding/decoding.
*
* On Android: wraps android.graphics.Bitmap
* On Desktop JVM: wraps java.awt.image.BufferedImage
*/
expect class PlatformImage {
val width: Int
val height: Int
/**
* Read ARGB pixels into the provided array.
* Pixels are in ARGB format (0xAARRGGBB).
*/
fun getPixels(
pixels: IntArray,
offset: Int,
stride: Int,
x: Int,
y: Int,
width: Int,
height: Int,
)
/**
* Create a scaled copy of this image.
*/
fun scale(
width: Int,
height: Int,
): PlatformImage
companion object {
/**
* Create a new image from ARGB pixel array.
*/
fun create(
pixels: IntArray,
width: Int,
height: Int,
): PlatformImage
}
}
@@ -20,13 +20,13 @@
*/
package com.vitorpamplona.amethyst.commons.compose
import android.util.LruCache
import androidx.collection.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
@Composable
fun <K, V> produceCachedStateAsync(
fun <K : Any, V : Any> produceCachedStateAsync(
cache: AsyncCachedState<K, V>,
key: K,
): State<V?> =
@@ -39,7 +39,7 @@ fun <K, V> produceCachedStateAsync(
}
@Composable
fun <K, V> produceCachedStateAsync(
fun <K : Any, V : Any> produceCachedStateAsync(
cache: AsyncCachedState<K, V>,
key: String,
updateValue: K,
@@ -52,13 +52,13 @@ fun <K, V> produceCachedStateAsync(
}
}
interface AsyncCachedState<K, V> {
interface AsyncCachedState<K : Any, V : Any> {
fun cached(k: K): V?
suspend fun update(k: K): V?
}
abstract class GenericBaseCacheAsync<K, V>(
abstract class GenericBaseCacheAsync<K : Any, V : Any>(
capacity: Int,
) : AsyncCachedState<K, V> {
private val cache = LruCache<K, V>(capacity)
@@ -20,13 +20,13 @@
*/
package com.vitorpamplona.amethyst.commons.compose
import android.util.LruCache
import androidx.collection.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
@Composable
fun <K, V> produceCachedState(
fun <K : Any, V : Any> produceCachedState(
cache: CachedState<K, V>,
key: K,
): State<V?> =
@@ -39,7 +39,7 @@ fun <K, V> produceCachedState(
}
@Composable
fun <K, V> produceCachedState(
fun <K : Any, V : Any> produceCachedState(
cache: CachedState<K, V>,
key: String,
updateValue: K,
@@ -52,13 +52,13 @@ fun <K, V> produceCachedState(
}
}
interface CachedState<K, V> {
interface CachedState<K : Any, V : Any> {
fun cached(k: K): V?
suspend fun update(k: K): V?
}
abstract class GenericBaseCache<K, V>(
abstract class GenericBaseCache<K : Any, V : Any>(
capacity: Int,
) : CachedState<K, V> {
private val cache = LruCache<K, V>(capacity)
@@ -32,10 +32,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsAmethystPreview() {
Image(
@@ -33,10 +33,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsBtcPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsCashuPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsCoffeePreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsFlowerstrPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsFootstrPreview() {
Image(
@@ -28,10 +28,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsGamestrPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsGrownostrPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsLightningPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsMatePreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsNostrPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsPlebsPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsSkullPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsTunestrPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsWeedPreview() {
Image(
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
fun CustomHashTagIconsZapPreview() {
Image(
@@ -29,10 +29,8 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(Following, null)
@@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(Like, null)
@@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(Liked, null)
@@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(Reply, null)
@@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(Repost, null)
@@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(Reposted, null)
@@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(Search, null)
@@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(Share, null)
@@ -31,9 +31,7 @@ import androidx.compose.ui.graphics.vector.DefaultFillType
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
@Preview
@Composable
private fun VectorPreview() {
Image(Zap, null)
@@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.DefaultFillType
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Preview
@Composable
private fun VectorPreview() {
Image(ZapSplit, null)
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.navigation
/**
* Main application screens shared between Desktop and Android.
* Each platform implements its own navigation using these identifiers.
*/
enum class AppScreen(
val label: String,
val route: String,
) {
Feed("Feed", "feed"),
Search("Search", "search"),
Messages("Messages", "messages"),
Notifications("Notifications", "notifications"),
Profile("Profile", "profile"),
Settings("Settings", "settings"),
}
/**
* Primary navigation destinations (shown in bottom bar on mobile, sidebar on desktop).
*/
val primaryScreens =
listOf(
AppScreen.Feed,
AppScreen.Search,
AppScreen.Messages,
AppScreen.Notifications,
AppScreen.Profile,
)
/**
* Secondary navigation destinations (settings, etc.)
*/
val secondaryScreens =
listOf(
AppScreen.Settings,
)
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.robohash
import android.util.LruCache
import androidx.collection.LruCache
import androidx.compose.ui.graphics.vector.ImageVector
object CachedRobohash {
@@ -32,7 +32,6 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.robohash.parts.accessory0Seven
import com.vitorpamplona.amethyst.commons.robohash.parts.accessory1Nose
@@ -107,7 +106,6 @@ val MediumGray = SolidColor(Color(0xFFd0d2d3))
val DefaultSize = 55.dp
const val VIEWPORT_SIZE = 300f
@Preview
@Composable
fun RobohashPreview() {
val assembler = RobohashAssembler()
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory0SevenPreview() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory1NosePreview() {
Image(
@@ -27,7 +27,6 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.LightBrown
@@ -36,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.robohash.LightRed
import com.vitorpamplona.amethyst.commons.robohash.MediumGray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory2HornRedPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.LightRed
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory3ButtonPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.LightRed
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory4SatellitePreview() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory5MustachePreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.LightRed
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory6Hat() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory7Antenna() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory8Brush() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.MediumGray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Accessory9Horn() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body0TropperPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body1ThinPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body2ThinnestPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body3FrontPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body4RoundPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body5NeckPreview() {
Image(
@@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.Yellow
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body6IronManPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body7NeckThinnerPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body8BigPreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Gray
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Body9HugePreview() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes0Squint() {
Image(
@@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.DarkYellow
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes1Round() {
Image(
@@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.LightRed
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes2Single() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.LightRed
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes3Scott() {
Image(
@@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.LightRed
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes4RoundSingle() {
Image(
@@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.LightRed
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes5RoundSmall() {
Image(
@@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.LightYellow
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes6WallE() {
Image(
@@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes7Bar() {
Image(
@@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.Brown
import com.vitorpamplona.amethyst.commons.robohash.LightYellow
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes8SmallBar() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Eyes9Shield() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Face0C3po() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Face1Rock() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Face2Long() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Face3Oval() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Face4Cylinder() {
Image(
@@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.PathData
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.robohash.Black
import com.vitorpamplona.amethyst.commons.robohash.roboBuilder
@Preview
@Composable
fun Face5Baloon() {
Image(

Some files were not shown because too many files have changed in this diff Show More