feat: Add Compose Multiplatform Desktop support foundation
- Add desktopApp module with JVM entry point and sidebar navigation - Add Claude specs for AI-assisted development: - Agent definitions: nostr-protocol, kotlin-multiplatform, compose-ui, kotlin-coroutines - Skills: quartz-kmp conversion, compose-desktop patterns - Commands: desktop-run, nip, extract - Update Gradle configuration with Compose Multiplatform 1.7.1 plugin - Add coroutines and secp256k1 JVM dependencies to version catalog Next steps: - Convert Quartz library to full KMP (expect/actual for crypto) - Implement relay connections in desktop app - Share UI components between Android and Desktop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# 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 (converted to multiplatform)
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Shared Nostr protocol
|
||||
│ ├── androidMain/ # Android-specific (crypto, storage)
|
||||
│ └── jvmMain/ # Desktop JVM-specific
|
||||
├── desktopApp/ # Desktop JVM application
|
||||
├── amethyst/ # Android app (existing)
|
||||
├── ammolite/ # Support module
|
||||
└── commons/ # Utilities
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Core** | Quartz (Nostr KMP) |
|
||||
| **UI** | Compose Multiplatform 1.7.x |
|
||||
| **Async** | kotlinx.coroutines + Flow |
|
||||
| **Network** | OkHttp (JVM) |
|
||||
| **Serialization** | Jackson |
|
||||
| **DI** | Manual / Koin |
|
||||
| **Build** | Gradle 8.x, Kotlin 2.1.0 |
|
||||
|
||||
## Agents
|
||||
|
||||
Use these specialized agents for domain expertise:
|
||||
|
||||
| Agent | Expertise | When to Use |
|
||||
|-------|-----------|-------------|
|
||||
| `nostr-protocol` | NIPs, events, relays, crypto | Protocol questions, NIP implementation |
|
||||
| `kotlin-multiplatform` | KMP, source sets, expect/actual | Project structure, code sharing |
|
||||
| `compose-ui` | Composables, Desktop features | UI components, navigation |
|
||||
| `kotlin-coroutines` | Flows, async, concurrency | Data streams, async operations |
|
||||
|
||||
## Commands
|
||||
|
||||
- `/desktop-run` - Build and run desktop app
|
||||
- `/extract <component>` - Move composable to shared code
|
||||
- `/nip <number>` - Get NIP implementation guidance
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
# Run desktop app
|
||||
./gradlew :desktopApp:run
|
||||
|
||||
# Run Android app
|
||||
./gradlew :amethyst:installDebug
|
||||
|
||||
# Build Quartz for all targets
|
||||
./gradlew :quartz:build
|
||||
|
||||
# Run tests
|
||||
./gradlew test
|
||||
|
||||
# Format code
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
|
||||
## Quartz KMP Structure
|
||||
|
||||
The Quartz library uses expect/actual for platform-specific implementations:
|
||||
|
||||
```kotlin
|
||||
// commonMain - shared protocol logic
|
||||
expect class CryptoProvider {
|
||||
fun sign(message: ByteArray, privateKey: ByteArray): ByteArray
|
||||
fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean
|
||||
}
|
||||
|
||||
// androidMain - uses secp256k1-kmp-jni-android
|
||||
actual class CryptoProvider { /* Android implementation */ }
|
||||
|
||||
// jvmMain - uses secp256k1-kmp-jni-jvm
|
||||
actual class CryptoProvider { /* JVM implementation */ }
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Platform Abstraction
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect fun openExternalUrl(url: String)
|
||||
|
||||
// androidMain
|
||||
actual fun openExternalUrl(url: String) {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
|
||||
// jvmMain (Desktop)
|
||||
actual fun openExternalUrl(url: String) {
|
||||
Desktop.getDesktop().browse(URI(url))
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation Shell
|
||||
- **Desktop**: Sidebar + main content area
|
||||
- **Android**: Bottom navigation
|
||||
|
||||
## Git Workflow
|
||||
|
||||
- Branch: `feat/desktop-<feature>` or `fix/desktop-<issue>`
|
||||
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
|
||||
- Never use `--no-verify`
|
||||
|
||||
## Resources
|
||||
|
||||
- [Nostr NIPs](https://github.com/nostr-protocol/nips)
|
||||
- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
|
||||
- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html)
|
||||
@@ -0,0 +1,243 @@
|
||||
# Compose Multiplatform UI Agent
|
||||
|
||||
## Expertise Domain
|
||||
|
||||
This agent specializes in Compose Multiplatform for building declarative UIs that share code across Android and Desktop JVM.
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Android | Stable | Jetpack Compose |
|
||||
| Desktop (JVM) | Stable | Windows, macOS, Linux |
|
||||
| iOS | Beta | Future consideration |
|
||||
| Web (Wasm) | Alpha | Future consideration |
|
||||
|
||||
## Core Knowledge Areas
|
||||
|
||||
### Shared Composables (commonMain)
|
||||
```kotlin
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
note: Note,
|
||||
onReply: () -> Unit,
|
||||
onRepost: () -> Unit,
|
||||
onZap: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(modifier = modifier) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
AuthorRow(note.author)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(note.content)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ActionRow(onReply, onRepost, onZap)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Desktop-Specific Features
|
||||
```kotlin
|
||||
// Window management
|
||||
fun main() = application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = rememberWindowState(
|
||||
width = 1200.dp,
|
||||
height = 800.dp,
|
||||
position = WindowPosition.Aligned(Alignment.Center)
|
||||
),
|
||||
title = "Amethyst Desktop"
|
||||
) {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
// Menu bar
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item("New Note", onClick = { }, shortcut = KeyShortcut(Key.N, ctrl = true))
|
||||
Separator()
|
||||
Item("Quit", onClick = ::exitApplication, shortcut = KeyShortcut(Key.Q, ctrl = true))
|
||||
}
|
||||
Menu("View") {
|
||||
Item("Feed", onClick = { navigateTo(Screen.Feed) })
|
||||
Item("Messages", onClick = { navigateTo(Screen.Messages) })
|
||||
}
|
||||
}
|
||||
|
||||
// System tray
|
||||
Tray(
|
||||
icon = painterResource("icon.png"),
|
||||
menu = {
|
||||
Item("Show", onClick = { windowVisible = true })
|
||||
Item("Exit", onClick = ::exitApplication)
|
||||
}
|
||||
)
|
||||
|
||||
// Keyboard shortcuts
|
||||
Modifier.onKeyEvent { event ->
|
||||
when {
|
||||
event.isCtrlPressed && event.key == Key.Enter -> {
|
||||
sendNote()
|
||||
true
|
||||
}
|
||||
event.key == Key.Escape -> {
|
||||
closeDialog()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// Context menus
|
||||
ContextMenuArea(items = {
|
||||
listOf(
|
||||
ContextMenuItem("Copy") { copyToClipboard(note.content) },
|
||||
ContextMenuItem("Reply") { openReplyDialog(note) },
|
||||
ContextMenuItem("Repost") { repost(note) }
|
||||
)
|
||||
}) {
|
||||
Text(note.content)
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation Patterns
|
||||
```kotlin
|
||||
// Sidebar + Content pattern for Desktop
|
||||
@Composable
|
||||
fun DesktopLayout(currentScreen: Screen, onNavigate: (Screen) -> Unit) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// Sidebar
|
||||
NavigationRail {
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == Screen.Feed,
|
||||
onClick = { onNavigate(Screen.Feed) }
|
||||
)
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Message, "Messages") },
|
||||
label = { Text("Messages") },
|
||||
selected = currentScreen == Screen.Messages,
|
||||
onClick = { onNavigate(Screen.Messages) }
|
||||
)
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Person, "Profile") },
|
||||
label = { Text("Profile") },
|
||||
selected = currentScreen == Screen.Profile,
|
||||
onClick = { onNavigate(Screen.Profile) }
|
||||
)
|
||||
}
|
||||
|
||||
// Content
|
||||
Box(Modifier.weight(1f)) {
|
||||
when (currentScreen) {
|
||||
Screen.Feed -> FeedScreen()
|
||||
Screen.Messages -> MessagesScreen()
|
||||
Screen.Profile -> ProfileScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### State Management
|
||||
```kotlin
|
||||
// StateFlow for shared state
|
||||
class FeedViewModel(private val repository: FeedRepository) {
|
||||
private val _notes = MutableStateFlow<List<Note>>(emptyList())
|
||||
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
fun loadFeed() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
repository.getFeed()
|
||||
.catch { /* handle error */ }
|
||||
.collect { _notes.value = it }
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In Composable
|
||||
@Composable
|
||||
fun FeedScreen(viewModel: FeedViewModel) {
|
||||
val notes by viewModel.notes.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn {
|
||||
items(notes, key = { it.id }) { note ->
|
||||
NoteCard(note)
|
||||
}
|
||||
}
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Capabilities
|
||||
|
||||
1. **Composable Design**
|
||||
- Create shareable UI components
|
||||
- Implement Material3 design
|
||||
- Handle responsive layouts
|
||||
|
||||
2. **Desktop UI Patterns**
|
||||
- Window management (size, position, multi-window)
|
||||
- Menu bars and context menus
|
||||
- System tray integration
|
||||
- Keyboard shortcuts
|
||||
- Native file dialogs
|
||||
|
||||
3. **Navigation Architecture**
|
||||
- Screen-based navigation
|
||||
- Platform-specific shells (sidebar vs bottom nav)
|
||||
- Deep linking patterns
|
||||
|
||||
4. **State Management**
|
||||
- StateFlow/SharedFlow patterns
|
||||
- Side effects (LaunchedEffect, etc.)
|
||||
- ViewModel integration
|
||||
|
||||
## Android vs Desktop Differences
|
||||
|
||||
| Aspect | Android | Desktop |
|
||||
|--------|---------|---------|
|
||||
| **Entry** | Activity | main() + Window |
|
||||
| **Navigation** | Bottom nav / Drawer | Sidebar / MenuBar |
|
||||
| **Input** | Touch | Mouse + Keyboard |
|
||||
| **Windows** | Single | Multi-window |
|
||||
| **Menus** | Overflow menu | MenuBar |
|
||||
| **Files** | SAF / MediaStore | JFileChooser |
|
||||
| **Notifications** | System notifications | Tray notifications |
|
||||
| **Clipboard** | ClipboardManager | Toolkit.clipboard |
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
- Composable architecture and design
|
||||
- UI component patterns
|
||||
- Desktop-specific features (menus, tray, keyboard)
|
||||
- Navigation patterns
|
||||
- State → UI binding
|
||||
- Theming and styling
|
||||
- Responsive layouts
|
||||
|
||||
### Out of Scope
|
||||
- KMP project setup (use kotlin-multiplatform agent)
|
||||
- Async data loading (use kotlin-coroutines agent)
|
||||
- Nostr protocol details (use nostr-protocol agent)
|
||||
- Business logic implementation
|
||||
|
||||
## Key References
|
||||
- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
|
||||
- [Desktop Tutorials](https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials)
|
||||
- [Material3 Components](https://m3.material.io/components)
|
||||
@@ -0,0 +1,284 @@
|
||||
# Kotlin Coroutines Agent
|
||||
|
||||
## Expertise Domain
|
||||
|
||||
This agent specializes in Kotlin coroutines and the kotlinx.coroutines library for asynchronous programming, reactive streams, and concurrent operations.
|
||||
|
||||
## Core Knowledge Areas
|
||||
|
||||
### Coroutine Fundamentals
|
||||
```kotlin
|
||||
// Suspending functions
|
||||
suspend fun fetchNote(id: String): Note {
|
||||
return withContext(Dispatchers.IO) {
|
||||
api.getNote(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Coroutine builders
|
||||
fun main() = runBlocking {
|
||||
// launch: fire-and-forget, returns Job
|
||||
val job = launch {
|
||||
delay(1000)
|
||||
println("World")
|
||||
}
|
||||
|
||||
// async: returns Deferred<T>
|
||||
val deferred = async {
|
||||
computeValue()
|
||||
}
|
||||
val result = deferred.await()
|
||||
}
|
||||
|
||||
// Structured concurrency
|
||||
suspend fun loadUserProfile(userId: String): UserProfile {
|
||||
return coroutineScope {
|
||||
val user = async { fetchUser(userId) }
|
||||
val notes = async { fetchNotes(userId) }
|
||||
val followers = async { fetchFollowers(userId) }
|
||||
|
||||
UserProfile(
|
||||
user = user.await(),
|
||||
notes = notes.await(),
|
||||
followers = followers.await()
|
||||
)
|
||||
} // All complete or all cancel together
|
||||
}
|
||||
```
|
||||
|
||||
### Dispatchers
|
||||
|
||||
| Dispatcher | Use Case | Notes |
|
||||
|------------|----------|-------|
|
||||
| `Dispatchers.Main` | UI updates | Main thread (Android/Desktop) |
|
||||
| `Dispatchers.IO` | Network, disk | Optimized for blocking I/O |
|
||||
| `Dispatchers.Default` | CPU-intensive | Parallelism = CPU cores |
|
||||
| `Dispatchers.Unconfined` | Testing only | Runs in caller's thread |
|
||||
|
||||
### Flow (Cold Streams)
|
||||
```kotlin
|
||||
// Creating flows
|
||||
fun observeNotes(): Flow<List<Note>> = flow {
|
||||
while (true) {
|
||||
val notes = repository.getNotes()
|
||||
emit(notes)
|
||||
delay(30_000) // Refresh every 30s
|
||||
}
|
||||
}
|
||||
|
||||
// Operators
|
||||
repository.observeNotes()
|
||||
.map { notes -> notes.filter { it.isVisible } }
|
||||
.distinctUntilChanged()
|
||||
.debounce(300)
|
||||
.catch { e ->
|
||||
log.error("Failed to load notes", e)
|
||||
emit(emptyList())
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { notes -> updateUI(notes) }
|
||||
|
||||
// Flow builders
|
||||
val numbersFlow = flowOf(1, 2, 3, 4, 5)
|
||||
val listFlow = listOf("a", "b", "c").asFlow()
|
||||
```
|
||||
|
||||
### StateFlow & SharedFlow (Hot Streams)
|
||||
```kotlin
|
||||
// StateFlow - always has a value, replays latest
|
||||
class FeedViewModel {
|
||||
private val _state = MutableStateFlow(FeedState())
|
||||
val state: StateFlow<FeedState> = _state.asStateFlow()
|
||||
|
||||
fun updateFilter(filter: Filter) {
|
||||
_state.update { current ->
|
||||
current.copy(filter = filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SharedFlow - no initial value, configurable replay
|
||||
class EventBus {
|
||||
private val _events = MutableSharedFlow<AppEvent>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 64,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val events: SharedFlow<AppEvent> = _events.asSharedFlow()
|
||||
|
||||
suspend fun emit(event: AppEvent) {
|
||||
_events.emit(event)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Channels
|
||||
```kotlin
|
||||
// Producer-consumer pattern
|
||||
val channel = Channel<Event>(Channel.BUFFERED)
|
||||
|
||||
// Producer
|
||||
launch {
|
||||
for (event in eventSource) {
|
||||
channel.send(event)
|
||||
}
|
||||
channel.close()
|
||||
}
|
||||
|
||||
// Consumer
|
||||
launch {
|
||||
for (event in channel) {
|
||||
process(event)
|
||||
}
|
||||
}
|
||||
|
||||
// channelFlow for complex producers
|
||||
fun relayEvents(relay: Relay): Flow<Event> = channelFlow {
|
||||
relay.connect()
|
||||
relay.onEvent { event ->
|
||||
trySend(event)
|
||||
}
|
||||
awaitClose { relay.disconnect() }
|
||||
}
|
||||
```
|
||||
|
||||
### Cancellation & Exception Handling
|
||||
```kotlin
|
||||
// Cooperative cancellation
|
||||
suspend fun processNotes(notes: List<Note>) {
|
||||
for (note in notes) {
|
||||
ensureActive() // Throws if cancelled
|
||||
process(note)
|
||||
yield() // Suspend point for cancellation
|
||||
}
|
||||
}
|
||||
|
||||
// Exception handling
|
||||
val handler = CoroutineExceptionHandler { _, exception ->
|
||||
log.error("Coroutine failed", exception)
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)
|
||||
|
||||
// supervisorScope: child failures don't cancel siblings
|
||||
supervisorScope {
|
||||
launch { task1() } // Can fail independently
|
||||
launch { task2() } // Continues even if task1 fails
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Coroutines
|
||||
```kotlin
|
||||
class FeedViewModelTest {
|
||||
@Test
|
||||
fun `loadFeed updates state with notes`() = runTest {
|
||||
val repository = mockk<FeedRepository>()
|
||||
coEvery { repository.getFeed() } returns flowOf(testNotes)
|
||||
|
||||
val viewModel = FeedViewModel(repository)
|
||||
viewModel.loadFeed()
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(testNotes, viewModel.state.value.notes)
|
||||
}
|
||||
}
|
||||
|
||||
// Inject test dispatcher
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
```
|
||||
|
||||
## Nostr-Specific Patterns
|
||||
|
||||
### Relay Connection Pool
|
||||
```kotlin
|
||||
class RelayPool(private val scope: CoroutineScope) {
|
||||
private val relays = ConcurrentHashMap<String, RelayConnection>()
|
||||
|
||||
fun connect(url: String) {
|
||||
scope.launch {
|
||||
val connection = RelayConnection(url)
|
||||
relays[url] = connection
|
||||
|
||||
supervisorScope {
|
||||
launch { connection.receiveLoop() }
|
||||
launch { connection.sendLoop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun observeEvents(): Flow<Event> = relays.values
|
||||
.map { it.events }
|
||||
.merge()
|
||||
.distinctBy { it.id }
|
||||
}
|
||||
```
|
||||
|
||||
### Subscription Management
|
||||
```kotlin
|
||||
fun subscribe(filters: List<Filter>): Flow<Event> = channelFlow {
|
||||
val subscriptionId = UUID.randomUUID().toString()
|
||||
|
||||
try {
|
||||
relayPool.activeRelays.collect { relays ->
|
||||
relays.forEach { relay ->
|
||||
launch {
|
||||
relay.subscribe(subscriptionId, filters)
|
||||
.collect { send(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
relayPool.unsubscribe(subscriptionId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Capabilities
|
||||
|
||||
1. **Async Architecture Design**
|
||||
- Coroutine scope hierarchy
|
||||
- Structured concurrency patterns
|
||||
- Error propagation strategies
|
||||
|
||||
2. **Flow Pipeline Design**
|
||||
- Cold vs hot stream selection
|
||||
- Operator chaining
|
||||
- Backpressure handling
|
||||
|
||||
3. **Concurrency Patterns**
|
||||
- Parallel decomposition
|
||||
- Rate limiting
|
||||
- Resource pooling
|
||||
|
||||
4. **Testing Strategies**
|
||||
- runTest usage
|
||||
- Dispatcher injection
|
||||
- Flow testing with Turbine
|
||||
|
||||
5. **Performance Optimization**
|
||||
- Dispatcher selection
|
||||
- Buffer sizing
|
||||
- Cancellation efficiency
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
- kotlinx.coroutines library
|
||||
- Flow/StateFlow/SharedFlow
|
||||
- Channels and select
|
||||
- Structured concurrency
|
||||
- Exception handling
|
||||
- Coroutine testing
|
||||
- Dispatcher management
|
||||
|
||||
### Out of Scope
|
||||
- UI updates (use compose-ui agent)
|
||||
- KMP configuration (use kotlin-multiplatform agent)
|
||||
- Nostr protocol details (use nostr-protocol agent)
|
||||
|
||||
## Key References
|
||||
- [Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html)
|
||||
- [Flow Documentation](https://kotlinlang.org/docs/flow.html)
|
||||
- [StateFlow/SharedFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/)
|
||||
@@ -0,0 +1,161 @@
|
||||
# Kotlin Multiplatform Agent
|
||||
|
||||
## Expertise Domain
|
||||
|
||||
This agent specializes in Kotlin Multiplatform (KMP) project architecture, enabling code sharing across Android, iOS, Desktop (JVM), and Web targets.
|
||||
|
||||
## Core Knowledge Areas
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
module/
|
||||
├── build.gradle.kts
|
||||
└── src/
|
||||
├── commonMain/ # Shared code (all targets)
|
||||
├── commonTest/ # Shared tests
|
||||
├── androidMain/ # Android-specific
|
||||
├── jvmMain/ # Desktop JVM-specific
|
||||
└── iosMain/ # iOS-specific (future)
|
||||
```
|
||||
|
||||
### Source Set Hierarchy
|
||||
```
|
||||
commonMain
|
||||
│
|
||||
┌───────────┼───────────┐
|
||||
│ │ │
|
||||
jvmMain nativeMain jsMain
|
||||
│ │
|
||||
┌──────┴───┐ ┌───┴───┐
|
||||
│ │ │ │
|
||||
androidMain desktopMain iosMain
|
||||
```
|
||||
|
||||
### expect/actual Mechanism
|
||||
```kotlin
|
||||
// commonMain - Declaration only
|
||||
expect class PlatformContext
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
|
||||
expect class SecureStorage {
|
||||
fun store(key: String, value: ByteArray)
|
||||
fun retrieve(key: String): ByteArray?
|
||||
}
|
||||
|
||||
// androidMain - Android implementation
|
||||
actual class PlatformContext(val context: Context)
|
||||
|
||||
actual fun getPlatform(): Platform = Platform.Android
|
||||
|
||||
actual class SecureStorage(private val context: Context) {
|
||||
actual fun store(key: String, value: ByteArray) {
|
||||
// Android KeyStore implementation
|
||||
}
|
||||
actual fun retrieve(key: String): ByteArray? = TODO()
|
||||
}
|
||||
|
||||
// jvmMain - Desktop implementation
|
||||
actual class PlatformContext
|
||||
|
||||
actual fun getPlatform(): Platform = Platform.Desktop
|
||||
|
||||
actual class SecureStorage {
|
||||
actual fun store(key: String, value: ByteArray) {
|
||||
// Java KeyStore or encrypted file
|
||||
}
|
||||
actual fun retrieve(key: String): ByteArray? = TODO()
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Management
|
||||
```kotlin
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.secp256k1.kmp.jni.android)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Capabilities
|
||||
|
||||
1. **Project Configuration**
|
||||
- Set up KMP modules from scratch
|
||||
- Configure targets (Android, JVM, iOS)
|
||||
- Manage Gradle build scripts
|
||||
- Version catalog setup
|
||||
|
||||
2. **Code Sharing Strategy**
|
||||
- Identify shareable vs platform-specific code
|
||||
- Design expect/actual interfaces
|
||||
- Create intermediate source sets
|
||||
- Maximize code reuse (target: 70-80%)
|
||||
|
||||
3. **Dependency Selection**
|
||||
- Recommend KMP-compatible libraries
|
||||
- Handle platform-specific variants
|
||||
- Resolve version conflicts
|
||||
|
||||
4. **Migration Guidance**
|
||||
- Port Android code to commonMain
|
||||
- Extract platform abstractions
|
||||
- Refactor for multiplatform
|
||||
|
||||
5. **Build & Tooling**
|
||||
- Gradle configuration
|
||||
- IDE setup (Android Studio)
|
||||
- CI/CD for multiple targets
|
||||
|
||||
## Platform-Specific Patterns
|
||||
|
||||
| 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 |
|
||||
| **Context** | Context object | None needed |
|
||||
|
||||
## Quartz KMP Conversion
|
||||
|
||||
Current Quartz is Android-only. Conversion steps:
|
||||
|
||||
1. **Add KMP plugin** to build.gradle.kts
|
||||
2. **Define targets**: android(), jvm()
|
||||
3. **Move shared code** to `src/commonMain/`
|
||||
4. **Create expect declarations** for platform-specific APIs
|
||||
5. **Implement actuals** in androidMain/jvmMain
|
||||
|
||||
Key abstractions needed:
|
||||
- `CryptoProvider` - secp256k1 signing/verification
|
||||
- `SodiumProvider` - NIP-44 encryption
|
||||
- `PlatformJson` - Jackson vs kotlinx.serialization
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
- KMP project structure and configuration
|
||||
- Source set hierarchy design
|
||||
- expect/actual declarations
|
||||
- Gradle multiplatform plugin
|
||||
- Cross-platform library selection
|
||||
- Migration from Android-only
|
||||
|
||||
### Out of Scope
|
||||
- UI implementation details (use compose-ui agent)
|
||||
- Coroutine patterns (use kotlin-coroutines agent)
|
||||
- Nostr protocol specifics (use nostr-protocol agent)
|
||||
|
||||
## Key References
|
||||
- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html)
|
||||
- [expect/actual](https://kotlinlang.org/docs/multiplatform-expect-actual.html)
|
||||
- [Hierarchical Structure](https://kotlinlang.org/docs/multiplatform-hierarchy.html)
|
||||
@@ -0,0 +1,102 @@
|
||||
# Nostr Protocol Agent
|
||||
|
||||
## Expertise Domain
|
||||
|
||||
This agent specializes in the Nostr decentralized social protocol, covering 94+ NIPs (Nostr Implementation Possibilities) that define the protocol specification.
|
||||
|
||||
## Core Knowledge Areas
|
||||
|
||||
### Protocol Fundamentals
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Events** | Signed JSON objects: id, pubkey, created_at, kind, tags, content, sig |
|
||||
| **Relays** | WebSocket servers that store/forward events |
|
||||
| **Keys** | secp256k1 keypairs, Schnorr signatures (BIP-340) |
|
||||
| **Filters** | Subscription queries (kinds, authors, tags, since, until, limit) |
|
||||
|
||||
### Event Structure
|
||||
```kotlin
|
||||
data class Event(
|
||||
val id: String, // SHA256 of serialized event
|
||||
val pubkey: String, // 32-byte hex public key
|
||||
val created_at: Long, // Unix timestamp
|
||||
val kind: Int, // Event type
|
||||
val tags: List<List<String>>,
|
||||
val content: String,
|
||||
val sig: String // 64-byte Schnorr signature
|
||||
)
|
||||
```
|
||||
|
||||
### NIP Categories
|
||||
|
||||
| Category | NIPs | Scope |
|
||||
|----------|------|-------|
|
||||
| **Core Protocol** | 01, 02, 10, 11 | Basic events, follows, threads, relay info |
|
||||
| **Messaging** | 04 (deprecated), 17, 44 | DMs, encrypted messaging |
|
||||
| **Social** | 18, 25, 32, 38, 51, 52 | Reactions, reports, lists, communities |
|
||||
| **Identity** | 05, 19, 39, 46, 55 | DNS, bech32, external identity, bunker, signer |
|
||||
| **Media** | 23, 30, 54, 71, 94 | Long-form, audio, video, blobs |
|
||||
| **Payments** | 47, 57, 60, 61 | Wallet Connect, zaps, cashu |
|
||||
| **Relay** | 42, 65, 66 | Auth, relay lists, closed groups |
|
||||
|
||||
### Cryptographic Operations
|
||||
- **Signing**: Schnorr signatures on secp256k1 curve
|
||||
- **Encryption**: NIP-44 (XChaCha20-Poly1305), NIP-04 (deprecated AES)
|
||||
- **Key derivation**: BIP-32/39 compatible
|
||||
- **Event ID**: SHA256 of `[0, pubkey, created_at, kind, tags, content]`
|
||||
|
||||
## Agent Capabilities
|
||||
|
||||
1. **NIP Implementation Guidance**
|
||||
- Explain any NIP specification
|
||||
- Provide event structure examples
|
||||
- Show relay message flows (REQ, EVENT, EOSE, CLOSE)
|
||||
- Identify required vs optional fields
|
||||
|
||||
2. **Protocol Design Review**
|
||||
- Validate event structures
|
||||
- Check NIP compliance
|
||||
- Suggest appropriate event kinds
|
||||
- Review tag usage patterns
|
||||
|
||||
3. **Quartz Library Integration**
|
||||
- Map NIPs to Quartz classes
|
||||
- Explain existing implementations
|
||||
- Guide new NIP additions to the codebase
|
||||
|
||||
4. **Security Analysis**
|
||||
- Key management best practices
|
||||
- Encryption scheme selection
|
||||
- Signature verification patterns
|
||||
- Privacy considerations
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
### In Scope
|
||||
- All NIP specifications and interactions
|
||||
- Relay protocol and WebSocket message types
|
||||
- Event signing and verification
|
||||
- Key derivation and management
|
||||
- Nostr-specific encryption (NIP-04, NIP-44)
|
||||
- Quartz library architecture and classes
|
||||
|
||||
### Out of Scope
|
||||
- General Kotlin/Android development (use kotlin-multiplatform agent)
|
||||
- UI implementation details (use compose-ui agent)
|
||||
- Generic async patterns (use kotlin-coroutines agent)
|
||||
- Non-Nostr networking
|
||||
|
||||
## Key References
|
||||
- [NIPs Repository](https://github.com/nostr-protocol/nips)
|
||||
- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md)
|
||||
- [NIP-44: Versioned Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md)
|
||||
- Quartz source: `quartz/src/`
|
||||
|
||||
## Example Queries
|
||||
|
||||
- "How do I implement NIP-57 zap receipts?"
|
||||
- "What's the correct tag structure for a reply?"
|
||||
- "How does NIP-44 encryption work?"
|
||||
- "Which event kind for a long-form article?"
|
||||
- "How to verify a Schnorr signature in Quartz?"
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: Build and run the desktop app
|
||||
---
|
||||
|
||||
Build and run the Amethyst Desktop application:
|
||||
|
||||
```bash
|
||||
./gradlew :desktopApp:run
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the build fails, check:
|
||||
|
||||
1. **JDK Version**: Requires JDK 17+
|
||||
```bash
|
||||
java -version
|
||||
```
|
||||
|
||||
2. **Compose Multiplatform Plugin**: Verify version in `gradle/libs.versions.toml`
|
||||
|
||||
3. **Quartz Build**: Ensure Quartz compiles first
|
||||
```bash
|
||||
./gradlew :quartz:build
|
||||
```
|
||||
|
||||
4. **Desktop Dependencies**: Check `desktopApp/build.gradle.kts` has:
|
||||
```kotlin
|
||||
implementation(compose.desktop.currentOs)
|
||||
```
|
||||
|
||||
## Creating Distributable
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
./gradlew :desktopApp:packageDmg
|
||||
|
||||
# Windows
|
||||
./gradlew :desktopApp:packageMsi
|
||||
|
||||
# Linux
|
||||
./gradlew :desktopApp:packageDeb
|
||||
```
|
||||
|
||||
Outputs will be in `desktopApp/build/compose/binaries/`
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
description: Extract a composable from amethyst to shared code
|
||||
---
|
||||
|
||||
Extract the component `$ARGUMENTS` from the Android app to shared KMP code:
|
||||
|
||||
## Process
|
||||
|
||||
1. **Locate the component** in the amethyst module:
|
||||
```bash
|
||||
find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*"
|
||||
grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/
|
||||
```
|
||||
|
||||
2. **Analyze dependencies**:
|
||||
- Android-specific imports (Context, Intent, etc.)
|
||||
- Platform APIs (Camera, MediaStore, etc.)
|
||||
- Android Compose specifics vs standard Compose
|
||||
|
||||
3. **Identify what can be shared**:
|
||||
- Pure Composable functions → `shared-ui/commonMain/`
|
||||
- Business logic → `quartz/commonMain/`
|
||||
- Platform-specific → create expect/actual
|
||||
|
||||
4. **Create shared version**:
|
||||
- Move to appropriate shared module
|
||||
- Replace Android imports with multiplatform alternatives
|
||||
- Add expect declarations for platform-specific parts
|
||||
|
||||
5. **Update references**:
|
||||
- Change imports in amethyst module
|
||||
- Add implementations in desktopApp if needed
|
||||
|
||||
## Common Replacements
|
||||
|
||||
| Android | Multiplatform |
|
||||
|---------|---------------|
|
||||
| `LocalContext.current` | expect/actual or parameter |
|
||||
| `stringResource()` | `Res.string.*` |
|
||||
| `painterResource()` | `painterResource(Res.drawable.*)` |
|
||||
| `Toast.makeText()` | Custom snackbar/notification |
|
||||
| `Intent` | expect/actual for navigation |
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
/extract NoteCard
|
||||
```
|
||||
|
||||
This will find NoteCard, analyze its dependencies, and guide you through extracting it to shared code.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: Get NIP specification and implementation guidance
|
||||
---
|
||||
|
||||
Fetch and explain NIP-$ARGUMENTS from the Nostr protocol:
|
||||
|
||||
1. **Get the specification** from https://github.com/nostr-protocol/nips/blob/master/$ARGUMENTS.md
|
||||
|
||||
2. **Show key details**:
|
||||
- Event kind(s) used
|
||||
- Required and optional fields
|
||||
- Tag structure
|
||||
- Message flow between client and relay
|
||||
|
||||
3. **Check implementation status** in Quartz:
|
||||
```bash
|
||||
grep -r "NIP-$ARGUMENTS\|nip$ARGUMENTS\|kind.*=" quartz/src/
|
||||
```
|
||||
|
||||
4. **Provide implementation guidance**:
|
||||
- Which Quartz classes to use or create
|
||||
- Event construction example
|
||||
- Relay subscription filters
|
||||
- Verification/validation logic
|
||||
|
||||
## Example Usage
|
||||
|
||||
```
|
||||
/nip 01 # Basic protocol
|
||||
/nip 44 # Versioned encryption
|
||||
/nip 57 # Zaps
|
||||
```
|
||||
@@ -0,0 +1,339 @@
|
||||
# Compose Desktop Skill
|
||||
|
||||
## Desktop Application Entry Point
|
||||
|
||||
```kotlin
|
||||
// desktopApp/src/jvmMain/kotlin/Main.kt
|
||||
package com.vitorpamplona.amethyst.desktop
|
||||
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
|
||||
fun main() = application {
|
||||
val windowState = rememberWindowState(
|
||||
width = 1200.dp,
|
||||
height = 800.dp,
|
||||
position = WindowPosition.Aligned(Alignment.Center)
|
||||
)
|
||||
|
||||
// System tray
|
||||
Tray(
|
||||
icon = painterResource("icon.png"),
|
||||
tooltip = "Amethyst",
|
||||
menu = {
|
||||
Item("Show", onClick = { windowState.isMinimized = false })
|
||||
Separator()
|
||||
Item("Exit", onClick = ::exitApplication)
|
||||
}
|
||||
)
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
state = windowState,
|
||||
title = "Amethyst",
|
||||
icon = painterResource("icon.png")
|
||||
) {
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { }
|
||||
Separator()
|
||||
Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { }
|
||||
Separator()
|
||||
Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication)
|
||||
}
|
||||
Menu("Edit") {
|
||||
Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { }
|
||||
Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { }
|
||||
}
|
||||
Menu("View") {
|
||||
Item("Feed") { }
|
||||
Item("Messages") { }
|
||||
Item("Notifications") { }
|
||||
}
|
||||
Menu("Help") {
|
||||
Item("About Amethyst") { }
|
||||
}
|
||||
}
|
||||
|
||||
App()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Desktop-Specific Components
|
||||
|
||||
### File Dialog
|
||||
```kotlin
|
||||
@Composable
|
||||
fun rememberFileDialog(): FileDialogState {
|
||||
return remember { FileDialogState() }
|
||||
}
|
||||
|
||||
class FileDialogState {
|
||||
var isOpen by mutableStateOf(false)
|
||||
var result by mutableStateOf<File?>(null)
|
||||
|
||||
fun open() { isOpen = true }
|
||||
|
||||
@Composable
|
||||
fun Dialog(
|
||||
title: String = "Select File",
|
||||
allowedExtensions: List<String> = emptyList()
|
||||
) {
|
||||
if (isOpen) {
|
||||
DisposableEffect(Unit) {
|
||||
val dialog = java.awt.FileDialog(null as java.awt.Frame?, title)
|
||||
if (allowedExtensions.isNotEmpty()) {
|
||||
dialog.setFilenameFilter { _, name ->
|
||||
allowedExtensions.any { name.endsWith(it) }
|
||||
}
|
||||
}
|
||||
dialog.isVisible = true
|
||||
result = dialog.file?.let { File(dialog.directory, it) }
|
||||
isOpen = false
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Scroll Behavior
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DesktopScrollableColumn(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(modifier) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
adapter = rememberScrollbarAdapter(scrollState)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
```kotlin
|
||||
@Composable
|
||||
fun KeyboardNavigableList(
|
||||
items: List<Note>,
|
||||
selectedIndex: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
onActivate: (Note) -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent { event ->
|
||||
when {
|
||||
event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> {
|
||||
onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex))
|
||||
true
|
||||
}
|
||||
event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> {
|
||||
onSelect((selectedIndex - 1).coerceAtLeast(0))
|
||||
true
|
||||
}
|
||||
event.key == Key.Enter && event.type == KeyEventType.KeyDown -> {
|
||||
items.getOrNull(selectedIndex)?.let { onActivate(it) }
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
) {
|
||||
itemsIndexed(items) { index, note ->
|
||||
NoteCard(
|
||||
note = note,
|
||||
isSelected = index == selectedIndex,
|
||||
modifier = Modifier.clickable { onSelect(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-Window Support
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ApplicationScope.NoteDetailWindow(
|
||||
note: Note,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Window(
|
||||
onCloseRequest = onClose,
|
||||
title = "Note by ${note.author.name}",
|
||||
state = rememberWindowState(width = 600.dp, height = 400.dp)
|
||||
) {
|
||||
NoteDetailScreen(note)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in main application
|
||||
var openNotes by remember { mutableStateOf<List<Note>>(emptyList()) }
|
||||
|
||||
openNotes.forEach { note ->
|
||||
key(note.id) {
|
||||
NoteDetailWindow(
|
||||
note = note,
|
||||
onClose = { openNotes = openNotes - note }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tooltips
|
||||
```kotlin
|
||||
@Composable
|
||||
fun TooltipButton(
|
||||
tooltip: String,
|
||||
onClick: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
TooltipArea(
|
||||
tooltip = {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.inverseSurface
|
||||
) {
|
||||
Text(
|
||||
text = tooltip,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
color = MaterialTheme.colorScheme.inverseOnSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Desktop Layout Pattern
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun DesktopAppLayout(
|
||||
currentScreen: Screen,
|
||||
onNavigate: (Screen) -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
// Sidebar navigation
|
||||
NavigationRail(
|
||||
modifier = Modifier.width(72.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Home, "Feed") },
|
||||
label = { Text("Feed") },
|
||||
selected = currentScreen == Screen.Feed,
|
||||
onClick = { onNavigate(Screen.Feed) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Email, "Messages") },
|
||||
label = { Text("DMs") },
|
||||
selected = currentScreen == Screen.Messages,
|
||||
onClick = { onNavigate(Screen.Messages) }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Notifications, "Notifications") },
|
||||
label = { Text("Alerts") },
|
||||
selected = currentScreen == Screen.Notifications,
|
||||
onClick = { onNavigate(Screen.Notifications) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Settings, "Settings") },
|
||||
label = { Text("Settings") },
|
||||
selected = currentScreen == Screen.Settings,
|
||||
onClick = { onNavigate(Screen.Settings) }
|
||||
)
|
||||
}
|
||||
|
||||
// Divider
|
||||
VerticalDivider()
|
||||
|
||||
// Main content
|
||||
Box(Modifier.weight(1f).fillMaxHeight()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
```kotlin
|
||||
// desktopApp/build.gradle.kts
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("org.jetbrains.compose")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.material3)
|
||||
implementation(project(":quartz"))
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.vitorpamplona.amethyst.desktop.MainKt"
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
|
||||
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb
|
||||
)
|
||||
|
||||
packageName = "Amethyst"
|
||||
packageVersion = "1.0.0"
|
||||
|
||||
macOS {
|
||||
bundleID = "com.vitorpamplona.amethyst.desktop"
|
||||
iconFile.set(project.file("icons/icon.icns"))
|
||||
}
|
||||
|
||||
windows {
|
||||
iconFile.set(project.file("icons/icon.ico"))
|
||||
menuGroup = "Amethyst"
|
||||
}
|
||||
|
||||
linux {
|
||||
iconFile.set(project.file("icons/icon.png"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,165 @@
|
||||
# Quartz KMP Conversion Skill
|
||||
|
||||
When working with Quartz library conversion to Kotlin Multiplatform:
|
||||
|
||||
## Current Structure (Android-only)
|
||||
```
|
||||
quartz/
|
||||
├── build.gradle
|
||||
└── src/
|
||||
├── main/kotlin/ # All Nostr code here
|
||||
├── test/
|
||||
└── androidTest/
|
||||
```
|
||||
|
||||
## Target Structure (KMP)
|
||||
```
|
||||
quartz/
|
||||
├── build.gradle.kts
|
||||
└── src/
|
||||
├── commonMain/kotlin/ # Shared protocol code
|
||||
├── commonTest/kotlin/ # Shared tests
|
||||
├── androidMain/kotlin/ # Android crypto, storage
|
||||
├── androidTest/kotlin/
|
||||
├── jvmMain/kotlin/ # Desktop crypto, storage
|
||||
└── jvmTest/kotlin/
|
||||
```
|
||||
|
||||
## Platform Abstractions Required
|
||||
|
||||
### 1. Cryptography (expect/actual)
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect object Secp256k1 {
|
||||
fun sign(data: ByteArray, privateKey: ByteArray): ByteArray
|
||||
fun verify(data: ByteArray, signature: ByteArray, pubKey: ByteArray): Boolean
|
||||
fun pubKeyCreate(privateKey: ByteArray): ByteArray
|
||||
}
|
||||
|
||||
// androidMain - uses secp256k1-kmp-jni-android
|
||||
actual object Secp256k1 {
|
||||
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
|
||||
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
// jvmMain - uses secp256k1-kmp-jni-jvm
|
||||
actual object Secp256k1 {
|
||||
actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray {
|
||||
return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey)
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. NIP-44 Encryption (Sodium)
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect object Nip44 {
|
||||
fun encrypt(plaintext: String, sharedSecret: ByteArray): String
|
||||
fun decrypt(ciphertext: String, sharedSecret: ByteArray): String
|
||||
}
|
||||
|
||||
// androidMain - lazysodium-android
|
||||
// jvmMain - lazysodium-java or libsodium-jni
|
||||
```
|
||||
|
||||
### 3. Secure Random
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect fun secureRandomBytes(size: Int): ByteArray
|
||||
|
||||
// androidMain
|
||||
actual fun secureRandomBytes(size: Int): ByteArray {
|
||||
return SecureRandom().let { random ->
|
||||
ByteArray(size).also { random.nextBytes(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// jvmMain
|
||||
actual fun secureRandomBytes(size: Int): ByteArray {
|
||||
return java.security.SecureRandom().let { random ->
|
||||
ByteArray(size).also { random.nextBytes(it) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
```kotlin
|
||||
// quartz/build.gradle.kts
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidTarget {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
jvm("desktop") {
|
||||
compilations.all {
|
||||
kotlinOptions.jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
}
|
||||
}
|
||||
|
||||
val androidMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.secp256k1.kmp.jni.android)
|
||||
implementation(libs.lazysodium.android)
|
||||
}
|
||||
}
|
||||
|
||||
val desktopMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.secp256k1.kmp.jni.jvm)
|
||||
// lazysodium-java or alternative
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.vitorpamplona.quartz"
|
||||
compileSdk = 35
|
||||
defaultConfig.minSdk = 26
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Steps
|
||||
|
||||
1. **Convert build.gradle to build.gradle.kts** with KMP plugin
|
||||
2. **Move pure Kotlin code** to `commonMain/`
|
||||
3. **Identify platform dependencies** (crypto, JNA, Android APIs)
|
||||
4. **Create expect declarations** for platform-specific APIs
|
||||
5. **Implement actuals** in androidMain and jvmMain
|
||||
6. **Update imports** in amethyst module
|
||||
7. **Test on both platforms**
|
||||
|
||||
## Files to Move to commonMain
|
||||
|
||||
Most of Quartz can be shared:
|
||||
- Event classes and parsing
|
||||
- Filter definitions
|
||||
- Relay message types
|
||||
- NIP implementations (logic only)
|
||||
- Utilities (hex encoding, bech32)
|
||||
|
||||
## Files Needing expect/actual
|
||||
|
||||
- `Secp256k1.kt` - Signature operations
|
||||
- `Nip04.kt` - Legacy encryption (uses AES)
|
||||
- `Nip44.kt` - Modern encryption (uses ChaCha)
|
||||
- `KeyPair.kt` - Key generation
|
||||
+2
-1
@@ -7,6 +7,7 @@ plugins {
|
||||
alias(libs.plugins.diffplugSpotless)
|
||||
alias(libs.plugins.googleServices) apply false
|
||||
alias(libs.plugins.jetbrainsComposeCompiler) apply false
|
||||
alias(libs.plugins.composeMultiplatform) apply false
|
||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||
alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false
|
||||
alias(libs.plugins.serialization)
|
||||
@@ -64,4 +65,4 @@ tasks.register('installGitHook', Copy) {
|
||||
into { new File(rootProject.rootDir, '.git/hooks') }
|
||||
filePermissions { unix(0777) }
|
||||
}
|
||||
tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook
|
||||
tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.jetbrainsKotlinJvm)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.materialIconsExtended)
|
||||
|
||||
// Quartz Nostr library (will use JVM target)
|
||||
implementation(project(":quartz"))
|
||||
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
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.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.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
|
||||
|
||||
enum class Screen {
|
||||
Feed,
|
||||
Search,
|
||||
Messages,
|
||||
Notifications,
|
||||
Profile,
|
||||
Settings
|
||||
}
|
||||
|
||||
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(Screen.Feed) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = darkColorScheme()
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
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 == Screen.Feed,
|
||||
onClick = { currentScreen = Screen.Feed }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
||||
label = { Text("Search") },
|
||||
selected = currentScreen == Screen.Search,
|
||||
onClick = { currentScreen = Screen.Search }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
|
||||
label = { Text("DMs") },
|
||||
selected = currentScreen == Screen.Messages,
|
||||
onClick = { currentScreen = Screen.Messages }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
|
||||
label = { Text("Alerts") },
|
||||
selected = currentScreen == Screen.Notifications,
|
||||
onClick = { currentScreen = Screen.Notifications }
|
||||
)
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||
label = { Text("Profile") },
|
||||
selected = currentScreen == Screen.Profile,
|
||||
onClick = { currentScreen = Screen.Profile }
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
NavigationRailItem(
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||
label = { Text("Settings") },
|
||||
selected = currentScreen == Screen.Settings,
|
||||
onClick = { currentScreen = Screen.Settings }
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
// Main Content
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)
|
||||
) {
|
||||
when (currentScreen) {
|
||||
Screen.Feed -> FeedPlaceholder()
|
||||
Screen.Search -> SearchPlaceholder()
|
||||
Screen.Messages -> MessagesPlaceholder()
|
||||
Screen.Notifications -> NotificationsPlaceholder()
|
||||
Screen.Profile -> ProfilePlaceholder()
|
||||
Screen.Settings -> SettingsPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedPlaceholder() {
|
||||
Column {
|
||||
Text(
|
||||
"Feed",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Your Nostr feed will appear here.\n\nConnect to relays and follow users to see notes.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchPlaceholder() {
|
||||
Column {
|
||||
Text(
|
||||
"Search",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Search for users, notes, and hashtags.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessagesPlaceholder() {
|
||||
Column {
|
||||
Text(
|
||||
"Messages",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Your encrypted direct messages will appear here.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationsPlaceholder() {
|
||||
Column {
|
||||
Text(
|
||||
"Notifications",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Mentions, replies, and reactions will appear here.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfilePlaceholder() {
|
||||
Column {
|
||||
Text(
|
||||
"Profile",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Login with your Nostr keys to see your profile.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsPlaceholder() {
|
||||
Column {
|
||||
Text(
|
||||
"Settings",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Configure relays, appearance, and account settings.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
@@ -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" }
|
||||
stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" }
|
||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
|
||||
|
||||
@@ -34,3 +34,4 @@ include ':benchmark'
|
||||
include ':quartz'
|
||||
include ':commons'
|
||||
include ':ammolite'
|
||||
include ':desktopApp'
|
||||
|
||||
Reference in New Issue
Block a user