mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
Merge branch 'vitorpamplona:main' into kmp-completeness
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/`
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Generated
-6
@@ -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>
|
||||
Generated
-13
@@ -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
@@ -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>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinNotebookOptionsProvider">
|
||||
<option name="shouldAddProjectLibrariesToClasspath" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
-2
@@ -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>
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+67
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
+15
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+8
-95
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -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))
|
||||
|
||||
+242
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+261
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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()
|
||||
+29
@@ -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()
|
||||
+65
@@ -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
|
||||
+3
-4
@@ -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 {
|
||||
+6
-3
@@ -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)
|
||||
+3
-2
@@ -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]!!
|
||||
+65
@@ -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
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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)
|
||||
+5
-5
@@ -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)
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
-2
@@ -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)
|
||||
+57
@@ -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,
|
||||
)
|
||||
+1
-1
@@ -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 {
|
||||
-2
@@ -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()
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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(
|
||||
-2
@@ -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
Reference in New Issue
Block a user