diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..9ecd75d86 --- /dev/null +++ b/.claude/CLAUDE.md @@ -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 ` - Move composable to shared code +- `/nip ` - 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 ` 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-` or `fix/desktop-` +- 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) diff --git a/.claude/agents/compose-ui.md b/.claude/agents/compose-ui.md new file mode 100644 index 000000000..d51b2eca8 --- /dev/null +++ b/.claude/agents/compose-ui.md @@ -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 = _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 diff --git a/.claude/agents/kotlin-coroutines.md b/.claude/agents/kotlin-coroutines.md new file mode 100644 index 000000000..ed1679972 --- /dev/null +++ b/.claude/agents/kotlin-coroutines.md @@ -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> = 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 = _state.asStateFlow() + + fun updateFilter(filter: Filter) { + _state.update { it.copy(filter = filter) } + } +} +``` + +### SharedFlow (Hot, Configurable Replay) +```kotlin +private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST +) +val events: SharedFlow = _events.asSharedFlow() +``` + +### Channels +```kotlin +fun relayEvents(relay: Relay): Flow = 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 = relays.values + .map { it.events } + .merge() + .distinctBy { it.id } +} +``` + +### Subscription Management +```kotlin +fun subscribe(filters: List): Flow = 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 diff --git a/.claude/agents/kotlin-multiplatform.md b/.claude/agents/kotlin-multiplatform.md new file mode 100644 index 000000000..419a7c740 --- /dev/null +++ b/.claude/agents/kotlin-multiplatform.md @@ -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 diff --git a/.claude/agents/nostr-protocol.md b/.claude/agents/nostr-protocol.md new file mode 100644 index 000000000..ea6073f66 --- /dev/null +++ b/.claude/agents/nostr-protocol.md @@ -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>, + 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", , ...] # Subscribe + ["EVENT", ] # Publish + ["CLOSE", ] # Unsubscribe + +Relay -> Client: + ["EVENT", , ] # Event received + ["EOSE", ] # End of stored events + ["OK", , , ] # Publish result + ["NOTICE", ] # 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 diff --git a/.claude/commands/desktop-run.md b/.claude/commands/desktop-run.md new file mode 100644 index 000000000..6b43f0d2b --- /dev/null +++ b/.claude/commands/desktop-run.md @@ -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/` diff --git a/.claude/commands/extract.md b/.claude/commands/extract.md new file mode 100644 index 000000000..99c01eea7 --- /dev/null +++ b/.claude/commands/extract.md @@ -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. diff --git a/.claude/commands/nip.md b/.claude/commands/nip.md new file mode 100644 index 000000000..2c7631be0 --- /dev/null +++ b/.claude/commands/nip.md @@ -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 +``` diff --git a/.claude/skills/compose-desktop.md b/.claude/skills/compose-desktop.md new file mode 100644 index 000000000..7f9d3b6c9 --- /dev/null +++ b/.claude/skills/compose-desktop.md @@ -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(null) + + fun open() { isOpen = true } + + @Composable + fun Dialog( + title: String = "Select File", + allowedExtensions: List = 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, + 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>(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")) + } + } + } +} +``` diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md new file mode 100644 index 000000000..49e5000ff --- /dev/null +++ b/.claude/skills/quartz-kmp.md @@ -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 diff --git a/.gitignore b/.gitignore index 72ba3e1b4..b4fc788e4 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ + +# Local task tracking +TASKS.md diff --git a/README.md b/README.md index 679c159b1..54297b800 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt index 50b1621bf..bbab146da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt index c755f8d14..f128bcd02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt @@ -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, ) diff --git a/build.gradle b/build.gradle index f27850355..5bf6769b7 100644 --- a/build.gradle +++ b/build.gradle @@ -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 \ No newline at end of file +tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook diff --git a/commons/build.gradle b/commons/build.gradle deleted file mode 100644 index b37d1acc4..000000000 --- a/commons/build.gradle +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts new file mode 100644 index 000000000..e66de925e --- /dev/null +++ b/commons/build.gradle.kts @@ -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) + } + } + } +} diff --git a/commons/src/main/AndroidManifest.xml b/commons/src/androidMain/AndroidManifest.xml similarity index 100% rename from commons/src/main/AndroidManifest.xml rename to commons/src/androidMain/AndroidManifest.xml diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt new file mode 100644 index 000000000..9340c5d08 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt @@ -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() diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt new file mode 100644 index 000000000..bf40e816b --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt @@ -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() diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt new file mode 100644 index 000000000..b864da631 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt @@ -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 diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt similarity index 97% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt index baf540fd3..140f3c86c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt @@ -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 { diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt similarity index 88% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt index 7a3bd652c..f476afb6a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt similarity index 96% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt index 7248cf5a1..a3d609915 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt @@ -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]!! diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt new file mode 100644 index 000000000..2e745ca8e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt @@ -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 + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt similarity index 91% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt index 733dfdac2..8fdaf3fd2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt @@ -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 produceCachedStateAsync( +fun produceCachedStateAsync( cache: AsyncCachedState, key: K, ): State = @@ -39,7 +39,7 @@ fun produceCachedStateAsync( } @Composable -fun produceCachedStateAsync( +fun produceCachedStateAsync( cache: AsyncCachedState, key: String, updateValue: K, @@ -52,13 +52,13 @@ fun produceCachedStateAsync( } } -interface AsyncCachedState { +interface AsyncCachedState { fun cached(k: K): V? suspend fun update(k: K): V? } -abstract class GenericBaseCacheAsync( +abstract class GenericBaseCacheAsync( capacity: Int, ) : AsyncCachedState { private val cache = LruCache(capacity) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt similarity index 91% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt index ba955f4d6..28cbeb6f9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt @@ -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 produceCachedState( +fun produceCachedState( cache: CachedState, key: K, ): State = @@ -39,7 +39,7 @@ fun produceCachedState( } @Composable -fun produceCachedState( +fun produceCachedState( cache: CachedState, key: String, updateValue: K, @@ -52,13 +52,13 @@ fun produceCachedState( } } -interface CachedState { +interface CachedState { fun cached(k: K): V? suspend fun update(k: K): V? } -abstract class GenericBaseCache( +abstract class GenericBaseCache( capacity: Int, ) : CachedState { private val cache = LruCache(capacity) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt index 0a7ee0dd0..c47beb329 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt index 5a4fc5ba2..1bb9aef79 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt index de72f6eb4..96be5b124 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt index f59def11e..6c2543cd2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt index 7cb20efa8..d8a9ba332 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt index 6b907ede5..70111c982 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt index fec5833a7..9a07e47ae 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt index dc6628974..afec134fe 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt index 281a1688d..3b884cc17 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt index 5b27fd5bd..037b8a7ec 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt index b88317911..24160dde7 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt index 7ce7b8ae6..d1a17353f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt index 6a4d95899..8e0a23216 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt index 9add8ed4b..2d307df19 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt index 4a40e3d16..668f43b67 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt index b98538cf0..eec30fc12 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt index 2eb7b112d..fe0b9cfc8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt index afeccd3ae..994b6db33 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt index b509ebc10..8e89cc135 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt index 4db2c8b12..31349a88b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt index a599d032f..ffbb79b22 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt index 6e11930eb..2cad31d4a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt index e996be38a..e967053d2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt index 5107eca3f..49923f688 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt index acf40c45c..d5169185f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt @@ -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) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt index 84ef32ff7..91a111e19 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt @@ -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) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt new file mode 100644 index 000000000..e3502dfc2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt @@ -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, + ) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt index 8811c0fb0..d3c7a5c22 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt @@ -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 { diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 99e81fb85..982196e72 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -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() diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt index 3f97d864f..e58577de5 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt index 2793862ab..b467f1c61 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt index 3f6f9813d..75ae3d3e6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt index bee5d0446..d52a26aaa 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt index cf10cd4e3..d414a5302 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt index 9967abd02..ec6cad933 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt index ea3809b9c..debad6a99 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt index 9bf7fd570..4802982ef 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt index 3b94fcf84..869baec5f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt index a9baa4597..c77558585 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt index e84bd23ed..f11ab8e24 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt @@ -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( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt index 6d5502289..b20f22d08 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body1ThinPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt index ead54ed93..520bc1566 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body2ThinnestPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt index aa95e8108..646f5e332 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body3FrontPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt index 1ec10ab8b..9a5115f7f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body4RoundPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt index ff939af00..11bb4cd4d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body5NeckPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt index 62c505821..373a62121 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body6IronManPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt index 58bef8c43..f6cf8f407 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body7NeckThinnerPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt index d12739adb..ab93ec93b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body8BigPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt index 841aef162..98810d09d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body9HugePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt index 52486c701..6e46a40db 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes0Squint() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt index 0535fad99..10ea055cf 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes1Round() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt index fcf23af08..0e38faffd 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes2Single() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt index f8ffd01ca..2e6aa7410 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes3Scott() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt index 5d3d55312..fe7d0b3f6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes4RoundSingle() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt index 4c30a3240..cfc62a1f2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes5RoundSmall() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt index 11e1bf901..b30766bf9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes6WallE() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt index 7bd1093ad..9ea12638c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes7Bar() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt index 3064d1665..a93424325 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes8SmallBar() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt index 20ab0a875..900be2d90 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes9Shield() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt index 4d18e3453..b353e9588 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face0C3po() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt index 1fbf5f73d..7ebd9acd8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face1Rock() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt index 6c5a51cab..c7979de54 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face2Long() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt index bc3979d89..39e25f75c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face3Oval() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt index 8707de72c..6f58c5a1a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face4Cylinder() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt index bc16e370f..e6d72fb92 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face5Baloon() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt index b592cd4ed..591425eb9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt @@ -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 Face6Triangle() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt index b9e888caa..29de82282 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt @@ -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 Face7Bent() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt index d0c5bf806..f7492afd6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt @@ -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 Face8TriangleInv() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt index cfd5b8f7b..240edc78c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt @@ -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 Face9Square() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt index fb0de441a..f7e6002f3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt @@ -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 Mouth0Horz() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt index 85925bc21..2f7917243 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt @@ -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 Mouth1Cylinder() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt index 44cb1951f..b74b0e6af 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt @@ -27,14 +27,12 @@ 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.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.OrangeOne import com.vitorpamplona.amethyst.commons.robohash.OrangeTwo import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth2Teeth() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt index 6069be799..dec4f1651 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth3Grid() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt index bf827cdde..443a5b183 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth4Vert() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt index 080db5980..4af586f96 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth5MidOpen() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt index bece6f091..f3dfe65ba 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth6Cell() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt index 30983dc1f..b2620d5a3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.OrangeThree import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth7Happy() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt index 9981f4391..c93675884 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt @@ -27,14 +27,12 @@ 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.OrangeThree import com.vitorpamplona.amethyst.commons.robohash.OrangeTwo import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth8Buttons() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt index 2890d7376..e792b0adc 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt @@ -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 Mouth9Closed() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt new file mode 100644 index 000000000..ea87b8ed1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt @@ -0,0 +1,92 @@ +/** + * 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.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * Standard button shape used for action buttons. + */ +val ActionButtonShape = RoundedCornerShape(20.dp) + +/** + * Standard content padding for action buttons. + */ +val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) + +/** + * An "Add" action button with consistent styling. + * + * @param onClick Action to perform when clicked + * @param modifier Modifier for the button + * @param text Button text (default: "Add") + * @param enabled Whether the button is enabled + */ +@Composable +fun AddButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Add", + enabled: Boolean = true, +) { + OutlinedButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + shape = ActionButtonShape, + contentPadding = ActionButtonPadding, + ) { + Text(text = text, textAlign = TextAlign.Center) + } +} + +/** + * A "Remove" action button with consistent styling. + * + * @param onClick Action to perform when clicked + * @param modifier Modifier for the button + * @param text Button text (default: "Remove") + * @param enabled Whether the button is enabled + */ +@Composable +fun RemoveButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Remove", + enabled: Boolean = true, +) { + OutlinedButton( + modifier = modifier, + onClick = onClick, + shape = ActionButtonShape, + enabled = enabled, + contentPadding = ActionButtonPadding, + ) { + Text(text = text) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt new file mode 100644 index 000000000..b549466dd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt @@ -0,0 +1,158 @@ +/** + * 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.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A centered loading state with a progress indicator and message. + * Can be used by both Desktop and Android apps. + */ +@Composable +fun LoadingState( + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * A centered empty state with title, optional description, and optional refresh action. + */ +@Composable +fun EmptyState( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + onRefresh: (() -> Unit)? = null, + refreshLabel: String = "Refresh", +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (description != null) { + Spacer(Modifier.height(8.dp)) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + if (onRefresh != null) { + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = onRefresh) { + Text(refreshLabel) + } + } + } +} + +/** + * A centered error state with message and optional retry action. + */ +@Composable +fun ErrorState( + message: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, + retryLabel: String = "Try Again", +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + if (onRetry != null) { + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(retryLabel) + } + } + } +} + +/** + * Empty feed state - a common pattern for feed views. + */ +@Composable +fun FeedEmptyState( + modifier: Modifier = Modifier, + title: String = "Feed is empty", + onRefresh: (() -> Unit)? = null, +) { + EmptyState( + title = title, + modifier = modifier, + onRefresh = onRefresh, + ) +} + +/** + * Feed error state - a common pattern for feed views. + */ +@Composable +fun FeedErrorState( + errorMessage: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + ErrorState( + message = "Error loading feed: $errorMessage", + modifier = modifier, + onRetry = onRetry, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt new file mode 100644 index 000000000..82e0123bf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt @@ -0,0 +1,110 @@ +/** + * 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.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash + +/** + * Determines if the current color scheme is light. + * Uses the luminance of the background color. + */ +@Composable +private fun isLightTheme(): Boolean { + val background = MaterialTheme.colorScheme.background + // Simple luminance check: if any RGB component > 0.5, consider it light + return (background.red + background.green + background.blue) / 3 > 0.5f +} + +/** + * Displays a robohash image based on a seed string (typically a public key). + * Falls back to a generic face icon if robohash is disabled. + * + * @param robot The seed string for generating the robohash (e.g., pubkey hex) + * @param modifier Modifier for the image + * @param contentDescription Accessibility description + * @param loadRobohash Whether to load the robohash or show a placeholder + */ +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + loadRobohash: Boolean = true, +) { + if (loadRobohash) { + Image( + imageVector = CachedRobohash.get(robot, isLightTheme()), + contentDescription = contentDescription, + modifier = modifier, + ) + } else { + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = modifier, + ) + } +} + +/** + * Displays a robohash image with configurable alignment and content scale. + */ +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + loadRobohash: Boolean = true, +) { + if (loadRobohash) { + Image( + painter = rememberVectorPainter(CachedRobohash.get(robot, isLightTheme())), + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + } else { + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = colorFilter ?: ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt new file mode 100644 index 000000000..36efd264d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt @@ -0,0 +1,116 @@ +/** + * 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.ui.feed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors + +/** + * Header component for feed screens with title and relay connection status. + * + * @param title The feed title + * @param connectedRelayCount Number of connected relays + * @param onRefresh Callback when refresh button is clicked + * @param modifier Modifier for the header row + */ +@Composable +fun FeedHeader( + title: String, + connectedRelayCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + RelayStatusIndicator( + connectedCount = connectedRelayCount, + onRefresh = onRefresh, + ) + } +} + +/** + * Compact relay connection status indicator with refresh button. + */ +@Composable +fun RelayStatusIndicator( + connectedCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val statusColor = + when { + connectedCount == 0 -> RelayStatusColors.Disconnected + connectedCount < 3 -> RelayStatusColors.Connecting + else -> RelayStatusColors.Connected + } + + Icon( + imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(16.dp), + ) + + Text( + "$connectedCount relay${if (connectedCount != 1) "s" else ""}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt new file mode 100644 index 000000000..4048a5c02 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt @@ -0,0 +1,89 @@ +/** + * 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.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Generic placeholder screen with title and description. + */ +@Composable +fun PlaceholderScreen( + title: String, + description: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(16.dp)) + Text( + description, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Search screen placeholder. + */ +@Composable +fun SearchPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Search", + description = "Search for users, notes, and hashtags.", + modifier = modifier, + ) +} + +/** + * Messages/DMs screen placeholder. + */ +@Composable +fun MessagesPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Messages", + description = "Your encrypted direct messages will appear here.", + modifier = modifier, + ) +} + +/** + * Notifications screen placeholder. + */ +@Composable +fun NotificationsPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Notifications", + description = "Mentions, replies, and reactions will appear here.", + modifier = modifier, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt new file mode 100644 index 000000000..edca6b6c3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -0,0 +1,84 @@ +/** + * 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.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary brand colors +val Primary50 = Color(red = 127, green = 103, blue = 190) +val Primary60 = Color(red = 154, green = 130, blue = 219) +val Primary70 = Color(red = 182, green = 157, blue = 248) +val Primary80 = Color(red = 208, green = 188, blue = 255) +val DefaultPrimary = Color(red = 208, green = 188, blue = 255) +val LightPurple = Color(red = 187, green = 134, blue = 252) + +// Material Design colors +val Purple200 = Color(0xFFBB86FC) +val Purple500 = Color(0xFF6200EE) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) + +// Bitcoin colors +val BitcoinOrange = Color(0xFFF7931A) +val BitcoinDark = Color(0xFFF7931A) +val BitcoinLight = Color(0xFFB66605) + +// Status colors +val RoyalBlue = Color(0xFF4169E1) +val Following = Color(0xFF03DAC5) +val FollowsFollow = Color.Yellow +val Nip05Verified = Color.Blue + +// NIP-05 email colors +val Nip05EmailColor = Color(0xFFb198ec) +val Nip05EmailColorDark = Color(0xFF6e5490) +val Nip05EmailColorLight = Color(0xFFa770f3) + +// Feedback colors +val DarkerGreen = Color.Green.copy(alpha = 0.32f) +val LightRedColor = Color(0xFFC62828) +val LighterRedColor = Color(0xFFFF0E0E) + +// Warning colors +val LightWarningColor = Color(0xFFffcc00) +val DarkWarningColor = Color(0xFFF8DE22) + +// Surface variant colors +val LightRedColorOnSecondSurface = Color(0xFFC62828) +val DarkRedColorOnSecondSurface = Color(0xFFF34747) +val LightWarningColorOnSecondSurface = Color(0xFFC09B14) +val DarkWarningColorOnSecondSurface = Color(0xFFE1C419) + +// Success colors +val LightAllGoodColor = Color(0xFF339900) +val DarkAllGoodColor = Color(0xFF99cc33) + +// Fundraiser colors +val LightFundraiserProgressColor = Color(0xFF3DB601) +val DarkFundraiserProgressColor = Color(0xFF61A229) + +// Relay status colors +object RelayStatusColors { + val Connected = Color.Green + val Connecting = Color.Yellow + val Disconnected = Color.Red + val Unknown = Color.Gray +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt new file mode 100644 index 000000000..abb8d1d47 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt @@ -0,0 +1,98 @@ +/** + * 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.util + +import kotlin.math.roundToInt + +/** + * Formats a count to human-readable format with suffix (K, M, G). + * Example: 1500 -> "1K items", 2500000 -> "2M items" + */ +fun countToHumanReadable( + counter: Int, + suffix: String, +): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix" + else -> "$counter $suffix" + } + +/** + * Formats a count to human-readable format with suffix (K, M, G). + */ +fun countToHumanReadable( + counter: Long, + suffix: String, +): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix" + else -> "$counter $suffix" + } + +/** + * Formats a count to human-readable format without suffix. + * Example: 1500 -> "1K", 2500000 -> "2M" + */ +fun countToHumanReadable(counter: Int): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K" + else -> "$counter" + } + +/** + * Formats a count to human-readable format without suffix. + */ +fun countToHumanReadable(counter: Long): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K" + else -> "$counter" + } + +/** + * Formats byte count to human-readable format (KB, MB, GB). + * Example: 1500 -> "1 KB", 2500000 -> "2 MB" + */ +fun countToHumanReadableBytes(counter: Int): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB" + else -> "$counter B" + } + +/** + * Formats byte count to human-readable format (KB, MB, GB). + */ +fun countToHumanReadableBytes(counter: Long): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB" + else -> "$counter B" + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt new file mode 100644 index 000000000..250e17ea1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt @@ -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.util + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Shortens a hex key or npub for display. + * Example: "npub1abcdefghijklmnop..." -> "npub1abc…mnop" + * + * @return Shortened string with ellipsis in the middle, or original if <= 16 chars + */ +fun String.toShortDisplay(): String { + if (length <= 16) return this + return replaceRange(8, length - 8, "…") +} + +/** + * Converts a ByteArray to a shortened hex display. + */ +fun ByteArray.toHexShortDisplay(): String = toHexKey().toShortDisplay() + +/** + * Shortens a HexKey for display. + */ +fun HexKey.toDisplayHexKey(): String = this.toShortDisplay() diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt new file mode 100644 index 000000000..6fb4df3e3 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt @@ -0,0 +1,125 @@ +/** + * 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.account + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed class AccountState { + data object LoggedOut : AccountState() + + data class LoggedIn( + val signer: NostrSigner, + val pubKeyHex: String, + val npub: String, + val nsec: String?, + val isReadOnly: Boolean, + ) : AccountState() +} + +class AccountManager { + private val _accountState = MutableStateFlow(AccountState.LoggedOut) + val accountState: StateFlow = _accountState.asStateFlow() + + fun generateNewAccount(): AccountState.LoggedIn { + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + return state + } + + fun loginWithKey(keyInput: String): Result { + val trimmedInput = keyInput.trim() + + // Try as private key first (nsec or hex) + val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput) + if (privKeyHex != null) { + return try { + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + Result.success(state) + } catch (e: Exception) { + Result.failure(IllegalArgumentException("Invalid private key format")) + } + } + + // Try as public key (npub or hex) - read-only mode + val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput) + if (pubKeyHex != null) { + return try { + val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = null, + isReadOnly = true, + ) + _accountState.value = state + Result.success(state) + } catch (e: Exception) { + Result.failure(IllegalArgumentException("Invalid public key format")) + } + } + + return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format.")) + } + + fun logout() { + _accountState.value = AccountState.LoggedOut + } + + fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn + + fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt similarity index 58% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt index cc26f5549..ac7bef89c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt @@ -20,33 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.base64Image -import android.R.attr.data -import android.graphics.Bitmap -import android.graphics.BitmapFactory import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import java.util.Base64 import java.util.regex.Pattern -class Base64Image { - companion object { - val pattern = Pattern.compile("data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") +object Base64Image { + val pattern: Pattern = + Pattern.compile( + "data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|")});base64,([a-zA-Z0-9+/]+={0,2})", + ) - fun isBase64(content: String): Boolean { - val matcher = pattern.matcher(content) - return matcher.find() - } - - fun toBitmap(content: String): Bitmap { - val matcher = pattern.matcher(data.toString()) - - 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") - } + fun isBase64(content: String): Boolean { + val matcher = pattern.matcher(content) + return matcher.find() } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt new file mode 100644 index 000000000..45f9571c2 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -0,0 +1,160 @@ +/** + * 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.network + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages Nostr relay connections, subscriptions, and status tracking. + * Can be used by both Android and Desktop apps. + * + * @param websocketBuilder Platform-specific websocket builder (e.g., OkHttp-based) + */ +open class RelayConnectionManager( + websocketBuilder: WebsocketBuilder, +) : IRelayClientListener { + private val client = NostrClient(websocketBuilder) + + private val _relayStatuses = MutableStateFlow>(emptyMap()) + val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() + + val connectedRelays: StateFlow> = client.connectedRelaysFlow() + val availableRelays: StateFlow> = client.availableRelaysFlow() + + init { + client.subscribe(this) + } + + fun connect() { + client.connect() + } + + fun disconnect() { + client.disconnect() + } + + fun addRelay(url: String): NormalizedRelayUrl? { + val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null + updateRelayStatus(normalized) { it.copy(connected = false, error = null) } + return normalized + } + + fun removeRelay(url: NormalizedRelayUrl) { + _relayStatuses.value = _relayStatuses.value - url + } + + fun addDefaultRelays() { + DefaultRelays.RELAYS.forEach { addRelay(it) } + } + + fun subscribe( + subId: String, + filters: List, + relays: Set = availableRelays.value, + listener: IRequestListener? = null, + ) { + val filterMap = relays.associateWith { filters } + client.openReqSubscription(subId, filterMap, listener) + } + + fun unsubscribe(subId: String) { + client.close(subId) + } + + fun send( + event: Event, + relays: Set = connectedRelays.value, + ) { + client.send(event, relays) + } + + private fun updateRelayStatus( + url: NormalizedRelayUrl, + update: (RelayStatus) -> RelayStatus, + ) { + _relayStatuses.value = + _relayStatuses.value.toMutableMap().apply { + val current = this[url] ?: RelayStatus(url, connected = false) + this[url] = update(current) + } + } + + override fun onConnecting(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = null) + } + } + + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + updateRelayStatus(relay.url) { + it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) + } + } + + override fun onDisconnected(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false) + } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = errorMessage) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + // Events are handled by subscription listeners + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + // Command send tracking + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt new file mode 100644 index 000000000..f446c7eba --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt @@ -0,0 +1,49 @@ +/** + * 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.network + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Represents the connection status of a Nostr relay. + * Used by both Android and Desktop apps. + */ +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val pingMs: Int? = null, + val compressed: Boolean = false, + val error: String? = null, +) + +/** + * Default relay URLs for Nostr connectivity. + */ +object DefaultRelays { + val RELAYS = + listOf( + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://nostr.wine", + ) +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt new file mode 100644 index 000000000..d0ef9308b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt @@ -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.richtext + +import java.util.regex.Pattern + +/** + * Pattern constants for email and phone validation. + * These replace android.util.Patterns for KMP compatibility. + */ +object Patterns { + /** + * Email address pattern from RFC 5322. + */ + val EMAIL_ADDRESS: Pattern = + Pattern.compile( + "[a-zA-Z0-9+._%-]+@[a-zA-Z0-9][a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", + ) + + /** + * Phone number pattern - matches common phone formats. + */ + val PHONE: Pattern = + Pattern.compile( + "^[+]?[(]?[0-9]{1,4}[)]?[-\\s./0-9]*\$", + ) +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 14bc34a4f..243a07957 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import android.util.Patterns import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.commons.base64Image.Base64Image diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt new file mode 100644 index 000000000..e27629c9f --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt @@ -0,0 +1,110 @@ +/** + * 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.ui.auth + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +/** + * Text field for entering Nostr keys (nsec or npub) with visibility toggle. + */ +@Composable +fun KeyInputField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "nsec or npub", + placeholder: String = "nsec1... or npub1...", + errorMessage: String? = null, +) { + var showKey by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + placeholder = { Text(placeholder) }, + modifier = modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = + if (showKey) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { showKey = !showKey }) { + Icon( + if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showKey) "Hide key" else "Show key", + ) + } + }, + isError = errorMessage != null, + supportingText = + errorMessage?.let { + { Text(it, color = MaterialTheme.colorScheme.error) } + }, + ) +} + +/** + * Card displaying a Nostr key that users can select/copy. + */ +@Composable +fun SelectableKeyText( + key: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Text( + text = key, + modifier = Modifier.padding(12.dp).fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt new file mode 100644 index 000000000..a80912b91 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt @@ -0,0 +1,133 @@ +/** + * 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.ui.auth + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Login card with Nostr key input field and action buttons. + * + * @param onLogin Callback when login is attempted with the key input + * @param onGenerateNew Callback when "Generate New" is clicked + * @param modifier Modifier for the card + * @param cardWidth Width of the card (default 400.dp) + * @param title Card title + * @param subtitle Subtitle/hint text + */ +@Composable +fun LoginCard( + onLogin: (String) -> Result, + onGenerateNew: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 400.dp, + title: String = "Login with your Nostr key", + subtitle: String = "Use nsec for full access or npub for read-only", +) { + var keyInput by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + + Card( + modifier = modifier.width(cardWidth), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + onLogin(keyInput).fold( + onSuccess = { /* handled by caller */ }, + onFailure = { errorMessage = it.message }, + ) + }, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text("Login") + } + + OutlinedButton( + onClick = onGenerateNew, + modifier = Modifier.weight(1f), + ) { + Text("Generate New") + } + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt new file mode 100644 index 000000000..4fa976aa9 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt @@ -0,0 +1,113 @@ +/** + * 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.ui.auth + +import androidx.compose.foundation.layout.Column +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.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Warning card displayed after generating a new Nostr key pair. + * Reminds users to save their keys and shows both public and secret keys. + * + * @param npub The public key in npub format + * @param nsec The secret key in nsec format (nullable for read-only accounts) + * @param onContinue Callback when user acknowledges they've saved their keys + * @param modifier Modifier for the card + * @param cardWidth Width of the card (default 500.dp) + */ +@Composable +fun NewKeyWarningCard( + npub: String, + nsec: String?, + onContinue: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 500.dp, +) { + Card( + modifier = modifier.width(cardWidth), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + ) { + Text( + "IMPORTANT: Save your keys!", + style = MaterialTheme.typography.titleMedium, + color = Color.Red, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Your secret key (nsec) is the ONLY way to access your account. " + + "If you lose it, your account is gone forever. Save it somewhere safe!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Public Key (shareable):", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectableKeyText(npub) + + Spacer(Modifier.height(12.dp)) + + nsec?.let { secretKey -> + Text( + "Secret Key (NEVER share this!):", + style = MaterialTheme.typography.labelMedium, + color = Color.Red, + ) + SelectableKeyText(secretKey) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) { + Text("I've saved my keys, continue") + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt new file mode 100644 index 000000000..a82e4faf8 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt @@ -0,0 +1,188 @@ +/** + * 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.ui.note + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.util.toTimeAgo + +/** + * Data class for displaying a note card. + */ +data class NoteDisplayData( + val id: String, + val pubKeyDisplay: String, + val content: String, + val createdAt: Long, +) + +/** + * Reusable note card composable that displays a Nostr note. + * Can be used by both Desktop and Android apps. + */ +@Composable +fun NoteCard( + note: NoteDisplayData, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val richTextParser = remember { RichTextParser() } + val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + onClick = onClick ?: {}, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Author (truncated) + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + + // Timestamp + Text( + text = note.createdAt.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(8.dp)) + + RichTextContent( + content = note.content, + urls = urls, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) + + Spacer(Modifier.height(4.dp)) + + // Event ID (truncated) + Text( + text = "ID: ${note.id.take(12)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } +} + +/** + * Renders text content with highlighted URLs. + * Uses RichTextParser from commons to detect and highlight links. + */ +@Composable +fun RichTextContent( + content: String, + urls: Set, + modifier: Modifier = Modifier, + maxLines: Int = 10, +) { + if (urls.isEmpty()) { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } else { + val annotatedText = + buildAnnotatedString { + var lastIndex = 0 + val sortedUrls = urls.sortedBy { content.indexOf(it) } + + for (url in sortedUrls) { + val startIndex = content.indexOf(url, lastIndex) + if (startIndex == -1) continue + + // Add text before URL + if (startIndex > lastIndex) { + append(content.substring(lastIndex, startIndex)) + } + + // Add URL with styling + withStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + append(url) + } + + lastIndex = startIndex + url.length + } + + // Add remaining text + if (lastIndex < content.length) { + append(content.substring(lastIndex)) + } + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt new file mode 100644 index 000000000..53bf73bbc --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt @@ -0,0 +1,90 @@ +/** + * 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.ui.profile + +import androidx.compose.foundation.layout.Column +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.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Card displaying Nostr account key information. + * Shows public key (npub), hex format, and optional read-only indicator. + */ +@Composable +fun ProfileInfoCard( + npub: String, + pubKeyHex: String, + isReadOnly: Boolean, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (isReadOnly) { + Text( + "Read-only mode", + style = MaterialTheme.typography.labelMedium, + color = Color.Yellow, + ) + Spacer(Modifier.height(8.dp)) + } + + Text( + "Public Key", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + npub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Hex", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + pubKeyHex, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt new file mode 100644 index 000000000..c82c5102b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt @@ -0,0 +1,133 @@ +/** + * 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.ui.relay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.network.RelayStatus + +/** + * Card displaying the status of a Nostr relay connection. + * Shows connection status, ping time, compression, and errors. + */ +@Composable +fun RelayStatusCard( + status: RelayStatus, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f), + ) { + val statusColor = + when { + status.connected -> Color.Green + status.error != null -> Color.Red + else -> Color.Gray + } + + if (status.connected) { + Icon( + Icons.Default.Check, + contentDescription = "Connected", + tint = statusColor, + modifier = Modifier.size(20.dp), + ) + } else if (status.error != null) { + Icon( + Icons.Default.Close, + contentDescription = "Error", + tint = statusColor, + modifier = Modifier.size(20.dp), + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } + + Column { + Text( + status.url.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (status.connected && status.pingMs != null) { + Text( + "${status.pingMs}ms${if (status.compressed) " • compressed" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + status.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = Color.Red.copy(alpha = 0.8f), + ) + } + } + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = "Remove relay", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt new file mode 100644 index 000000000..1f0de406e --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -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.util + +import com.vitorpamplona.amethyst.commons.ui.note.NoteDisplayData +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip19Bech32.toNpub + +/** + * Extension to convert Event to NoteDisplayData for the shared NoteCard. + */ +fun Event.toNoteDisplayData(): NoteDisplayData { + val npub = + try { + pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." + } catch (e: Exception) { + pubKey.take(16) + "..." + } + + return NoteDisplayData( + id = id, + pubKeyDisplay = npub, + content = content, + createdAt = createdAt, + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt new file mode 100644 index 000000000..88ef514b9 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -0,0 +1,118 @@ +/** + * 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.util + +import com.vitorpamplona.quartz.utils.TimeUtils +import java.text.SimpleDateFormat +import java.util.Locale + +private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" +private const val MONTH_DATE_FORMAT = "MMM dd" + +private var locale = Locale.getDefault() +private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) +private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + +private fun updateFormattersIfNeeded() { + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } +} + +/** + * Formats a Unix timestamp (seconds) as a human-readable time ago string. + * Returns strings like " • 5m", " • 2h", " • Dec 12" + * + * @param time Unix timestamp in seconds, or null + * @param withDot Whether to prefix with " • " (default true) + * @return Formatted time ago string + */ +fun timeAgo( + time: Long?, + withDot: Boolean = true, +): String { + if (time == null) return " " + if (time == 0L) return if (withDot) " • never" else "never" + + val timeDifference = TimeUtils.now() - time + val prefix = if (withDot) " • " else "" + + return when { + timeDifference > TimeUtils.ONE_YEAR -> { + updateFormattersIfNeeded() + prefix + yearFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_MONTH -> { + updateFormattersIfNeeded() + prefix + monthFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_DAY -> { + prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + "d" + } + timeDifference > TimeUtils.ONE_HOUR -> { + prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + "h" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + "m" + } + else -> { + prefix + "now" + } + } +} + +/** + * Formats a Unix timestamp as a date string. + * For recent dates (< 1 day), returns the provided today string. + * + * @param time Unix timestamp in seconds + * @param never String to show for time == 0 + * @param today String to show for today's date + */ +fun dateFormatter( + time: Long?, + never: String = "never", + today: String = "today", +): String { + if (time == null) return " " + if (time == 0L) return " $never" + + val timeDifference = TimeUtils.now() - time + + return when { + timeDifference > TimeUtils.ONE_YEAR -> { + updateFormattersIfNeeded() + yearFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_DAY -> { + updateFormattersIfNeeded() + monthFormatter.format(time * 1000) + } + else -> today + } +} + +/** + * Extension function for Long timestamps. + */ +fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt new file mode 100644 index 000000000..7e0e3c6cb --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt @@ -0,0 +1,82 @@ +/** + * 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.util + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat + +private val TenGiga = BigDecimal(10_000_000_000) +private val OneGiga = BigDecimal(1_000_000_000) +private val TenMega = BigDecimal(10_000_000) +private val OneMega = BigDecimal(1_000_000) +private val TenKilo = BigDecimal(10_000) +private val OneKilo = BigDecimal(1_000) + +private val dfGBig = ThreadLocal.withInitial { DecimalFormat("#.#G") } +private val dfGSmall = ThreadLocal.withInitial { DecimalFormat("#.0G") } +private val dfMBig = ThreadLocal.withInitial { DecimalFormat("#.#M") } +private val dfMSmall = ThreadLocal.withInitial { DecimalFormat("#.0M") } +private val dfK = ThreadLocal.withInitial { DecimalFormat("#.#k") } +private val dfN = ThreadLocal.withInitial { DecimalFormat("#") } + +/** + * Formats a BigDecimal amount to human-readable format with G/M/K suffixes. + * Returns empty string for null or very small amounts. + * + * Examples: + * - 1500 -> "1.5k" + * - 2500000 -> "2.5M" + * - 10000000000 -> "10G" + */ +fun showAmount(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= TenGiga -> dfGBig.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneGiga -> dfGSmall.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= TenMega -> dfMBig.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfMSmall.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.get()!!.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.get()!!.format(amount) + } +} + +/** + * Formats a BigDecimal amount to human-readable format. + * Returns "0" for null or very small amounts instead of empty string. + */ +fun showAmountWithZero(amount: BigDecimal?): String { + if (amount == null) return "0" + if (amount.abs() < BigDecimal(0.01)) return "0" + return showAmount(amount) +} + +/** + * Extension function to format Long as zap amount. + */ +fun Long.toZapAmount(): String = showAmount(BigDecimal(this)) + +/** + * Extension function to format Int as zap amount. + */ +fun Int.toZapAmount(): String = showAmount(BigDecimal(this)) diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt new file mode 100644 index 000000000..6c523a33e --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt @@ -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 com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import java.io.ByteArrayInputStream +import java.util.Base64 +import javax.imageio.ImageIO + +/** + * Converts a base64 image data URI to a PlatformImage (BufferedImage wrapper). + */ +fun Base64Image.toPlatformImage(content: String): PlatformImage { + val matcher = pattern.matcher(content) + + if (matcher.find()) { + val base64String = matcher.group(2) + val byteArray = Base64.getDecoder().decode(base64String) + val bufferedImage = + ImageIO.read(ByteArrayInputStream(byteArray)) + ?: throw Exception("Unable to decode base64 image: $content") + return bufferedImage.toPlatformImage() + } + + throw Exception("Unable to convert base64 to image $content") +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt new file mode 100644 index 000000000..7cb20fc93 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt @@ -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 java.awt.image.BufferedImage + +/** + * Encodes this BufferedImage to a blurhash string. + * Delegates to PlatformImage.toBlurhash() for the actual encoding. + */ +fun BufferedImage.toBlurhash(): String = this.toPlatformImage().toBlurhash() diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt new file mode 100644 index 000000000..8d4c990f6 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt @@ -0,0 +1,75 @@ +/** + * 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 java.awt.Image +import java.awt.image.BufferedImage + +actual class PlatformImage( + val image: BufferedImage, +) { + actual val width: Int get() = image.width + actual val height: Int get() = image.height + + actual fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + image.getRGB(x, y, width, height, pixels, offset, stride) + } + + actual fun scale( + width: Int, + height: Int, + ): PlatformImage { + val scaled = image.getScaledInstance(width, height, Image.SCALE_FAST) + val buffered = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + buffered.graphics.drawImage(scaled, 0, 0, null) + return PlatformImage(buffered) + } + + actual companion object { + actual fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + image.setRGB(0, 0, width, height, pixels, 0, width) + return PlatformImage(image) + } + } +} + +/** + * Extension to convert BufferedImage to PlatformImage. + */ +fun BufferedImage.toPlatformImage(): PlatformImage = PlatformImage(this) + +/** + * Extension to get the underlying BufferedImage. + */ +fun PlatformImage.toBufferedImage(): BufferedImage = this.image diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 000000000..5bd8c6c60 --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,73 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.jetbrainsComposeCompiler) +} + +sourceSets { + main { + kotlin.srcDir("src/jvmMain/kotlin") + resources.srcDir("src/jvmMain/resources") + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + + // Quartz Nostr library (will use JVM target) + implementation(project(":quartz")) + + // Commons library + implementation(project(":commons")) + + // Coroutines + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + + // Networking + implementation(libs.okhttp) + + // JSON + implementation(libs.jackson.module.kotlin) + + // Collections + implementation(libs.kotlinx.collections.immutable) +} + +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + + packageName = "Amethyst" + packageVersion = "1.0.0" + description = "Nostr client for desktop" + vendor = "Amethyst Contributors" + + macOS { + bundleID = "com.vitorpamplona.amethyst.desktop" + iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + } + + windows { + iconFile.set(project.file("src/jvmMain/resources/icon.ico")) + menuGroup = "Amethyst" + upgradeUuid = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + } + + linux { + iconFile.set(project.file("src/jvmMain/resources/icon.png")) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt new file mode 100644 index 000000000..7e91eb32d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -0,0 +1,381 @@ +/** + * 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.desktop + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.MenuBar +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.vitorpamplona.amethyst.commons.account.AccountManager +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.navigation.AppScreen +import com.vitorpamplona.amethyst.commons.ui.profile.ProfileInfoCard +import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard +import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder +import com.vitorpamplona.amethyst.commons.ui.screens.NotificationsPlaceholder +import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.FeedScreen +import com.vitorpamplona.amethyst.desktop.ui.LoginScreen + +fun main() = + application { + val windowState = + rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center), + ) + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst", + ) { + MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = KeyShortcut(Key.N, ctrl = true), + onClick = { /* TODO: Open new note dialog */ }, + ) + Separator() + Item( + "Settings", + shortcut = KeyShortcut(Key.Comma, ctrl = true), + onClick = { /* TODO: Open settings */ }, + ) + Separator() + Item( + "Quit", + shortcut = KeyShortcut(Key.Q, ctrl = true), + onClick = ::exitApplication, + ) + } + Menu("Edit") { + Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { }) + Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { }) + } + Menu("View") { + Item("Feed", onClick = { }) + Item("Messages", onClick = { }) + Item("Notifications", onClick = { }) + } + Menu("Help") { + Item("About Amethyst", onClick = { }) + Item("Keyboard Shortcuts", onClick = { }) + } + } + + App() + } + } + +@Composable +fun App() { + var currentScreen by remember { mutableStateOf(AppScreen.Feed) } + val relayManager = remember { DesktopRelayConnectionManager() } + val accountManager = remember { AccountManager() } + val accountState by accountManager.accountState.collectAsState() + + DisposableEffect(Unit) { + relayManager.addDefaultRelays() + relayManager.connect() + onDispose { + relayManager.disconnect() + } + } + + MaterialTheme( + colorScheme = darkColorScheme(), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + when (accountState) { + is AccountState.LoggedOut -> { + LoginScreen( + accountManager = accountManager, + onLoginSuccess = { currentScreen = AppScreen.Feed }, + ) + } + is AccountState.LoggedIn -> { + MainContent( + currentScreen = currentScreen, + onScreenChange = { currentScreen = it }, + relayManager = relayManager, + accountManager = accountManager, + account = accountState as AccountState.LoggedIn, + ) + } + } + } + } +} + +@Composable +fun MainContent( + currentScreen: AppScreen, + onScreenChange: (AppScreen) -> Unit, + relayManager: DesktopRelayConnectionManager, + accountManager: AccountManager, + account: AccountState.LoggedIn, +) { + Row(Modifier.fillMaxSize()) { + // Sidebar Navigation + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + Spacer(Modifier.height(16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + label = { Text("Search") }, + selected = currentScreen == AppScreen.Search, + onClick = { onScreenChange(AppScreen.Search) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == AppScreen.Messages, + onClick = { onScreenChange(AppScreen.Messages) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == AppScreen.Notifications, + onClick = { onScreenChange(AppScreen.Notifications) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == AppScreen.Profile, + onClick = { onScreenChange(AppScreen.Profile) }, + ) + + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == AppScreen.Settings, + onClick = { onScreenChange(AppScreen.Settings) }, + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // Main Content + Box( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), + ) { + when (currentScreen) { + AppScreen.Feed -> FeedScreen(relayManager) + AppScreen.Search -> SearchPlaceholder() + AppScreen.Messages -> MessagesPlaceholder() + AppScreen.Notifications -> NotificationsPlaceholder() + AppScreen.Profile -> ProfileScreen(account, accountManager) + AppScreen.Settings -> RelaySettingsScreen(relayManager) + } + } + } +} + +@Composable +fun ProfileScreen( + account: AccountState.LoggedIn, + accountManager: AccountManager, +) { + Column { + Text( + "Profile", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(16.dp)) + + ProfileInfoCard( + npub = account.npub, + pubKeyHex = account.pubKeyHex, + isReadOnly = account.isReadOnly, + ) + + Spacer(Modifier.height(24.dp)) + + OutlinedButton( + onClick = { accountManager.logout() }, + colors = + androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = Color.Red, + ), + ) { + Text("Logout") + } + } +} + +@Composable +fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() + var newRelayUrl by remember { mutableStateOf("") } + + Column(modifier = Modifier.fillMaxSize()) { + Text( + "Relay Settings", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "${connectedRelays.size} of ${relayStatuses.size} relays connected", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newRelayUrl, + onValueChange = { newRelayUrl = it }, + label = { Text("Add relay") }, + placeholder = { Text("wss://relay.example.com") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Button( + onClick = { + if (newRelayUrl.isNotBlank()) { + relayManager.addRelay(newRelayUrl) + newRelayUrl = "" + } + }, + enabled = newRelayUrl.isNotBlank(), + ) { + Text("Add") + } + } + + Spacer(Modifier.height(16.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + items(relayStatuses.values.toList()) { status -> + RelayStatusCard( + status = status, + onRemove = { relayManager.removeRelay(status.url) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { + Text("Reset to Defaults") + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt new file mode 100644 index 000000000..c94df538f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt @@ -0,0 +1,40 @@ +/** + * 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.desktop.network + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +object DesktopHttpClient { + private val client: OkHttpClient by lazy { + OkHttpClient + .Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + + fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient = client +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt new file mode 100644 index 000000000..34dd8db54 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -0,0 +1,33 @@ +/** + * 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.desktop.network + +import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket + +/** + * Desktop-specific relay connection manager that configures OkHttp for websockets. + * Delegates to the shared RelayConnectionManager from commons. + */ +class DesktopRelayConnectionManager : + RelayConnectionManager( + websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), + ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt new file mode 100644 index 000000000..9eb70e313 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -0,0 +1,136 @@ +/** + * 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.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader +import com.vitorpamplona.amethyst.commons.ui.note.NoteCard +import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent + +@Composable +fun FeedScreen(relayManager: DesktopRelayConnectionManager) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val events = remember { mutableStateListOf() } + val seenIds = remember { mutableSetOf() } + + // Use relayStatuses.keys (configured relays) to initiate subscription, + // not connectedRelays (which is empty until subscriptions trigger connections) + val configuredRelays = relayStatuses.keys + + DisposableEffect(configuredRelays) { + if (configuredRelays.isNotEmpty()) { + val subId = "global-feed-${System.currentTimeMillis()}" + val filters = + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 50, + ), + ) + + relayManager.subscribe( + subId = subId, + filters = filters, + relays = configuredRelays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.id !in seenIds) { + seenIds.add(event.id) + events.add(0, event) + if (events.size > 200) { + val removed = events.removeAt(events.size - 1) + seenIds.remove(removed.id) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // End of stored events + } + }, + ) + + onDispose { + relayManager.unsubscribe(subId) + } + } else { + onDispose { } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + FeedHeader( + title = "Global Feed", + connectedRelayCount = connectedRelays.size, + onRefresh = { relayManager.connect() }, + ) + + Spacer(Modifier.height(16.dp)) + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (events.isEmpty()) { + LoadingState("Loading notes...") + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(events, key = { it.id }) { event -> + // Use NoteCard from commons + NoteCard( + note = event.toNoteDisplayData(), + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt new file mode 100644 index 000000000..68761246e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -0,0 +1,97 @@ +/** + * 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.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountManager +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard +import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard + +@Composable +fun LoginScreen( + accountManager: AccountManager, + onLoginSuccess: () -> Unit, +) { + var showNewKeyDialog by remember { mutableStateOf(false) } + var generatedAccount by remember { mutableStateOf(null) } + + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + "Welcome to Amethyst", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "A Nostr client for desktop", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(48.dp)) + + LoginCard( + onLogin = { keyInput -> + accountManager.loginWithKey(keyInput).map { + onLoginSuccess() + } + }, + onGenerateNew = { + generatedAccount = accountManager.generateNewAccount() + showNewKeyDialog = true + }, + ) + + if (showNewKeyDialog && generatedAccount != null) { + Spacer(Modifier.height(24.dp)) + NewKeyWarningCard( + npub = generatedAccount!!.npub, + nsec = generatedAccount!!.nsec, + onContinue = { + showNewKeyDialog = false + onLoginSuccess() + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/resources/icon.png b/desktopApp/src/jvmMain/resources/icon.png new file mode 100644 index 000000000..dbb5d7153 Binary files /dev/null and b/desktopApp/src/jvmMain/resources/icon.png differ diff --git a/docs/shared-ui-analysis.md b/docs/shared-ui-analysis.md new file mode 100644 index 000000000..174d2d058 --- /dev/null +++ b/docs/shared-ui-analysis.md @@ -0,0 +1,219 @@ +# Shared UI Components Analysis + +Analysis of Android vs Desktop UI to identify reusable Compose Multiplatform components. + +## Executive Summary + +| Category | Android Files | Shareable As-Is | Needs Abstraction | +|----------|---------------|-----------------|-------------------| +| Formatters | 4 | 2 | 2 | +| Theme | 4 | 4 | 0 | +| Note Components | 35 | ~10 | ~25 | +| Total Potential | ~450+ | ~100+ | ~200+ | + +## Immediately Shareable Components + +These have **zero Android dependencies** and can be moved to a shared module today: + +### Formatters (Pure Kotlin) +``` +amethyst/ui/note/PubKeyFormatter.kt -> shared/ui/formatters/ +amethyst/ui/note/ZapFormatter.kt -> shared/ui/formatters/ +amethyst/ui/note/ZapFormatterNoDecimals.kt -> shared/ui/formatters/ +``` + +### Theme (Pure Compose) +``` +amethyst/ui/theme/Color.kt -> shared/ui/theme/ +amethyst/ui/theme/Shape.kt -> shared/ui/theme/ +amethyst/ui/theme/Type.kt -> shared/ui/theme/ +``` + +### Layout Primitives +``` +amethyst/ui/theme/ButtonBorder (padding values) +amethyst/ui/theme/Size* (dimension constants) +``` + +## Components Requiring Abstraction + +### 1. String Resources (R.string.*) + +**Problem:** Android uses `R.string.xxx` for localized strings. + +**Solution:** Create `StringProvider` interface: + +```kotlin +// commonMain +interface StringProvider { + fun get(key: String): String + fun get(key: String, vararg args: Any): String +} + +// androidMain +class AndroidStringProvider(private val context: Context) : StringProvider { + override fun get(key: String) = context.getString(keyToId(key)) +} + +// jvmMain +class DesktopStringProvider : StringProvider { + private val strings = mapOf( + "post_not_found" to "Post not found", + "now" to "now", + // ... + ) + override fun get(key: String) = strings[key] ?: key +} +``` + +**Affected files:** +- TimeAgoFormatter.kt +- BlankNote.kt +- All UI components with text + +### 2. AccountViewModel + +**Problem:** Most UI components depend on `AccountViewModel` for: +- User profile data +- Settings (image loading, NSFW, etc.) +- Actions (follow, mute, report) + +**Solution:** Create interface in shared module: + +```kotlin +// commonMain +interface IAccountState { + val userPubKey: String + val userNpub: String + val settings: IUserSettings +} + +interface IUserSettings { + val showImages: Boolean + val showSensitiveContent: Boolean +} +``` + +### 3. Navigation (INav) + +**Problem:** Android uses custom `INav` interface tied to Compose Navigation. + +**Solution:** Already abstracted! `INav` is an interface - just needs to be in shared module. + +### 4. Image Loading + +**Problem:** Uses Coil with Android-specific caching. + +**Solution:** Coil 3.x supports Compose Multiplatform. Create shared wrapper: + +```kotlin +// commonMain +@Composable +expect fun AsyncImage( + url: String, + contentDescription: String?, + modifier: Modifier, +) +``` + +## Recommended Shared Module Structure + +``` +shared-ui/ +├── src/ +│ ├── commonMain/kotlin/ +│ │ ├── formatters/ +│ │ │ ├── PubKeyFormatter.kt +│ │ │ ├── ZapFormatter.kt +│ │ │ └── TimeAgoFormatter.kt (abstracted) +│ │ ├── theme/ +│ │ │ ├── Color.kt +│ │ │ ├── Shape.kt +│ │ │ ├── Type.kt +│ │ │ └── Dimensions.kt +│ │ ├── components/ +│ │ │ ├── NoteCard.kt +│ │ │ ├── UserAvatar.kt +│ │ │ └── LoadingIndicator.kt +│ │ └── providers/ +│ │ ├── StringProvider.kt +│ │ └── ImageProvider.kt +│ ├── androidMain/kotlin/ +│ │ └── providers/ +│ │ └── AndroidStringProvider.kt +│ └── jvmMain/kotlin/ +│ └── providers/ +│ └── DesktopStringProvider.kt +└── build.gradle.kts +``` + +## Implementation Priority + +### Phase 1: Low-Hanging Fruit (1-2 days) +1. Create `shared-ui` module +2. Move pure Kotlin formatters (PubKeyFormatter, ZapFormatter) +3. Move theme colors and dimensions +4. Update both apps to use shared module + +### Phase 2: String Abstraction (2-3 days) +1. Create StringProvider interface +2. Implement for Android and Desktop +3. Migrate TimeAgoFormatter +4. Create composable wrappers for common strings + +### Phase 3: Core UI Components (1 week) +1. Abstract AccountViewModel to IAccountState +2. Move simple components (BlankNote, LoadingIndicator) +3. Create NoteCard shared component +4. Integrate with both apps + +### Phase 4: Complex Components (2+ weeks) +1. Image loading abstraction +2. RichTextViewer +3. ReactionsRow +4. Full NoteCompose migration + +## Desktop vs Android Comparison + +| Feature | Android | Desktop | Share Strategy | +|---------|---------|---------|----------------| +| Note display | NoteCompose.kt (51KB) | NoteCard in FeedScreen | Extract common layout | +| Time formatting | TimeAgoFormatter.kt | SimpleDateFormat inline | Abstract with interface | +| Key display | PubKeyFormatter.kt | Manual truncation | Use shared formatter | +| Colors | Color.kt | darkColorScheme() | Share Color.kt | +| Navigation | INav + Compose Nav | enum Screen | Abstract navigation | +| Images | Coil + OkHttp | Not implemented | Coil Multiplatform | + +## Current Desktop Implementation Gaps + +Desktop currently has basic implementations that could benefit from sharing: + +1. **NoteCard** - Has inline date formatting, could use TimeAgoFormatter +2. **Key display** - Manual truncation, should use PubKeyFormatter +3. **Theme** - Uses default darkColorScheme, could use shared theme +4. **No image loading** - Needs Coil integration + +## Quick Wins + +Immediate improvements to desktop using patterns from Android: + +```kotlin +// Instead of: +val npub = event.pubKey.take(16) + "..." + +// Use shared formatter: +val npub = event.pubKey.toDisplayHexKey() + +// Instead of: +val format = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) + +// Use shared formatter: +val timeAgo = timeAgoNoDot(event.createdAt, stringProvider) +``` + +## Next Steps + +1. **Create shared-ui module** in project structure +2. **Move formatters** as proof of concept +3. **Create StringProvider** abstraction +4. **Iterate** on more complex components diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65bff466a..b28210259 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] accompanistAdaptive = "0.37.3" +composeMultiplatform = "1.7.1" activityCompose = "1.12.1" agp = "8.13.2" android-compileSdk = "36" @@ -122,6 +123,7 @@ jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } lazysodium-java = { group = "com.goterl", name = "lazysodium-java", version.ref = "lazysodiumJava" } @@ -167,4 +169,5 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" } \ No newline at end of file +stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index eae01d726..e59fd24a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -29,6 +29,8 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this) fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this) +fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null + const val PUBKEY_LENGTH = 64 const val EVENT_ID_LENGTH = 64 diff --git a/settings.gradle b/settings.gradle index 0a0b52faa..cad6e3772 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,3 +34,4 @@ include ':benchmark' include ':quartz' include ':commons' include ':ammolite' +include ':desktopApp'