Merge branch 'vitorpamplona:main' into kmp-completeness

This commit is contained in:
KotlinGeekDev
2025-12-30 00:39:11 +01:00
committed by GitHub
215 changed files with 7699 additions and 999 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
+6
View File
@@ -19,6 +19,9 @@
/.idea/kotlinNotebook.xml
/.idea/ChatHistory_schema_v3.xml
/.idea/markdown.xml
/.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
.DS_Store
/build
/captures
@@ -138,3 +141,6 @@ lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Local task tracking
TASKS.md
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
-13
View File
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
-77
View File
@@ -1,77 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinNotebookOptionsProvider">
<option name="shouldAddProjectLibrariesToClasspath" value="true" />
</component>
</project>
+6 -2
View File
@@ -4,9 +4,13 @@
<option name="moduleKind" value="plain" />
</component>
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
<option name="jvmTarget" value="21" />
</component>
<component name="KotlinCommonCompilerArguments">
<option name="apiVersion" value="2.3" />
<option name="languageVersion" value="2.3" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.0" />
<option name="version" value="2.3.0" />
</component>
</project>
+34 -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
@@ -369,6 +377,28 @@ When your app goes to the background, you can use NostrClient's `connect` and `d
methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect`
to `onPause` methods.
### Feature Parity Table
| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes |
| :--- | :--- | :---: | :---: | :--- |
| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. |
| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. |
| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. |
| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. |
| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. |
| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. |
| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. |
| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. |
| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. |
| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. |
| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. |
| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. |
| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. |
| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. |
| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. |
| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. |
## Contributing
Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst)
@@ -202,6 +202,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType
import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.Log
@@ -1002,7 +1003,7 @@ class Account(
hash: String,
duration: Int,
waveform: List<Float>,
replyTo: EventHintBundle<VoiceEvent>,
replyTo: EventHintBundle<BaseVoiceEvent>,
) {
signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo))
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -36,23 +37,24 @@ class UiSettingsFlow(
val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED),
val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
theme,
preferredLanguage,
automaticallyShowImages,
automaticallyStartPlayback,
automaticallyShowUrlPreview,
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
dontShowPushNotificationSelector,
dontAskForNotificationPermissions,
featureSet,
gallerySet,
)
// emits at every change in any of the propertyes.
val propertyWatchFlow =
combine(
listOf(
theme,
preferredLanguage,
automaticallyShowImages,
automaticallyStartPlayback,
automaticallyShowUrlPreview,
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
dontShowPushNotificationSelector,
dontAskForNotificationPermissions,
featureSet,
gallerySet,
),
) { flows ->
val propertyWatchFlow: Flow<UiSettings> =
combine<Any?, UiSettings>(listOfFlows) { flows: Array<Any?> ->
UiSettings(
flows[0] as ThemeType,
flows[1] as String?,
@@ -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,
)
@@ -0,0 +1,67 @@
/**
* 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.ui.actions.mediaServers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.toImmutableList
@Composable
fun FileServerSelectionRow(
fileServers: List<ServerName>,
selectedServer: ServerName?,
onSelect: (ServerName) -> Unit,
) {
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
val fileServerOptions =
remember(fileServers) {
fileServers
.map {
if (it.type == ServerType.NIP95) {
TitleExplainer(it.name, nip95description)
} else {
TitleExplainer(it.name, it.baseUrl)
}
}.toImmutableList()
}
val placeholder =
fileServers
.firstOrNull { it == selectedServer }
?.name
?: fileServers.firstOrNull()?.name
?: ""
SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner(
label = "",
placeholder = placeholder,
options = fileServerOptions,
onSelect = { index -> fileServers.getOrNull(index)?.let(onSelect) },
)
}
}
@@ -175,18 +175,27 @@ fun FloatingRecordingIndicator(
modifier: Modifier = Modifier,
isRecording: Boolean,
elapsedSeconds: Int,
isCompact: Boolean = false,
) {
if (!isRecording) return
val recordingLabel = stringRes(id = R.string.recording_indicator_description)
val recordingWithTime = stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds))
val recordingWithTime =
if (isCompact) {
formatSecondsToTime(elapsedSeconds)
} else {
stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds))
}
val horizontalPadding = if (isCompact) 8.dp else 16.dp
val innerPadding = if (isCompact) 6.dp else 12.dp
val textSize = if (isCompact) 12.sp else 14.sp
Box(
modifier =
modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 16.dp)
.padding(horizontal = horizontalPadding)
.background(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(12.dp),
@@ -195,7 +204,7 @@ fun FloatingRecordingIndicator(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp),
modifier = Modifier.padding(horizontal = innerPadding),
) {
// Pulsing red dot
val infiniteTransition = rememberInfiniteTransition(label = "recording_dot")
@@ -223,8 +232,10 @@ fun FloatingRecordingIndicator(
Text(
text = recordingWithTime,
color = Color.White,
fontSize = 14.sp,
fontSize = textSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
softWrap = false,
)
}
}
@@ -0,0 +1,101 @@
/**
* 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.ui.actions.uploads
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@Composable
fun UploadProgressIndicator(
orchestrator: UploadOrchestrator,
modifier: Modifier = Modifier,
) {
val progressValue = orchestrator.progress.collectAsState().value
val progressStatusValue = orchestrator.progressState.collectAsState().value
Box(
modifier =
modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier.size(55.dp),
contentAlignment = Alignment.Center,
) {
val animatedProgress =
animateFloatAsState(
targetValue = progressValue.toFloat(),
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
).value
CircularProgressIndicator(
progress = { animatedProgress },
modifier =
Size55Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background),
strokeWidth = 5.dp,
)
val txt =
when (progressStatusValue) {
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error)
}
Text(
txt,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 10.sp,
textAlign = TextAlign.Center,
)
}
}
}
@@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen
@@ -289,6 +290,18 @@ fun AppNavigation(
nav = nav,
)
}
composableFromBottomArgs<Route.VoiceReply> {
VoiceReplyScreen(
replyToNoteId = it.replyToNoteId,
recordingFilePath = it.recordingFilePath,
mimeType = it.mimeType,
duration = it.duration,
amplitudesJson = it.amplitudes,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -285,6 +285,15 @@ sealed class Route {
val draft: String? = null,
) : Route()
@Serializable
data class VoiceReply(
val replyToNoteId: String,
val recordingFilePath: String,
val mimeType: String,
val duration: Int,
val amplitudes: String, // JSON-encoded List<Float>
) : Route()
@Serializable
data class ManualZapSplitPayment(
val paymentId: String,
@@ -334,6 +343,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.Nip47NWCSetup>() -> entry.toRoute<Route.Nip47NWCSetup>()
dest.hasRoute<Route.Room>() -> entry.toRoute<Route.Room>()
dest.hasRoute<Route.NewShortNote>() -> entry.toRoute<Route.NewShortNote>()
dest.hasRoute<Route.VoiceReply>() -> entry.toRoute<Route.VoiceReply>()
dest.hasRoute<Route.NewProduct>() -> entry.toRoute<Route.NewProduct>()
dest.hasRoute<Route.GeoPost>() -> entry.toRoute<Route.GeoPost>()
dest.hasRoute<Route.HashtagPost>() -> entry.toRoute<Route.HashtagPost>()
@@ -47,6 +47,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -61,6 +63,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -109,6 +112,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.ui.components.ClickableBox
@@ -169,6 +173,7 @@ import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -204,9 +209,12 @@ private fun InnerReactionRow(
accountViewModel: AccountViewModel,
nav: INav,
) {
val voiceRecordingState = remember(baseNote.idHex) { mutableStateOf(false) }
GenericInnerReactionRow(
showReactionDetail = showReactionDetail,
addPadding = addPadding,
weightTwo = if (voiceRecordingState.value) 2f else 1f,
one = {
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) {
RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel)
@@ -218,6 +226,7 @@ private fun InnerReactionRow(
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
voiceRecordingState = voiceRecordingState,
)
},
three = {
@@ -288,6 +297,7 @@ fun ShareReaction(
private fun GenericInnerReactionRow(
showReactionDetail: Boolean,
addPadding: Boolean,
weightTwo: Float = 1f,
one: @Composable () -> Unit,
two: @Composable () -> Unit,
three: @Composable () -> Unit,
@@ -309,7 +319,7 @@ private fun GenericInnerReactionRow(
}
}
Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { two() }
Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(weightTwo)) { two() }
Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { three() }
@@ -583,9 +593,16 @@ private fun ReplyReactionWithDialog(
grayTint: Color,
accountViewModel: AccountViewModel,
nav: INav,
voiceRecordingState: MutableState<Boolean>? = null,
) {
if (baseNote.event is BaseVoiceEvent) {
ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel)
ReplyViaVoiceReaction(
baseNote,
grayTint,
accountViewModel,
nav,
voiceRecordingState = voiceRecordingState,
)
} else {
ReplyReaction(baseNote, grayTint, accountViewModel) {
nav.nav { routeReplyTo(baseNote, accountViewModel.account) }
@@ -599,18 +616,45 @@ fun ReplyViaVoiceReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
nav: INav,
showCounter: Boolean = true,
iconSizeModifier: Modifier = Size19Modifier,
voiceRecordingState: MutableState<Boolean>? = null,
) {
val context = LocalContext.current
RecordAudioBox(
modifier = iconSizeModifier,
modifier = Modifier,
onRecordTaken = { audio ->
accountViewModel.sendVoiceReply(baseNote, audio, context)
nav.nav {
Route.VoiceReply(
replyToNoteId = baseNote.idHex,
recordingFilePath = audio.file.absolutePath,
mimeType = audio.mimeType,
duration = audio.duration,
amplitudes = Json.encodeToString(audio.amplitudes),
)
}
},
) { _, _ ->
VoiceReplyIcon(iconSizeModifier, grayTint)
) { isRecording, elapsedSeconds ->
if (voiceRecordingState != null) {
SideEffect {
if (voiceRecordingState.value != isRecording) {
voiceRecordingState.value = isRecording
}
}
}
if (isRecording) {
FloatingRecordingIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(40.dp),
isRecording = true,
elapsedSeconds = elapsedSeconds,
isCompact = true,
)
} else {
VoiceReplyIcon(iconSizeModifier, grayTint)
}
}
if (showCounter) {
@@ -134,7 +134,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -1154,7 +1154,7 @@ class AccountViewModel(
context: Context,
) {
if (isWriteable()) {
val hint = note.toEventHint<VoiceEvent>() ?: return
val hint = note.toEventHint<BaseVoiceEvent>() ?: return
launchSigner {
val uploader = UploadOrchestrator()
@@ -24,10 +24,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -38,47 +35,37 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
@@ -111,7 +98,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@@ -365,24 +351,11 @@ private fun NewPostScreenBody(
// Show preview for both uploaded messages (voiceMetadata) and pending recordings
(postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata ->
val nip95description = stringRes(id = R.string.upload_server_relays_nip95)
val fileServersState =
accountViewModel.account.serverLists.liveServerList
.collectAsState()
val fileServers = fileServersState.value
val fileServerOptions =
remember(fileServers) {
fileServers
.map {
if (it.type == ServerType.NIP95) {
TitleExplainer(it.name, nip95description)
} else {
TitleExplainer(it.name, it.baseUrl)
}
}.toImmutableList()
}
Column(
modifier =
Modifier
@@ -391,7 +364,7 @@ private fun NewPostScreenBody(
) {
// Display voice preview or uploading progress
postViewModel.voiceOrchestrator?.let { orchestrator ->
VoiceUploadingProgress(orchestrator)
UploadProgressIndicator(orchestrator)
} ?: run {
VoiceMessagePreview(
voiceMetadata = metadata,
@@ -400,18 +373,11 @@ private fun NewPostScreenBody(
)
}
SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner(
label = "",
placeholder =
fileServers
.firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) }
?.name
?: fileServers[0].name,
options = fileServerOptions,
onSelect = { postViewModel.voiceSelectedServer = fileServers[it] },
)
}
FileServerSelectionRow(
fileServers = fileServers,
selectedServer = postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer,
onSelect = { postViewModel.voiceSelectedServer = it },
)
}
}
@@ -584,56 +550,3 @@ private fun AddPollButton(
}
}
}
@Composable
private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) {
val progressValue = orchestrator.progress.collectAsState().value
val progressStatusValue = orchestrator.progressState.collectAsState().value
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
Box(
modifier = Modifier.size(55.dp),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
val animatedProgress =
animateFloatAsState(
targetValue = progressValue.toFloat(),
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
).value
CircularProgressIndicator(
progress = { animatedProgress },
modifier =
Size55Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background),
strokeWidth = 5.dp,
)
val txt =
when (progressStatusValue) {
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished)
is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error)
}
Text(
txt,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 10.sp,
textAlign = TextAlign.Center,
)
}
}
}
@@ -121,6 +121,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash
import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.Log
@@ -539,8 +540,8 @@ open class ShortNotePostViewModel :
private suspend fun createTemplate(): EventTemplate<out Event>? {
// Check if this is a voice message
voiceMetadata?.let { audioMeta ->
// Only create voice reply if original note is also a VoiceEvent
val originalVoiceHint = originalNote?.toEventHint<VoiceEvent>()
// Only create voice reply if original note is also a voice message
val originalVoiceHint = originalNote?.toEventHint<BaseVoiceEvent>()
return if (originalVoiceHint != null) {
// Create voice reply event
VoiceReplyEvent.build(
@@ -1024,9 +1025,6 @@ open class ShortNotePostViewModel :
onError(uploadErrorTitle, uploadVoiceFailed)
voiceRecording = null
}
else -> {
onError(uploadErrorTitle, uploadVoiceUnexpected)
}
}
} catch (e: Exception) {
onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName))
@@ -0,0 +1,242 @@
/**
* 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.ui.screen.loggedIn.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VoiceReplyScreen(
replyToNoteId: String,
recordingFilePath: String,
mimeType: String,
duration: Int,
amplitudesJson: String,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val viewModel: VoiceReplyViewModel = viewModel()
LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) {
viewModel.init(accountViewModel)
viewModel.load(replyToNoteId, recordingFilePath, mimeType, duration, amplitudesJson)
}
BackHandler {
viewModel.cancel()
nav.popBack()
}
Scaffold(
topBar = {
PostingTopBar(
isActive = viewModel::canSend,
onPost = {
viewModel.sendVoiceReply {
nav.popBack()
}
},
onCancel = {
viewModel.cancel()
nav.popBack()
},
)
},
bottomBar = {
ReRecordButton(viewModel)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad),
) {
VoiceReplyScreenBody(viewModel, accountViewModel, nav)
}
}
}
@Composable
private fun VoiceReplyScreenBody(
viewModel: VoiceReplyViewModel,
accountViewModel: AccountViewModel,
nav: Nav,
) {
val scrollState = rememberScrollState()
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = Size10dp),
) {
// Show the note being replied to
viewModel.replyToNote?.let { note ->
Spacer(modifier = StdVertSpacer)
NoteCompose(
baseNote = note,
modifier = MaterialTheme.colorScheme.replyModifier,
isQuotedNote = true,
unPackReply = false,
makeItShort = true,
quotesLeft = 1,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
// Voice preview or upload progress
viewModel.voiceOrchestrator?.let { orchestrator ->
UploadProgressIndicator(orchestrator)
} ?: run {
viewModel.getVoicePreviewMetadata()?.let { metadata ->
VoiceMessagePreview(
voiceMetadata = metadata,
localFile = viewModel.voiceLocalFile,
onRemove = {
viewModel.cancel()
nav.popBack()
},
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Server selection
val fileServers =
accountViewModel.account.serverLists.liveServerList
.collectAsState()
.value
FileServerSelectionRow(
fileServers = fileServers,
selectedServer = viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer,
onSelect = { viewModel.voiceSelectedServer = it },
)
Spacer(modifier = Modifier.height(80.dp))
}
}
@Composable
private fun ReRecordButton(viewModel: VoiceReplyViewModel) {
Column(
modifier =
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(vertical = 16.dp, horizontal = Size10dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (viewModel.isUploading) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringRes(id = R.string.record_a_message),
tint = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringRes(id = R.string.re_record),
color = MaterialTheme.colorScheme.onBackground,
)
}
return
}
RecordAudioBox(
modifier = Modifier,
onRecordTaken = { recording ->
viewModel.selectRecording(recording)
},
) { isRecording, _ ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = stringRes(id = R.string.record_a_message),
tint =
if (isRecording) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
)
Text(
text = stringRes(id = R.string.re_record),
color =
if (isRecording) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
)
}
}
}
}
@@ -0,0 +1,261 @@
/**
* 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.ui.screen.loggedIn.home
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
@Stable
class VoiceReplyViewModel : ViewModel() {
lateinit var accountViewModel: AccountViewModel
var replyToNote: Note? by mutableStateOf(null)
var voiceRecording: RecordingResult? by mutableStateOf(null)
var voiceLocalFile: File? by mutableStateOf(null)
var voiceMetadata: AudioMeta? by mutableStateOf(null)
var voiceSelectedServer: ServerName? by mutableStateOf(null)
var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null)
var isUploading: Boolean by mutableStateOf(false)
private var uploadJob: Job? = null
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
}
fun load(
replyToNoteId: String,
recordingFilePath: String,
mimeType: String,
duration: Int,
amplitudesJson: String,
) {
replyToNote = accountViewModel.getNoteIfExists(replyToNoteId)
val amplitudes =
try {
Json.decodeFromString<List<Float>>(amplitudesJson)
} catch (e: Exception) {
Log.w("VoiceReplyViewModel", "Failed to parse amplitudes", e)
emptyList()
}
val file = File(recordingFilePath)
voiceLocalFile = file
voiceRecording =
RecordingResult(
file = file,
mimeType = mimeType,
duration = duration,
amplitudes = amplitudes,
)
}
fun getVoicePreviewMetadata(): AudioMeta? =
voiceRecording?.let { recording ->
AudioMeta(
url = "",
mimeType = recording.mimeType,
duration = recording.duration,
waveform = recording.amplitudes,
)
}
fun selectRecording(recording: RecordingResult) {
cancelUpload()
deleteVoiceLocalFile()
voiceRecording = recording
voiceLocalFile = recording.file
voiceMetadata = null
}
private fun cancelUpload() {
uploadJob?.cancel()
uploadJob = null
}
private fun deleteVoiceLocalFile() {
voiceLocalFile?.let { file ->
try {
if (file.exists()) {
file.delete()
Log.d("VoiceReplyViewModel", "Deleted voice file: ${file.absolutePath}")
}
} catch (e: Exception) {
Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e)
}
}
}
fun canSend(): Boolean = voiceRecording != null && !isUploading
fun sendVoiceReply(onSuccess: () -> Unit) {
val note = replyToNote ?: return
val recording = voiceRecording ?: return
val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer
cancelUpload()
uploadJob =
viewModelScope.launch {
isUploading = true
val orchestrator = UploadOrchestrator()
voiceOrchestrator = orchestrator
try {
val result =
withContext(Dispatchers.IO) {
val uri = android.net.Uri.fromFile(recording.file)
orchestrator.upload(
uri = uri,
mimeType = recording.mimeType,
alt = null,
contentWarningReason = null,
compressionQuality = CompressorQuality.UNCOMPRESSED,
server = serverToUse,
account = accountViewModel.account,
context = Amethyst.instance.appContext,
useH265 = false,
)
}
handleUploadResult(note, recording, serverToUse, result, onSuccess)
} catch (e: CancellationException) {
Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e)
} catch (e: Exception) {
val appContext = Amethyst.instance.appContext
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
val uploadVoiceExceptionMessage: (String) -> String = { detail ->
stringRes(appContext, R.string.upload_error_voice_message_exception, detail)
}
accountViewModel.toastManager.toast(
uploadErrorTitle,
uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName),
)
} finally {
isUploading = false
voiceOrchestrator = null
uploadJob = null
}
}
}
private suspend fun handleUploadResult(
note: Note,
recording: RecordingResult,
server: ServerName,
result: UploadingState,
onSuccess: () -> Unit,
) {
val appContext = Amethyst.instance.appContext
val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title)
val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported)
val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed)
when (result) {
is UploadingState.Finished -> {
when (val orchestratorResult = result.result) {
is UploadOrchestrator.OrchestratorResult.ServerResult -> {
val hint = note.toEventHint<BaseVoiceEvent>()
if (hint == null) {
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed)
return
}
val audioMeta =
AudioMeta(
url = orchestratorResult.url,
mimeType = recording.mimeType,
hash = orchestratorResult.fileHeader.hash,
duration = recording.duration,
waveform = recording.amplitudes,
)
accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, hint))
if (server.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(server)
}
deleteVoiceLocalFile()
voiceLocalFile = null
voiceRecording = null
voiceMetadata = audioMeta
onSuccess()
}
is UploadOrchestrator.OrchestratorResult.NIP95Result -> {
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported)
}
}
}
is UploadingState.Error -> {
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed)
}
else -> {
accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed)
}
}
}
fun cancel() {
cancelUpload()
deleteVoiceLocalFile()
voiceRecording = null
voiceLocalFile = null
voiceMetadata = null
voiceSelectedServer = null
isUploading = false
voiceOrchestrator = null
}
override fun onCleared() {
cancel()
super.onCleared()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
}
}
@@ -190,8 +190,8 @@ val VideoReactionColumnPadding = Modifier.padding(bottom = 75.dp)
val DividerThickness = 0.25.dp
val ReactionRowHeight = Modifier.padding(vertical = 7.dp).height(24.dp)
val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).height(24.dp).padding(horizontal = 10.dp)
val ReactionRowHeight = Modifier.padding(vertical = 7.dp).heightIn(min = 24.dp)
val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).heightIn(min = 24.dp).padding(horizontal = 10.dp)
val ReactionRowHeightChat = Modifier.height(20.dp)
val ReactionRowHeightChatMaxWidth = Modifier.height(25.dp).fillMaxWidth()
val UserNameRowHeight = Modifier.fillMaxWidth()
@@ -40,7 +40,7 @@ class TorSettingsFlow(
) {
// emits at every change in any of the properties.
val propertyWatchFlow =
combine(
combine<Any, TorSettings>(
listOf(
torType,
externalSocksPort,
@@ -158,6 +158,7 @@
<string name="record_a_message">Nahrajte zprávu</string>
<string name="record_a_message_title">Nahrajte zprávu</string>
<string name="record_a_message_description">Stiskněte a podržte pro nahrání zprávy</string>
<string name="re_record">Znovu nahrát</string>
<string name="recording_indicator_description">Nahrávání</string>
<string name="recording_indicator_with_time">Nahrávání %1$s</string>
<string name="uploading">Nahrávání…</string>
@@ -160,6 +160,7 @@ erie gespeichert</string>
<string name="record_a_message">Eine Nachricht aufnehmen</string>
<string name="record_a_message_title">Eine Nachricht aufnehmen</string>
<string name="record_a_message_description">Zum Aufnehmen einer Nachricht gedrückt halten</string>
<string name="re_record">Neu aufnehmen</string>
<string name="recording_indicator_description">Aufnahme</string>
<string name="recording_indicator_with_time">Aufnahme %1$s</string>
<string name="uploading">Hochladen…</string>
@@ -158,6 +158,7 @@
<string name="record_a_message">Gravar uma mensagem</string>
<string name="record_a_message_title">Gravar uma mensagem</string>
<string name="record_a_message_description">Clique e segure para gravar uma mensagem</string>
<string name="re_record">Gravar novamente</string>
<string name="recording_indicator_description">Gravando</string>
<string name="recording_indicator_with_time">Gravando %1$s</string>
<string name="uploading">Enviando…</string>
@@ -158,6 +158,7 @@
<string name="record_a_message">Spela in ett meddelande</string>
<string name="record_a_message_title">Spela in ett meddelande</string>
<string name="record_a_message_description">Tryck och håll in för att spela in ett meddelande</string>
<string name="re_record">Spela in igen</string>
<string name="recording_indicator_description">Spelar in</string>
<string name="recording_indicator_with_time">Spelar in %1$s</string>
<string name="uploading">Laddar upp…</string>
+1
View File
@@ -170,6 +170,7 @@
<string name="record_a_message">Record a message</string>
<string name="record_a_message_title">Record a message</string>
<string name="record_a_message_description">Click and hold to record a message</string>
<string name="re_record">Re-record</string>
<string name="recording_indicator_description">Recording</string>
<string name="recording_indicator_with_time">Recording %1$s</string>
<string name="uploading">Uploading…</string>
+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(

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