From f654af9d8afed8c85afdf0a30ae28f094a9449ca Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 30 Dec 2025 15:18:48 +0200 Subject: [PATCH] initial skills --- .claude/core-skills-plan.md | 288 +++++ .claude/skills/android-expert/SKILL.md | 1066 +++++++++++++++++ .../references/android-navigation.md | 615 ++++++++++ .../references/android-permissions.md | 659 ++++++++++ .../references/proguard-rules.md | 468 ++++++++ .../scripts/analyze-apk-size.sh | 230 ++++ .claude/skills/desktop-expert/SKILL.md | 748 ++++++++++++ .../references/desktop-compose-apis.md | 597 +++++++++ .../references/desktop-navigation.md | 464 +++++++ .../references/keyboard-shortcuts.md | 400 +++++++ .../desktop-expert/references/os-detection.md | 579 +++++++++ .claude/skills/kotlin-expert/SKILL.md | 811 +++++++++++++ .../references/dsl-builder-examples.md | 602 ++++++++++ .../kotlin-expert/references/flow-patterns.md | 405 +++++++ .../references/immutability-patterns.md | 641 ++++++++++ .../references/sealed-class-catalog.md | 482 ++++++++ .claude/skills/kotlin-multiplatform/SKILL.md | 400 +++++++ .../references/abstraction-examples.md | 311 +++++ .../references/expect-actual-catalog.md | 163 +++ .../references/source-set-hierarchy.md | 332 +++++ .../references/target-compatibility.md | 345 ++++++ .../scripts/suggest-kmp-dependency.sh | 166 +++ .../scripts/validate-kmp-structure.sh | 126 ++ 23 files changed, 10898 insertions(+) create mode 100644 .claude/core-skills-plan.md create mode 100644 .claude/skills/android-expert/SKILL.md create mode 100644 .claude/skills/android-expert/references/android-navigation.md create mode 100644 .claude/skills/android-expert/references/android-permissions.md create mode 100644 .claude/skills/android-expert/references/proguard-rules.md create mode 100755 .claude/skills/android-expert/scripts/analyze-apk-size.sh create mode 100644 .claude/skills/desktop-expert/SKILL.md create mode 100644 .claude/skills/desktop-expert/references/desktop-compose-apis.md create mode 100644 .claude/skills/desktop-expert/references/desktop-navigation.md create mode 100644 .claude/skills/desktop-expert/references/keyboard-shortcuts.md create mode 100644 .claude/skills/desktop-expert/references/os-detection.md create mode 100644 .claude/skills/kotlin-expert/SKILL.md create mode 100644 .claude/skills/kotlin-expert/references/dsl-builder-examples.md create mode 100644 .claude/skills/kotlin-expert/references/flow-patterns.md create mode 100644 .claude/skills/kotlin-expert/references/immutability-patterns.md create mode 100644 .claude/skills/kotlin-expert/references/sealed-class-catalog.md create mode 100644 .claude/skills/kotlin-multiplatform/SKILL.md create mode 100644 .claude/skills/kotlin-multiplatform/references/abstraction-examples.md create mode 100644 .claude/skills/kotlin-multiplatform/references/expect-actual-catalog.md create mode 100644 .claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md create mode 100644 .claude/skills/kotlin-multiplatform/references/target-compatibility.md create mode 100755 .claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh create mode 100755 .claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md new file mode 100644 index 0000000000..ab4dcad6e3 --- /dev/null +++ b/.claude/core-skills-plan.md @@ -0,0 +1,288 @@ +# AmethystMultiplatform Skills Creation Plan + +## Overview +Create 8 hybrid domain skills combining general expertise with AmethystMultiplatform-specific patterns. + +**Approach:** Each skill provides domain knowledge + project-specific implementation patterns from codebase. + +## Skills to Implement + +### 1. kotlin-multiplatform ✅ COMPLETED +**Focus:** KMP architecture, jvmAndroid source set pattern, expect/actual + +**SKILL.md sections:** +- Mental model: KMP hierarchy as dependency graph +- Source set architecture: commonMain → jvmAndroid → {androidMain, jvmMain} +- The jvmAndroid pattern (unique to this project, verified in quartz/build.gradle.kts:132-149) +- expect/actual mechanics with 24+ examples from codebase +- iOS framework setup for Quartz distribution + +**Bundled resources:** +- `references/source-set-hierarchy.md` - Visual diagram + examples +- `references/expect-actual-catalog.md` - All 24 expect/actual pairs with patterns +- `scripts/validate-kmp-structure.sh` - Verify source set dependencies +- `assets/kmp-hierarchy-diagram.png` - Visual graph + +**Differentiation:** Existing kotlin-multiplatform agent = general KMP. This skill = Amethyst's unique jvmAndroid pattern, concrete examples. + +**Status:** ✅ Skill created and packaged at `.claude/skills/kotlin-multiplatform/` + +--- + +### 2. gradle-expert +**Focus:** Build optimization, dependency resolution, multi-module KMP troubleshooting + +**SKILL.md sections:** +- Build architecture: 4 modules, dependency flow +- Version catalog mastery (libs.versions.toml) +- Module dependency patterns (api vs implementation) +- Android-specific: compileSdk, proguard +- Desktop packaging: TargetFormat, distributions +- Build performance: daemon, parallel, caching +- Common errors: compose version conflicts, secp256k1 JNI variants + +**Bundled resources:** +- `references/build-commands.md` - Common gradle tasks +- `references/dependency-graph.md` - Module visualization +- `scripts/analyze-build-time.sh` - Performance report +- `scripts/fix-dependency-conflicts.sh` - Conflict patterns + +**Differentiation:** Focus on 4-module structure, KMP + Android + Desktop combo, specific issues (compose conflicts). + +--- + +### 3. kotlin-expert ✅ DRAFT COMPLETE +**Focus:** Flow state management, sealed hierarchies, immutability, DSL builders, inline/reified + +**SKILL.md sections:** +- Flow state management: StateFlow/SharedFlow patterns (AccountManager, RelayConnectionManager) +- Sealed hierarchies: sealed class vs sealed interface decision trees (AccountState, SignerResult) +- Immutability: @Immutable for Compose performance (173+ event classes) +- DSL builders: Type-safe fluent APIs (TagArrayBuilder, TlvBuilder) +- Inline functions: reified generics, performance optimization (OptimizedJsonMapper) +- Value classes: Zero-cost wrappers (optimization opportunity) + +**Bundled resources:** +- `references/flow-patterns.md` - StateFlow/SharedFlow with AccountManager, RelayManager patterns +- `references/sealed-class-catalog.md` - All 8 sealed types in quartz with usage patterns +- `references/dsl-builder-examples.md` - TagArrayBuilder, PrivateTagArrayBuilder, TlvBuilder, custom DSL patterns +- `references/immutability-patterns.md` - @Immutable annotation, data classes, ImmutableList/Map/Set + +**Differentiation:** Complements kotlin-coroutines agent (deep async). This skill = Amethyst Kotlin idioms (StateFlow state management, sealed for type safety, @Immutable for Compose, DSL builders). + +**Status:** ✅ SKILL.md (455 lines) + 4 references created at `.claude/skills/kotlin-expert/` + +**10-Step Progress:** +1. ✅ UNDERSTAND - Defined scope (Flow/sealed/DSL/immutability/inline) +2. ✅ EXPLORE - Found 173 @Immutable events, StateFlow in AccountManager/RelayManager, SignerResult generics, TagArrayBuilder +3. ✅ RESEARCH - StateFlow vs SharedFlow, sealed class vs interface best practices 2025 +4. ✅ SYNTHESIZE - Extracted Amethyst patterns (hot flows for state, sealed for results, @Immutable for perf) +5. ✅ DRAFT - Created SKILL.md + 4 reference files (flow, sealed, dsl, immutability) +6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) +7. ✅ ITERATE - Draft complete (skipping deep iteration for now) +8. ⏸️ TEST - Deferred to later (requires real usage scenarios) +9. ⏸️ FINALIZE - Deferred to later +10. ✅ DOCUMENT - Updated plan + +--- + +### 4. compose-expert +**Focus:** Shared composables, state management, animations, Material3 + +**SKILL.md sections:** +- Shared composables philosophy (100+ already shared in commons/commonMain) +- State management: remember, derivedStateOf, produceState +- Recomposition optimization: @Stable/@Immutable +- Material3 conventions: theming +- Custom icons: ImageVector builders (38+ icons) +- Platform differences: Desktop vs Android UI +- Performance: lazy lists, image loading + +**Bundled resources:** +- `references/shared-composables-catalog.md` - 100+ composables +- `references/state-patterns.md` - State hoisting examples +- `references/icon-assets.md` - Custom icon catalog +- `scripts/find-composables.sh` - Grep @Composable + +**Differentiation:** Multiplatform Compose patterns, shared vs platform UI philosophy, Amethyst conventions (robohash, custom icons). + +--- + +### 5. ios-expert +**Focus:** iosMain patterns, Swift/KMP interop, XCFramework generation + +**SKILL.md sections:** +- iOS source sets: iosMain, iosX64Main, iosArm64Main +- Swift interop: type mapping, nullability +- expect/actual iOS: 10+ examples from quartz/iosMain +- XCFramework setup: baseName = "quartz-kmpKit" +- Platform APIs: platform.posix, CFNetwork, Security +- CocoaPods integration +- XCode project setup + +**Bundled resources:** +- `references/ios-actual-implementations.md` - 10 iosMain actuals +- `references/swift-interop-guide.md` - Type mapping +- `references/xcode-integration.md` - XCode setup +- `scripts/generate-xcframework.sh` - Build all iOS targets + +**Differentiation:** iOS platform specialization with Amethyst iosMain patterns, Quartz framework setup. + +--- + +### 6. desktop-expert ✅ DRAFT COMPLETE +**Focus:** Desktop UX, window management, Compose Desktop APIs, OS-specific conventions + +**SKILL.md sections:** +- Desktop entry point: application {} DSL +- Window management: WindowState, positioning, multi-window +- Menu system: MenuBar, keyboard shortcuts (OS-aware) +- System tray: minimize to tray +- Desktop navigation: NavigationRail pattern (vs Android bottom nav) +- File system: Desktop.getDesktop(), file pickers, drag-drop +- Desktop UX principles: keyboard-first, native feel, tooltips +- OS-specific behavior: macOS vs Windows vs Linux +- Platform detection: PlatformDetector utility +- Packaging: DMG, MSI, DEB distribution + +**Bundled resources:** +- `references/desktop-compose-apis.md` - Complete Desktop API catalog (Window, Tray, MenuBar, Dialog, etc.) +- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns +- `references/keyboard-shortcuts.md` - Standard shortcuts by OS with DesktopShortcuts helper +- `references/os-detection.md` - Platform detection, file paths, system integration + +**Differentiation:** Desktop-only APIs, OS conventions (Cmd vs Ctrl), NavigationRail, delegates build to gradle-expert and shared code to kotlin-multiplatform/compose-expert. + +**Status:** ✅ SKILL.md + 4 references created at `.claude/skills/desktop-expert/` + +**10-Step Progress:** +1. ✅ UNDERSTAND - Defined desktop usage scenarios +2. ✅ EXPLORE - Analyzed desktopApp/ module patterns (Main.kt, FeedScreen.kt, LoginScreen.kt) +3. ✅ RESEARCH - Compose Desktop APIs, OS-specific UX conventions (JetBrains docs, HIG) +4. ✅ SYNTHESIZE - Extracted desktop principles from codebase +5. ✅ DRAFT - Created SKILL.md + 4 reference files +6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) +7. ✅ ITERATE - Draft complete (skipping deep iteration for now) +8. ⏸️ TEST - Deferred to later (requires real desktop scenarios) +9. ⏸️ FINALIZE - Deferred to later +10. ✅ DOCUMENT - Updated plan + +--- + +### 7. android-expert ✅ DRAFT COMPLETE +**Focus:** Android platform APIs, navigation, permissions, Material Design + +**SKILL.md sections:** +- Android module structure: amethyst/ layout +- Navigation: Navigation Compose, bottom nav +- Permissions: runtime (camera, biometric) +- Platform APIs: Intent, Context, ContentResolver +- Lifecycle: Lifecycle-aware, ViewModel +- Material Design: Android Material 3 +- Build config: Proguard, R8 +- Android UX: mobile-first patterns + +**Bundled resources:** +- `references/android-navigation.md` - Navigation Compose +- `references/android-permissions.md` - Permission handling +- `references/proguard-rules.md` - Proguard explanation +- `scripts/analyze-apk-size.sh` - APK optimization + +**Differentiation:** amethyst module structure, Android vs desktop patterns, Amethyst conventions. + +**Status:** ✅ SKILL.md + 3 references + 1 script created at `.claude/skills/android-expert/` + +**10-Step Progress:** +1. ✅ UNDERSTAND - Defined Android usage scenarios +2. ✅ EXPLORE - Analyzed amethyst/ module patterns +3. ✅ RESEARCH - Android best practices + KMP Android patterns +4. ✅ SYNTHESIZE - Extracted Android principles from codebase +5. ✅ DRAFT - Initialized skill, created resources +6. ✅ SELF-CRITIQUE - Reviewed against 4 Core Truths (all PASS) +7. ✅ ITERATE - Draft complete (skipping deep iteration for now) +8. ⏸️ TEST - Deferred to later +9. ⏸️ FINALIZE - Deferred to later +10. ✅ DOCUMENT - Updated plan + +--- + +### 8. nostr-expert +**Focus:** Nostr protocol, NIPs, Quartz architecture, event patterns + +**SKILL.md sections:** +- Quartz architecture: package structure by NIP (57 NIPs implemented) +- Event anatomy: IEvent, Event, kinds, tags (verified in TextNoteEvent.kt) +- NIP implementation patterns: Event class, tags, builders +- Common event types: TextNoteEvent, MetadataEvent, ReactionEvent +- Tag patterns: aTag, eTag, pTag builders +- Relay client: WebSocket, subscriptions +- Cryptography: secp256k1, NIP-44 encryption +- Event builders: TagArrayBuilder DSL + +**Bundled resources:** +- `references/nip-catalog.md` - All 57 NIPs with code links +- `references/event-hierarchy.md` - Event class diagram +- `references/tag-patterns.md` - Tag builders from nip01Core/tags/ +- `scripts/nip-lookup.sh` - Find NIP implementation files + +**Differentiation:** nostr-protocol agent = NIP specs. This skill = Quartz implementation (57 NIPs), code patterns. + +--- + +## Implementation Workflow + +Using skill-creator 10-step methodology per skill: + +**Overall Plan:** +1. **UNDERSTAND** ✅ - 8 skills defined, user clarifications obtained +2. **EXPLORE** ✅ - Codebase analyzed via Explore agent +3. **RESEARCH** ✅ - Domain patterns identified via Plan agent +4. **SYNTHESIZE** ✅ - Skills designed above + +**Per-Skill Implementation:** +- kotlin-multiplatform: ✅ COMPLETED +- android-expert: ✅ DRAFT COMPLETE +- desktop-expert: ✅ DRAFT COMPLETE +- kotlin-expert: ✅ DRAFT COMPLETE +- Remaining 4 skills: Pending (gradle-expert, compose-expert, ios-expert, nostr-expert) + +## Critical Files Referenced + +**Build patterns:** +- `/quartz/build.gradle.kts:132-149` - jvmAndroid source set +- `/commons/build.gradle.kts` - Shared UI setup + +**Code patterns:** +- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt` - Event structure +- `/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt` - StateFlow pattern +- `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Platform.kt` - expect/actual + +**Documentation:** +- `/docs/shared-ui-analysis.md` - UI migration strategy + +## Output Location +`.claude/skills//` for each skill + +## Next Steps + +1. ✅ Save this plan as `.claude/core-skills-plan.md` for reference +2. ✅ Completed kotlin-multiplatform skill +3. ✅ Completed android-expert skill (DRAFT) +4. ✅ Completed desktop-expert skill (DRAFT) +5. ⏳ **Next: Pick one of the remaining 5 skills:** + - gradle-expert - Build optimization, dependency resolution + - kotlin-expert - Coroutines, Flow, sealed classes + - compose-expert - Shared composables, state management + - ios-expert - iOS platform patterns, XCFramework + - nostr-expert - Nostr protocol, NIPs, Quartz + +## Current Focus: 3 skills completed + +**Completed:** +- kotlin-multiplatform ✅ +- android-expert ✅ +- desktop-expert ✅ + +**Remaining:** 5 skills + +Ready to start next skill when requested. diff --git a/.claude/skills/android-expert/SKILL.md b/.claude/skills/android-expert/SKILL.md new file mode 100644 index 0000000000..7754d58029 --- /dev/null +++ b/.claude/skills/android-expert/SKILL.md @@ -0,0 +1,1066 @@ +# android-expert + +Android platform expertise for Amethyst Multiplatform project. Covers Compose Navigation, Material3, permissions, lifecycle, and Android-specific patterns in KMP architecture. + +## When to Use + +Auto-invoke when working with: +- Android navigation (Navigation Compose, routes, bottom nav) +- Runtime permissions (camera, notifications, biometric) +- Platform APIs (Intent, Context, Activity) +- Material3 theming and edge-to-edge UI +- Android build configuration (Proguard, APK optimization) +- AndroidManifest.xml configuration +- Android lifecycle (ViewModel, collectAsStateWithLifecycle) + +## Core Mental Model + +**Single Activity Architecture + Compose Navigation** + +``` +MainActivity (Single Entry Point) + ├── enableEdgeToEdge() + ├── AmethystTheme { } + └── NavHost + ├── Route.Home → HomeScreen + ├── Route.Profile(id) → ProfileScreen + └── Route.Settings → SettingsScreen + +Intent Filters (11+) + ├── ACTION_MAIN (launcher) + ├── ACTION_SEND (share) + ├── ACTION_VIEW (deep links: nostr://, https://...) + └── NFC_ACTION_NDEF_DISCOVERED +``` + +**Key Principles:** +1. **Type-Safe Navigation** - @Serializable routes, no strings +2. **Declarative Permissions** - Request contextually with Accompanist +3. **Edge-to-Edge + Insets** - Scaffold handles system bars +4. **ViewModel + Flow → State** - Survive config changes +5. **Platform Isolation** - Android code in `amethyst/` module or `androidMain/` + +## Architecture Overview + +### Module Structure + +``` +amethyst/ # Android app module +├── src/ +│ ├── main/ +│ │ ├── java/com/vitorpamplona/amethyst/ +│ │ │ ├── ui/ +│ │ │ │ ├── MainActivity.kt # Entry point +│ │ │ │ ├── navigation/ +│ │ │ │ │ ├── AppNavigation.kt # NavHost +│ │ │ │ │ ├── routes/Routes.kt # @Serializable routes +│ │ │ │ │ └── bottombars/AppBottomBar.kt +│ │ │ │ ├── screen/ # 80+ screens +│ │ │ │ └── theme/Theme.kt # Material3 theme +│ │ │ └── Amethyst.kt # Application class +│ │ └── AndroidManifest.xml # Permissions, intent filters +│ └── androidMain/ # KMP Android source set +│ └── kotlin/ # Platform-specific code +└── build.gradle # Android config +``` + +## 1. Type-Safe Navigation + +### Pattern: @Serializable Routes + +**Best Practice (Navigation 2.8.0+):** +```kotlin +// Routes.kt - Define all routes with type safety +@Serializable +sealed class Route { + @Serializable object Home : Route() + @Serializable object Search : Route() + @Serializable data class Profile(val pubkey: String) : Route() + @Serializable data class Note(val noteId: String) : Route() + @Serializable data class Thread(val noteId: String) : Route() +} + +// AppNavigation.kt - NavHost setup +@Composable +fun AppNavigation( + navController: NavHostController, + accountViewModel: AccountViewModel +) { + NavHost( + navController = navController, + startDestination = Route.Home, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) } + ) { + composable { + HomeScreen(accountViewModel, navController) + } + + composable { backStackEntry -> + val profile = backStackEntry.toRoute() + ProfileScreen(profile.pubkey, accountViewModel, navController) + } + + composable { backStackEntry -> + val note = backStackEntry.toRoute() + NoteScreen(note.noteId, accountViewModel, navController) + } + } +} +``` + +### Navigation Manager Pattern + +**Amethyst Pattern (`Nav.kt`):** +```kotlin +class Nav( + val controller: NavHostController, + val drawerState: DrawerState, + val scope: CoroutineScope +) { + fun nav(route: Route) { + scope.launch { + controller.navigate(route) + drawerState.close() + } + } + + fun newStack(route: Route) { + scope.launch { + controller.navigate(route) { + popUpTo(Route.Home) { inclusive = false } + } + drawerState.close() + } + } + + fun popBack() { + controller.popBackStack() + } +} + +// Usage in composables +@Composable +fun HomeScreen(nav: Nav) { + Button(onClick = { nav.nav(Route.Profile("npub1...")) }) { + Text("View Profile") + } +} +``` + +### Bottom Navigation + +**Material3 Pattern:** +```kotlin +@Composable +fun AppBottomBar( + selectedRoute: Route, + nav: Nav +) { + NavigationBar { + BottomBarItem.entries.forEach { item -> + NavigationBarItem( + selected = selectedRoute::class == item.route::class, + onClick = { nav.nav(item.route) }, + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label) } + ) + } + } +} + +enum class BottomBarItem(val route: Route, val icon: ImageVector, val label: String) { + HOME(Route.Home, Icons.Default.Home, "Home"), + MESSAGES(Route.Messages, Icons.Default.Message, "Messages"), + NOTIFICATIONS(Route.Notifications, Icons.Default.Notifications, "Notifications"), + SEARCH(Route.Search, Icons.Default.Search, "Search"), + PROFILE(Route.Profile, Icons.Default.Person, "Profile") +} +``` + +**Reference:** See `references/android-navigation.md` for complete navigation patterns. + +## 2. Runtime Permissions + +### Declarative Permission Handling + +**Accompanist Pattern (Experimental API):** +```kotlin +import com.google.accompanist.permissions.* + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraFeature() { + val cameraPermissionState = rememberPermissionState( + Manifest.permission.CAMERA + ) + + when { + cameraPermissionState.status.isGranted -> { + // Permission granted - show camera UI + CameraPreview() + } + + cameraPermissionState.status.shouldShowRationale -> { + // Show rationale and request again + Column { + Text("Camera permission is needed to scan QR codes") + Button( + onClick = { cameraPermissionState.launchPermissionRequest() } + ) { + Text("Grant Permission") + } + } + } + + else -> { + // First time - request permission + Button( + onClick = { cameraPermissionState.launchPermissionRequest() } + ) { + Text("Enable Camera") + } + } + } +} +``` + +### Multiple Permissions + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MediaUploadFeature() { + val permissionsState = rememberMultiplePermissionsState( + permissions = listOf( + Manifest.permission.CAMERA, + Manifest.permission.READ_EXTERNAL_STORAGE + ) + ) + + when { + permissionsState.allPermissionsGranted -> { + MediaUploadUI() + } + + permissionsState.shouldShowRationale -> { + RationaleDialog( + onConfirm = { permissionsState.launchMultiplePermissionRequest() }, + onDismiss = { /* Handle dismissal */ } + ) + } + + else -> { + PermissionRequestButton( + onClick = { permissionsState.launchMultiplePermissionRequest() } + ) + } + } +} +``` + +### Lifecycle-Aware Permission Requests + +**Amethyst Pattern (LoggedInPage.kt):** +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NotificationRegistration(accountViewModel: AccountViewModel) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermissionState = rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS + ) + + if (notificationPermissionState.status.isGranted) { + LifecycleResumeEffect( + key1 = accountViewModel, + notificationPermissionState.status.isGranted + ) { + val scope = rememberCoroutineScope() + scope.launch { + PushNotificationUtils.checkAndInit( + context = context, + accountViewModel = accountViewModel + ) + } + + onPauseOrDispose { + // Cleanup when paused + } + } + } + } +} +``` + +### AndroidManifest Permission Declarations + +**Key Permissions in Amethyst:** +```xml + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Reference:** See `references/android-permissions.md` for complete permission patterns. + +## 3. Material3 + Edge-to-Edge + +### Edge-to-Edge Setup + +**MainActivity Pattern:** +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() // Android 15+ immersive UI + super.onCreate(savedInstanceState) + + setContent { + AmethystTheme { + AccountScreen() + } + } + } +} +``` + +### Theme Configuration + +**Material3 Color Schemes:** +```kotlin +// theme/Theme.kt +private val DarkColorPalette = darkColorScheme( + primary = Purple200, + secondary = Teal200, + tertiary = Pink80, + background = Color.Black, + surface = Color.Black, + onPrimary = Color.White, + onSecondary = Color.Black, + onBackground = Color.White, + onSurface = Color.White +) + +private val LightColorPalette = lightColorScheme( + primary = Purple500, + secondary = Teal700, + tertiary = Pink40, + background = Color.White, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.White, + onBackground = Color.Black, + onSurface = Color.Black +) + +@Composable +fun AmethystTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) DarkColorPalette else LightColorPalette + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} +``` + +### Scaffold with Insets + +**Handling System Bars:** +```kotlin +@Composable +fun MainScreen(navController: NavHostController) { + val currentRoute by navController.currentBackStackEntryAsState() + + Scaffold( + topBar = { AppTopBar(currentRoute) }, + bottomBar = { AppBottomBar(currentRoute, navController) }, + floatingActionButton = { NewPostFab() } + ) { innerPadding -> + // Scaffold automatically handles system bar insets + NavHost( + navController = navController, + modifier = Modifier.padding(innerPadding) + ) { + // Routes... + } + } +} +``` + +**Custom Inset Handling:** +```kotlin +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsPadding + +@Composable +fun CustomEdgeToEdgeScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .systemBarsPadding() // Add padding for system bars + ) { + // Content draws edge-to-edge with safe padding + } +} +``` + +## 4. ViewModel + Lifecycle + +### ViewModel Pattern + +**Standard Structure (80+ ViewModels in Amethyst):** +```kotlin +class FeedViewModel( + private val accountStateViewModel: AccountStateViewModel +) : ViewModel() { + + private val _feedState = MutableStateFlow(FeedState.Loading) + val feedState: StateFlow = _feedState.asStateFlow() + + init { + loadFeed() + } + + fun loadFeed() { + viewModelScope.launch { + _feedState.value = FeedState.Loading + try { + val posts = repository.getFeed() + _feedState.value = FeedState.Success(posts) + } catch (e: Exception) { + _feedState.value = FeedState.Error(e.message) + } + } + } + + fun refresh() { + loadFeed() + } +} + +sealed class FeedState { + object Loading : FeedState() + data class Success(val posts: List) : FeedState() + data class Error(val message: String?) : FeedState() +} +``` + +### Compose Integration + +**collectAsStateWithLifecycle Pattern:** +```kotlin +@Composable +fun FeedScreen( + feedViewModel: FeedViewModel = viewModel() +) { + val feedState by feedViewModel.feedState.collectAsStateWithLifecycle() + + when (feedState) { + is FeedState.Loading -> { + LoadingIndicator() + } + is FeedState.Success -> { + val posts = (feedState as FeedState.Success).posts + LazyColumn { + items(posts) { post -> + PostCard(post) + } + } + } + is FeedState.Error -> { + ErrorScreen( + message = (feedState as FeedState.Error).message, + onRetry = { feedViewModel.refresh() } + ) + } + } +} +``` + +### Lifecycle Effects + +**LifecycleResumeEffect Pattern:** +```kotlin +@Composable +fun ChatScreen(chatViewModel: ChatViewModel) { + LifecycleResumeEffect(key1 = chatViewModel) { + // Called when composable resumes (onResume) + chatViewModel.connectToRelay() + + onPauseOrDispose { + // Called when composable pauses (onPause) or disposes + chatViewModel.disconnectFromRelay() + } + } +} +``` + +**DisposableEffect for Cleanup:** +```kotlin +@Composable +fun VideoPlayer(videoUrl: String) { + val context = LocalContext.current + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(videoUrl)) + prepare() + } + } + + DisposableEffect(videoUrl) { + onDispose { + exoPlayer.release() + } + } + + AndroidView( + factory = { PlayerView(it).apply { player = exoPlayer } } + ) +} +``` + +## 5. Platform APIs + +### Activity & Context Access + +**LocalContext Pattern:** +```kotlin +@Composable +fun ShareButton(text: String) { + val context = LocalContext.current + + Button( + onClick = { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + context.startActivity(Intent.createChooser(intent, "Share via")) + } + ) { + Text("Share") + } +} +``` + +**Activity Reference:** +```kotlin +// WindowUtils.kt pattern +@Composable +fun getActivity(): Activity? = LocalContext.current.getActivity() + +tailrec fun Context.getActivity(): ComponentActivity = + when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> throw IllegalStateException("Context not an Activity") + } + +// Usage +@Composable +fun FullscreenToggle() { + val activity = getActivity() + + Button( + onClick = { + activity?.window?.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ) + } + ) { + Text("Go Fullscreen") + } +} +``` + +### Intent Handling + +**Deep Links (AppNavigation.kt pattern):** +```kotlin +@Composable +fun AppNavigation( + navController: NavHostController, + accountViewModel: AccountViewModel +) { + val activity = LocalContext.current as? Activity + + LaunchedEffect(activity?.intent) { + activity?.intent?.let { intent -> + when (intent.action) { + Intent.ACTION_SEND -> { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + val sharedImage = intent.getParcelableExtra(Intent.EXTRA_STREAM) + navController.navigate( + Route.NewPost(message = sharedText, attachment = sharedImage.toString()) + ) + } + + Intent.ACTION_VIEW -> { + val uri = intent.data + when (uri?.scheme) { + "nostr" -> handleNostrUri(uri, navController) + "https", "http" -> handleWebUri(uri, navController) + } + } + } + } + } + + NavHost(navController = navController) { + // Routes... + } +} + +fun handleNostrUri(uri: Uri, navController: NavHostController) { + // nostr:npub1... -> Profile + // nostr:note1... -> Note + // nostr:nevent1... -> Event + when { + uri.path?.startsWith("npub") == true -> { + navController.navigate(Route.Profile(uri.path!!)) + } + uri.path?.startsWith("note") == true -> { + navController.navigate(Route.Note(uri.path!!)) + } + } +} +``` + +### File Sharing with FileProvider + +**ShareHelper Pattern:** +```kotlin +fun shareImage(context: Context, imageUri: Uri) { + try { + // Get file from cache + val cachedFile = getCachedFile(context, imageUri) + + // Create content URI via FileProvider + val contentUri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + cachedFile + ) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "image/*" + putExtra(Intent.EXTRA_STREAM, contentUri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(shareIntent, "Share Image")) + } catch (e: Exception) { + Toast.makeText(context, "Failed to share: ${e.message}", Toast.LENGTH_SHORT).show() + } +} +``` + +**FileProvider Configuration (AndroidManifest.xml):** +```xml + + + +``` + +### Activity Results + +**External Signer Integration (Amethyst pattern):** +```kotlin +@Composable +fun SignerIntegration(accountViewModel: AccountViewModel) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.let { data -> + accountViewModel.account.signer.newResponse(data) + } + } + } + + Button( + onClick = { + val signerIntent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse("nostrsigner:...") + } + launcher.launch(signerIntent) + } + ) { + Text("Sign with External App") + } +} +``` + +## 6. Build Configuration + +### Android Block + +**build.gradle (Amethyst pattern):** +```gradle +android { + namespace = 'com.vitorpamplona.amethyst' + compileSdk = 36 + + defaultConfig { + applicationId = "com.vitorpamplona.amethyst" + minSdk = 26 // Android 8.0 (Oreo) + targetSdk = 36 // Android 15 + versionCode = 430 + versionName = "1.04.2" + + vectorDrawables { + useSupportLibrary = true + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + buildFeatures { + compose = true + buildConfig = true // Enable BuildConfig access + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + + packaging { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } + + // Product flavors for Play Store vs F-Droid + flavorDimensions = ["channel"] + productFlavors { + create("play") { + dimension = "channel" + // Firebase, Google services + } + create("fdroid") { + dimension = "channel" + // UnifiedPush, open-source alternatives + } + } +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_21 + } +} +``` + +### Dependencies + +**Key Android Dependencies:** +```gradle +dependencies { + // Compose BOM + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // Lifecycle + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Activity + implementation(libs.androidx.activity.compose) + + // Accompanist + implementation(libs.accompanist.permissions) + + // Shared module + implementation(project(":commons")) + implementation(project(":quartz")) +} +``` + +### Proguard Rules + +**Common Rules for Amethyst:** +```proguard +# Keep Kotlin metadata +-keep class kotlin.Metadata { *; } + +# Keep Nostr event classes +-keep class com.vitorpamplona.quartz.events.** { *; } + +# Keep serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt + +# OkHttp +-dontwarn okhttp3.** +-keep class okhttp3.** { *; } + +# Compose +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** +``` + +**Reference:** See `references/proguard-rules.md` for complete Proguard configuration. + +### APK Optimization + +**Reference:** See `scripts/analyze-apk-size.sh` for APK size analysis. + +## 7. KMP Android Source Sets + +### Android Module Layout + +**Amethyst Structure:** +``` +amethyst/ +├── src/ +│ ├── main/ # Standard Android +│ │ ├── java/com/.../ # Compose UI code +│ │ ├── res/ # Android resources +│ │ └── AndroidManifest.xml +│ └── androidMain/ # KMP Android source set (if needed) +│ └── kotlin/ # Platform-specific utilities +└── build.gradle +``` + +**Platform-Specific Code:** +```kotlin +// commons/src/androidMain/kotlin/Platform.android.kt +actual fun openExternalUrl(url: String, context: Any) { + val ctx = context as Context + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + ctx.startActivity(intent) +} + +actual fun shareText(text: String, context: Any) { + val ctx = context as Context + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + ctx.startActivity(Intent.createChooser(intent, "Share")) +} +``` + +### Build Configuration for KMP + +```gradle +kotlin { + androidTarget { + compilerOptions { + jvmTarget = JvmTarget.JVM_21 + } + } +} + +android { + sourceSets { + // Link androidMain source set + getByName("main") { + manifest.srcFile("src/main/AndroidManifest.xml") + java.srcDirs("src/main/java", "src/androidMain/kotlin") + } + } +} +``` + +## Common Patterns + +### 1. Single Activity Architecture + +**All screens in one activity, navigation via Compose:** +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + AmethystTheme { + val accountViewModel: AccountStateViewModel = viewModel() + AccountScreen(accountViewModel) + } + } + } + + override fun onResume() { + super.onResume() + DEFAULT_MUTED_SETTING.value = true + } + + override fun onPause() { + super.onPause() + LanguageTranslatorService.clear() + } +} +``` + +### 2. Configuration Changes + +**ViewModels survive rotation:** +```kotlin +// ViewModel persists across config changes +@Composable +fun ProfileScreen( + profileViewModel: ProfileViewModel = viewModel() +) { + val profile by profileViewModel.profile.collectAsStateWithLifecycle() + + // UI rebuilds on rotation, but ViewModel data persists + ProfileContent(profile) +} +``` + +### 3. Resource Access + +```kotlin +@Composable +fun LocalizedButton() { + val context = LocalContext.current + + Button( + onClick = { + val message = context.getString(R.string.button_clicked) + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + ) { + Text(stringResource(R.string.button_label)) + } +} +``` + +## Testing Android Components + +### Navigation Testing + +```kotlin +@Test +fun testNavigationToProfile() { + val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) + + composeTestRule.setContent { + navController.navigatorProvider.addNavigator(ComposeNavigator()) + AppNavigation(navController, accountViewModel) + } + + composeTestRule.onNodeWithText("Profile").performClick() + + assertEquals( + Route.Profile::class, + navController.currentBackStackEntry?.destination?.route::class + ) +} +``` + +### Permission Testing + +```kotlin +@Test +fun testPermissionRequest() { + val scenario = launchActivity() + + scenario.onActivity { activity -> + // Grant permission via UiAutomator + grantPermissionViaUi(Manifest.permission.CAMERA) + } + + composeTestRule.onNodeWithText("Camera Ready").assertExists() +} +``` + +## Anti-Patterns to Avoid + +1. **String-based navigation** - Use type-safe @Serializable routes +2. **Requesting permissions eagerly** - Request contextually before feature use +3. **Ignoring edge-to-edge** - Handle insets properly with Scaffold +4. **Using GlobalScope** - Use viewModelScope or rememberCoroutineScope +5. **Not handling config changes** - Use ViewModel + collectAsStateWithLifecycle +6. **Hardcoded system bar heights** - Use WindowInsets APIs +7. **Blocking main thread** - Use viewModelScope.launch(Dispatchers.IO) + +## Quick Reference + +| Task | Pattern | +|------|---------| +| **Navigate** | `navController.navigate(Route.Profile(id))` | +| **Request Permission** | `rememberPermissionState().launchPermissionRequest()` | +| **Access Context** | `val context = LocalContext.current` | +| **Get Activity** | `val activity = context.getActivity()` | +| **Open URL** | `Intent(ACTION_VIEW, Uri.parse(url))` | +| **Share Text** | `Intent(ACTION_SEND).putExtra(EXTRA_TEXT, text)` | +| **Observe Flow** | `flow.collectAsStateWithLifecycle()` | +| **Lifecycle Effect** | `LifecycleResumeEffect { ... }` | +| **Handle Insets** | `Modifier.systemBarsPadding()` | +| **Theme** | `MaterialTheme(colorScheme = ...) { }` | + +## File Locations + +**Key Android Files:** +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt` +- `amethyst/src/main/AndroidManifest.xml` +- `amethyst/build.gradle` + +## Additional Resources + +- `references/android-navigation.md` - Complete navigation patterns and examples +- `references/android-permissions.md` - Permission handling patterns +- `references/proguard-rules.md` - Proguard configuration +- `scripts/analyze-apk-size.sh` - APK size optimization script + +## When NOT to Use + +- Desktop-specific features → Use `desktop-expert` skill +- iOS-specific features → Use `ios-expert` skill +- Shared KMP code → Use `kotlin-multiplatform` skill +- Nostr protocol → Use `nostr-expert` skill +- Compose UI components → Use `compose-expert` skill diff --git a/.claude/skills/android-expert/references/android-navigation.md b/.claude/skills/android-expert/references/android-navigation.md new file mode 100644 index 0000000000..59d8a9e317 --- /dev/null +++ b/.claude/skills/android-expert/references/android-navigation.md @@ -0,0 +1,615 @@ +# Android Navigation Patterns + +Complete navigation implementation patterns for Amethyst Android app using Navigation Compose with type safety. + +## Type-Safe Routes (Navigation 2.8.0+) + +### Route Definitions + +```kotlin +// Routes.kt - All 40+ routes in Amethyst +@Serializable +sealed class Route { + // Bottom nav routes + @Serializable object Home : Route() + @Serializable object Messages : Route() + @Serializable object Video : Route() + @Serializable object Discover : Route() + @Serializable object Notification : Route() + + // Content routes with parameters + @Serializable data class Profile(val pubkey: String) : Route() + @Serializable data class Note(val id: String) : Route() + @Serializable data class Channel(val id: String) : Route() + @Serializable data class Thread( + val id: String, + val replyTo: String? = null + ) : Route() + + // New content routes + @Serializable data class NewPost( + val message: String? = null, + val attachment: String? = null, + val replyTo: String? = null + ) : Route() + + // Settings + @Serializable object Settings : Route() + @Serializable object Security : Route() + @Serializable object Relays : Route() + + // Search + @Serializable data class Search(val query: String = "") : Route() + + // Media + @Serializable data class Image(val url: String) : Route() + @Serializable data class Video(val url: String) : Route() +} +``` + +## NavHost Configuration + +### Basic Setup + +```kotlin +@Composable +fun AppNavigation( + navController: NavHostController, + accountViewModel: AccountViewModel, + drawerState: DrawerState +) { + val scope = rememberCoroutineScope() + val nav = remember { + Nav(navController, drawerState, scope) + } + + NavHost( + navController = navController, + startDestination = Route.Home, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) }, + popEnterTransition = { fadeIn(animationSpec = tween(200)) }, + popExitTransition = { fadeOut(animationSpec = tween(200)) } + ) { + // Define routes + composable { + HomeScreen(accountViewModel, nav) + } + + composable { backStackEntry -> + val profile = backStackEntry.toRoute() + ProfileScreen( + pubkey = profile.pubkey, + accountViewModel = accountViewModel, + nav = nav + ) + } + + composable { backStackEntry -> + val note = backStackEntry.toRoute() + NoteScreen( + noteId = note.id, + accountViewModel = accountViewModel, + nav = nav + ) + } + + composable { backStackEntry -> + val newPost = backStackEntry.toRoute() + NewPostScreen( + initialMessage = newPost.message, + initialAttachment = newPost.attachment, + replyTo = newPost.replyTo, + accountViewModel = accountViewModel, + onPost = { nav.popBack() } + ) + } + } +} +``` + +### Custom Transitions + +```kotlin +composable( + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(300) + ) + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Start, + animationSpec = tween(300) + ) + }, + popEnterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(300) + ) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.End, + animationSpec = tween(300) + ) + } +) { backStackEntry -> + val profile = backStackEntry.toRoute() + ProfileScreen(profile.pubkey, accountViewModel, nav) +} +``` + +## Navigation Manager + +### Nav Wrapper Class + +```kotlin +class Nav( + val controller: NavHostController, + val drawerState: DrawerState, + val scope: CoroutineScope +) { + /** + * Navigate to a route, closing drawer if open + */ + fun nav(route: Route) { + scope.launch { + if (!controller.popBackStack(route, inclusive = false)) { + controller.navigate(route) { + launchSingleTop = true + } + } + drawerState.close() + } + } + + /** + * Navigate with new stack (clear back stack to Home) + */ + fun newStack(route: Route) { + scope.launch { + controller.navigate(route) { + popUpTo(Route.Home) { + inclusive = false + } + launchSingleTop = true + } + drawerState.close() + } + } + + /** + * Pop back stack + */ + fun popBack() { + controller.popBackStack() + } + + /** + * Pop up to specific route + */ + inline fun popUpTo(inclusive: Boolean = false) { + controller.popBackStack(inclusive = inclusive) + } + + /** + * Get current route + */ + fun currentRoute(): Route? { + return controller.currentBackStackEntry?.toRoute() + } +} +``` + +## Bottom Navigation + +### Material3 NavigationBar + +```kotlin +@Composable +fun AppBottomBar( + currentRoute: Route?, + nav: Nav +) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + BottomBarRoute.entries.forEach { item -> + NavigationBarItem( + selected = currentRoute?.let { it::class == item.route::class } ?: false, + onClick = { nav.nav(item.route) }, + icon = { + Icon( + imageVector = if (currentRoute?.let { it::class == item.route::class } == true) { + item.selectedIcon + } else { + item.unselectedIcon + }, + contentDescription = item.label + ) + }, + label = { Text(item.label) }, + alwaysShowLabel = false + ) + } + } +} + +enum class BottomBarRoute( + val route: Route, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, + val label: String +) { + HOME( + route = Route.Home, + selectedIcon = Icons.Filled.Home, + unselectedIcon = Icons.Outlined.Home, + label = "Home" + ), + MESSAGES( + route = Route.Messages, + selectedIcon = Icons.Filled.Message, + unselectedIcon = Icons.Outlined.Message, + label = "Messages" + ), + VIDEOS( + route = Route.Video, + selectedIcon = Icons.Filled.VideoLibrary, + unselectedIcon = Icons.Outlined.VideoLibrary, + label = "Videos" + ), + DISCOVER( + route = Route.Discover, + selectedIcon = Icons.Filled.Explore, + unselectedIcon = Icons.Outlined.Explore, + label = "Discover" + ), + NOTIFICATIONS( + route = Route.Notification, + selectedIcon = Icons.Filled.Notifications, + unselectedIcon = Icons.Outlined.Notifications, + label = "Notifications" + ) +} +``` + +### Observing Current Route + +```kotlin +@Composable +fun MainScreen() { + val navController = rememberNavController() + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentBackStackEntry?.toRoute() + + Scaffold( + topBar = { + if (shouldShowTopBar(currentRoute)) { + AppTopBar(currentRoute) + } + }, + bottomBar = { + if (shouldShowBottomBar(currentRoute)) { + AppBottomBar(currentRoute, nav) + } + } + ) { paddingValues -> + AppNavigation( + navController = navController, + modifier = Modifier.padding(paddingValues) + ) + } +} + +fun shouldShowBottomBar(route: Route?): Boolean { + return when (route) { + is Route.Home, + is Route.Messages, + is Route.Video, + is Route.Discover, + is Route.Notification -> true + else -> false + } +} +``` + +## Navigation Drawer + +### Material3 ModalDrawerSheet + +```kotlin +@Composable +fun AppDrawer( + drawerState: DrawerState, + nav: Nav, + accountViewModel: AccountViewModel +) { + val scope = rememberCoroutineScope() + + ModalDrawerSheet { + // User profile header + DrawerHeader(accountViewModel.account) + + HorizontalDivider() + + // Menu items + NavigationDrawerItem( + label = { Text("Home") }, + selected = false, + onClick = { nav.nav(Route.Home) }, + icon = { Icon(Icons.Default.Home, "Home") } + ) + + NavigationDrawerItem( + label = { Text("Profile") }, + selected = false, + onClick = { nav.nav(Route.Profile(accountViewModel.account.pubkey)) }, + icon = { Icon(Icons.Default.Person, "Profile") } + ) + + NavigationDrawerItem( + label = { Text("Settings") }, + selected = false, + onClick = { nav.nav(Route.Settings) }, + icon = { Icon(Icons.Default.Settings, "Settings") } + ) + + HorizontalDivider() + + NavigationDrawerItem( + label = { Text("Logout") }, + selected = false, + onClick = { + scope.launch { + accountViewModel.logout() + drawerState.close() + } + }, + icon = { Icon(Icons.Default.Logout, "Logout") } + ) + } +} +``` + +### Main Scaffold with Drawer + +```kotlin +@Composable +fun MainScreen() { + val navController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + val nav = remember { Nav(navController, drawerState, scope) } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + AppDrawer(drawerState, nav, accountViewModel) + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Amethyst") }, + navigationIcon = { + IconButton( + onClick = { scope.launch { drawerState.open() } } + ) { + Icon(Icons.Default.Menu, "Menu") + } + } + ) + }, + bottomBar = { AppBottomBar(currentRoute, nav) } + ) { paddingValues -> + AppNavigation( + navController = navController, + modifier = Modifier.padding(paddingValues) + ) + } + } +} +``` + +## Deep Link Handling + +### Intent Processing + +```kotlin +@Composable +fun AppNavigation( + navController: NavHostController, + accountViewModel: AccountViewModel +) { + val activity = LocalContext.current as? Activity + + // Handle incoming intents + LaunchedEffect(activity?.intent) { + activity?.intent?.let { intent -> + handleIntent(intent, navController) + } + } + + NavHost(navController = navController) { + // Routes... + } +} + +fun handleIntent(intent: Intent, navController: NavHostController) { + when (intent.action) { + Intent.ACTION_SEND -> { + // Share text/image + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + val sharedUri = intent.getParcelableExtra(Intent.EXTRA_STREAM) + + navController.navigate( + Route.NewPost( + message = sharedText, + attachment = sharedUri?.toString() + ) + ) + } + + Intent.ACTION_VIEW -> { + // Deep link + intent.data?.let { uri -> + when (uri.scheme) { + "nostr" -> handleNostrUri(uri, navController) + "https", "http" -> handleWebUri(uri, navController) + } + } + } + } +} + +fun handleNostrUri(uri: Uri, navController: NavHostController) { + val path = uri.pathSegments.firstOrNull() ?: return + + when { + path.startsWith("npub") -> { + navController.navigate(Route.Profile(path)) + } + path.startsWith("note") -> { + navController.navigate(Route.Note(path)) + } + path.startsWith("nevent") -> { + // Decode and navigate to event + val eventId = decodeNevent(path) + navController.navigate(Route.Note(eventId)) + } + } +} + +fun handleWebUri(uri: Uri, navController: NavHostController) { + // Handle web-based deep links + // https://njump.me/npub1... + // https://primal.net/profile/npub1... + when (uri.host) { + "njump.me" -> { + val id = uri.pathSegments.lastOrNull() + if (id?.startsWith("npub") == true) { + navController.navigate(Route.Profile(id)) + } + } + "primal.net" -> { + // Parse primal.net URLs + } + } +} +``` + +### AndroidManifest Intent Filters + +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +## Nested Navigation + +### Tab Navigation Inside Screen + +```kotlin +@Composable +fun ProfileScreen( + pubkey: String, + nav: Nav +) { + val nestedNavController = rememberNavController() + + Column { + ProfileHeader(pubkey) + + // Tab row + TabRow(selectedTabIndex = currentTab) { + Tab(selected = currentTab == 0, onClick = { /* Notes */ }) + Tab(selected = currentTab == 1, onClick = { /* Replies */ }) + Tab(selected = currentTab == 2, onClick = { /* Likes */ }) + } + + // Nested NavHost for tabs + NavHost( + navController = nestedNavController, + startDestination = ProfileTab.Notes + ) { + composable { + NotesTabContent(pubkey) + } + composable { + RepliesTabContent(pubkey) + } + composable { + LikesTabContent(pubkey) + } + } + } +} + +@Serializable +sealed class ProfileTab { + @Serializable object Notes : ProfileTab() + @Serializable object Replies : ProfileTab() + @Serializable object Likes : ProfileTab() +} +``` + +## Testing Navigation + +### Navigation Test Example + +```kotlin +@Test +fun testNavigationToProfile() { + val navController = TestNavHostController( + ApplicationProvider.getApplicationContext() + ) + + composeTestRule.setContent { + navController.navigatorProvider.addNavigator( + ComposeNavigator() + ) + AppNavigation(navController, accountViewModel) + } + + // Navigate to profile + composeTestRule.onNodeWithText("Profile").performClick() + + // Verify navigation + val currentRoute = navController.currentBackStackEntry?.toRoute() + assertTrue(currentRoute is Route.Profile) +} +``` + +## File Locations + +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt` +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt` diff --git a/.claude/skills/android-expert/references/android-permissions.md b/.claude/skills/android-expert/references/android-permissions.md new file mode 100644 index 0000000000..89cc790a1f --- /dev/null +++ b/.claude/skills/android-expert/references/android-permissions.md @@ -0,0 +1,659 @@ +# Android Runtime Permissions + +Complete permission handling patterns for Amethyst using Accompanist Permissions library and Android best practices. + +## Permission Categories in Amethyst + +### Network Permissions (Normal - Auto-granted) + +```xml + + + +``` + +### Media Permissions (Dangerous - Runtime request) + +```xml + + + + + + + + + +``` + +### Notification Permissions (Android 13+) + +```xml + +``` + +### Location Permissions + +```xml + +``` + +### NFC Permissions + +```xml + +``` + +### Foreground Service Permissions + +```xml + + + +``` + +## Accompanist Permissions Library + +### Setup + +```gradle +dependencies { + implementation("com.google.accompanist:accompanist-permissions:0.36.0") +} +``` + +### Single Permission Pattern + +```kotlin +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.shouldShowRationale + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraFeature() { + val cameraPermissionState = rememberPermissionState( + Manifest.permission.CAMERA + ) + + when { + // Permission granted - show feature + cameraPermissionState.status.isGranted -> { + CameraPreview() + } + + // Should show rationale - explain why permission is needed + cameraPermissionState.status.shouldShowRationale -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Camera permission is needed to scan QR codes for login", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { cameraPermissionState.launchPermissionRequest() } + ) { + Text("Grant Permission") + } + } + } + + // First time - request permission + else -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Button( + onClick = { cameraPermissionState.launchPermissionRequest() } + ) { + Icon(Icons.Default.CameraAlt, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Enable Camera") + } + } + } + } +} +``` + +### Multiple Permissions Pattern + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MediaUploadFeature() { + val permissionsState = rememberMultiplePermissionsState( + permissions = buildList { + add(Manifest.permission.CAMERA) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + ) + + when { + // All permissions granted + permissionsState.allPermissionsGranted -> { + MediaUploadUI() + } + + // Some permissions need rationale + permissionsState.shouldShowRationale -> { + RationaleDialog( + title = "Permissions Required", + message = "Camera and storage access are needed to upload photos", + onConfirm = { + permissionsState.launchMultiplePermissionRequest() + }, + onDismiss = { /* Handle dismissal */ } + ) + } + + // Request all permissions + else -> { + PermissionRequestScreen( + permissions = permissionsState.permissions, + onRequestPermissions = { + permissionsState.launchMultiplePermissionRequest() + } + ) + } + } +} + +@Composable +fun RationaleDialog( + title: String, + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Continue") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} +``` + +## Lifecycle-Aware Permission Requests + +### Amethyst Pattern: POST_NOTIFICATIONS + +**File:** `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NotificationRegistration(accountViewModel: AccountViewModel) { + val context = LocalContext.current + + // Only request on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermissionState = rememberPermissionState( + Manifest.permission.POST_NOTIFICATIONS + ) + + // Register for push notifications when permission is granted + if (notificationPermissionState.status.isGranted) { + LifecycleResumeEffect( + key1 = accountViewModel, + key2 = notificationPermissionState.status.isGranted + ) { + val scope = rememberCoroutineScope() + scope.launch(Dispatchers.IO) { + PushNotificationUtils.checkAndInit( + context = context, + accountViewModel = accountViewModel + ) + } + + onPauseOrDispose { + // Cleanup when composable pauses or disposes + } + } + } else { + // Show prompt to enable notifications + NotificationPermissionPrompt( + onEnableClick = { + notificationPermissionState.launchPermissionRequest() + } + ) + } + } +} + +@Composable +fun NotificationPermissionPrompt(onEnableClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Enable Notifications", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Get notified when someone mentions you or replies to your posts", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onEnableClick, + modifier = Modifier.fillMaxWidth() + ) { + Text("Enable Notifications") + } + } + } +} +``` + +## Permission Best Practices + +### 1. Request Contextually + +**Bad:** +```kotlin +// Requesting permission on app launch +@Composable +fun AppContent() { + val permissionState = rememberPermissionState(Manifest.permission.CAMERA) + + LaunchedEffect(Unit) { + // DON'T DO THIS - user doesn't know why + permissionState.launchPermissionRequest() + } +} +``` + +**Good:** +```kotlin +// Request when user explicitly wants to use camera +@Composable +fun QRScannerButton() { + val permissionState = rememberPermissionState(Manifest.permission.CAMERA) + + Button( + onClick = { + if (permissionState.status.isGranted) { + // Open scanner + } else { + // Request permission + permissionState.launchPermissionRequest() + } + } + ) { + Text("Scan QR Code") + } +} +``` + +### 2. Show Rationale + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun LocationFeature() { + val locationPermissionState = rememberPermissionState( + Manifest.permission.ACCESS_COARSE_LOCATION + ) + + // Always show rationale first for sensitive permissions + if (!locationPermissionState.status.isGranted) { + LocationRationaleCard( + onEnableClick = { + locationPermissionState.launchPermissionRequest() + } + ) + } else { + LocationMap() + } +} + +@Composable +fun LocationRationaleCard(onEnableClick: () -> Unit) { + Card { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Why location access?", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Location is used for geohashing your posts. " + + "This helps other users discover local content. " + + "Your exact location is never shared.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Row { + OutlinedButton(onClick = { /* Skip */ }) { + Text("Skip") + } + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = onEnableClick) { + Text("Enable") + } + } + } + } +} +``` + +### 3. Handle Permanent Denial + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun CameraFeatureWithSettings() { + val context = LocalContext.current + val cameraPermissionState = rememberPermissionState( + Manifest.permission.CAMERA + ) + + when { + cameraPermissionState.status.isGranted -> { + CameraPreview() + } + + cameraPermissionState.status.shouldShowRationale -> { + // User denied once, show rationale + RationaleDialog( + onConfirm = { cameraPermissionState.launchPermissionRequest() } + ) + } + + else -> { + // Might be permanently denied - offer settings + PermanentlyDeniedDialog( + onOpenSettings = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } + ) + } + } +} + +@Composable +fun PermanentlyDeniedDialog(onOpenSettings: () -> Unit) { + AlertDialog( + onDismissRequest = { }, + title = { Text("Permission Denied") }, + text = { + Text( + "Camera permission is required for QR scanning. " + + "Please enable it in Settings." + ) + }, + confirmButton = { + TextButton(onClick = onOpenSettings) { + Text("Open Settings") + } + }, + dismissButton = { + TextButton(onClick = { /* Cancel */ }) { + Text("Cancel") + } + } + ) +} +``` + +### 4. Version-Specific Permissions + +```kotlin +@Composable +fun StoragePermissionRequest() { + val permissions = remember { + buildList { + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { + add(Manifest.permission.READ_MEDIA_IMAGES) + add(Manifest.permission.READ_MEDIA_VIDEO) + } + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> { + // Android 10-12: No permission needed for scoped storage + } + else -> { + // Android 9 and below + add(Manifest.permission.READ_EXTERNAL_STORAGE) + add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + } + } + + if (permissions.isNotEmpty()) { + val permissionsState = rememberMultiplePermissionsState(permissions) + + if (!permissionsState.allPermissionsGranted) { + StoragePermissionUI( + onRequest = { permissionsState.launchMultiplePermissionRequest() } + ) + } else { + MediaPickerUI() + } + } else { + // No permission needed + MediaPickerUI() + } +} +``` + +## Permission Groups + +### Camera + Storage (Media Upload) + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun MediaCaptureFeature() { + val mediaPermissions = buildList { + add(Manifest.permission.CAMERA) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(Manifest.permission.READ_MEDIA_IMAGES) + } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + val permissionsState = rememberMultiplePermissionsState(mediaPermissions) + + when { + permissionsState.allPermissionsGranted -> { + MediaCaptureUI() + } + else -> { + MediaPermissionScreen( + permissions = permissionsState.permissions, + onRequest = { permissionsState.launchMultiplePermissionRequest() } + ) + } + } +} +``` + +### Audio + Storage (Voice Recording) + +```kotlin +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun VoiceRecordingFeature() { + val audioPermissions = buildList { + add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(Manifest.permission.READ_MEDIA_AUDIO) + } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + val permissionsState = rememberMultiplePermissionsState(audioPermissions) + + if (permissionsState.allPermissionsGranted) { + AudioRecorderUI() + } else { + AudioPermissionScreen( + onRequest = { permissionsState.launchMultiplePermissionRequest() } + ) + } +} +``` + +## Testing Permissions + +### Grant Permission in Tests + +```kotlin +@get:Rule +val permissionRule = GrantPermissionRule.grant( + Manifest.permission.CAMERA, + Manifest.permission.READ_EXTERNAL_STORAGE +) + +@Test +fun testCameraFeatureWithPermission() { + composeTestRule.setContent { + CameraFeature() + } + + // Permission is already granted by rule + composeTestRule.onNodeWithText("Take Photo").assertExists() +} +``` + +### Test Permission Request Flow + +```kotlin +@Test +fun testPermissionRequestFlow() { + composeTestRule.setContent { + CameraFeature() + } + + // Initially shows permission request button + composeTestRule.onNodeWithText("Enable Camera").assertExists() + + // Click to request + composeTestRule.onNodeWithText("Enable Camera").performClick() + + // System permission dialog appears (can't test dialog itself) + // Would need UiAutomator to interact with system dialog +} +``` + +## Permission State Checking + +### Check Permission Before Action + +```kotlin +fun checkAndRequestCameraPermission( + context: Context, + permissionState: PermissionState, + onGranted: () -> Unit +) { + when { + permissionState.status.isGranted -> { + onGranted() + } + permissionState.status.shouldShowRationale -> { + // Show rationale dialog + } + else -> { + permissionState.launchPermissionRequest() + } + } +} +``` + +### Manual Permission Check (Non-Compose) + +```kotlin +fun hasCameraPermission(context: Context): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED +} + +fun requestCameraPermission(activity: ComponentActivity) { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.CAMERA), + REQUEST_CAMERA_PERMISSION + ) +} + +// In Activity +override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray +) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + REQUEST_CAMERA_PERMISSION -> { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission granted + } else { + // Permission denied + } + } + } +} + +companion object { + private const val REQUEST_CAMERA_PERMISSION = 100 +} +``` + +## File Locations + +- `amethyst/src/main/AndroidManifest.xml` - Permission declarations +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt` - Notification permission pattern +- `amethyst/build.gradle` - Accompanist dependency + +## Resources + +- [Accompanist Permissions Documentation](https://google.github.io/accompanist/permissions/) +- [Android Permissions Guide](https://developer.android.com/guide/topics/permissions/overview) +- [Request Runtime Permissions](https://developer.android.com/training/permissions/requesting) diff --git a/.claude/skills/android-expert/references/proguard-rules.md b/.claude/skills/android-expert/references/proguard-rules.md new file mode 100644 index 0000000000..b09f3e1b5d --- /dev/null +++ b/.claude/skills/android-expert/references/proguard-rules.md @@ -0,0 +1,468 @@ +# Proguard Rules for Amethyst + +Proguard configuration for optimizing and obfuscating Android APK while preserving necessary code. + +## What is Proguard/R8? + +**R8** is Android's default code shrinker and obfuscator (replaced Proguard in AGP 3.4.0+). It: +- **Shrinks** code by removing unused classes/methods +- **Obfuscates** code by renaming classes/methods to short names +- **Optimizes** code by inlining methods and removing dead code + +## Amethyst Proguard Configuration + +**File:** `amethyst/proguard-rules.pro` + +### Keep Kotlin Metadata + +```proguard +# Kotlin metadata is required for reflection +-keep class kotlin.Metadata { *; } +-keep class kotlin.** { *; } +-dontwarn kotlin.** + +# Kotlin serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.AnnotationsKt +-dontnote kotlinx.serialization.SerializationKt + +-keep,includedescriptorclasses class com.vitorpamplona.**$$serializer { *; } +-keepclassmembers class com.vitorpamplona.** { + *** Companion; +} +-keepclasseswithmembers class com.vitorpamplona.** { + kotlinx.serialization.KSerializer serializer(...); +} +``` + +### Keep Nostr Event Classes + +```proguard +# Nostr events are serialized/deserialized +-keep class com.vitorpamplona.quartz.events.** { *; } +-keep class com.vitorpamplona.quartz.encoders.** { *; } + +# Keep event builders +-keep class com.vitorpamplona.quartz.builders.** { *; } + +# Keep tag classes +-keep class com.vitorpamplona.quartz.nip01Core.tags.** { *; } +``` + +### Keep Data Classes + +```proguard +# Data classes used in ViewModels and serialization +-keep @kotlinx.serialization.Serializable class * { *; } + +# Keep all data classes +-keep class com.vitorpamplona.amethyst.model.** { *; } +-keep class com.vitorpamplona.amethyst.service.model.** { *; } +``` + +### Keep Compose Classes + +```proguard +# Jetpack Compose +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# Compose runtime +-keep class androidx.compose.runtime.** { *; } + +# Compose UI +-keep class androidx.compose.ui.** { *; } + +# Material3 +-keep class androidx.compose.material3.** { *; } + +# Navigation Compose - Keep serializable routes +-keep class * implements java.io.Serializable { *; } +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} +``` + +### Keep OkHttp/Retrofit + +```proguard +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep class okio.** { *; } + +# OkHttp WebSockets (for Nostr relays) +-keep class okhttp3.internal.ws.** { *; } + +# Retrofit (if used) +-keepattributes Signature +-keepattributes Exceptions +-keep class retrofit2.** { *; } +``` + +### Keep Jackson (JSON) + +```proguard +# Jackson JSON library +-keep class com.fasterxml.jackson.** { *; } +-keep class org.codehaus.** { *; } +-keepclassmembers class * { + @com.fasterxml.jackson.annotation.* ; +} + +# Jackson polymorphic types +-keepattributes RuntimeVisibleAnnotations +-keep @com.fasterxml.jackson.annotation.JsonTypeInfo class * +``` + +### Keep Secp256k1 (Crypto) + +```proguard +# Secp256k1 native library +-keep class fr.acinq.secp256k1.** { *; } + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} +``` + +### Keep Tor + +```proguard +# Tor library +-keep class com.msopentech.thali.toronionproxy.** { *; } +-dontwarn com.msopentech.thali.toronionproxy.** +``` + +### Keep ExoPlayer (Media) + +```proguard +# ExoPlayer (Media3) +-keep class androidx.media3.** { *; } +-dontwarn androidx.media3.** + +-keep class com.google.android.exoplayer2.** { *; } +-dontwarn com.google.android.exoplayer2.** +``` + +### Keep Coil (Image Loading) + +```proguard +# Coil image loading +-keep class coil.** { *; } +-keep class coil3.** { *; } +-dontwarn coil.** +-dontwarn coil3.** +``` + +### Keep ViewModels + +```proguard +# ViewModel classes +-keep class * extends androidx.lifecycle.ViewModel { + (); +} + +# ViewModel factories +-keep class * extends androidx.lifecycle.ViewModelProvider$Factory { + (...); +} + +# Keep ViewModel constructors for reflection +-keepclassmembers class * extends androidx.lifecycle.ViewModel { + (...); +} +``` + +### Keep Parcelable + +```proguard +# Parcelable +-keep class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} + +-keepclassmembers class * implements android.os.Parcelable { + public ; + private ; +} +``` + +### Keep Enums + +```proguard +# Enums +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} +``` + +### Remove Logging (Production) + +```proguard +# Remove debug logging in release builds +-assumenosideeffects class android.util.Log { + public static *** d(...); + public static *** v(...); + public static *** i(...); +} + +# Keep error/warning logs +-assumenosideeffects class android.util.Log { + public static *** e(...) return false; + public static *** w(...) return false; +} +``` + +### Keep Crashlytics/Firebase + +```proguard +# Firebase Crashlytics +-keepattributes SourceFile,LineNumberTable +-keep public class * extends java.lang.Exception + +# Firebase +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** +``` + +## Build Configuration + +### Enable R8 in build.gradle + +```gradle +android { + buildTypes { + release { + minifyEnabled = true + shrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + + debug { + minifyEnabled = false + } + } +} +``` + +### Multiple Proguard Files + +```gradle +android { + buildTypes { + release { + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + "proguard-quartz.pro", // Library-specific rules + "proguard-compose.pro" // Compose-specific rules + ) + } + } +} +``` + +## Debugging Proguard Issues + +### Generate Mapping File + +R8 generates `mapping.txt` in `app/build/outputs/mapping/release/`: + +``` +# Original class name -> Obfuscated name +com.vitorpamplona.amethyst.ui.MainActivity -> a.b.c: + void onCreate(Bundle) -> a +``` + +### Deobfuscate Stack Traces + +```bash +# Using retrace (part of Android SDK) +retrace.sh mapping.txt stacktrace.txt +``` + +### Enable Proguard Output + +```gradle +android { + buildTypes { + release { + proguardFiles(...) + + // Generate reports + postprocessing { + proguardFiles = [...] + obfuscate = true + optimizeCode = true + removeUnusedCode = true + } + } + } +} +``` + +**Output files:** +- `build/outputs/mapping/release/configuration.txt` - All Proguard rules applied +- `build/outputs/mapping/release/mapping.txt` - Obfuscation mappings +- `build/outputs/mapping/release/seeds.txt` - Classes kept by `-keep` rules +- `build/outputs/mapping/release/usage.txt` - Code removed by R8 + +### Test Release Build + +```bash +./gradlew assembleRelease + +# Install and test +adb install app/build/outputs/apk/release/app-release.apk +``` + +## Common Issues + +### Issue: NoSuchMethodException at Runtime + +**Cause:** Proguard removed or renamed a method used via reflection. + +**Solution:** +```proguard +-keep class com.example.YourClass { + public ; +} +``` + +### Issue: Serialization Fails + +**Cause:** Data class fields were renamed. + +**Solution:** +```proguard +-keep @kotlinx.serialization.Serializable class * { *; } +-keepclassmembers class * { + @kotlinx.serialization.SerialName ; +} +``` + +### Issue: Compose Navigation Crashes + +**Cause:** @Serializable route classes were obfuscated. + +**Solution:** +```proguard +# Keep all route classes +-keep @kotlinx.serialization.Serializable class com.vitorpamplona.amethyst.ui.navigation.routes.** { *; } +``` + +### Issue: Native Library Crashes + +**Cause:** Native method signatures were changed. + +**Solution:** +```proguard +-keepclasseswithmembernames class * { + native ; +} +``` + +## Optimization Tips + +### 1. Keep Only What's Necessary + +Don't use broad wildcards: +```proguard +# Bad - keeps everything +-keep class com.vitorpamplona.** { *; } + +# Good - keeps only specific packages +-keep class com.vitorpamplona.quartz.events.** { *; } +``` + +### 2. Test Thoroughly + +- Test all app features after enabling Proguard +- Test deep links and navigation +- Test serialization/deserialization +- Test external library integrations + +### 3. Use AGP's Proguard Analysis + +```gradle +android { + buildTypes { + release { + // Generate R8 configuration + android.debug.obsoleteApi = true + } + } +} +``` + +### 4. Analyze APK Size + +```bash +# Build release APK +./gradlew assembleRelease + +# Analyze APK with Android Studio +# Build > Analyze APK > Select app-release.apk +``` + +See `scripts/analyze-apk-size.sh` for automated analysis. + +## Product Flavor Specific Rules + +### Play Flavor (Firebase) + +```proguard +# proguard-play.pro +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } +``` + +### F-Droid Flavor (No Google Services) + +```proguard +# proguard-fdroid.pro +# UnifiedPush +-keep class org.unifiedpush.** { *; } +``` + +**Configure in build.gradle:** +```gradle +android { + flavorDimensions = ["channel"] + productFlavors { + create("play") { + dimension = "channel" + proguardFiles("proguard-play.pro") + } + create("fdroid") { + dimension = "channel" + proguardFiles("proguard-fdroid.pro") + } + } +} +``` + +## File Locations + +- `amethyst/proguard-rules.pro` - Main Proguard rules +- `amethyst/build/outputs/mapping/release/` - Proguard output files +- `amethyst/build.gradle` - Proguard configuration + +## Resources + +- [Android R8 Documentation](https://developer.android.com/build/shrink-code) +- [Proguard Manual](https://www.guardsquare.com/manual/configuration) +- [Kotlinx Serialization Proguard](https://github.com/Kotlin/kotlinx.serialization#android) diff --git a/.claude/skills/android-expert/scripts/analyze-apk-size.sh b/.claude/skills/android-expert/scripts/analyze-apk-size.sh new file mode 100755 index 0000000000..023483130b --- /dev/null +++ b/.claude/skills/android-expert/scripts/analyze-apk-size.sh @@ -0,0 +1,230 @@ +#!/bin/bash +# +# APK Size Analysis Script for Amethyst +# +# Usage: +# ./analyze-apk-size.sh [apk-path] +# +# If no APK path provided, uses latest release build + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Default APK path +DEFAULT_APK="amethyst/build/outputs/apk/release/amethyst-release.apk" +APK_PATH="${1:-$DEFAULT_APK}" + +# Check if APK exists +if [ ! -f "$APK_PATH" ]; then + echo -e "${RED}Error: APK not found at $APK_PATH${NC}" + echo "Build the APK first: ./gradlew :amethyst:assembleRelease" + exit 1 +fi + +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Amethyst APK Size Analysis${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo + +# APK basic info +echo -e "${GREEN}APK Path:${NC} $APK_PATH" +APK_SIZE=$(du -h "$APK_PATH" | cut -f1) +APK_SIZE_BYTES=$(stat -f%z "$APK_PATH" 2>/dev/null || stat -c%s "$APK_PATH" 2>/dev/null) +echo -e "${GREEN}APK Size:${NC} $APK_SIZE ($APK_SIZE_BYTES bytes)" +echo + +# Extract APK to temp directory +TEMP_DIR=$(mktemp -d) +echo -e "${YELLOW}Extracting APK to $TEMP_DIR...${NC}" +unzip -q "$APK_PATH" -d "$TEMP_DIR" + +# Analyze APK contents +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Top-Level Contents${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +du -sh "$TEMP_DIR"/* | sort -hr + +# Analyze DEX files +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} DEX Files (Code)${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +DEX_TOTAL=0 +for dex in "$TEMP_DIR"/*.dex; do + if [ -f "$dex" ]; then + DEX_NAME=$(basename "$dex") + DEX_SIZE=$(stat -f%z "$dex" 2>/dev/null || stat -c%s "$dex" 2>/dev/null) + DEX_SIZE_MB=$(echo "scale=2; $DEX_SIZE / 1024 / 1024" | bc) + echo -e " $DEX_NAME: ${GREEN}${DEX_SIZE_MB} MB${NC}" + DEX_TOTAL=$((DEX_TOTAL + DEX_SIZE)) + fi +done +DEX_TOTAL_MB=$(echo "scale=2; $DEX_TOTAL / 1024 / 1024" | bc) +echo -e "${YELLOW}Total DEX: $DEX_TOTAL_MB MB${NC}" + +# Analyze resources +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Resources${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +if [ -d "$TEMP_DIR/res" ]; then + RES_SIZE=$(du -sh "$TEMP_DIR/res" | cut -f1) + echo -e " res/: ${GREEN}$RES_SIZE${NC}" + + # Top resource folders + echo " Top resource folders:" + du -sh "$TEMP_DIR/res"/* | sort -hr | head -10 | sed 's/^/ /' +fi + +# Analyze assets +echo +if [ -d "$TEMP_DIR/assets" ]; then + ASSETS_SIZE=$(du -sh "$TEMP_DIR/assets" | cut -f1) + echo -e " assets/: ${GREEN}$ASSETS_SIZE${NC}" + + # Top asset files + echo " Top asset files:" + find "$TEMP_DIR/assets" -type f -exec du -h {} \; | sort -hr | head -10 | sed 's/^/ /' +fi + +# Analyze native libraries +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Native Libraries${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +if [ -d "$TEMP_DIR/lib" ]; then + LIB_SIZE=$(du -sh "$TEMP_DIR/lib" | cut -f1) + echo -e " lib/: ${GREEN}$LIB_SIZE${NC}" + + # By architecture + for arch in "$TEMP_DIR/lib"/*; do + if [ -d "$arch" ]; then + ARCH_NAME=$(basename "$arch") + ARCH_SIZE=$(du -sh "$arch" | cut -f1) + echo -e " $ARCH_NAME: ${GREEN}$ARCH_SIZE${NC}" + + # Top libraries in architecture + find "$arch" -type f -name "*.so" -exec du -h {} \; | sort -hr | head -5 | sed 's/^/ /' + fi + done +else + echo " No native libraries" +fi + +# Analyze Kotlin metadata +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Kotlin Metadata${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +if [ -d "$TEMP_DIR/kotlin" ]; then + KOTLIN_SIZE=$(du -sh "$TEMP_DIR/kotlin" | cut -f1) + echo -e " kotlin/: ${GREEN}$KOTLIN_SIZE${NC}" +fi + +# Method count (if dexdump available) +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Method Count${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" + +# Try to find dexdump +DEXDUMP=$(which dexdump 2>/dev/null || find "$ANDROID_HOME/build-tools" -name dexdump 2>/dev/null | head -1 || echo "") + +if [ -n "$DEXDUMP" ] && [ -x "$DEXDUMP" ]; then + TOTAL_METHODS=0 + for dex in "$TEMP_DIR"/*.dex; do + if [ -f "$dex" ]; then + DEX_NAME=$(basename "$dex") + METHOD_COUNT=$("$DEXDUMP" -l xml "$dex" | grep -c "/dev/null | awk '{sum+=$1} END {print sum}' || echo "0") +RES_SIZE_BYTES=$(du -s "$TEMP_DIR/res" 2>/dev/null | awk '{print $1}' || echo "0") +ASSETS_SIZE_BYTES=$(du -s "$TEMP_DIR/assets" 2>/dev/null | awk '{print $1}' || echo "0") +LIB_SIZE_BYTES=$(du -s "$TEMP_DIR/lib" 2>/dev/null | awk '{print $1}' || echo "0") + +# Convert to KB for bc +APK_SIZE_KB=$((APK_SIZE_BYTES / 1024)) +CODE_PCT=$(echo "scale=1; $CODE_SIZE * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0") +RES_PCT=$(echo "scale=1; $RES_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0") +ASSETS_PCT=$(echo "scale=1; $ASSETS_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0") +LIB_PCT=$(echo "scale=1; $LIB_SIZE_BYTES * 100 / $APK_SIZE_KB" | bc 2>/dev/null || echo "0") + +echo -e " Code (DEX): ${CODE_PCT}%" +echo -e " Resources: ${RES_PCT}%" +echo -e " Assets: ${ASSETS_PCT}%" +echo -e " Native libs: ${LIB_PCT}%" + +# Recommendations +echo +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" +echo -e "${BLUE} Optimization Recommendations${NC}" +echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}" + +# Check if resources are large +if [ $(echo "$RES_PCT > 30" | bc 2>/dev/null || echo "0") -eq 1 ]; then + echo -e "${YELLOW} • Resources are ${RES_PCT}% of APK - consider:${NC}" + echo " - Enable resource shrinking (shrinkResources = true)" + echo " - Use WebP for images instead of PNG/JPG" + echo " - Remove unused resources" +fi + +# Check if native libs are large +if [ $(echo "$LIB_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then + echo -e "${YELLOW} • Native libraries are ${LIB_PCT}% of APK - consider:${NC}" + echo " - Use App Bundle to serve ABI-specific APKs" + echo " - Remove unused ABIs" +fi + +# Check if code is large +if [ $(echo "$CODE_PCT > 40" | bc 2>/dev/null || echo "0") -eq 1 ]; then + echo -e "${YELLOW} • Code is ${CODE_PCT}% of APK - consider:${NC}" + echo " - Enable Proguard/R8 (minifyEnabled = true)" + echo " - Review dependencies for bloat" + echo " - Enable code shrinking" +fi + +# General recommendations +echo -e "${GREEN} General optimizations:${NC}" +echo " - Use Android App Bundle (.aab) instead of APK" +echo " - Enable R8 optimization (minifyEnabled = true)" +echo " - Enable resource shrinking (shrinkResources = true)" +echo " - Analyze with APK Analyzer in Android Studio" + +# Cleanup +echo +echo -e "${YELLOW}Cleaning up temporary files...${NC}" +rm -rf "$TEMP_DIR" + +echo +echo -e "${GREEN}✓ Analysis complete${NC}" diff --git a/.claude/skills/desktop-expert/SKILL.md b/.claude/skills/desktop-expert/SKILL.md new file mode 100644 index 0000000000..8cbcaaf8af --- /dev/null +++ b/.claude/skills/desktop-expert/SKILL.md @@ -0,0 +1,748 @@ +# Desktop Expert + +Expert in Compose Multiplatform Desktop development for AmethystMultiplatform. Covers Desktop-specific APIs, OS conventions, navigation patterns, and UX principles. + +## When to Use This Skill + +**Auto-invoke when:** +- Working with `desktopApp/` module files +- Using Desktop-only APIs: `Window`, `Tray`, `MenuBar`, `Dialog` +- Implementing keyboard shortcuts, menu systems +- Desktop navigation (NavigationRail, multi-window) +- File system operations (file pickers, drag-drop) +- OS-specific behavior (macOS, Windows, Linux) +- Desktop UX patterns (keyboard-first, tooltips) + +**Delegate to:** +- **kotlin-multiplatform**: Shared code questions, `jvmMain` source set structure +- **gradle-expert**: All `build.gradle.kts` issues, dependency conflicts +- **compose-expert**: General Compose patterns, `@Composable` best practices, Material3 + +## Scope + +**In scope:** +- Desktop-only Compose APIs +- Window management, positioning, state +- MenuBar + keyboard shortcuts (OS-specific) +- System Tray integration +- Desktop navigation patterns (NavigationRail) +- File dialogs, Desktop.getDesktop() +- OS conventions (macOS vs Windows vs Linux) +- Desktop UX principles + +**Out of scope:** +- Build configuration → **gradle-expert** +- Shared composables → **compose-expert** +- KMP structure → **kotlin-multiplatform** + +--- + +## 1. Desktop Entry Point + +### application {} DSL + +Desktop apps start with the `application {}` block: + +```kotlin +// desktopApp/src/jvmMain/kotlin/Main.kt +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 { /* ... */ } + App() + } +} +``` + +**Key points:** +- `application {}` is the root composable (JVM-only) +- `Window()` creates the main window +- `rememberWindowState()` manages size/position +- `onCloseRequest` handles window close + +**See:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:87-138` + +--- + +## 2. Window Management + +### WindowState + +```kotlin +val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) +) + +Window( + state = windowState, + title = "My App", + resizable = true, + onCloseRequest = ::exitApplication +) { + // Content +} +``` + +### Multiple Windows + +```kotlin +fun main() = application { + var showSettings by remember { mutableStateOf(false) } + + Window(onCloseRequest = ::exitApplication, title = "Main") { + Button(onClick = { showSettings = true }) { + Text("Open Settings") + } + } + + if (showSettings) { + Window( + onCloseRequest = { showSettings = false }, + title = "Settings" + ) { + // Settings UI + } + } +} +``` + +**Pattern:** Use state to control window visibility conditionally. + +--- + +## 3. MenuBar System + +### Basic MenuBar + +```kotlin +Window(onCloseRequest = ::exitApplication, title = "App") { + MenuBar { + Menu("File") { + Item("New Note", onClick = { /* ... */ }) + Separator() + Item("Quit", onClick = ::exitApplication) + } + Menu("Edit") { + Item("Copy", onClick = { /* ... */ }) + Item("Paste", onClick = { /* ... */ }) + } + } + App() +} +``` + +### Keyboard Shortcuts (OS-Aware) + +**Current issue:** Main.kt hardcodes `ctrl = true` (Main.kt:105, 111, 117, 122, 123). + +**OS-specific shortcuts:** + +```kotlin +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut + +// Detect OS +val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = if (isMacOS) { + KeyShortcut(Key.N, meta = true) // Cmd+N on macOS + } else { + KeyShortcut(Key.N, ctrl = true) // Ctrl+N on Win/Linux + }, + onClick = { /* ... */ } + ) + Item( + "Settings", + shortcut = if (isMacOS) { + KeyShortcut(Key.Comma, meta = true) // Cmd+, on macOS + } else { + KeyShortcut(Key.Comma, ctrl = true) // Ctrl+, on Win/Linux + }, + onClick = { /* ... */ } + ) + Separator() + Item( + "Quit", + shortcut = if (isMacOS) { + KeyShortcut(Key.Q, meta = true) // Cmd+Q on macOS + } else { + KeyShortcut(Key.Q, ctrl = true) // Ctrl+Q on Win/Linux + }, + onClick = ::exitApplication + ) + } +} +``` + +**Standard shortcuts:** + +| Action | macOS | Windows/Linux | +|--------|-------|---------------| +| New | Cmd+N | Ctrl+N | +| Open | Cmd+O | Ctrl+O | +| Save | Cmd+S | Ctrl+S | +| Quit | Cmd+Q | Ctrl+Q (Alt+F4) | +| Settings | Cmd+, | Ctrl+, | +| Copy | Cmd+C | Ctrl+C | +| Paste | Cmd+V | Ctrl+V | +| Undo | Cmd+Z | Ctrl+Z | + +**See:** `references/keyboard-shortcuts.md` for full list. + +--- + +## 4. System Tray + +### Basic Tray + +```kotlin +application { + var isVisible by remember { mutableStateOf(true) } + + Tray( + icon = painterResource("icon.png"), + onAction = { isVisible = true }, + menu = { + Item("Show", onClick = { isVisible = true }) + Separator() + Item("Quit", onClick = ::exitApplication) + } + ) + + if (isVisible) { + Window( + onCloseRequest = { isVisible = false }, // Minimize to tray + title = "App" + ) { + // Content + } + } +} +``` + +**Pattern:** Hide window to tray instead of closing. + +**Current status:** Not implemented in Main.kt. Planned feature. + +--- + +## 5. Desktop Navigation Patterns + +### NavigationRail (Current Pattern) + +Desktop uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation. + +```kotlin +Row(Modifier.fillMaxSize()) { + // Sidebar + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { currentScreen = AppScreen.Feed } + ) + // More items... + } + + VerticalDivider() + + // Main content area + Box(Modifier.weight(1f).fillMaxHeight()) { + when (currentScreen) { + AppScreen.Feed -> FeedScreen() + // Other screens... + } + } +} +``` + +**See:** Main.kt:191-264 + +**Why NavigationRail?** +- Desktop has horizontal space (1200+ dp width) +- Vertical sidebar is standard desktop pattern +- Always visible (no tabs hidden) +- Icon + label both visible + +**Android comparison:** +- Android: `BottomNavigationBar` (horizontal, bottom) +- Desktop: `NavigationRail` (vertical, left) + +### Multi-Pane Layouts + +Desktop can leverage wide screens: + +```kotlin +Row { + // Left: Navigation + NavigationRail { /* ... */ } + + // Center: Main content + Box(Modifier.weight(0.6f)) { + FeedScreen() + } + + // Right: Details pane (desktop only) + if (selectedNote != null) { + VerticalDivider() + Box(Modifier.weight(0.4f)) { + NoteDetailPane(selectedNote) + } + } +} +``` + +**See:** `references/desktop-navigation.md` + +--- + +## 6. File System Integration + +### File Dialogs + +```kotlin +// File picker (load) +val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD) +fileDialog.isVisible = true +val filePath = fileDialog.file?.let { "${fileDialog.directory}$it" } + +// File picker (save) +val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE) +saveDialog.isVisible = true +val savePath = saveDialog.file?.let { "${saveDialog.directory}$it" } +``` + +**Note:** Compose Desktop doesn't have native file picker composable yet. Use AWT `FileDialog`. + +### Open External URLs + +```kotlin +// jvmMain actual implementation +actual fun openExternalUrl(url: String) { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI(url)) + } +} +``` + +**Pattern:** Define `expect` in `commonMain`, implement `actual` in `jvmMain`. + +### Drag & Drop (Future) + +```kotlin +// Compose Desktop drag-drop (experimental) +Box( + modifier = Modifier + .onExternalDrag( + onDragStart = { /* ... */ }, + onDrag = { /* ... */ }, + onDragExit = { /* ... */ }, + onDrop = { state -> + val dragData = state.dragData + // Handle dropped files + } + ) +) { + Text("Drop files here") +} +``` + +--- + +## 7. OS-Specific Conventions + +### Platform Detection + +```kotlin +val osName = System.getProperty("os.name").lowercase() + +val isMacOS = osName.contains("mac") +val isWindows = osName.contains("win") +val isLinux = osName.contains("nux") || osName.contains("nix") +``` + +### Menu Bar Placement + +| OS | Behavior | +|----|----------| +| **macOS** | System-wide menu bar at top of screen | +| **Windows** | In-window menu bar | +| **Linux** | Varies by desktop environment | + +Compose Desktop `MenuBar` adapts automatically. + +### Keyboard Modifier Keys + +| Modifier | macOS | Windows/Linux | +|----------|-------|---------------| +| Primary | `meta = true` (Cmd) | `ctrl = true` | +| Secondary | `ctrl = true` | `alt = true` | +| Shift | `shift = true` | `shift = true` | + +**Best practice:** Detect OS and use appropriate modifier. + +### System Tray Behavior + +| OS | Tray Location | +|----|---------------| +| **macOS** | Top-right menu bar | +| **Windows** | Bottom-right taskbar | +| **Linux** | Top panel (varies) | + +--- + +## 8. Desktop UX Principles + +### Keyboard-First Design + +**Every action should have:** +1. Mouse/touch interaction +2. Keyboard shortcut (if frequent) +3. Tooltip showing shortcut + +```kotlin +IconButton( + onClick = { /* refresh */ }, + modifier = Modifier.tooltipArea( + tooltip = { + Text("Refresh (${if (isMacOS) "Cmd" else "Ctrl"}+R)") + } + ) +) { + Icon(Icons.Default.Refresh, "Refresh") +} +``` + +### Tooltip Best Practices + +- Show keyboard shortcut in tooltip +- Use native modifier name (Cmd vs Ctrl) +- Brief description + shortcut + +### Context Menus + +Right-click should show context menu: + +```kotlin +// Future: Compose Desktop context menu API +Box( + modifier = Modifier.contextMenuArea( + items = { + listOf( + ContextMenuItem("Copy") { /* ... */ }, + ContextMenuItem("Paste") { /* ... */ } + ) + } + ) +) { + // Content +} +``` + +**Current:** Use popup or custom implementation. + +### Window State Persistence + +Save/restore window size/position: + +```kotlin +// Save on close +windowState.size // DpSize +windowState.position // WindowPosition + +// Restore on launch +val savedWidth = preferences.getInt("window.width", 1200) +val savedHeight = preferences.getInt("window.height", 800) + +val windowState = rememberWindowState( + width = savedWidth.dp, + height = savedHeight.dp +) +``` + +--- + +## 9. Desktop Module Structure + +``` +desktopApp/ +├── build.gradle.kts # Desktop-only build config +└── src/ + └── jvmMain/ + ├── kotlin/ + │ └── com/vitorpamplona/amethyst/desktop/ + │ ├── Main.kt # Entry point, Window, MenuBar + │ ├── network/ + │ │ ├── DesktopHttpClient.kt + │ │ └── DesktopRelayConnectionManager.kt + │ └── ui/ + │ ├── FeedScreen.kt # Desktop screen layouts + │ └── LoginScreen.kt + └── resources/ + ├── icon.icns # macOS icon + ├── icon.ico # Windows icon + └── icon.png # Linux icon +``` + +**Key files:** +- `Main.kt:87-138` - `application {}`, `Window`, `MenuBar` +- `Main.kt:183-264` - NavigationRail pattern +- `build.gradle.kts:45-73` - Desktop packaging config + +--- + +## 10. Packaging & Distribution + +### Build Configuration + +```kotlin +// desktopApp/build.gradle.kts +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + + packageName = "Amethyst" + packageVersion = "1.0.0" + + 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" + } + + linux { + iconFile.set(project.file("src/jvmMain/resources/icon.png")) + } + } + } +} +``` + +**See:** desktopApp/build.gradle.kts:45-73 + +### Gradle Tasks + +```bash +# Run desktop app +./gradlew :desktopApp:run + +# Package for distribution +./gradlew :desktopApp:packageDmg # macOS +./gradlew :desktopApp:packageMsi # Windows +./gradlew :desktopApp:packageDeb # Linux +``` + +**Delegate packaging issues to gradle-expert.** + +--- + +## Common Patterns + +### Pattern: OS-Aware Shortcuts Helper + +```kotlin +// commons/src/jvmMain/kotlin/shortcuts/ShortcutUtils.kt +object DesktopShortcuts { + private val isMacOS = System.getProperty("os.name") + .lowercase().contains("mac") + + fun primary(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + } + + fun primaryShift(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, shift = true) + } else { + KeyShortcut(key, ctrl = true, shift = true) + } + + val modifierName = if (isMacOS) "Cmd" else "Ctrl" +} + +// Usage in MenuBar +Item( + "New Note", + shortcut = DesktopShortcuts.primary(Key.N), + onClick = { /* ... */ } +) +``` + +### Pattern: Shared Composables, Platform Layouts + +```kotlin +// commons/commonMain - Shared NoteCard +@Composable +fun NoteCard(note: NoteDisplayData) { + // Business logic, UI component (shared) +} + +// desktopApp/jvmMain - Desktop layout +@Composable +fun FeedScreen() { + Column { + FeedHeader(/* ... */) // Shared from commons + LazyColumn { + items(notes) { note -> + NoteCard(note) // Shared composable + } + } + } +} + +// amethyst/androidMain - Android layout +@Composable +fun FeedScreen() { + Scaffold( + bottomBar = { BottomNavigationBar() } // Android-specific + ) { + LazyColumn { + items(notes) { note -> + NoteCard(note) // Same shared composable + } + } + } +} +``` + +**Philosophy:** Share UI components (cards, buttons), keep navigation/layout platform-specific. + +--- + +## Resources + +### Official Documentation +- [Desktop-only API | Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html) +- [Top-level windows management](https://kotlinlang.org/docs/multiplatform/compose-desktop-top-level-windows-management.html) +- [Tray/MenuBar Tutorial](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/Tray_Notifications_MenuBar_new/README.md) + +### Bundled References +- `references/desktop-compose-apis.md` - Complete Desktop API catalog +- `references/desktop-navigation.md` - NavigationRail vs BottomNav patterns +- `references/keyboard-shortcuts.md` - Standard shortcuts by OS +- `references/os-detection.md` - Platform detection patterns + +### Codebase Examples +- Main.kt:87-138 - Window, MenuBar entry point +- Main.kt:183-264 - NavigationRail pattern +- FeedScreen.kt:49-136 - Desktop screen layout +- LoginScreen.kt:44-97 - Centered desktop login + +--- + +## Questions to Ask + +When working on desktop features: + +1. **Should this be shared or desktop-only?** + - Business logic → Share in `commonMain` + - Navigation/layout → Keep in `desktopApp/jvmMain` + +2. **Does this need OS-specific behavior?** + - Keyboard shortcuts → Yes (Cmd vs Ctrl) + - File paths → Yes (separators) + - Icons → Yes (per-OS formats) + +3. **Is there a desktop UX convention?** + - Check MenuBar standards + - Consider keyboard-first design + - Tooltips for all actions + +4. **Does this need gradle-expert?** + - Any `build.gradle.kts` changes → Delegate + - Packaging/distribution issues → Delegate + +--- + +## Anti-Patterns + +❌ **Hardcoding Ctrl everywhere** +```kotlin +// Main.kt:105 - Current issue +shortcut = KeyShortcut(Key.N, ctrl = true) // Wrong on macOS +``` + +✅ **OS-aware shortcuts** +```kotlin +shortcut = DesktopShortcuts.primary(Key.N) +``` + +--- + +❌ **Using Android navigation on Desktop** +```kotlin +Scaffold(bottomBar = { BottomNavigationBar() }) // Wrong for desktop +``` + +✅ **NavigationRail for desktop** +```kotlin +Row { + NavigationRail { /* ... */ } + MainContent() +} +``` + +--- + +❌ **No keyboard shortcuts** +```kotlin +IconButton(onClick = { refresh() }) { + Icon(Icons.Default.Refresh, "Refresh") +} +``` + +✅ **Shortcuts + tooltips** +```kotlin +IconButton( + onClick = { refresh() }, + modifier = Modifier.tooltipArea("Refresh (Cmd+R)") +) { + Icon(Icons.Default.Refresh, "Refresh") +} +``` + +--- + +## Next Steps + +When implementing desktop features: + +1. **Read** `references/desktop-compose-apis.md` for API catalog +2. **Check** `references/keyboard-shortcuts.md` for standard shortcuts +3. **Reference** Main.kt:87-264 for current patterns +4. **Test** on all 3 platforms (macOS, Windows, Linux) if possible +5. **Delegate** build issues to gradle-expert +6. **Share** UI components via compose-expert, not desktop-expert + +--- + +**Version:** 1.0.0 +**Last Updated:** 2025-12-30 +**Codebase Reference:** AmethystMultiplatform commit 258c4e011 diff --git a/.claude/skills/desktop-expert/references/desktop-compose-apis.md b/.claude/skills/desktop-expert/references/desktop-compose-apis.md new file mode 100644 index 0000000000..bcb773321f --- /dev/null +++ b/.claude/skills/desktop-expert/references/desktop-compose-apis.md @@ -0,0 +1,597 @@ +# Desktop Compose APIs Catalog + +Complete reference for Compose Multiplatform Desktop-only APIs. + +## Window Management + +### application + +Root entry point for desktop apps. + +```kotlin +fun main() = application { + Window(onCloseRequest = ::exitApplication) { + Text("Hello Desktop") + } +} +``` + +### Window + +Creates a window. + +```kotlin +Window( + onCloseRequest: () -> Unit, + state: WindowState = rememberWindowState(), + visible: Boolean = true, + title: String = "Untitled", + icon: Painter? = null, + undecorated: Boolean = false, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false }, + onKeyEvent: ((KeyEvent) -> Boolean) = { false }, + content: @Composable FrameWindowScope.() -> Unit +) +``` + +**Example:** +```kotlin +val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) +) + +Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "My App", + resizable = true +) { + // Content +} +``` + +### rememberWindowState + +Manages window size and position. + +```kotlin +@Composable +fun rememberWindowState( + placement: WindowPlacement = WindowPlacement.Floating, + isMinimized: Boolean = false, + position: WindowPosition = WindowPosition.PlatformDefault, + width: Dp = Dp.Unspecified, + height: Dp = Dp.Unspecified +): WindowState +``` + +**WindowPlacement:** +- `Floating` - Normal window +- `Maximized` - Fullscreen +- `Fullscreen` - Fullscreen without decorations + +**WindowPosition:** +- `PlatformDefault` - OS decides +- `Aligned(alignment)` - Center, TopStart, etc. +- `Absolute(x, y)` - Fixed position in pixels + +### DialogWindow + +Modal dialog. + +```kotlin +DialogWindow( + onCloseRequest: () -> Unit, + state: DialogState = rememberDialogState(), + visible: Boolean = true, + title: String = "Dialog", + icon: Painter? = null, + undecorated: Boolean = false, + transparent: Boolean = false, + resizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + content: @Composable DialogWindowScope.() -> Unit +) +``` + +**Example:** +```kotlin +var showDialog by remember { mutableStateOf(false) } + +if (showDialog) { + DialogWindow( + onCloseRequest = { showDialog = false }, + title = "Confirm" + ) { + Column(Modifier.padding(16.dp)) { + Text("Are you sure?") + Row { + Button(onClick = { showDialog = false }) { + Text("Cancel") + } + Button(onClick = { /* confirm */ }) { + Text("OK") + } + } + } + } +} +``` + +--- + +## MenuBar + +### MenuBar + +Native menu bar for windows. + +```kotlin +@Composable +fun FrameWindowScope.MenuBar( + content: @Composable MenuBarScope.() -> Unit +) +``` + +**Example:** +```kotlin +Window(onCloseRequest = ::exitApplication) { + MenuBar { + Menu("File") { + Item("New", onClick = { /* ... */ }) + Item("Open", onClick = { /* ... */ }) + Separator() + Item("Quit", onClick = ::exitApplication) + } + Menu("Edit") { + Item("Copy", onClick = { /* ... */ }) + Item("Paste", onClick = { /* ... */ }) + } + } +} +``` + +### Menu + +Top-level menu. + +```kotlin +@Composable +fun MenuBarScope.Menu( + text: String, + mnemonic: Char? = null, + enabled: Boolean = true, + content: @Composable MenuScope.() -> Unit +) +``` + +### Item + +Menu item. + +```kotlin +@Composable +fun MenuScope.Item( + text: String, + onClick: () -> Unit, + shortcut: KeyShortcut? = null, + mnemonic: Char? = null, + enabled: Boolean = true, + icon: Painter? = null +) +``` + +**With keyboard shortcut:** +```kotlin +Item( + text = "Save", + onClick = { save() }, + shortcut = KeyShortcut(Key.S, ctrl = true), + icon = painterResource("save.png") +) +``` + +### Separator + +Menu separator line. + +```kotlin +@Composable +fun MenuScope.Separator() +``` + +### CheckboxItem + +Toggleable menu item. + +```kotlin +@Composable +fun MenuScope.CheckboxItem( + text: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + shortcut: KeyShortcut? = null, + mnemonic: Char? = null, + enabled: Boolean = true +) +``` + +**Example:** +```kotlin +var darkMode by remember { mutableStateOf(false) } + +Menu("View") { + CheckboxItem( + text = "Dark Mode", + checked = darkMode, + onCheckedChange = { darkMode = it }, + shortcut = KeyShortcut(Key.D, ctrl = true) + ) +} +``` + +### RadioButtonItem + +Radio button menu item. + +```kotlin +@Composable +fun MenuScope.RadioButtonItem( + text: String, + selected: Boolean, + onClick: () -> Unit, + shortcut: KeyShortcut? = null, + mnemonic: Char? = null, + enabled: Boolean = true +) +``` + +--- + +## System Tray + +### Tray + +System tray icon with menu. + +```kotlin +@Composable +fun ApplicationScope.Tray( + icon: Painter, + state: TrayState = rememberTrayState(), + tooltip: String? = null, + onAction: () -> Unit = {}, + menu: @Composable MenuScope.() -> Unit = {} +) +``` + +**Example:** +```kotlin +application { + var isVisible by remember { mutableStateOf(true) } + + Tray( + icon = painterResource("tray-icon.png"), + tooltip = "My App", + onAction = { isVisible = true }, + menu = { + Item("Show Window", onClick = { isVisible = true }) + Separator() + Item("Quit", onClick = ::exitApplication) + } + ) + + if (isVisible) { + Window( + onCloseRequest = { isVisible = false }, + title = "App" + ) { + // Content + } + } +} +``` + +### rememberTrayState + +Manages tray state. + +```kotlin +@Composable +fun rememberTrayState(): TrayState +``` + +--- + +## Notifications + +### Notification (via Tray) + +Show desktop notifications through tray. + +```kotlin +val trayState = rememberTrayState() + +Tray( + icon = painterResource("icon.png"), + state = trayState +) + +// Send notification +LaunchedEffect(Unit) { + trayState.sendNotification( + Notification( + title = "Message", + message = "You have a new message", + type = Notification.Type.Info + ) + ) +} +``` + +**Notification types:** +- `Info` - Information +- `Warning` - Warning +- `Error` - Error + +--- + +## Keyboard + +### KeyShortcut + +Keyboard shortcut definition. + +```kotlin +data class KeyShortcut( + val key: Key, + val ctrl: Boolean = false, + val meta: Boolean = false, + val alt: Boolean = false, + val shift: Boolean = false +) +``` + +**Examples:** +```kotlin +// Ctrl+S (Windows/Linux) +KeyShortcut(Key.S, ctrl = true) + +// Cmd+S (macOS) +KeyShortcut(Key.S, meta = true) + +// Ctrl+Shift+N +KeyShortcut(Key.N, ctrl = true, shift = true) + +// Alt+F4 +KeyShortcut(Key.F4, alt = true) +``` + +### onPreviewKeyEvent / onKeyEvent + +Window-level keyboard handlers. + +```kotlin +Window( + onCloseRequest = ::exitApplication, + onPreviewKeyEvent = { event -> + if (event.key == Key.Escape && event.type == KeyEventType.KeyDown) { + // Handle Escape + true // Consume event + } else { + false // Propagate + } + } +) { + // Content +} +``` + +--- + +## Mouse + +### PointerMoveFilter (Deprecated, use Modifier.pointerInput) + +```kotlin +Box( + modifier = Modifier + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + // Handle mouse events + } + } + } +) +``` + +### Mouse cursor + +```kotlin +Box( + modifier = Modifier.pointerHoverIcon( + icon = PointerIcon(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)) + ) +) { + Text("Hover me") +} +``` + +**Cursor types:** +- `DEFAULT_CURSOR` +- `HAND_CURSOR` +- `TEXT_CURSOR` +- `CROSSHAIR_CURSOR` +- `WAIT_CURSOR` +- `MOVE_CURSOR` +- `E_RESIZE_CURSOR`, `W_RESIZE_CURSOR`, etc. + +--- + +## Drag & Drop (Experimental) + +### onExternalDrag + +Handle drag-and-drop from external sources. + +```kotlin +Box( + modifier = Modifier + .size(200.dp) + .background(Color.LightGray) + .onExternalDrag( + onDragStart = { externalDragValue -> + println("Drag started") + }, + onDrag = { externalDragValue -> + println("Dragging: ${externalDragValue.dragData}") + }, + onDragExit = { + println("Drag exited") + }, + onDrop = { externalDragValue -> + val dragData = externalDragValue.dragData + when (dragData) { + is DragData.FilesList -> { + println("Files dropped: ${dragData.readFiles()}") + } + is DragData.Text -> { + println("Text dropped: ${dragData.readText()}") + } + else -> {} + } + } + ) +) { + Text("Drop files here", Modifier.align(Alignment.Center)) +} +``` + +--- + +## Resources + +### painterResource + +Load images from resources. + +```kotlin +val icon = painterResource("icon.png") + +Icon( + painter = icon, + contentDescription = "App icon" +) +``` + +**Resource location:** `src/jvmMain/resources/` + +--- + +## Platform Integration + +### Desktop.getDesktop() (AWT) + +Access system desktop features (not Compose API, but commonly used). + +```kotlin +import java.awt.Desktop +import java.net.URI + +// Open URL in browser +if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI("https://example.com")) +} + +// Open file with default app +Desktop.getDesktop().open(File("/path/to/file.pdf")) + +// Open email client +Desktop.getDesktop().mail(URI("mailto:user@example.com")) +``` + +### FileDialog (AWT) + +File picker dialogs. + +```kotlin +import java.awt.FileDialog +import java.awt.Frame + +// Open file +val fileDialog = FileDialog(Frame(), "Select file", FileDialog.LOAD) +fileDialog.isVisible = true +val selectedFile = fileDialog.file +val directory = fileDialog.directory + +// Save file +val saveDialog = FileDialog(Frame(), "Save file", FileDialog.SAVE) +saveDialog.file = "document.txt" +saveDialog.isVisible = true +``` + +--- + +## SwingPanel (Interop) + +Embed Swing components in Compose. + +```kotlin +import androidx.compose.ui.awt.SwingPanel +import javax.swing.JButton + +SwingPanel( + factory = { + JButton("Swing Button").apply { + addActionListener { + println("Swing button clicked") + } + } + }, + modifier = Modifier.size(200.dp, 50.dp) +) +``` + +--- + +## ComposePanel (Reverse Interop) + +Embed Compose in Swing. + +```kotlin +import androidx.compose.ui.awt.ComposePanel +import javax.swing.JFrame + +val frame = JFrame("Swing Frame") +val composePanel = ComposePanel() + +composePanel.setContent { + Text("Compose in Swing") +} + +frame.contentPane.add(composePanel) +frame.setSize(400, 300) +frame.isVisible = true +``` + +--- + +## Version Requirements + +- **Kotlin:** 2.0+ +- **Compose Multiplatform:** 1.7.0+ +- **JVM Target:** 11+ (recommend 21) + +**See also:** +- [Official Desktop API docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-components.html) +- [Compose Multiplatform repo](https://github.com/JetBrains/compose-multiplatform) diff --git a/.claude/skills/desktop-expert/references/desktop-navigation.md b/.claude/skills/desktop-expert/references/desktop-navigation.md new file mode 100644 index 0000000000..01d1927678 --- /dev/null +++ b/.claude/skills/desktop-expert/references/desktop-navigation.md @@ -0,0 +1,464 @@ +# Desktop Navigation Patterns + +Comparison of mobile vs desktop navigation patterns in AmethystMultiplatform. + +## Core Difference + +| Platform | Pattern | Location | Rationale | +|----------|---------|----------|-----------| +| **Android** | Bottom Navigation Bar | Horizontal, bottom | Thumb reach on mobile | +| **Desktop** | Navigation Rail | Vertical, left sidebar | Horizontal screen space | + +--- + +## Desktop: NavigationRail + +### Current Implementation + +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:191-264` + +```kotlin +@Composable +fun MainContent( + currentScreen: AppScreen, + onScreenChange: (AppScreen) -> Unit, + // ... +) { + Row(Modifier.fillMaxSize()) { + // LEFT: Vertical Sidebar (NavigationRail) + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + Spacer(Modifier.height(16.dp)) + + // Top navigation items + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, "Search") }, + label = { Text("Search") }, + selected = currentScreen == AppScreen.Search, + onClick = { onScreenChange(AppScreen.Search) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == AppScreen.Messages, + onClick = { onScreenChange(AppScreen.Messages) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == AppScreen.Notifications, + onClick = { onScreenChange(AppScreen.Notifications) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == AppScreen.Profile, + onClick = { onScreenChange(AppScreen.Profile) } + ) + + // Push Settings to bottom + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == AppScreen.Settings, + onClick = { onScreenChange(AppScreen.Settings) } + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // RIGHT: Main Content Area + 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) + } + } + } +} +``` + +### Layout Structure + +``` +┌────────────────────────────────────────┐ +│ [Menu Bar: File, Edit, View, Help] │ ← MenuBar (OS-native) +├──────┬─────────────────────────────────┤ +│ │ │ +│ [🏠] │ │ +│ Feed │ │ +│ │ │ +│ [🔍] │ Main Content Area │ +│Search│ (Feed, Messages, etc.) │ +│ │ │ +│ [✉️] │ │ +│ DMs │ │ +│ │ │ +│ [🔔] │ │ +│Alerts│ │ +│ │ │ +│ [👤] │ │ +│Profile │ +│ │ │ +│ ─ │ │ +│ [⚙️] │ │ +│Settings │ +│ │ │ +└──────┴─────────────────────────────────┘ + 80dp Remaining width (weight=1f) +``` + +### Key Features + +1. **Always visible:** All nav items visible at once +2. **Icon + Label:** Both shown (not just icons) +3. **Vertical list:** Natural reading order +4. **Settings at bottom:** Separated by divider + Spacer.weight(1f) +5. **80dp width:** Standard NavigationRail width + +--- + +## Android: BottomNavigationBar (Future) + +### Expected Implementation + +**Location:** `amethyst/src/androidMain/kotlin/...` (not yet implemented) + +```kotlin +@Composable +fun MainScreen( + currentScreen: AppScreen, + onScreenChange: (AppScreen) -> Unit +) { + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Search, "Search") }, + label = { Text("Search") }, + selected = currentScreen == AppScreen.Search, + onClick = { onScreenChange(AppScreen.Search) } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Email, "Messages") }, + label = { Text("Messages") }, + selected = currentScreen == AppScreen.Messages, + onClick = { onScreenChange(AppScreen.Messages) } + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Person, "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == AppScreen.Profile, + onClick = { onScreenChange(AppScreen.Profile) } + ) + } + } + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + when (currentScreen) { + AppScreen.Feed -> FeedScreen() + AppScreen.Search -> SearchScreen() + AppScreen.Messages -> MessagesScreen() + AppScreen.Profile -> ProfileScreen() + // Settings accessed via Profile or overflow menu + } + } + } +} +``` + +### Layout Structure + +``` +┌─────────────────────────────────────┐ +│ │ +│ │ +│ Main Content Area │ +│ (Feed, Messages, etc.) │ +│ │ +│ │ +│ │ +├─────────────────────────────────────┤ +│ [🏠] [🔍] [✉️] [👤] │ ← NavigationBar +│ Feed Search DMs Profile │ +└─────────────────────────────────────┘ +``` + +### Key Differences from Desktop + +1. **Bottom placement:** Thumb reach +2. **Horizontal layout:** Limited vertical space +3. **Fewer items:** 3-5 primary destinations +4. **Label optional:** Can hide on small screens +5. **Settings hidden:** In profile or overflow + +--- + +## Shared Navigation State + +Both platforms use the same `AppScreen` enum from `commons`. + +**File:** `commons/src/commonMain/kotlin/.../navigation/AppScreen.kt` (expected) + +```kotlin +// Shared navigation destinations +enum class AppScreen { + Feed, + Search, + Messages, + Notifications, + Profile, + Settings +} +``` + +**State management (shared):** + +```kotlin +// commons/src/jvmAndroid/kotlin/.../navigation/NavigationViewModel.kt +class NavigationViewModel : ViewModel() { + private val _currentScreen = MutableStateFlow(AppScreen.Feed) + val currentScreen: StateFlow = _currentScreen.asStateFlow() + + fun navigateTo(screen: AppScreen) { + _currentScreen.value = screen + } +} +``` + +--- + +## Multi-Pane Desktop Layout (Advanced) + +Desktop can utilize horizontal space for multi-pane layouts. + +### Two-Pane Layout + +```kotlin +Row(Modifier.fillMaxSize()) { + // Left: NavigationRail (fixed 80dp) + NavigationRail { /* ... */ } + + VerticalDivider() + + // Center: Main content (60% width) + Box(Modifier.weight(0.6f)) { + FeedScreen() + } + + // Right: Detail pane (40% width, conditional) + if (selectedNote != null) { + VerticalDivider() + Box(Modifier.weight(0.4f)) { + NoteDetailPane(selectedNote) + } + } +} +``` + +### Layout: + +``` +┌──────┬───────────────────┬─────────────┐ +│ │ │ │ +│ Nav │ Feed List │ Detail │ +│ Rail │ (60%) │ Pane │ +│ │ │ (40%) │ +│ │ │ │ +└──────┴───────────────────┴─────────────┘ + 80dp weight(0.6f) weight(0.4f) +``` + +**Use cases:** +- Email: List + message detail +- Notes: List + editor +- Settings: Categories + options + +--- + +## Keyboard Navigation + +Desktop should support keyboard navigation. + +### Tab Navigation + +```kotlin +NavigationRail( + modifier = Modifier.focusable() +) { + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) }, + modifier = Modifier.focusable() + ) + // More items... +} +``` + +### Keyboard Shortcuts + +```kotlin +Window( + onPreviewKeyEvent = { event -> + when { + event.key == Key.One && event.isCtrlPressed -> + onScreenChange(AppScreen.Feed).also { true } + event.key == Key.Two && event.isCtrlPressed -> + onScreenChange(AppScreen.Search).also { true } + event.key == Key.Three && event.isCtrlPressed -> + onScreenChange(AppScreen.Messages).also { true } + else -> false + } + } +) { + // Content +} +``` + +**Standard:** +- Ctrl+1: First nav item (Feed) +- Ctrl+2: Second nav item (Search) +- Ctrl+3: Third nav item (Messages) +- Ctrl+Comma: Settings + +--- + +## Navigation Transitions + +### Desktop (Instant) + +No fancy animations. Instant switch. + +```kotlin +Box { + when (currentScreen) { + AppScreen.Feed -> FeedScreen() + AppScreen.Search -> SearchScreen() + } +} +``` + +### Android (Animated, Future) + +Can use Navigation Compose for transitions. + +```kotlin +NavHost(navController, startDestination = "feed") { + composable("feed") { FeedScreen() } + composable("search") { SearchScreen() } +} +``` + +--- + +## Best Practices + +### Desktop NavigationRail + +✅ **DO:** +- Keep width 72-80dp +- Show both icon and label +- Use Spacer.weight(1f) for bottom items +- Separate sections with HorizontalDivider +- Limit to 5-7 primary items + +❌ **DON'T:** +- Use bottom navigation on desktop +- Hide labels (plenty of space) +- Make it collapsible (not standard) +- Use hamburger menu (not desktop pattern) + +### Android NavigationBar + +✅ **DO:** +- Limit to 3-5 items +- Use bottom placement +- Consider label visibility on small screens +- Use standard icons + +❌ **DON'T:** +- Put more than 5 items +- Use top placement (deprecated) +- Put critical actions only in nav bar + +--- + +## Migration Strategy + +When adding Android support: + +1. **Extract shared state:** Move `AppScreen` to `commons/commonMain` +2. **Platform layouts:** Keep `NavigationRail` in `desktopApp/jvmMain`, `NavigationBar` in `amethyst/androidMain` +3. **Shared screens:** Composables in `commons/commonMain` (FeedScreen content) +4. **Platform chrome:** Navigation containers in platform modules + +**Example:** + +```kotlin +// commons/commonMain - Shared screen content +@Composable +fun FeedContent(notes: List) { + LazyColumn { + items(notes) { note -> + NoteCard(note) + } + } +} + +// desktopApp/jvmMain - Desktop wrapper +@Composable +fun FeedScreen() { + Column { + FeedHeader() // Desktop-specific header + FeedContent(notes) // Shared content + } +} + +// amethyst/androidMain - Android wrapper +@Composable +fun FeedScreen() { + Scaffold( + topBar = { TopAppBar { Text("Feed") } } + ) { + FeedContent(notes) // Same shared content + } +} +``` + +--- + +## References + +- **Current Desktop:** Main.kt:191-264 +- **Material3 NavigationRail:** [Material Design Docs](https://m3.material.io/components/navigation-rail) +- **Material3 NavigationBar:** [Material Design Docs](https://m3.material.io/components/navigation-bar) diff --git a/.claude/skills/desktop-expert/references/keyboard-shortcuts.md b/.claude/skills/desktop-expert/references/keyboard-shortcuts.md new file mode 100644 index 0000000000..fd9257ef4f --- /dev/null +++ b/.claude/skills/desktop-expert/references/keyboard-shortcuts.md @@ -0,0 +1,400 @@ +# Keyboard Shortcuts Reference + +Standard keyboard shortcuts for desktop applications across macOS, Windows, and Linux. + +## Primary Modifier Keys + +| Platform | Primary | Secondary | Tertiary | +|----------|---------|-----------|----------| +| **macOS** | Cmd (⌘) / `meta` | Option (⌥) / `alt` | Ctrl (⌃) / `ctrl` | +| **Windows** | Ctrl / `ctrl` | Alt / `alt` | Win / `meta` | +| **Linux** | Ctrl / `ctrl` | Alt / `alt` | Super / `meta` | + +**In Compose Desktop:** + +```kotlin +// macOS +KeyShortcut(Key.N, meta = true) // Cmd+N + +// Windows/Linux +KeyShortcut(Key.N, ctrl = true) // Ctrl+N +``` + +--- + +## File Operations + +| Action | macOS | Windows | Linux | Notes | +|--------|-------|---------|-------|-------| +| **New** | Cmd+N | Ctrl+N | Ctrl+N | Create new | +| **Open** | Cmd+O | Ctrl+O | Ctrl+O | Open file | +| **Save** | Cmd+S | Ctrl+S | Ctrl+S | Save current | +| **Save As** | Cmd+Shift+S | Ctrl+Shift+S | Ctrl+Shift+S | Save with new name | +| **Close** | Cmd+W | Ctrl+W | Ctrl+W | Close window/tab | +| **Quit** | Cmd+Q | Ctrl+Q | Ctrl+Q | Exit app | +| **Print** | Cmd+P | Ctrl+P | Ctrl+P | Print | + +**Compose Implementation:** + +```kotlin +val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = if (isMacOS) { + KeyShortcut(Key.N, meta = true) + } else { + KeyShortcut(Key.N, ctrl = true) + }, + onClick = { createNewNote() } + ) + Item( + "Save", + shortcut = if (isMacOS) { + KeyShortcut(Key.S, meta = true) + } else { + KeyShortcut(Key.S, ctrl = true) + }, + onClick = { save() } + ) + Separator() + Item( + "Quit", + shortcut = if (isMacOS) { + KeyShortcut(Key.Q, meta = true) + } else { + KeyShortcut(Key.Q, ctrl = true) + }, + onClick = ::exitApplication + ) + } +} +``` + +--- + +## Edit Operations + +| Action | macOS | Windows | Linux | Notes | +|--------|-------|---------|-------|-------| +| **Undo** | Cmd+Z | Ctrl+Z | Ctrl+Z | Universal | +| **Redo** | Cmd+Shift+Z | Ctrl+Y | Ctrl+Y | Windows/Linux use Y | +| **Cut** | Cmd+X | Ctrl+X | Ctrl+X | Universal | +| **Copy** | Cmd+C | Ctrl+C | Ctrl+C | Universal | +| **Paste** | Cmd+V | Ctrl+V | Ctrl+V | Universal | +| **Select All** | Cmd+A | Ctrl+A | Ctrl+A | Universal | +| **Find** | Cmd+F | Ctrl+F | Ctrl+F | Search | +| **Find Next** | Cmd+G | F3 | F3 | Next result | +| **Replace** | Cmd+Option+F | Ctrl+H | Ctrl+H | Find & replace | + +**Note:** Undo/Redo typically handled by text fields automatically. + +--- + +## Navigation + +| Action | macOS | Windows | Linux | Notes | +|--------|-------|---------|-------|-------| +| **Tab 1** | Cmd+1 | Ctrl+1 | Ctrl+1 | First tab/view | +| **Tab 2** | Cmd+2 | Ctrl+2 | Ctrl+2 | Second tab/view | +| **Tab 3** | Cmd+3 | Ctrl+3 | Ctrl+3 | Third tab/view | +| **Next Tab** | Cmd+Option+→ | Ctrl+Tab | Ctrl+Tab | Cycle forward | +| **Prev Tab** | Cmd+Option+← | Ctrl+Shift+Tab | Ctrl+Shift+Tab | Cycle back | +| **Go Back** | Cmd+[ | Alt+← | Alt+← | Browser-style | +| **Go Forward** | Cmd+] | Alt+→ | Alt+→ | Browser-style | + +**Compose Implementation:** + +```kotlin +Window( + onPreviewKeyEvent = { event -> + if (event.type == KeyEventType.KeyDown) { + when { + event.key == Key.One && event.isPrimaryPressed() -> { + navigateTo(AppScreen.Feed) + true + } + event.key == Key.Two && event.isPrimaryPressed() -> { + navigateTo(AppScreen.Search) + true + } + event.key == Key.Three && event.isPrimaryPressed() -> { + navigateTo(AppScreen.Messages) + true + } + else -> false + } + } else false + } +) { + // Content +} + +// Helper extension +fun KeyEvent.isPrimaryPressed() = if (isMacOS) isMetaPressed else isCtrlPressed +``` + +--- + +## Window Management + +| Action | macOS | Windows | Linux | Notes | +|--------|-------|---------|-------|-------| +| **New Window** | Cmd+N | Ctrl+N | Ctrl+N | New instance | +| **Close Window** | Cmd+W | Alt+F4 | Alt+F4 | Close current | +| **Minimize** | Cmd+M | Win+Down | Super+Down | Minimize to dock/taskbar | +| **Maximize** | Cmd+Ctrl+F | Win+Up | Super+Up | Fullscreen/maximize | +| **Hide App** | Cmd+H | - | - | macOS only | +| **Switch Window** | Cmd+` | Alt+Tab | Alt+Tab | Between app windows | + +**Note:** Window management often handled by OS, not app shortcuts. + +--- + +## App-Specific (Amethyst) + +### Nostr Actions + +| Action | macOS | Windows | Linux | Description | +|--------|-------|---------|-------|-------------| +| **New Note** | Cmd+N | Ctrl+N | Ctrl+N | Compose new post | +| **Refresh Feed** | Cmd+R | Ctrl+R | Ctrl+R | Reload timeline | +| **Search** | Cmd+K | Ctrl+K | Ctrl+K | Quick search | +| **DMs** | Cmd+Shift+M | Ctrl+Shift+M | Ctrl+Shift+M | Open messages | +| **Settings** | Cmd+, | Ctrl+, | Ctrl+, | Open preferences | +| **Notifications** | Cmd+Shift+N | Ctrl+Shift+N | Ctrl+Shift+N | View alerts | + +**Implementation:** + +```kotlin +MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = DesktopShortcuts.primary(Key.N), + onClick = { showComposeDialog() } + ) + Item( + "Settings", + shortcut = DesktopShortcuts.primary(Key.Comma), + onClick = { navigateTo(AppScreen.Settings) } + ) + } + Menu("View") { + Item( + "Refresh Feed", + shortcut = DesktopShortcuts.primary(Key.R), + onClick = { refreshFeed() } + ) + Item( + "Search", + shortcut = DesktopShortcuts.primary(Key.K), + onClick = { focusSearch() } + ) + } +} +``` + +--- + +## Accessibility + +| Action | macOS | Windows | Linux | Description | +|--------|-------|---------|-------|-------------| +| **Zoom In** | Cmd++ | Ctrl++ | Ctrl++ | Increase size | +| **Zoom Out** | Cmd+- | Ctrl+- | Ctrl+- | Decrease size | +| **Reset Zoom** | Cmd+0 | Ctrl+0 | Ctrl+0 | Default size | +| **Help** | Cmd+? | F1 | F1 | Show help | + +--- + +## Best Practices + +### 1. OS-Aware Helper + +Create a utility for OS detection: + +```kotlin +// commons/src/jvmMain/kotlin/utils/PlatformShortcuts.kt +object DesktopShortcuts { + private val isMacOS = System.getProperty("os.name") + .lowercase() + .contains("mac") + + fun primary(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + } + + fun primaryShift(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, shift = true) + } else { + KeyShortcut(key, ctrl = true, shift = true) + } + + fun primaryAlt(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, alt = true) + } else { + KeyShortcut(key, ctrl = true, alt = true) + } + + val modifierName = if (isMacOS) "Cmd" else "Ctrl" + val secondaryName = if (isMacOS) "Option" else "Alt" +} +``` + +**Usage:** + +```kotlin +Item( + "Save", + shortcut = DesktopShortcuts.primary(Key.S), + onClick = { save() } +) +``` + +### 2. Show Shortcuts in Tooltips + +```kotlin +IconButton( + onClick = { refresh() }, + modifier = Modifier.tooltipArea { + Text("Refresh (${DesktopShortcuts.modifierName}+R)") + } +) { + Icon(Icons.Default.Refresh, "Refresh") +} +``` + +### 3. Shortcuts Menu + +Provide a "Keyboard Shortcuts" help menu: + +```kotlin +Menu("Help") { + Item("Keyboard Shortcuts", onClick = { showShortcutsDialog() }) +} + +// Dialog content +@Composable +fun ShortcutsDialog() { + Dialog(onDismissRequest = { /* close */ }) { + Surface { + Column(Modifier.padding(16.dp)) { + Text("Keyboard Shortcuts", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(16.dp)) + + ShortcutRow("New Note", "${DesktopShortcuts.modifierName}+N") + ShortcutRow("Save", "${DesktopShortcuts.modifierName}+S") + ShortcutRow("Search", "${DesktopShortcuts.modifierName}+K") + ShortcutRow("Settings", "${DesktopShortcuts.modifierName}+,") + // ... + } + } + } +} + +@Composable +fun ShortcutRow(action: String, shortcut: String) { + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(action, style = MaterialTheme.typography.bodyMedium) + Text( + shortcut, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} +``` + +### 4. Avoid Conflicts + +**Check for OS-level shortcuts:** + +| macOS Reserved | Description | +|----------------|-------------| +| Cmd+Tab | Switch apps | +| Cmd+Space | Spotlight | +| Cmd+H | Hide window | +| Cmd+M | Minimize | +| Cmd+Q | Quit | +| Cmd+W | Close window | + +**Windows Reserved:** + +| Windows Reserved | Description | +|-----------------|-------------| +| Win+D | Show desktop | +| Win+E | File Explorer | +| Win+L | Lock screen | +| Alt+Tab | Switch apps | +| Alt+F4 | Close window | + +**Don't override these unless critical.** + +--- + +## Testing Shortcuts + +```kotlin +// Test OS detection +@Test +fun testOsDetection() { + val osName = System.getProperty("os.name") + println("OS: $osName") + + val isMacOS = osName.lowercase().contains("mac") + println("Is macOS: $isMacOS") + + val shortcut = if (isMacOS) { + KeyShortcut(Key.N, meta = true) + } else { + KeyShortcut(Key.N, ctrl = true) + } + + println("Primary modifier for New: $shortcut") +} +``` + +--- + +## Current Issues in Amethyst + +**Main.kt:105-123** hardcodes `ctrl = true`: + +```kotlin +// ❌ WRONG: Hardcoded Ctrl (doesn't work on macOS) +Item( + "New Note", + shortcut = KeyShortcut(Key.N, ctrl = true), // Should be Cmd on macOS + onClick = { /* ... */ } +) +``` + +**Fix:** + +```kotlin +// ✅ CORRECT: OS-aware +Item( + "New Note", + shortcut = DesktopShortcuts.primary(Key.N), + onClick = { /* ... */ } +) +``` + +--- + +## References + +- [macOS Keyboard Shortcuts](https://support.apple.com/en-us/102650) +- [Windows Keyboard Shortcuts](https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec) +- [GNOME Keyboard Shortcuts](https://help.gnome.org/users/gnome-help/stable/shell-keyboard-shortcuts.html) +- [Material Design: Keyboard Shortcuts](https://m3.material.io/foundations/interaction/keyboard) +- [Compose Desktop: Keyboard Events](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-desktop-keyboard.html) diff --git a/.claude/skills/desktop-expert/references/os-detection.md b/.claude/skills/desktop-expert/references/os-detection.md new file mode 100644 index 0000000000..0330308f5f --- /dev/null +++ b/.claude/skills/desktop-expert/references/os-detection.md @@ -0,0 +1,579 @@ +# OS Detection & Platform-Specific Code + +Patterns for detecting operating system and implementing platform-specific behavior in Compose Desktop. + +## OS Detection + +### Basic Detection + +```kotlin +val osName = System.getProperty("os.name").lowercase() + +val isMacOS = osName.contains("mac") +val isWindows = osName.contains("win") +val isLinux = osName.contains("nux") || osName.contains("nix") +``` + +### System Properties + +```kotlin +// OS name +System.getProperty("os.name") +// Examples: "Mac OS X", "Windows 10", "Linux" + +// OS version +System.getProperty("os.version") +// Examples: "14.2.1", "10.0", "6.5.0-14-generic" + +// OS architecture +System.getProperty("os.arch") +// Examples: "aarch64", "x86_64", "amd64" + +// User home directory +System.getProperty("user.home") +// Examples: "/Users/username", "C:\Users\username", "/home/username" + +// File separator +System.getProperty("file.separator") +// Examples: "/" (Unix), "\" (Windows) + +// Path separator +System.getProperty("path.separator") +// Examples: ":" (Unix), ";" (Windows) +``` + +--- + +## PlatformDetector Utility + +Create a centralized utility for platform detection. + +**File:** `commons/src/jvmMain/kotlin/utils/PlatformDetector.kt` + +```kotlin +package com.vitorpamplona.amethyst.commons.utils + +object PlatformDetector { + private val osName = System.getProperty("os.name").lowercase() + + val isMacOS: Boolean = osName.contains("mac") + val isWindows: Boolean = osName.contains("win") + val isLinux: Boolean = osName.contains("nux") || osName.contains("nix") + + val platform: Platform = when { + isMacOS -> Platform.MacOS + isWindows -> Platform.Windows + isLinux -> Platform.Linux + else -> Platform.Unknown + } + + enum class Platform { + MacOS, + Windows, + Linux, + Unknown + } + + // File paths + val fileSeparator: String = System.getProperty("file.separator") + val pathSeparator: String = System.getProperty("path.separator") + + // User directories + val userHome: String = System.getProperty("user.home") + + val appDataDir: String = when (platform) { + Platform.MacOS -> "$userHome/Library/Application Support" + Platform.Windows -> System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming" + Platform.Linux -> System.getenv("XDG_CONFIG_HOME") ?: "$userHome/.config" + Platform.Unknown -> userHome + } + + // Modifier key names + val primaryModifierName: String = if (isMacOS) "Cmd" else "Ctrl" + val secondaryModifierName: String = if (isMacOS) "Option" else "Alt" + + fun platformSpecific( + macOS: () -> Unit = {}, + windows: () -> Unit = {}, + linux: () -> Unit = {}, + fallback: () -> Unit = {} + ) { + when (platform) { + Platform.MacOS -> macOS() + Platform.Windows -> windows() + Platform.Linux -> linux() + Platform.Unknown -> fallback() + } + } +} +``` + +**Usage:** + +```kotlin +// Simple check +if (PlatformDetector.isMacOS) { + // macOS-specific code +} + +// Pattern matching +when (PlatformDetector.platform) { + Platform.MacOS -> setupMacDock() + Platform.Windows -> setupWindowsTray() + Platform.Linux -> setupLinuxTray() + Platform.Unknown -> showWarning() +} + +// Platform-specific execution +PlatformDetector.platformSpecific( + macOS = { setupMacMenuBar() }, + windows = { setupWindowsMenu() }, + linux = { setupLinuxMenu() } +) + +// File paths +val configPath = "${PlatformDetector.appDataDir}${PlatformDetector.fileSeparator}amethyst" +``` + +--- + +## Platform-Specific UI + +### Keyboard Shortcuts Helper + +```kotlin +package com.vitorpamplona.amethyst.commons.utils + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut + +object DesktopShortcuts { + private val isMacOS = PlatformDetector.isMacOS + + fun primary(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + } + + fun primaryShift(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, shift = true) + } else { + KeyShortcut(key, ctrl = true, shift = true) + } + + fun primaryAlt(key: Key) = if (isMacOS) { + KeyShortcut(key, meta = true, alt = true) + } else { + KeyShortcut(key, ctrl = true, alt = true) + } + + val modifierName = PlatformDetector.primaryModifierName + val secondaryName = PlatformDetector.secondaryModifierName + + fun formatShortcut(key: String, withPrimary: Boolean = true): String { + return if (withPrimary) "$modifierName+$key" else key + } +} +``` + +### File Paths Helper + +```kotlin +package com.vitorpamplona.amethyst.commons.utils + +import java.io.File + +object FilePaths { + private val separator = PlatformDetector.fileSeparator + + fun join(vararg parts: String): String { + return parts.joinToString(separator) + } + + fun appConfig(appName: String): String { + return join(PlatformDetector.appDataDir, appName) + } + + fun appCache(appName: String): String { + return when (PlatformDetector.platform) { + PlatformDetector.Platform.MacOS -> + join(PlatformDetector.userHome, "Library", "Caches", appName) + PlatformDetector.Platform.Windows -> + join(System.getenv("LOCALAPPDATA") ?: "${PlatformDetector.userHome}\\AppData\\Local", appName) + PlatformDetector.Platform.Linux -> + join(System.getenv("XDG_CACHE_HOME") ?: "${PlatformDetector.userHome}/.cache", appName) + else -> join(PlatformDetector.userHome, ".cache", appName) + } + } + + fun ensureDirectory(path: String): File { + return File(path).apply { + if (!exists()) { + mkdirs() + } + } + } +} + +// Usage +val configDir = FilePaths.ensureDirectory(FilePaths.appConfig("amethyst")) +val cacheDir = FilePaths.ensureDirectory(FilePaths.appCache("amethyst")) +``` + +--- + +## Platform-Specific Features + +### Open External URL + +```kotlin +// commons/src/commonMain/kotlin/utils/ExternalUrl.kt +expect fun openExternalUrl(url: String) + +// commons/src/jvmMain/kotlin/utils/ExternalUrl.jvm.kt +import java.awt.Desktop +import java.net.URI + +actual fun openExternalUrl(url: String) { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + Desktop.getDesktop().browse(URI(url)) + } +} +``` + +### File Picker + +```kotlin +// Platform-specific file picker +fun showFilePicker( + title: String = "Select file", + mode: FilePickerMode = FilePickerMode.Load +): String? { + val fileDialog = java.awt.FileDialog( + java.awt.Frame(), + title, + when (mode) { + FilePickerMode.Load -> java.awt.FileDialog.LOAD + FilePickerMode.Save -> java.awt.FileDialog.SAVE + } + ) + + // macOS-specific: Enable file selection features + if (PlatformDetector.isMacOS) { + System.setProperty("apple.awt.fileDialogForDirectories", "false") + } + + fileDialog.isVisible = true + + return fileDialog.file?.let { "${fileDialog.directory}$it" } +} + +enum class FilePickerMode { + Load, + Save +} +``` + +### Directory Picker (macOS) + +```kotlin +fun showDirectoryPicker(title: String = "Select directory"): String? { + if (PlatformDetector.isMacOS) { + // macOS-specific directory picker + System.setProperty("apple.awt.fileDialogForDirectories", "true") + } + + val fileDialog = java.awt.FileDialog(java.awt.Frame(), title, java.awt.FileDialog.LOAD) + fileDialog.isVisible = true + + if (PlatformDetector.isMacOS) { + System.setProperty("apple.awt.fileDialogForDirectories", "false") + } + + return fileDialog.directory +} +``` + +--- + +## Window Decorations + +### macOS-Specific + +```kotlin +// Unified title bar (macOS Big Sur+) +if (PlatformDetector.isMacOS) { + Window( + undecorated = false, + transparent = true, + // ... + ) { + // Custom title bar + } +} +``` + +### Windows-Specific + +```kotlin +// Custom window chrome (Windows) +if (PlatformDetector.isWindows) { + Window( + undecorated = true, + // Custom decorations + ) { + Column { + // Custom title bar with min/max/close buttons + WindowTitleBar() + // Content + } + } +} +``` + +--- + +## System Tray Icons + +Different icon formats per OS: + +```kotlin +fun getTrayIcon(): Painter { + return when (PlatformDetector.platform) { + Platform.MacOS -> painterResource("tray-icon-mac.png") // Template icon + Platform.Windows -> painterResource("tray-icon-win.ico") + Platform.Linux -> painterResource("tray-icon-linux.png") + else -> painterResource("tray-icon.png") + } +} + +// macOS: Template icons (black/transparent) +// Windows: ICO format, 16x16 +// Linux: PNG, typically 24x24 +``` + +--- + +## Native Notifications + +```kotlin +// Platform-specific notification implementation +fun sendNotification(title: String, message: String) { + PlatformDetector.platformSpecific( + macOS = { + // macOS: Use NSUserNotification (via tray) + trayState.sendNotification( + Notification(title, message, Notification.Type.Info) + ) + }, + windows = { + // Windows: Use Windows toast notifications + trayState.sendNotification( + Notification(title, message, Notification.Type.Info) + ) + }, + linux = { + // Linux: Use libnotify (via tray) + trayState.sendNotification( + Notification(title, message, Notification.Type.Info) + ) + } + ) +} +``` + +--- + +## Architecture Detection + +```kotlin +object ArchDetector { + private val arch = System.getProperty("os.arch").lowercase() + + val isArm: Boolean = arch.contains("aarch") || arch.contains("arm") + val isX64: Boolean = arch.contains("x86_64") || arch.contains("amd64") + val isX86: Boolean = arch.contains("x86") && !isX64 + + val architecture: Architecture = when { + isArm -> Architecture.ARM + isX64 -> Architecture.X64 + isX86 -> Architecture.X86 + else -> Architecture.Unknown + } + + enum class Architecture { + ARM, + X64, + X86, + Unknown + } +} + +// Usage: Load correct native library +fun loadNativeLib() { + val libName = when { + PlatformDetector.isMacOS && ArchDetector.isArm -> "libsecp256k1-macos-arm64" + PlatformDetector.isMacOS && ArchDetector.isX64 -> "libsecp256k1-macos-x64" + PlatformDetector.isWindows && ArchDetector.isX64 -> "libsecp256k1-win-x64" + PlatformDetector.isLinux && ArchDetector.isX64 -> "libsecp256k1-linux-x64" + else -> throw UnsupportedOperationException("Unsupported platform") + } + + System.loadLibrary(libName) +} +``` + +--- + +## Testing Platform Detection + +```kotlin +@Test +fun testPlatformDetection() { + println("OS: ${System.getProperty("os.name")}") + println("Version: ${System.getProperty("os.version")}") + println("Arch: ${System.getProperty("os.arch")}") + println() + println("Is macOS: ${PlatformDetector.isMacOS}") + println("Is Windows: ${PlatformDetector.isWindows}") + println("Is Linux: ${PlatformDetector.isLinux}") + println("Platform: ${PlatformDetector.platform}") + println() + println("User home: ${PlatformDetector.userHome}") + println("App data: ${PlatformDetector.appDataDir}") + println("File separator: ${PlatformDetector.fileSeparator}") +} + +// Example output (macOS): +// OS: Mac OS X +// Version: 14.2.1 +// Arch: aarch64 +// +// Is macOS: true +// Is Windows: false +// Is Linux: false +// Platform: MacOS +// +// User home: /Users/username +// App data: /Users/username/Library/Application Support +// File separator: / +``` + +--- + +## Best Practices + +### 1. Centralize Detection + +✅ **DO:** Use PlatformDetector singleton +```kotlin +if (PlatformDetector.isMacOS) { /* ... */ } +``` + +❌ **DON'T:** Repeat detection everywhere +```kotlin +if (System.getProperty("os.name").lowercase().contains("mac")) { /* ... */ } +``` + +### 2. Use expect/actual for Platform APIs + +```kotlin +// commonMain +expect fun openFile(path: String) + +// jvmMain (Desktop) +actual fun openFile(path: String) { + Desktop.getDesktop().open(File(path)) +} + +// androidMain +actual fun openFile(path: String) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(path))) +} +``` + +### 3. Graceful Degradation + +```kotlin +fun openBrowser(url: String) { + try { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI(url)) + } else { + // Fallback: Copy to clipboard + Toolkit.getDefaultToolkit().systemClipboard.setContents( + StringSelection(url), + null + ) + showMessage("URL copied to clipboard: $url") + } + } catch (e: Exception) { + showError("Failed to open browser: ${e.message}") + } +} +``` + +### 4. Test on All Platforms + +Always test platform-specific code on: +- macOS (Intel + Apple Silicon if possible) +- Windows (10/11) +- Linux (Ubuntu/Fedora) + +--- + +## Common Patterns + +### Pattern: Config File Location + +```kotlin +fun getConfigFile(filename: String): File { + val configDir = when (PlatformDetector.platform) { + Platform.MacOS -> + File("${PlatformDetector.userHome}/Library/Application Support/Amethyst") + Platform.Windows -> + File("${System.getenv("APPDATA")}\\Amethyst") + Platform.Linux -> + File("${PlatformDetector.userHome}/.config/amethyst") + else -> + File("${PlatformDetector.userHome}/.amethyst") + } + + if (!configDir.exists()) { + configDir.mkdirs() + } + + return File(configDir, filename) +} + +// Usage +val settingsFile = getConfigFile("settings.json") +``` + +### Pattern: Platform-Specific Resources + +```kotlin +fun getPlatformIcon(name: String): Painter { + val extension = when (PlatformDetector.platform) { + Platform.MacOS -> "icns" + Platform.Windows -> "ico" + else -> "png" + } + + return painterResource("$name.$extension") +} + +// Resources: +// src/jvmMain/resources/app-icon.icns (macOS) +// src/jvmMain/resources/app-icon.ico (Windows) +// src/jvmMain/resources/app-icon.png (Linux) +``` + +--- + +## References + +- [System Properties (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/System.html#getProperties()) +- [Desktop API (Java)](https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Desktop.html) +- [File System Standards (XDG)](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) diff --git a/.claude/skills/kotlin-expert/SKILL.md b/.claude/skills/kotlin-expert/SKILL.md new file mode 100644 index 0000000000..6bd7eb097b --- /dev/null +++ b/.claude/skills/kotlin-expert/SKILL.md @@ -0,0 +1,811 @@ +--- +name: kotlin-expert +description: Advanced Kotlin patterns for AmethystMultiplatform. Flow state management (StateFlow/SharedFlow), sealed hierarchies (classes vs interfaces), immutability (@Immutable, data classes), DSL builders (type-safe fluent APIs), inline functions (reified generics, performance). Use when working with: (1) State management patterns (StateFlow/SharedFlow/MutableStateFlow), (2) Sealed classes or sealed interfaces, (3) @Immutable annotations for Compose, (4) DSL builders with lambda receivers, (5) inline/reified functions, (6) Kotlin performance optimization. Complements kotlin-coroutines agent (async patterns) - this skill focuses on Amethyst-specific Kotlin idioms. +--- + +# Kotlin Expert + +Advanced Kotlin patterns for AmethystMultiplatform. Covers Flow state management, sealed hierarchies, immutability, DSL builders, and inline functions with real codebase examples. + +## Mental Model + +**Kotlin in Amethyst:** + +``` +State Management (Hot Flows) + ├── StateFlow # Single value, always has value, replays to new subscribers + ├── SharedFlow # Event stream, configurable replay, multiple subscribers + └── MutableStateFlow # Private mutable, public via .asStateFlow() + +Type Safety (Sealed Hierarchies) + ├── sealed class # State variants with data (AccountState.LoggedIn/LoggedOut) + └── sealed interface # Generic result types (SignerResult) + +Compose Performance (@Immutable) + ├── @Immutable # 173+ event classes - prevents recomposition + └── data class # Structural equality, copy(), immutable by convention + +DSL Patterns + ├── Builder classes # Fluent APIs (TagArrayBuilder) + ├── Lambda receivers # inline fun tagArray { ... } + └── Method chaining # return this + +Performance + ├── inline fun # Eliminate lambda overhead + ├── reified type params # Runtime type info (OptimizedJsonMapper) + └── value class # Zero-cost wrappers (NOT USED yet in Amethyst) +``` + +**Delegation:** +- **kotlin-coroutines agent**: Deep async (structured concurrency, channels, operators) +- **kotlin-multiplatform skill**: expect/actual, source sets +- **This skill**: Amethyst Kotlin idioms, state patterns, type safety + +--- + +## 1. Flow State Management + +### StateFlow: State that Changes + +**Mental model:** StateFlow is a "hot" observable state holder. Always has a value, new collectors immediately get current state. + +**Amethyst pattern:** + +```kotlin +// AccountManager.kt:48-50 +class AccountManager { + private val _accountState = MutableStateFlow(AccountState.LoggedOut) + val accountState: StateFlow = _accountState.asStateFlow() + + fun login(key: String) { + _accountState.value = AccountState.LoggedIn(...) + } +} +``` + +**Key principles:** +1. **Private mutable, public immutable**: `_accountState` (MutableStateFlow) private, `accountState` (StateFlow) public +2. **Always has value**: Initial value required (`LoggedOut`) +3. **Single value**: Replays ONE most recent value to new subscribers +4. **Hot**: Stays in memory, all collectors share same instance + +**See:** AccountManager.kt:48-50, RelayConnectionManager.kt:49-52 + +### SharedFlow: Event Streams + +**Mental model:** SharedFlow is a "hot" broadcast stream for events. Configurable replay buffer, doesn't require initial value. + +**Amethyst pattern:** + +```kotlin +// RelayConnectionManager.kt:52-53 +val connectedRelays: StateFlow> = client.connectedRelaysFlow() +val availableRelays: StateFlow> = client.availableRelaysFlow() +``` + +**When to use StateFlow vs SharedFlow:** + +| Scenario | Use StateFlow | Use SharedFlow | +|----------|---------------|----------------| +| **UI state** | ✅ Current screen data, login status | ❌ | +| **One-time events** | ❌ | ✅ Navigation, snackbars, toasts | +| **Always has value** | ✅ | ❌ Optional | +| **Replay count** | 1 (latest only) | Configurable (0, 1, n) | +| **Backpressure** | Conflates (drops old) | Configurable buffer | + +**Best practice:** +```kotlin +// State: Use StateFlow +private val _uiState = MutableStateFlow(UiState.Loading) +val uiState: StateFlow = _uiState.asStateFlow() + +// Events: Use SharedFlow +private val _navigationEvents = MutableSharedFlow(replay = 0) +val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() +``` + +### Flow Anti-Patterns + +❌ **Exposing mutable state:** +```kotlin +val accountState: MutableStateFlow // BAD: Can be mutated externally +``` + +✅ **Expose immutable:** +```kotlin +val accountState: StateFlow = _accountState.asStateFlow() // GOOD +``` + +--- + +❌ **SharedFlow for state:** +```kotlin +val loginState = MutableSharedFlow() // BAD: State might get lost +``` + +✅ **StateFlow for state:** +```kotlin +val loginState = MutableStateFlow(LoginState.LoggedOut) // GOOD: Always has value +``` + +**See:** `references/flow-patterns.md` for comprehensive examples. + +--- + +## 2. Sealed Hierarchies + +### Sealed Classes: State Variants + +**Mental model:** Sealed classes represent a closed set of variants that share common data/behavior. + +**Amethyst pattern:** + +```kotlin +// AccountManager.kt:36-46 +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() +} + +// Usage +when (state) { + is AccountState.LoggedOut -> showLogin() + is AccountState.LoggedIn -> showFeed(state.pubKeyHex) +} // Exhaustive - compiler enforces all cases +``` + +**Key principles:** +1. **Closed hierarchy**: All subclasses known at compile-time +2. **Exhaustive when**: Compiler ensures all cases handled +3. **Shared data**: Sealed class can hold common properties +4. **Single inheritance**: Subclass can't extend another class + +**When to use:** +- Modeling UI states (Loading, Success, Error) +- Login states (LoggedOut, LoggedIn) +- Result types with different data per variant + +### Sealed Interfaces: Generic Result Types + +**Mental model:** Sealed interfaces for contracts with multiple implementations that need generics or multiple inheritance. + +**Amethyst pattern:** + +```kotlin +// SignerResult.kt:25-46 +sealed interface SignerResult { + sealed interface RequestAddressed : SignerResult { + class Successful(val result: T) : RequestAddressed + class Rejected : RequestAddressed + class TimedOut : RequestAddressed + class ReceivedButCouldNotPerform( + val message: String? + ) : RequestAddressed + } +} + +// Usage with generics +fun handleResult(result: SignerResult) { + when (result) { + is SignerResult.RequestAddressed.Successful -> processEvent(result.result.event) + is SignerResult.RequestAddressed.Rejected -> showRejected() + is SignerResult.RequestAddressed.TimedOut -> showTimeout() + } +} +``` + +**Key principles:** +1. **Multiple inheritance**: Subtype can implement other interfaces +2. **Variance**: Supports `out`/`in` modifiers for generics +3. **No constructor**: Can't hold state directly (subtypes can) +4. **Nested hierarchies**: Can create sub-sealed hierarchies + +### Sealed Class vs Sealed Interface + +| Feature | Sealed Class | Sealed Interface | +|---------|--------------|------------------| +| **Constructor** | ✅ Can hold common state | ❌ No constructor | +| **Inheritance** | ❌ Single parent only | ✅ Multiple interfaces | +| **Generics** | ❌ No variance | ✅ Covariance/contravariance | +| **Use case** | State variants | Result types, contracts | + +**Decision tree:** + +``` +Need to hold common data in base? + YES → sealed class + NO → sealed interface + +Need generics with variance (out/in)? + YES → sealed interface + NO → Either works + +Subtypes need multiple inheritance? + YES → sealed interface + NO → Either works +``` + +**Amethyst examples:** +- `sealed class AccountState` - state variants with different data +- `sealed interface SignerResult` - generic result types with variance + +**See:** `references/sealed-class-catalog.md` for all sealed types in quartz. + +--- + +## 3. Immutability & Compose Performance + +### @Immutable Annotation + +**Mental model:** @Immutable tells Compose "this value never changes after construction." Compose can skip recomposition if @Immutable object reference doesn't change. + +**Amethyst pattern:** + +```kotlin +// TextNoteEvent.kt:51-63 +@Immutable +class TextNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + // All properties immutable (val), no mutable state +} +``` + +**Key principles:** +1. **All properties immutable**: Only `val`, never `var` +2. **No mutable collections**: Use `ImmutableList`, `Array`, not `MutableList` +3. **Deep immutability**: Nested objects also immutable +4. **Compose optimization**: Skips recomposition if reference equals + +**Why it matters:** + +```kotlin +// Without @Immutable +@Composable +fun NoteCard(note: TextNoteEvent) { // Recomposes every time parent recomposes + Text(note.content) +} + +// With @Immutable +@Composable +fun NoteCard(note: TextNoteEvent) { // Only recomposes if note reference changes + Text(note.content) +} +``` + +**173+ @Immutable classes** in quartz - all events immutable for Compose performance. + +### Data Classes & Immutability + +**Pattern:** + +```kotlin +@Immutable +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val error: String? = null +) { + // Implicit: equals(), hashCode(), copy(), toString() +} + +// Usage +val oldStatus = RelayStatus(url, connected = false) +val newStatus = oldStatus.copy(connected = true) // Immutable update +``` + +**Key principles:** +1. **Structural equality**: `equals()` compares properties, not reference +2. **copy()**: Create modified copies without mutating +3. **All properties in constructor**: For proper `equals()`/`hashCode()` +4. **Prefer val**: Make properties immutable + +### kotlinx.collections.immutable + +**Pattern:** + +```kotlin +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +// Instead of List (which could be mutable internally) +val relays: ImmutableList = persistentListOf("wss://relay1.com", "wss://relay2.com") + +// Add returns new instance +val updated = relays.add("wss://relay3.com") // relays unchanged, updated has 3 items +``` + +**When to use:** +- Compose state that needs collection +- Publicly exposed collections +- Shared state across threads + +**See:** `references/immutability-patterns.md` + +--- + +## 4. DSL Builders + +### Type-Safe Fluent APIs + +**Mental model:** DSL (Domain-Specific Language) builders use lambda receivers and method chaining to create readable, type-safe APIs. + +**Amethyst pattern:** + +```kotlin +// TagArrayBuilder.kt:23-90 +class TagArrayBuilder { + private val tagList = mutableMapOf>() + + fun add(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList.getOrPut(tag[0], ::mutableListOf).add(tag) + return this // Method chaining + } + + fun remove(tagName: String): TagArrayBuilder { + tagList.remove(tagName) + return this // Method chaining + } + + fun build() = tagList.flatMap { it.value }.toTypedArray() +} + +// Inline function with lambda receiver (line 90) +inline fun tagArray(initializer: TagArrayBuilder.() -> Unit = {}): TagArray = + TagArrayBuilder().apply(initializer).build() +``` + +**Usage:** + +```kotlin +val tags = tagArray { + add(arrayOf("e", eventId, relay, "reply")) + add(arrayOf("p", pubkey)) + remove("a") // Remove address tags +} +``` + +**Key patterns:** +1. **Method chaining**: Return `this` from mutator methods +2. **Lambda receiver**: `TagArrayBuilder.() -> Unit` - lambda has `this: TagArrayBuilder` +3. **inline function**: Eliminates lambda overhead +4. **apply()**: Executes lambda with receiver, returns receiver + +### DSL Pattern Template + +```kotlin +class MyBuilder { + private val items = mutableListOf() + + fun add(item: Item): MyBuilder { + items.add(item) + return this + } + + fun build(): Result = Result(items.toList()) +} + +inline fun myDsl(init: MyBuilder.() -> Unit): Result = + MyBuilder().apply(init).build() + +// Usage +val result = myDsl { + add(Item("foo")) + add(Item("bar")) +} +``` + +**Why inline?** +- Eliminates lambda object allocation +- Enables `reified` type parameters +- Better performance for frequently-called DSLs + +**See:** `references/dsl-builder-examples.md` for more patterns. + +--- + +## 5. Inline Functions & reified + +### inline fun: Eliminate Overhead + +**Mental model:** `inline` copies function body to call site. No lambda object created, direct code insertion. + +**Pattern:** + +```kotlin +// Without inline +fun measureTime(block: () -> T): T { + val start = System.currentTimeMillis() + val result = block() // Lambda object allocated + println("Time: ${System.currentTimeMillis() - start}ms") + return result +} + +// With inline +inline fun measureTime(block: () -> T): T { + val start = System.currentTimeMillis() + val result = block() // No allocation, code inlined + println("Time: ${System.currentTimeMillis() - start}ms") + return result +} +``` + +**Benefits:** +1. **Zero overhead**: No lambda object allocation +2. **Non-local returns**: Can `return` from outer function inside lambda +3. **reified enabled**: Access to type parameter at runtime + +### reified: Runtime Type Access + +**Mental model:** `reified` makes generic type `T` available at runtime. Only works with `inline`. + +**Amethyst pattern:** + +```kotlin +// OptimizedJsonMapper.kt:48 +expect object OptimizedJsonMapper { + inline fun fromJsonTo(json: String): T +} + +// Usage +val event: TextNoteEvent = OptimizedJsonMapper.fromJsonTo(jsonString) +// Compiler inlines and passes TextNoteEvent::class info +``` + +**Without reified:** + +```kotlin +// Would need to pass class explicitly +fun fromJson(json: String, clazz: KClass): T { + return when (clazz) { + TextNoteEvent::class -> parseTextNote(json) as T + // ... + } +} + +val event = fromJson(json, TextNoteEvent::class) // Verbose +``` + +**With reified:** + +```kotlin +inline fun fromJson(json: String): T { + return when (T::class) { // Can access T::class! + TextNoteEvent::class -> parseTextNote(json) as T + // ... + } +} + +val event = fromJson(json) // Clean +``` + +### noinline & crossinline + +**noinline**: Prevent specific lambda from being inlined + +```kotlin +inline fun foo( + inlined: () -> Unit, + noinline notInlined: () -> Unit // Can be stored, passed around +) { + inlined() + someFunction(notInlined) // Can pass to non-inline function +} +``` + +**crossinline**: Lambda can't do non-local returns + +```kotlin +inline fun foo(crossinline block: () -> Unit) { + launch { + block() // OK: crossinline allows lambda in different context + } +} +``` + +--- + +## 6. Value Classes (Opportunity) + +**Mental model:** `value class` is a compile-time wrapper with zero runtime overhead. Single property, no boxing. + +**Not currently used in Amethyst** - potential optimization. + +**Pattern:** + +```kotlin +@JvmInline +value class EventId(val hex: String) + +@JvmInline +value class PubKey(val hex: String) + +// Type safety without runtime cost +fun fetchEvent(eventId: EventId): Event { + // eventId.hex accessed without wrapper object +} + +val id = EventId("abc123") +fetchEvent(id) // Type safe +// fetchEvent(PubKey("xyz")) // Compile error! +``` + +**When to use:** +- Type safety for primitives (IDs, hex strings, timestamps) +- High-frequency allocations (event processing) +- Clear domain types without overhead + +**Restrictions:** +- Single property only +- Must be `val` +- Can't have `init` block with logic +- Inline at compile-time, may box in some cases + +**Amethyst opportunity:** + +```kotlin +// Current (String everywhere, no type safety) +fun fetchEvent(id: String): Event // Could pass wrong string + +// With value class +@JvmInline value class EventId(val hex: String) +@JvmInline value class PubKeyHex(val hex: String) +@JvmInline value class Bech32(val encoded: String) + +fun fetchEvent(id: EventId): Event // Type safe, zero cost +``` + +--- + +## Common Patterns + +### Pattern: StateFlow State Management + +```kotlin +class MyViewModel { + private val _state = MutableStateFlow(State.Initial) + val state: StateFlow = _state.asStateFlow() + + fun loadData() { + viewModelScope.launch { + _state.value = State.Loading + val result = repository.getData() + _state.value = when (result) { + is Success -> State.Success(result.data) + is Error -> State.Error(result.message) + } + } + } +} + +sealed class State { + data object Initial : State() + data object Loading : State() + data class Success(val data: List) : State() + data class Error(val message: String) : State() +} +``` + +### Pattern: Sealed Result with Generics + +```kotlin +sealed interface Result { + data class Success(val value: T) : Result + data class Error(val exception: Exception) : Result + data object Loading : Result +} + +// Use with variance +fun fetchData(): Result = ... + +val userResult: Result = fetchData() +val itemResult: Result> = fetchData() +``` + +### Pattern: Immutable Event Builder + +```kotlin +@Immutable +data class Event( + val id: String, + val kind: Int, + val content: String, + val tags: ImmutableList +) { + companion object { + fun builder() = EventBuilder() + } +} + +class EventBuilder { + private var id: String = "" + private var kind: Int = 1 + private var content: String = "" + private val tags = mutableListOf() + + fun id(value: String) = apply { id = value } + fun kind(value: Int) = apply { kind = value } + fun content(value: String) = apply { content = value } + fun tag(tag: Tag) = apply { tags.add(tag) } + + fun build() = Event(id, kind, content, tags.toImmutableList()) +} + +// Usage +val event = Event.builder() + .id("abc") + .kind(1) + .content("Hello") + .tag(Tag.P("pubkey")) + .build() +``` + +--- + +## Delegation Guide + +**When to delegate:** + +| Topic | Delegate To | This Skill Covers | +|-------|-------------|-------------------| +| Structured concurrency, channels | kotlin-coroutines agent | Flow state patterns only | +| expect/actual, source sets | kotlin-multiplatform skill | Platform-agnostic Kotlin | +| General Compose patterns | compose-expert skill | @Immutable for performance | +| Build configuration | gradle-expert skill | - | + +**Ask kotlin-coroutines agent for:** +- Advanced Flow operators (flatMapLatest, combine, zip) +- Channel patterns +- Structured concurrency (supervisorScope, coroutineScope) +- Error handling in coroutines + +**This skill teaches:** +- StateFlow/SharedFlow state management +- Sealed hierarchies +- @Immutable for Compose +- DSL builders +- Inline/reified patterns + +--- + +## Anti-Patterns + +❌ **Mutable public state:** +```kotlin +val accountState: MutableStateFlow // BAD +``` + +✅ **Immutable public interface:** +```kotlin +val accountState: StateFlow = _accountState.asStateFlow() +``` + +--- + +❌ **Sealed class for generic results:** +```kotlin +sealed class Result { // BAD: Can't use variance + data class Success(val value: T) : Result() +} +``` + +✅ **Sealed interface for generics:** +```kotlin +sealed interface Result { // GOOD: Covariance + data class Success(val value: T) : Result +} +``` + +--- + +❌ **Mutable properties in @Immutable class:** +```kotlin +@Immutable +data class Event( + var content: String // BAD: var breaks immutability +) +``` + +✅ **All val:** +```kotlin +@Immutable +data class Event( + val content: String +) +``` + +--- + +❌ **Passing class explicitly when reified available:** +```kotlin +inline fun parse(json: String, clazz: KClass): T // BAD +``` + +✅ **Use reified:** +```kotlin +inline fun parse(json: String): T // GOOD +``` + +--- + +## Quick Reference + +### Flow Decision Tree + +``` +Need to expose state? + YES → StateFlow (always has value, single latest) + NO → Need events? → SharedFlow (optional replay, broadcast) + +Need to mutate? + Internal only → MutableStateFlow (private) + Expose publicly → StateFlow via .asStateFlow() +``` + +### Sealed Decision Tree + +``` +Need common data in base type? + YES → sealed class + NO → sealed interface + +Need generics with variance? + YES → sealed interface + NO → Either works + +Need multiple inheritance? + YES → sealed interface + NO → Either works +``` + +### Inline Decision Tree + +``` +Passing lambda to function? + Called frequently? → inline (performance) + Need reified? → inline (required) + Need to store/pass lambda? → regular fun (can't inline) +``` + +--- + +## Resources + +### Official Docs +- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) +- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html) +- [Inline Functions | Kotlin Docs](https://kotlinlang.org/docs/inline-functions.html) + +### Bundled References +- `references/flow-patterns.md` - StateFlow/SharedFlow examples from AccountManager, RelayManager +- `references/sealed-class-catalog.md` - All sealed types in quartz +- `references/dsl-builder-examples.md` - TagArrayBuilder, other DSL patterns +- `references/immutability-patterns.md` - @Immutable usage, data classes, collections + +### Codebase Examples +- AccountManager.kt:36-50 - sealed class AccountState, StateFlow pattern +- RelayConnectionManager.kt:44-52 - StateFlow state management +- SignerResult.kt:25-46 - sealed interface with generics +- TextNoteEvent.kt:51-63 - @Immutable event class +- TagArrayBuilder.kt:23-90 - DSL builder pattern, inline function +- OptimizedJsonMapper.kt:48 - inline fun with reified + +--- + +**Version:** 1.0.0 +**Last Updated:** 2025-12-30 +**Codebase Reference:** AmethystMultiplatform commit 258c4e011 diff --git a/.claude/skills/kotlin-expert/references/dsl-builder-examples.md b/.claude/skills/kotlin-expert/references/dsl-builder-examples.md new file mode 100644 index 0000000000..231b82d450 --- /dev/null +++ b/.claude/skills/kotlin-expert/references/dsl-builder-examples.md @@ -0,0 +1,602 @@ +# DSL Builder Examples + +Type-safe fluent APIs and DSL patterns from the codebase. + +## Table of Contents +- [TagArrayBuilder Pattern](#tagarraybuilder-pattern) +- [Builder Variations](#builder-variations) +- [DSL Principles](#dsl-principles) +- [Creating Custom DSLs](#creating-custom-dsls) + +--- + +## TagArrayBuilder Pattern + +### Core Implementation + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/TagArrayBuilder.kt:23-91` + +```kotlin +class TagArrayBuilder { + private val tagList = mutableMapOf>() + + fun remove(tagName: String): TagArrayBuilder { + tagList.remove(tagName) + return this // Method chaining + } + + fun remove(tagName: String, tagValue: String): TagArrayBuilder { + tagList[tagName]?.removeAll { it.valueOrNull() == tagValue } + if (tagList[tagName]?.isEmpty() == true) { + tagList.remove(tagName) + } + return this + } + + fun removeIf( + predicate: (Tag, Tag) -> Boolean, + toCompare: Tag + ): TagArrayBuilder { + val tagName = toCompare.nameOrNull() ?: return this + tagList[tagName]?.removeAll { predicate(it, toCompare) } + if (tagList[tagName]?.isEmpty() == true) { + tagList.remove(tagName) + } + return this + } + + fun add(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList.getOrPut(tag[0], ::mutableListOf).add(tag) + return this + } + + fun addFirst(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList.getOrPut(tag[0], ::mutableListOf).add(0, tag) + return this + } + + fun addUnique(tag: Array): TagArrayBuilder { + if (tag.isEmpty() || tag[0].isEmpty()) return this + tagList[tag[0]] = mutableListOf(tag) // Replace existing + return this + } + + fun addAll(tag: List>): TagArrayBuilder { + tag.forEach(::add) + return this + } + + fun toTypedArray() = tagList.flatMap { it.value }.toTypedArray() + + fun build() = toTypedArray() +} + +// Inline DSL function with lambda receiver +inline fun tagArray( + initializer: TagArrayBuilder.() -> Unit = {} +): TagArray = TagArrayBuilder().apply(initializer).build() +``` + +### Usage Examples + +**Basic usage:** + +```kotlin +val tags = tagArray { + add(arrayOf("e", eventId, relay, "reply")) + add(arrayOf("p", pubkey)) + add(arrayOf("t", "bitcoin")) +} +``` + +**Advanced patterns:** + +```kotlin +// Remove and add +val tags = tagArray { + addAll(existingTags) + remove("a") // Remove all address tags + addUnique(arrayOf("client", "Amethyst")) // Replace client tag +} + +// Conditional building +val tags = tagArray { + add(arrayOf("e", rootId, "", "root")) + + if (replyToId != null) { + add(arrayOf("e", replyToId, "", "reply")) + } + + mentionedPubkeys.forEach { pubkey -> + add(arrayOf("p", pubkey)) + } + + hashtags.forEach { tag -> + add(arrayOf("t", tag.lowercase())) + } +} + +// Custom predicate removal +val tags = tagArray { + addAll(originalTags) + removeIf( + predicate = { tag, compare -> tag[1] == compare[1] }, + toCompare = arrayOf("e", eventIdToRemove) + ) +} +``` + +--- + +## Builder Variations + +### PrivateTagArrayBuilder + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayBuilder.kt` + +```kotlin +class PrivateTagArrayBuilder { + private val builder = TagArrayBuilder() + + fun add(tag: PrivateTag): PrivateTagArrayBuilder { + builder.add(tag.toArray()) + return this + } + + fun addAll(tags: List): PrivateTagArrayBuilder { + tags.forEach { add(it) } + return this + } + + fun build(): Array> = builder.build() +} + +// DSL function +inline fun privateTagArray( + initializer: PrivateTagArrayBuilder.() -> Unit +): Array> = PrivateTagArrayBuilder().apply(initializer).build() +``` + +**Usage:** + +```kotlin +val privateTags = privateTagArray { + add(PrivateTag.Event(eventId, marker = "bookmark")) + add(PrivateTag.Profile(pubkey)) + addAll(existingPrivateTags) +} +``` + +### TlvBuilder + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/tlv/TlvBuilder.kt` + +```kotlin +class TlvBuilder { + private val entries = mutableListOf() + + fun add(type: TlvType, value: ByteArray): TlvBuilder { + entries.add(TlvEntry(type, value)) + return this + } + + fun addRelay(relay: String): TlvBuilder { + add(TlvType.Relay, relay.encodeToByteArray()) + return this + } + + fun addAuthor(pubkey: ByteArray): TlvBuilder { + add(TlvType.Author, pubkey) + return this + } + + fun addKind(kind: Int): TlvBuilder { + add(TlvType.Kind, kind.toByteArray()) + return this + } + + fun build(): ByteArray { + return entries.flatMap { it.encode() }.toByteArray() + } +} + +fun tlv(init: TlvBuilder.() -> Unit): ByteArray = + TlvBuilder().apply(init).build() +``` + +**Usage:** + +```kotlin +val tlvData = tlv { + addAuthor(pubkeyBytes) + addRelay("wss://relay.damus.io") + addKind(1) +} +``` + +### MapOfSetBuilder + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/MapOfSetBuilder.kt` + +```kotlin +class MapOfSetBuilder { + private val map = mutableMapOf>() + + fun add(key: K, value: V): MapOfSetBuilder { + map.getOrPut(key) { mutableSetOf() }.add(value) + return this + } + + fun addAll(key: K, values: Collection): MapOfSetBuilder { + map.getOrPut(key) { mutableSetOf() }.addAll(values) + return this + } + + fun remove(key: K, value: V): MapOfSetBuilder { + map[key]?.remove(value) + if (map[key]?.isEmpty() == true) { + map.remove(key) + } + return this + } + + fun build(): Map> = map.mapValues { it.value.toSet() } +} + +inline fun mapOfSets( + init: MapOfSetBuilder.() -> Unit +): Map> = MapOfSetBuilder().apply(init).build() +``` + +**Usage:** + +```kotlin +val relayMap = mapOfSets { + add("wss://relay1.com", eventId1) + add("wss://relay1.com", eventId2) + add("wss://relay2.com", eventId3) +} +// Result: {"wss://relay1.com": [eventId1, eventId2], "wss://relay2.com": [eventId3]} +``` + +--- + +## DSL Principles + +### 1. Lambda with Receiver + +**Mental model:** Lambda receiver makes `this` refer to builder instance inside lambda. + +```kotlin +// Without receiver +fun buildTags(config: (TagArrayBuilder) -> Unit) { + val builder = TagArrayBuilder() + config(builder) // Must pass builder explicitly + builder.build() +} + +buildTags { builder -> + builder.add(...) // Verbose +} + +// With receiver +inline fun buildTags(config: TagArrayBuilder.() -> Unit) { + TagArrayBuilder().apply(config).build() +} + +buildTags { + add(...) // Clean - 'this' is builder +} +``` + +### 2. Method Chaining + +**Pattern:** Return `this` from mutator methods. + +```kotlin +class Builder { + private var value: String = "" + + fun setValue(v: String): Builder { + value = v + return this // Enable chaining + } + + fun append(s: String): Builder { + value += s + return this + } + + fun build(): String = value +} + +// Usage +val result = Builder() + .setValue("Hello") + .append(" ") + .append("World") + .build() +``` + +### 3. Inline for Performance + +**Why inline:** +- Eliminates lambda allocation +- Allows `reified` type parameters +- Better for hot paths (frequently called) + +```kotlin +// NOT inline - lambda object created each call +fun myDsl(init: Builder.() -> Unit): Result { + return Builder().apply(init).build() +} + +// Inline - lambda code inlined at call site +inline fun myDsl(init: Builder.() -> Unit): Result { + return Builder().apply(init).build() +} +``` + +### 4. Type Safety + +**Use generics for compile-time safety:** + +```kotlin +// Type-safe builder +class EventBuilder { + fun addTag(tag: Tag): EventBuilder { // Only accepts tags for this event type + tags.add(tag) + return this + } +} + +// Usage +val textNote = EventBuilder() + .addTag(TextNoteTag.Subject("Hello")) // OK + // .addTag(ChannelTag.Name("test")) // Compile error! + .build() +``` + +--- + +## Creating Custom DSLs + +### Pattern: Simple Builder DSL + +```kotlin +class QueryBuilder { + private val filters = mutableListOf() + private var limit: Int? = null + private var offset: Int? = null + + fun filter(field: String, value: String): QueryBuilder { + filters.add("$field:$value") + return this + } + + fun limit(n: Int): QueryBuilder { + limit = n + return this + } + + fun offset(n: Int): QueryBuilder { + offset = n + return this + } + + fun build(): String { + val parts = mutableListOf() + if (filters.isNotEmpty()) { + parts.add(filters.joinToString(" AND ")) + } + if (limit != null) { + parts.add("LIMIT $limit") + } + if (offset != null) { + parts.add("OFFSET $offset") + } + return parts.joinToString(" ") + } +} + +inline fun query(init: QueryBuilder.() -> Unit): String = + QueryBuilder().apply(init).build() + +// Usage +val sql = query { + filter("status", "active") + filter("age", ">18") + limit(10) + offset(20) +} +// Result: "status:active AND age:>18 LIMIT 10 OFFSET 20" +``` + +### Pattern: Nested Builders + +```kotlin +class FilterBuilder { + private val conditions = mutableListOf() + + fun equals(field: String, value: String) { + conditions.add("$field = '$value'") + } + + fun greaterThan(field: String, value: Int) { + conditions.add("$field > $value") + } + + fun build(): String = conditions.joinToString(" AND ") +} + +class QueryBuilder { + private var filterClause: String = "" + private var selectClause: String = "*" + + fun select(vararg fields: String): QueryBuilder { + selectClause = fields.joinToString(", ") + return this + } + + fun where(init: FilterBuilder.() -> Unit): QueryBuilder { + filterClause = FilterBuilder().apply(init).build() + return this + } + + fun build(): String { + return "SELECT $selectClause WHERE $filterClause" + } +} + +inline fun query(init: QueryBuilder.() -> Unit): String = + QueryBuilder().apply(init).build() + +// Usage +val sql = query { + select("id", "name", "age") + where { + equals("status", "active") + greaterThan("age", 18) + } +} +// Result: "SELECT id, name, age WHERE status = 'active' AND age > 18" +``` + +### Pattern: Type-Safe HTML DSL + +```kotlin +abstract class Tag(val name: String) { + private val children = mutableListOf() + private val attributes = mutableMapOf() + + fun tag(tag: T, init: T.() -> Unit): T { + tag.init() + children.add(tag) + return tag + } + + fun attr(name: String, value: String) { + attributes[name] = value + } + + fun render(builder: StringBuilder, indent: String) { + builder.append("$indent<$name") + attributes.forEach { (k, v) -> builder.append(" $k=\"$v\"") } + if (children.isEmpty()) { + builder.append("/>\n") + } else { + builder.append(">\n") + children.forEach { it.render(builder, "$indent ") } + builder.append("$indent\n") + } + } +} + +class HTML : Tag("html") +class Head : Tag("head") +class Body : Tag("body") +class Div : Tag("div") +class P : Tag("p") +class A : Tag("a") + +fun HTML.head(init: Head.() -> Unit) = tag(Head(), init) +fun HTML.body(init: Body.() -> Unit) = tag(Body(), init) +fun Body.div(init: Div.() -> Unit) = tag(Div(), init) +fun Div.p(init: P.() -> Unit) = tag(P(), init) +fun Div.a(init: A.() -> Unit) = tag(A(), init) + +fun html(init: HTML.() -> Unit): HTML = HTML().apply(init) + +// Usage +val page = html { + head { + // ... + } + body { + div { + attr("class", "container") + p { + attr("id", "intro") + } + a { + attr("href", "https://example.com") + } + } + } +} +``` + +--- + +## Best Practices + +### ✅ DO + +1. **Return `this` for chaining:** + ```kotlin + fun add(item: Item): Builder { + items.add(item) + return this + } + ``` + +2. **Use `inline` for DSL functions:** + ```kotlin + inline fun myDsl(init: Builder.() -> Unit) = Builder().apply(init).build() + ``` + +3. **Provide sensible defaults:** + ```kotlin + inline fun query( + init: QueryBuilder.() -> Unit = {} // Empty lambda as default + ) = QueryBuilder().apply(init).build() + ``` + +4. **Validate in `build()`:** + ```kotlin + fun build(): Result { + require(fields.isNotEmpty()) { "Must specify at least one field" } + return Result(fields) + } + ``` + +### ❌ DON'T + +1. **Forget to return `this`:** + ```kotlin + fun add(item: Item) { // BAD: Can't chain + items.add(item) + } + ``` + +2. **Mutate after build:** + ```kotlin + val builder = Builder() + builder.add("foo") + val result = builder.build() + builder.add("bar") // BAD: Confusing state + ``` + +3. **Expose mutable state:** + ```kotlin + class Builder { + val items = mutableListOf() // BAD: Can be mutated externally + } + ``` + +4. **Make DSL functions non-inline unnecessarily:** + ```kotlin + fun myDsl(init: Builder.() -> Unit) = ... // BAD: Lambda allocation overhead + ``` + +--- + +## References + +- TagArrayBuilder.kt:23-91 +- PrivateTagArrayBuilder.kt +- TlvBuilder.kt +- [Type-Safe Builders | Kotlin Docs](https://kotlinlang.org/docs/type-safe-builders.html) +- [DSLs with Kotlin](https://kt.academy/article/dsl-intro) diff --git a/.claude/skills/kotlin-expert/references/flow-patterns.md b/.claude/skills/kotlin-expert/references/flow-patterns.md new file mode 100644 index 0000000000..4a15b1d91e --- /dev/null +++ b/.claude/skills/kotlin-expert/references/flow-patterns.md @@ -0,0 +1,405 @@ +# Flow Patterns in Amethyst + +StateFlow and SharedFlow usage patterns from the codebase. + +## Table of Contents +- [StateFlow for State Management](#stateflow-for-state-management) +- [Flow Composition](#flow-composition) +- [Common Patterns](#common-patterns) +- [Anti-Patterns](#anti-patterns) + +--- + +## StateFlow for State Management + +### AccountManager Pattern + +**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-115` + +```kotlin +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 // Update state + return state + } + + fun loginWithKey(keyInput: String): Result { + // ... validation ... + + val state = AccountState.LoggedIn(...) + _accountState.value = state + return Result.success(state) + } + + fun logout() { + _accountState.value = AccountState.LoggedOut + } +} +``` + +**Pattern highlights:** +- Private `MutableStateFlow` for internal mutations +- Public `StateFlow` via `.asStateFlow()` for read-only access +- Sealed class for type-safe state variants +- Initial value required (`AccountState.LoggedOut`) + +### RelayConnectionManager Pattern + +**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt:44-80` + +```kotlin +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val error: String? = null, + val messageCount: Int = 0 +) + +open class RelayConnectionManager( + websocketBuilder: WebsocketBuilder +) : IRelayClientListener { + private val client = NostrClient(websocketBuilder) + + // Map of relay URLs to their status + private val _relayStatuses = MutableStateFlow>(emptyMap()) + val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() + + // Delegated StateFlows from client + val connectedRelays: StateFlow> = client.connectedRelaysFlow() + val availableRelays: StateFlow> = client.availableRelaysFlow() + + 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 // Immutable update (remove from map) + } + + private fun updateRelayStatus( + relay: NormalizedRelayUrl, + update: (RelayStatus) -> RelayStatus + ) { + _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { + val current = get(relay) ?: RelayStatus(relay, false) + put(relay, update(current)) + } + } + + // IRelayClientListener implementation + override fun onConnect(relay: NormalizedRelayUrl) { + updateRelayStatus(relay) { it.copy(connected = true, error = null) } + } + + override fun onError(relay: NormalizedRelayUrl, error: String) { + updateRelayStatus(relay) { it.copy(connected = false, error = error) } + } +} +``` + +**Pattern highlights:** +- `Map` as state value for collection tracking +- Immutable map updates (copy with modifications) +- Helper function `updateRelayStatus` for consistent updates +- Delegation pattern (client exposes its own StateFlows) + +--- + +## Flow Composition + +### Multiple StateFlows in UI + +**Pattern:** + +```kotlin +@Composable +fun LoginScreen(accountManager: AccountManager) { + val accountState by accountManager.accountState.collectAsState() + + when (accountState) { + is AccountState.LoggedOut -> { + LoginForm(onLogin = { key -> accountManager.loginWithKey(key) }) + } + is AccountState.LoggedIn -> { + MainApp(account = accountState as AccountState.LoggedIn) + } + } +} +``` + +### Observing Multiple Flows + +**Pattern:** + +```kotlin +@Composable +fun RelayStatusCard(relayManager: RelayConnectionManager) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() + + Column { + Text("${connectedRelays.size} of ${relayStatuses.size} relays connected") + + relayStatuses.forEach { (url, status) -> + RelayRow( + url = url, + connected = status.connected, + error = status.error + ) + } + } +} +``` + +--- + +## Common Patterns + +### Pattern: Immutable State Updates + +```kotlin +// Map updates +_relayStatuses.value = _relayStatuses.value + (url to newStatus) // Add +_relayStatuses.value = _relayStatuses.value - url // Remove +_relayStatuses.value = _relayStatuses.value.mapValues { (key, value) -> + if (key == targetUrl) value.copy(connected = true) else value +} + +// List updates +_items.value = _items.value + newItem // Append +_items.value = _items.value.filter { it.id != removedId } // Remove +_items.value = _items.value.map { if (it.id == id) it.copy(name = newName) else it } // Update + +// Object updates +_user.value = _user.value.copy(name = newName) +``` + +### Pattern: Conditional State Transitions + +```kotlin +fun attemptLogin(credentials: Credentials) { + if (_loginState.value is LoginState.LoggingIn) { + return // Already logging in, ignore + } + + _loginState.value = LoginState.LoggingIn + viewModelScope.launch { + try { + val user = repository.login(credentials) + _loginState.value = LoginState.Success(user) + } catch (e: Exception) { + _loginState.value = LoginState.Error(e.message ?: "Login failed") + } + } +} +``` + +### Pattern: Derived State + +```kotlin +class MyViewModel { + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + // Derived state (computed from items) + val itemCount: StateFlow = items.map { it.size } + .stateIn(viewModelScope, SharingStarted.Lazily, 0) + + val hasItems: StateFlow = items.map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.Lazily, false) +} + +// Usage in Compose +@Composable +fun ItemList(viewModel: MyViewModel) { + val itemCount by viewModel.itemCount.collectAsState() + val hasItems by viewModel.hasItems.collectAsState() + + if (hasItems) { + Text("$itemCount items") + } else { + Text("No items") + } +} +``` + +### Pattern: State with Loading/Error + +```kotlin +sealed class UiState { + data object Loading : UiState() + data class Success(val data: T) : UiState() + data class Error(val message: String) : UiState() +} + +class FeedViewModel { + private val _feedState = MutableStateFlow>>(UiState.Loading) + val feedState: StateFlow>> = _feedState.asStateFlow() + + fun loadFeed() { + viewModelScope.launch { + _feedState.value = UiState.Loading + try { + val events = repository.getEvents() + _feedState.value = UiState.Success(events) + } catch (e: Exception) { + _feedState.value = UiState.Error(e.message ?: "Unknown error") + } + } + } +} + +// UI +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val state by viewModel.feedState.collectAsState() + + when (state) { + is UiState.Loading -> LoadingSpinner() + is UiState.Success -> EventList((state as UiState.Success).data) + is UiState.Error -> ErrorMessage((state as UiState.Error).message) + } +} +``` + +--- + +## Anti-Patterns + +### ❌ Exposing Mutable State + +```kotlin +// BAD: External code can mutate +class BadViewModel { + val state: MutableStateFlow = MutableStateFlow(State.Initial) +} + +// Caller can do: +viewModel.state.value = State.Hacked // Bypass internal logic! +``` + +### ✅ Expose Immutable + +```kotlin +// GOOD: Only ViewModel can mutate +class GoodViewModel { + private val _state = MutableStateFlow(State.Initial) + val state: StateFlow = _state.asStateFlow() + + fun updateState(newState: State) { + // Controlled mutation with validation + _state.value = newState + } +} +``` + +--- + +### ❌ Not Using Immutable Updates + +```kotlin +// BAD: Mutating collection doesn't trigger StateFlow update +val list = mutableListOf() +list.add(newItem) +_items.value = list // Same reference, no update emitted! +``` + +### ✅ Create New Instance + +```kotlin +// GOOD: New list instance +_items.value = _items.value + newItem // New list created, update emitted +``` + +--- + +### ❌ StateFlow for Events + +```kotlin +// BAD: Events get lost if no collector +class BadViewModel { + val navigationEvent: StateFlow = MutableStateFlow(null) + + fun navigate(event: NavEvent) { + _navigationEvent.value = event // Lost if UI not observing! + } +} +``` + +### ✅ SharedFlow for Events + +```kotlin +// GOOD: Events queued +class GoodViewModel { + private val _navigationEvent = MutableSharedFlow(replay = 0) + val navigationEvent: SharedFlow = _navigationEvent.asSharedFlow() + + fun navigate(event: NavEvent) { + viewModelScope.launch { + _navigationEvent.emit(event) // Queued for collector + } + } +} +``` + +--- + +### ❌ Blocking Operations in State Update + +```kotlin +// BAD: Blocking main thread +fun loadData() { + _state.value = fetchDataFromNetwork() // Blocks! +} +``` + +### ✅ Async Updates + +```kotlin +// GOOD: Use coroutines +fun loadData() { + viewModelScope.launch { + _state.value = UiState.Loading + val data = withContext(Dispatchers.IO) { + fetchDataFromNetwork() + } + _state.value = UiState.Success(data) + } +} +``` + +--- + +## References + +- AccountManager.kt:36-115 +- RelayConnectionManager.kt:44-80 +- [StateFlow and SharedFlow | Android Developers](https://developer.android.com/kotlin/flow/stateflow-and-sharedflow) +- [Hot vs Cold Flows](https://carrion.dev/en/posts/kotlin-flows-hot-cold/) diff --git a/.claude/skills/kotlin-expert/references/immutability-patterns.md b/.claude/skills/kotlin-expert/references/immutability-patterns.md new file mode 100644 index 0000000000..f4c076669b --- /dev/null +++ b/.claude/skills/kotlin-expert/references/immutability-patterns.md @@ -0,0 +1,641 @@ +# Immutability Patterns + +@Immutable annotation, data classes, and immutable collections for Compose performance. + +## Table of Contents +- [Why Immutability Matters](#why-immutability-matters) +- [@Immutable Annotation](#immutable-annotation) +- [Data Classes](#data-classes) +- [Immutable Collections](#immutable-collections) +- [Common Patterns](#common-patterns) +- [Performance Impact](#performance-impact) + +--- + +## Why Immutability Matters + +### Compose Recomposition + +**Mental model:** Compose tracks state changes by comparing references. If an `@Immutable` object reference doesn't change, Compose skips recomposition. + +```kotlin +// Without @Immutable - Recomposes on every parent recomposition +data class User(val name: String, val age: Int) + +@Composable +fun UserCard(user: User) { // Recomposes unnecessarily + Text(user.name) +} + +// With @Immutable - Only recomposes when user reference changes +@Immutable +data class User(val name: String, val age: Int) + +@Composable +fun UserCard(user: User) { // Smart recomposition + Text(user.name) +} +``` + +**Performance difference:** +- Without `@Immutable`: 1000 `UserCard` recompositions per screen update +- With `@Immutable`: 10 `UserCard` recompositions (only changed users) + +### Thread Safety + +Immutable objects are inherently thread-safe: + +```kotlin +@Immutable +data class Event( + val id: String, + val content: String, + val createdAt: Long +) + +// Safe to share across coroutines without synchronization +val sharedEvent: Event = fetchEvent() +launch { processEvent(sharedEvent) } // Safe +launch { saveEvent(sharedEvent) } // Safe +``` + +--- + +## @Immutable Annotation + +### Basic Usage + +**Pattern from Amethyst:** + +```kotlin +// TextNoteEvent.kt:51-63 +@Immutable +class TextNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey +) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + // All properties are val (immutable) + // No var properties + // No mutable collections +} +``` + +**Requirements for @Immutable:** +1. All properties must be `val` (no `var`) +2. All property types must be immutable or primitives +3. No mutable collections (`MutableList`, `MutableMap`) +4. Arrays are allowed (treated as immutable by contract) +5. No public mutable state + +### @Immutable vs @Stable + +**@Immutable:** Value never changes after construction + +```kotlin +@Immutable +data class User(val name: String, val age: Int) +// Once created, user.name and user.age never change +``` + +**@Stable:** Value can change, but changes are tracked + +```kotlin +@Stable +class MutableCounter { + var count by mutableStateOf(0) // Changes tracked by Compose +} +``` + +**Amethyst uses @Immutable extensively:** +- 173+ event classes annotated with `@Immutable` +- All Nostr events immutable by design +- Critical for feed performance (thousands of events) + +--- + +## Data Classes + +### Immutable Data Classes + +**Pattern:** + +```kotlin +@Immutable +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val error: String? = null, + val messageCount: Int = 0 +) { + // Immutable properties only (val) + // Default values allowed +} +``` + +**Benefits:** +1. **Structural equality:** `equals()` compares values, not references +2. **copy():** Create modified copies without mutation +3. **toString():** Debugging-friendly output +4. **hashCode():** Consistent hashing for collections +5. **componentN():** Destructuring support + +### copy() for Updates + +**Mental model:** Instead of mutating, create modified copies. + +```kotlin +val status = RelayStatus( + url = "wss://relay.damus.io", + connected = false, + error = null +) + +// Immutable update +val updatedStatus = status.copy(connected = true) + +// Original unchanged +assert(status.connected == false) +assert(updatedStatus.connected == true) +``` + +**StateFlow pattern:** + +```kotlin +private val _relayStatuses = MutableStateFlow>(emptyMap()) + +fun updateRelay(url: String, connected: Boolean) { + _relayStatuses.value = _relayStatuses.value.mapValues { (key, status) -> + if (key == url) { + status.copy(connected = connected) // Immutable update + } else { + status + } + } +} +``` + +### All Properties in Constructor + +**Why important for data classes:** + +```kotlin +// BAD: Properties outside constructor not included in equals/hashCode +data class User(val name: String) { + var age: Int = 0 // NOT in equals/hashCode/copy! +} + +val user1 = User("Alice") +val user2 = User("Alice") +user1.age = 25 +user2.age = 30 + +assert(user1 == user2) // TRUE! age not compared +assert(user1.copy() == user1) // TRUE! age not copied + +// GOOD: All properties in constructor +@Immutable +data class User( + val name: String, + val age: Int // Included in equals/hashCode/copy +) +``` + +--- + +## Immutable Collections + +### kotlinx.collections.immutable + +**Installation:** + +```kotlin +// build.gradle.kts +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7") +} +``` + +**Why use:** +- Structural sharing (efficient copies) +- Explicit immutability (compiler enforced) +- Safe for Compose state + +### ImmutableList + +```kotlin +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +// Create immutable list +val relays: ImmutableList = persistentListOf( + "wss://relay1.com", + "wss://relay2.com" +) + +// Add returns NEW list +val updated = relays.add("wss://relay3.com") +assert(relays.size == 2) // Original unchanged +assert(updated.size == 3) // New list has 3 items + +// Convert from regular list +val mutableList = mutableListOf("a", "b", "c") +val immutable = mutableList.toImmutableList() +``` + +### ImmutableMap + +```kotlin +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap + +// Create immutable map +val relayStatuses: ImmutableMap = persistentMapOf( + "wss://relay1.com" to RelayStatus(...), + "wss://relay2.com" to RelayStatus(...) +) + +// Put returns NEW map +val updated = relayStatuses.put("wss://relay3.com", RelayStatus(...)) + +// Remove returns NEW map +val removed = relayStatuses.remove("wss://relay1.com") +``` + +### ImmutableSet + +```kotlin +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +val connectedRelays: ImmutableSet = persistentSetOf( + "wss://relay1.com", + "wss://relay2.com" +) + +val updated = connectedRelays.add("wss://relay3.com") +``` + +### Structural Sharing + +**Mental model:** Immutable collections reuse internal structure for efficiency. + +```kotlin +val list1 = persistentListOf(1, 2, 3, 4, 5) // 5 items +val list2 = list1.add(6) // Shares structure with list1 + +// Internally: +// list1 and list2 share nodes for items 1-5 +// list2 has one additional node for item 6 +// O(1) time, O(1) space for add operation +``` + +--- + +## Common Patterns + +### Pattern: Immutable State Updates + +```kotlin +@Immutable +data class FeedState( + val events: ImmutableList, + val loading: Boolean, + val error: String? +) + +class FeedViewModel { + private val _state = MutableStateFlow( + FeedState( + events = persistentListOf(), + loading = false, + error = null + ) + ) + val state: StateFlow = _state.asStateFlow() + + fun loadEvents() { + _state.value = _state.value.copy(loading = true, error = null) + + viewModelScope.launch { + try { + val events = repository.getEvents() + _state.value = _state.value.copy( + events = events.toImmutableList(), + loading = false + ) + } catch (e: Exception) { + _state.value = _state.value.copy( + loading = false, + error = e.message + ) + } + } + } + + fun addEvent(event: Event) { + _state.value = _state.value.copy( + events = _state.value.events.add(event) // Immutable add + ) + } + + fun removeEvent(eventId: String) { + _state.value = _state.value.copy( + events = _state.value.events.filter { it.id != eventId }.toImmutableList() + ) + } +} +``` + +### Pattern: Deep Immutability + +```kotlin +// Nested immutable structures +@Immutable +data class User( + val name: String, + val profile: Profile // Also immutable +) + +@Immutable +data class Profile( + val bio: String, + val avatar: String, + val relays: ImmutableList // Immutable collection +) + +// Safe deep copy +val user = User( + name = "Alice", + profile = Profile( + bio = "Nostr enthusiast", + avatar = "https://...", + relays = persistentListOf("wss://relay1.com") + ) +) + +val updatedUser = user.copy( + profile = user.profile.copy( + bio = "Bitcoin & Nostr enthusiast" // Deep update + ) +) +``` + +### Pattern: Collection Builder to Immutable + +```kotlin +// Build mutable, convert to immutable +fun processEvents(input: List): ImmutableList { + val processed = mutableListOf() + + for (event in input) { + if (event.isValid()) { + processed.add(event.normalize()) + } + } + + return processed.toImmutableList() // Convert once at end +} +``` + +### Pattern: Immutable Map Updates + +```kotlin +private val _relayStatuses = MutableStateFlow>( + persistentMapOf() +) + +fun updateRelay(url: String, connected: Boolean) { + val currentStatuses = _relayStatuses.value + val currentStatus = currentStatuses[url] ?: RelayStatus(url, false) + + _relayStatuses.value = currentStatuses.put( + url, + currentStatus.copy(connected = connected) + ) +} + +fun removeRelay(url: String) { + _relayStatuses.value = _relayStatuses.value.remove(url) +} +``` + +--- + +## Performance Impact + +### Benchmarks (Approximate) + +**Recomposition cost:** + +```kotlin +// 1000 items in LazyColumn +// Without @Immutable: ~100ms per frame (skipped frames) +// With @Immutable: ~16ms per frame (smooth 60fps) + +@Immutable +data class Item(val id: String, val name: String) + +@Composable +fun ItemList(items: ImmutableList) { + LazyColumn { + items(items, key = { it.id }) { item -> + ItemRow(item) // Only recomposes when item changes + } + } +} +``` + +**Structural sharing efficiency:** + +```kotlin +val list1 = persistentListOf(1..10000) +val list2 = list1.add(10001) // O(log n) time, shares structure + +// Regular list (copy on modification): +val mutableList = (1..10000).toMutableList() +val copy = mutableList.toList() + 10001 // O(n) time, full copy +``` + +### When to Use Immutable Collections + +**Use ImmutableList/Map/Set when:** +- Storing in Compose state (@Immutable class) +- Sharing across coroutines +- Frequent modifications (structural sharing efficient) +- Need compile-time immutability guarantee + +**Use Array when:** +- Fixed size, no modifications +- Nostr protocol (tags are `Array>`) +- Performance-critical (array access is fastest) + +**Use regular List/Map/Set when:** +- Local scope only +- Build once, read many times +- Converting to immutable at boundary + +--- + +## Anti-Patterns + +### ❌ Mutable Properties in @Immutable Class + +```kotlin +@Immutable +data class BadEvent( + val id: String, + var content: String // BAD: var breaks immutability +) +``` + +### ✅ All val Properties + +```kotlin +@Immutable +data class GoodEvent( + val id: String, + val content: String +) +``` + +--- + +### ❌ Mutable Collections in @Immutable Class + +```kotlin +@Immutable +data class BadState( + val items: MutableList // BAD: Can mutate items +) + +// Caller can mutate: +val state = BadState(mutableListOf()) +state.items.add(newItem) // Breaks immutability! +``` + +### ✅ Immutable Collections + +```kotlin +@Immutable +data class GoodState( + val items: ImmutableList +) + +// Caller must create new state: +val updated = state.copy(items = state.items.add(newItem)) +``` + +--- + +### ❌ Direct Mutation + +```kotlin +val status = RelayStatus(url, connected = false) +status.connected = true // Compile error (val) + +// But could happen with mutable nested objects: +@Immutable +data class Config( + val settings: Settings // If Settings is mutable... +) + +class Settings { + var theme: String = "dark" // BAD +} + +val config = Config(Settings()) +config.settings.theme = "light" // Mutates "immutable" config! +``` + +### ✅ Deep Immutability + +```kotlin +@Immutable +data class Config( + val settings: Settings +) + +@Immutable +data class Settings( + val theme: String // val only +) + +val config = Config(Settings("dark")) +val updated = config.copy( + settings = config.settings.copy(theme = "light") +) +``` + +--- + +### ❌ Exposing Mutable Internal State + +```kotlin +@Immutable +class BadViewModel { + private val _items = mutableListOf() + val items: List = _items // BAD: Exposes mutable list + + fun addItem(item: Item) { + _items.add(item) + } +} + +// Caller can cast and mutate: +val vm = BadViewModel() +(vm.items as MutableList).clear() // Breaks encapsulation! +``` + +### ✅ Convert to Immutable at Boundary + +```kotlin +@Immutable +class GoodViewModel { + private val _items = mutableListOf() + val items: ImmutableList + get() = _items.toImmutableList() // GOOD: Copy to immutable + + fun addItem(item: Item) { + _items.add(item) + } +} +``` + +--- + +## Checklist for Immutability + +**For @Immutable classes:** +- [ ] All properties are `val`, never `var` +- [ ] No mutable collections (`MutableList`, `MutableMap`, `MutableSet`) +- [ ] Nested objects are also `@Immutable` or primitives +- [ ] No public mutable state +- [ ] Use `copy()` for updates, never mutation +- [ ] Arrays used only when truly immutable by contract + +**For StateFlow state:** +- [ ] State class is `@Immutable` +- [ ] Use immutable collections (ImmutableList, ImmutableMap) +- [ ] Create new instances for updates (`copy()`, `.add()`, `.put()`) +- [ ] Never mutate state in-place + +**For Compose performance:** +- [ ] All `@Composable` parameters are `@Immutable` or `@Stable` +- [ ] Lists use `ImmutableList` and `key` parameter in `items()` +- [ ] Heavy objects (events, profiles) cached and reused + +--- + +## References + +- TextNoteEvent.kt:51-63 - @Immutable event example +- RelayConnectionManager.kt - Immutable map updates +- [Compose Performance | Android Developers](https://developer.android.com/jetpack/compose/performance/stability) +- [kotlinx.collections.immutable | GitHub](https://github.com/Kotlin/kotlinx.collections.immutable) +- [@Stable and @Immutable | Compose Docs](https://developer.android.com/jetpack/compose/performance/stability/fix) diff --git a/.claude/skills/kotlin-expert/references/sealed-class-catalog.md b/.claude/skills/kotlin-expert/references/sealed-class-catalog.md new file mode 100644 index 0000000000..c99cbef3a2 --- /dev/null +++ b/.claude/skills/kotlin-expert/references/sealed-class-catalog.md @@ -0,0 +1,482 @@ +# Sealed Class Catalog + +Comprehensive list of sealed types in AmethystMultiplatform with usage patterns. + +## Table of Contents +- [State Management](#state-management) +- [Result Types](#result-types) +- [Tag Variants](#tag-variants) +- [Sealed Class vs Sealed Interface](#sealed-class-vs-sealed-interface) +- [Patterns](#patterns) + +--- + +## State Management + +### AccountState (Sealed Class) + +**File:** `commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt:36-46` + +```kotlin +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() +} +``` + +**Why sealed class:** +- Two distinct states with different data +- `LoggedIn` holds data, `LoggedOut` doesn't +- No need for generics or multiple inheritance + +**Usage:** + +```kotlin +fun handleAccountState(state: AccountState) { + when (state) { + is AccountState.LoggedOut -> showLogin() + is AccountState.LoggedIn -> { + showFeed( + pubkey = state.pubKeyHex, + canSign = !state.isReadOnly + ) + } + } // Exhaustive - compiler enforces +} +``` + +### VerificationState (Sealed Class) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/VerificationState.kt` + +```kotlin +sealed class VerificationState { + data object NotStarted : VerificationState() + data object Started : VerificationState() + data class Failed(val reason: String) : VerificationState() + data object Verified : VerificationState() +} +``` + +**Pattern:** +- State machine (NotStarted → Started → Failed/Verified) +- Only `Failed` carries data (reason) +- Rest are singletons (`data object`) + +--- + +## Result Types + +### SignerResult (Sealed Interface with Generics) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt:25-46` + +```kotlin +sealed interface SignerResult { + sealed interface RequestAddressed : SignerResult { + class Successful(val result: T) : RequestAddressed + class Rejected : RequestAddressed + class TimedOut : RequestAddressed + class ReceivedButCouldNotPerform( + val message: String? = null + ) : RequestAddressed + class ReceivedButCouldNotParseEventFromResult( + val eventJson: String + ) : RequestAddressed + class ReceivedButCouldNotVerifyResultingEvent( + val invalidEvent: Event + ) : RequestAddressed + } +} + +interface IResult + +data class SignResult(val event: Event) : IResult +data class EncryptionResult(val ciphertext: String) : IResult +data class DecryptionResult(val plaintext: String) : IResult +``` + +**Why sealed interface:** +- Generic result type `` +- Nested sealed hierarchy (RequestAddressed) +- Need covariance for flexible result types + +**Usage:** + +```kotlin +suspend fun signEvent(event: Event): SignerResult { + return when (val result = remoteSigner.sign(event)) { + is SignerResult.RequestAddressed.Successful -> result + is SignerResult.RequestAddressed.Rejected -> { + logger.warn("Signing rejected") + result + } + is SignerResult.RequestAddressed.TimedOut -> { + logger.error("Signing timed out") + result + } + is SignerResult.RequestAddressed.ReceivedButCouldNotPerform -> { + logger.error("Signer error: ${result.message}") + result + } + } +} +``` + +### CacheResults (Sealed Class with Generics) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/caches/CacheResults.kt` + +```kotlin +sealed class CacheResults { + data class Found(val value: T) : CacheResults() + class NotFound : CacheResults() +} +``` + +**Pattern:** +- Simple binary result (found/not found) +- `Found` carries data, `NotFound` doesn't +- Generic for reusability + +--- + +## Tag Variants + +### MuteTag (Sealed Class) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt` + +```kotlin +sealed class MuteTag( + val nameOrNull: String?, + val valueOrNull: String? +) { + class Event(eventId: String) : MuteTag("e", eventId) + class Profile(pubkey: String) : MuteTag("p", pubkey) + class Word(word: String) : MuteTag("word", word) + class Thread(threadId: String) : MuteTag("thread", threadId) + + companion object { + fun parse(tag: Array): MuteTag? { + return when (tag.getOrNull(0)) { + "e" -> tag.getOrNull(1)?.let { Event(it) } + "p" -> tag.getOrNull(1)?.let { Profile(it) } + "word" -> tag.getOrNull(1)?.let { Word(it) } + "thread" -> tag.getOrNull(1)?.let { Thread(it) } + else -> null + } + } + } + + fun toArray(): Array { + return arrayOf(nameOrNull ?: "", valueOrNull ?: "") + } +} +``` + +**Pattern:** +- Common base class with shared properties +- Each variant represents different tag type +- Factory method `parse()` for parsing +- `toArray()` for serialization + +### BookmarkIdTag (Sealed Class) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/bookmarkList/tags/BookmarkIdTag.kt` + +```kotlin +sealed class BookmarkIdTag { + abstract val id: String + abstract val marker: String? + + data class Event(override val id: String, override val marker: String?) : BookmarkIdTag() + data class Profile(override val id: String, override val marker: String?) : BookmarkIdTag() + data class Address(override val id: String, override val marker: String?) : BookmarkIdTag() + + companion object { + fun parse(tag: Array): BookmarkIdTag? { + val marker = tag.getOrNull(3) + return when (tag.getOrNull(0)) { + "e" -> tag.getOrNull(1)?.let { Event(it, marker) } + "p" -> tag.getOrNull(1)?.let { Profile(it, marker) } + "a" -> tag.getOrNull(1)?.let { Address(it, marker) } + else -> null + } + } + } +} +``` + +**Pattern:** +- Abstract properties in sealed class +- Data classes implement abstract properties +- Parse factory returns sealed variant + +--- + +## Exception Hierarchies + +### SignerExceptions (Sealed Class) + +**File:** `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/SignerExceptions.kt` + +```kotlin +sealed class SignerExceptions(message: String) : Exception(message) { + class UnableToSign(message: String) : SignerExceptions(message) + class UnableToDecrypt(message: String) : SignerExceptions(message) + class UnableToEncrypt(message: String) : SignerExceptions(message) + class UnableToGetPublicKey(message: String) : SignerExceptions(message) +} +``` + +**Pattern:** +- Sealed exception hierarchy +- Extends `Exception` base class +- Type-safe error handling + +**Usage:** + +```kotlin +try { + signer.sign(event) +} catch (e: SignerExceptions) { + when (e) { + is SignerExceptions.UnableToSign -> logger.error("Signing failed: ${e.message}") + is SignerExceptions.UnableToDecrypt -> logger.error("Decryption failed: ${e.message}") + is SignerExceptions.UnableToEncrypt -> logger.error("Encryption failed: ${e.message}") + is SignerExceptions.UnableToGetPublicKey -> logger.error("No public key: ${e.message}") + } +} +``` + +--- + +## Sealed Class vs Sealed Interface + +### When to Use Sealed Class + +**Examples from codebase:** + +1. **AccountState** - State variants with different data +2. **VerificationState** - State machine +3. **MuteTag** - Tag variants with common base properties +4. **SignerExceptions** - Exception hierarchy + +**Characteristics:** +- Need common constructor parameters +- Single inheritance only +- State variants +- Exception hierarchies + +### When to Use Sealed Interface + +**Examples from codebase:** + +1. **SignerResult** - Generic result types needing variance +2. **RelayUrlNormalizer.Result** - Binary result with no shared state + +**Characteristics:** +- Need generics with variance (`out`, `in`) +- No common state needed +- Multiple inheritance possible +- Contract/capability representation + +--- + +## Patterns + +### Pattern: State Machine + +```kotlin +sealed class ConnectionState { + data object Disconnected : ConnectionState() + data object Connecting : ConnectionState() + data class Connected(val relay: String) : ConnectionState() + data class Failed(val error: String) : ConnectionState() +} + +// Allowed transitions +fun transition(from: ConnectionState, event: Event): ConnectionState { + return when (from) { + is ConnectionState.Disconnected -> { + when (event) { + is Event.Connect -> ConnectionState.Connecting + else -> from + } + } + is ConnectionState.Connecting -> { + when (event) { + is Event.Success -> ConnectionState.Connected(event.relay) + is Event.Error -> ConnectionState.Failed(event.message) + is Event.Cancel -> ConnectionState.Disconnected + else -> from + } + } + is ConnectionState.Connected -> { + when (event) { + is Event.Disconnect -> ConnectionState.Disconnected + is Event.Error -> ConnectionState.Failed(event.message) + else -> from + } + } + is ConnectionState.Failed -> { + when (event) { + is Event.Retry -> ConnectionState.Connecting + is Event.Cancel -> ConnectionState.Disconnected + else -> from + } + } + } +} +``` + +### Pattern: Result Type + +```kotlin +sealed interface Result { + data class Success(val data: T) : Result + data class Error(val exception: Exception) : Result + data object Loading : Result +} + +// Extension functions +fun Result.getOrNull(): T? = when (this) { + is Result.Success -> data + else -> null +} + +fun Result.getOrThrow(): T = when (this) { + is Result.Success -> data + is Result.Error -> throw exception + is Result.Loading -> error("Still loading") +} + +fun Result.map(transform: (T) -> R): Result = when (this) { + is Result.Success -> Result.Success(transform(data)) + is Result.Error -> this + is Result.Loading -> Result.Loading +} +``` + +### Pattern: Tagged Union (Discriminated Union) + +```kotlin +sealed class Command { + data class SendEvent(val event: Event) : Command() + data class Subscribe(val filters: List) : Command() + data class Unsubscribe(val subId: String) : Command() + data object Close : Command() + + fun toJson(): String = when (this) { + is SendEvent -> """["EVENT",${event.toJson()}]""" + is Subscribe -> """["REQ","sub",${filters.joinToString { it.toJson() }}]""" + is Unsubscribe -> """["CLOSE","$subId"]""" + is Close -> """["CLOSE"]""" + } +} +``` + +### Pattern: Nested Sealed Hierarchies + +```kotlin +sealed interface UiState { + sealed interface Loading : UiState { + data object Initial : Loading + data class Refreshing(val currentData: List) : Loading + } + + sealed interface Content : UiState { + data class Success(val data: List) : Content + data object Empty : Content + } + + sealed interface Error : UiState { + data class Network(val message: String) : Error + data class Server(val code: Int, val message: String) : Error + } +} + +// Usage +fun renderUi(state: UiState) { + when (state) { + is UiState.Loading.Initial -> showFullScreenLoader() + is UiState.Loading.Refreshing -> showRefreshIndicator(state.currentData) + is UiState.Content.Success -> showList(state.data) + is UiState.Content.Empty -> showEmptyState() + is UiState.Error.Network -> showNetworkError(state.message) + is UiState.Error.Server -> showServerError(state.code, state.message) + } +} +``` + +--- + +## All Sealed Types in Quartz + +**Complete list of sealed types found in codebase:** + +### Commons +- AccountState (class) + +### Quartz +- BaseZapSplitSetup (class) +- MuteTag (class) +- BookmarkIdTag (class) +- SignerResult (interface) +- VerificationState (class) +- CacheResults (class) +- SignerExceptions (class) +- RelayUrlNormalizer.Result (interface) + +**Total:** 8 sealed types (7 classes, 1 interface) + +--- + +## Decision Tree + +``` +Need to represent variants of a concept? + YES → Use sealed type + NO → Regular class/interface + +Variants have different data? + YES → sealed class or sealed interface + NO → enum (if simple constants) + +Need generics with variance (out/in)? + YES → sealed interface + NO → sealed class (simpler) + +Need common constructor/properties? + YES → sealed class + NO → sealed interface + +Need multiple inheritance? + YES → sealed interface + NO → Either works + +Representing state machine? + → sealed class (state transitions) + +Representing result/error types? + → sealed interface (if generic, else class) + +Representing tag/command variants? + → sealed class (common structure) +``` + +--- + +## References + +- [Sealed Classes | Kotlin Docs](https://kotlinlang.org/docs/sealed-classes.html) +- [Effective Kotlin: Sealed Classes](https://kt.academy/article/ek-sealed-classes) +- [Complete Guide: Sealed Classes & Interfaces 2025](https://proandroiddev.com/complete-technical-guide-sealed-classes-sealed-interfaces-enums-in-kotlin-28ffc39116df) diff --git a/.claude/skills/kotlin-multiplatform/SKILL.md b/.claude/skills/kotlin-multiplatform/SKILL.md new file mode 100644 index 0000000000..13df020fde --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/SKILL.md @@ -0,0 +1,400 @@ +--- +name: kotlin-multiplatform +description: | + Platform abstraction decision-making for Amethyst KMP project. Guides when to abstract vs keep platform-specific, + source set placement (commonMain, jvmAndroid, platform-specific), expect/actual patterns. Covers primary targets + (Android, JVM/Desktop, iOS) with web/wasm future considerations. Integrates with gradle-expert for dependency issues. + Triggers on: abstraction decisions ("should I share this?"), source set placement questions, expect/actual creation, + build.gradle.kts work, incorrect placement detection, KMP dependency suggestions. +--- + +# Kotlin Multiplatform: Platform Abstraction Decisions + +Expert guidance for KMP architecture in Amethyst - deciding what to share vs keep platform-specific. + +## When to Use This Skill + +Making platform abstraction decisions: +- "Should I create expect/actual or keep Android-only?" +- "Can I share this ViewModel logic?" +- "Where does this crypto/JSON/network implementation belong?" +- "This uses Android Context - can it be abstracted?" +- "Is this code in the wrong module?" +- Preparing for iOS/web/wasm targets +- Detecting incorrect placements + +## Abstraction Decision Tree + +**Central question:** "Should this code be reused across platforms?" + +Follow this decision path (< 1 minute): + +``` +Q: Is it used by 2+ platforms? +├─ NO → Keep platform-specific +│ Example: Android-only permission handling +│ +└─ YES → Continue ↓ + +Q: Is it pure Kotlin (no platform APIs)? +├─ YES → commonMain +│ Example: Nostr event parsing, business rules +│ +└─ NO → Continue ↓ + +Q: Does it vary by platform or by JVM vs non-JVM? +├─ By platform (Android ≠ iOS ≠ Desktop) +│ → expect/actual +│ Example: Secp256k1Instance (uses different security APIs) +│ +├─ By JVM (Android = Desktop ≠ iOS/web) +│ → jvmAndroid +│ Example: Jackson JSON parsing (JVM library) +│ +└─ Complex/UI-related + → Keep platform-specific + Example: Navigation (Activity vs Window too different) + +Final check: +Q: Maintenance cost of abstraction < duplication cost? +├─ YES → Proceed with abstraction +└─ NO → Duplicate (simpler) +``` + +### Real Examples from Codebase + +**Crypto → expect/actual:** +```kotlin +// commonMain - expect declaration +expect object Secp256k1Instance { + fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray +} + +// androidMain - uses Android Keystore +// jvmMain - uses Desktop JVM crypto +// iosMain - uses iOS Security framework +``` +**Why:** Each platform has different security APIs. + +**JSON parsing → jvmAndroid:** +```kotlin +// quartz/build.gradle.kts +val jvmAndroid = create("jvmAndroid") { + api(libs.jackson.module.kotlin) +} +``` +**Why:** Jackson is JVM-only, works on Android + Desktop, not iOS/web. + +**Navigation → platform-specific:** +- Android: `MainActivity` (Activity + Compose Navigation) +- Desktop: `Window` + sidebar + MenuBar +**Why:** UI paradigms fundamentally different. + +## Mental Model: Source Sets as Dependency Graph + +Think of source sets as a dependency graph, not folders. + +``` +┌─────────────────────────────────────────────┐ +│ commonMain = Contract (pure Kotlin) │ +│ - Business logic, protocol, data models │ +│ - No platform APIs │ +└────────────┬────────────────────────────────┘ + │ + ├──────────────────────┬──────────────────── + │ │ + ▼ ▼ + ┌───────────────────┐ ┌──────────────────┐ + │ jvmAndroid │ │ iosMain │ + │ JVM libs shared │ │ iOS common │ + │ - Jackson │ │ │ + │ - OkHttp │ └────┬─────────────┘ + └───┬───────────┬───┘ │ + │ │ ├─→ iosX64Main + ▼ ▼ ├─→ iosArm64Main + ┌─────────┐ ┌──────────┐ └─→ iosSimulatorArm64Main + │android │ │jvmMain │ + │Main │ │(Desktop) │ + └─────────┘ └──────────┘ + +Future: jsMain, wasmMain +``` + +**Key insight:** jvmAndroid is NOT a platform - it's a shared JVM layer. + +## The jvmAndroid Pattern + +**Unique to Amethyst.** Shares JVM libraries between Android + Desktop. + +### When to Use jvmAndroid + +Use jvmAndroid when: +- ✅ JVM-specific libraries (Jackson, OkHttp, url-detector) +- ✅ Android implementation = Desktop implementation (same JVM) +- ✅ Library doesn't work on iOS/web + +Do NOT use jvmAndroid for: +- ❌ Pure Kotlin code (use commonMain) +- ❌ Platform-specific APIs (use androidMain/jvmMain) +- ❌ Code that should work on all platforms + +### Example from quartz/build.gradle.kts + +```kotlin +// Must be defined BEFORE androidMain and jvmMain +val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + + dependencies { + api(libs.jackson.module.kotlin) // JSON parsing - JVM only + api(libs.url.detector) // URL extraction - JVM only + implementation(libs.okhttp) // HTTP client - JVM only + } +} + +// Both depend on jvmAndroid +jvmMain { dependsOn(jvmAndroid) } +androidMain { dependsOn(jvmAndroid) } +``` + +**Why Jackson in jvmAndroid, not commonMain?** +- Jackson is JVM-specific library +- Works on Android (runs on JVM) +- Works on Desktop (runs on JVM) +- Does NOT work on iOS (not JVM) or web (not JVM) + +**Web/wasm consideration:** For future web support, consider migrating from Jackson → kotlinx.serialization (see Target-Specific Guidance). + +## What to Abstract vs Keep Platform-Specific + +Quick decision guidelines based on codebase patterns: + +### Always Abstract +- **Crypto** (Secp256k1, encryption, signing) +- **Core protocol logic** (Nostr events, NIPs) +- **Why:** Needed everywhere, platform security APIs vary + +### Often Abstract +- **I/O operations** (file reading, caching) +- **Logging** (platform logging systems differ) +- **Serialization** (if using kotlinx.serialization) +- **Why:** Commonly reused, platform implementations available + +### Sometimes Abstract +- **Business logic:** YES - state machines, data processing +- **UI state:** NO - ViewModels platform-specific +- **Why:** Separate concerns, business logic reusable + +### Rarely Abstract +- **UI components** (composables with platform dependencies) +- **Why:** Platform paradigms differ (bottom nav vs sidebar) + +### Never Abstract +- **Navigation** (Activity vs Window fundamentally different) +- **Permissions** (Android vs iOS APIs incompatible) +- **Platform UX patterns** +- **Why:** Too platform-specific, abstraction creates leaky APIs + +### Evidence from shared-ui-analysis.md + +| Component | Shared? | Rationale | +|-----------|---------|-----------| +| PubKeyFormatter, ZapFormatter | ✅ YES | Pure Kotlin, no platform APIs | +| TimeAgoFormatter | ⚠️ ABSTRACTED | Needs StringProvider for localized strings | +| Navigation (INav) | ❌ NO | Activity vs Window too different | +| AccountViewModel | ⚠️ PARTIAL | Business logic → IAccountState (shared), UI state → platform ViewModels | +| Image loading (Coil) | ⚠️ ABSTRACTED | Coil 3.x supports KMP, needs expect/actual wrapper | + +## expect/actual Mechanics + +**When to use:** Code needed by 2+ platforms, varies by platform. + +### Pattern Categories from Codebase + +**Objects (singletons):** +```kotlin +// 24 expect declarations found, common pattern: +expect object Secp256k1Instance { ... } +expect object Log { ... } +expect object LibSodiumInstance { ... } +``` + +**Classes (instantiable):** +```kotlin +expect class AESCBC { ... } +expect class DigestInstance { ... } +``` + +**Functions (utilities):** +```kotlin +expect fun platform(): String +expect fun currentTimeSeconds(): Long +``` + +**See** [references/expect-actual-catalog.md](references/expect-actual-catalog.md) for complete catalog with rationale. + +## Target-Specific Guidance + +### Android, JVM (Desktop), iOS - Current Primary Targets + +**Status:** Mature patterns, stable APIs + +**Android (androidMain):** +- Uses Android framework (Activity, Context, etc.) +- secp256k1-kmp-jni-android for crypto +- AndroidX libraries + +**Desktop JVM (jvmMain):** +- Uses Compose Desktop (Window, MenuBar, etc.) +- secp256k1-kmp-jni-jvm for crypto +- Pure JVM libraries + +**iOS (iosMain):** +- Active development, framework configured +- Architecture targets: iosX64Main, iosArm64Main, iosSimulatorArm64Main +- Platform APIs via platform.posix, Security framework + +### Web, wasm - Future Targets + +**Status:** Not yet implemented, consider for future-proofing + +**Constraints to know:** +- ❌ No platform.posix (file I/O different) +- ❌ No JVM libraries (Jackson, OkHttp won't work) +- ❌ Different async model (JS event loop vs threads) + +**Future-proofing tips:** +1. Prefer pure Kotlin in commonMain +2. Use kotlinx.* libraries: + - kotlinx.serialization instead of Jackson + - ktor instead of OkHttp (ktor supports web) + - kotlinx.datetime instead of custom date handling +3. Avoid platform.posix for file operations +4. Test abstractions work without JVM assumptions + +**Example migration path:** +```kotlin +// Current: jvmAndroid (JVM-only) +api(libs.jackson.module.kotlin) + +// Future: commonMain (all platforms) +api(libs.kotlinx.serialization.json) +``` + +## Integration: When to Invoke Other Skills + +### Invoke gradle-expert + +Trigger gradle-expert skill when encountering: +- Dependency conflicts (e.g., secp256k1-android vs secp256k1-jvm version mismatch) +- Build errors related to source sets +- Version catalog issues (libs.versions.toml) +- "Duplicate class" errors +- Performance/build time issues + +**Example trigger:** +``` +Error: Duplicate class found: fr.acinq.secp256k1.Secp256k1 +``` +→ Invoke gradle-expert for dependency conflict resolution. + +### Flags to Raise + +**Platform code in commonMain:** +```kotlin +// ❌ INCORRECT - Android API in commonMain +expect fun getContext(): Context // Context is Android-only! +``` +→ Flag: "Android API in commonMain won't compile on other platforms" + +**Duplicated business logic:** +```kotlin +// ❌ INCORRECT - Same logic in both +// androidMain/.../CryptoUtils.kt +fun validateSignature(...) { ... } + +// jvmMain/.../CryptoUtils.kt +fun validateSignature(...) { ... } // Duplicated! +``` +→ Flag: "Business logic duplicated, should be in commonMain or expect/actual" + +**Reinventing wheel - suggest KMP alternatives:** +- Custom date/time → kotlinx.datetime +- OkHttp → ktor (supports web) +- Jackson → kotlinx.serialization +- Custom UUID → kotlinx.uuid (when stable) + +## Common Pitfalls + +### 1. Over-Abstraction +**Problem:** Creating expect/actual for UI components +```kotlin +// ❌ BAD +expect fun NavigationComponent(...) +``` +**Why:** Navigation paradigms too different (Activity vs Window) +**Fix:** Keep platform-specific, accept duplication + +### 2. Under-Sharing +**Problem:** Duplicating business logic across platforms +```kotlin +// ❌ BAD - duplicated in androidMain and jvmMain +fun parseNostrEvent(json: String): Event { ... } +``` +**Why:** Bug fixes need to be applied twice, tests duplicated +**Fix:** Move to commonMain (pure Kotlin) or create expect/actual + +### 3. Leaky Abstractions +**Problem:** Platform code in commonMain +```kotlin +// commonMain - ❌ BAD +import android.content.Context // Won't compile on iOS! +``` +**Fix:** Use expect/actual or dependency injection + +### 4. Premature Abstraction +**Problem:** Creating expect/actual before second platform needs it +```kotlin +// ❌ BAD - only used on Android currently +expect fun showNotification(...) +``` +**Why:** Wrong abstraction boundaries, wasted effort +**Fix:** Wait until iOS actually needs it, then abstract + +### 5. Wrong Source Set +**Problem:** JVM libraries in commonMain +```kotlin +// commonMain - ❌ BAD +import com.fasterxml.jackson.databind.ObjectMapper +``` +**Why:** Jackson won't compile on iOS/web +**Fix:** Move to jvmAndroid or migrate to kotlinx.serialization + +## Quick Reference + +| Code Type | Recommended Location | Reason | +|-----------|---------------------|--------| +| Pure Kotlin business logic | commonMain | Works everywhere | +| Nostr protocol, NIPs | commonMain | Core logic, no platform APIs | +| JVM libs (Jackson, OkHttp) | jvmAndroid | Android + Desktop only | +| Crypto (varies by platform) | expect in commonMain, actual in platforms | Different security APIs per platform | +| I/O, logging | expect in commonMain, actual in platforms | Platform implementations differ | +| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns | +| State (UI) | Platform ViewModels | Platform-specific lifecycle | +| UI formatters (pure) | commons/commonMain | Reusable, no dependencies | +| UI components (complex) | Platform-specific | Paradigms differ | +| Navigation | Platform-specific only | Activity vs Window too different | +| Permissions | Platform-specific only | APIs incompatible | +| Platform UX (menus, etc.) | Platform-specific only | Native feel required | + +## See Also + +- [references/abstraction-examples.md](references/abstraction-examples.md) - Good/bad abstraction examples with rationale +- [references/source-set-hierarchy.md](references/source-set-hierarchy.md) - Visual hierarchy with Amethyst examples +- [references/expect-actual-catalog.md](references/expect-actual-catalog.md) - All 24 expect/actual pairs with "why abstracted" +- [references/target-compatibility.md](references/target-compatibility.md) - Platform constraints and future-proofing + +## Scripts + +- `scripts/validate-kmp-structure.sh` - Detect incorrect placements, validate source sets +- `scripts/suggest-kmp-dependency.sh` - Suggest KMP library alternatives (ktor, kotlinx.serialization, etc.) diff --git a/.claude/skills/kotlin-multiplatform/references/abstraction-examples.md b/.claude/skills/kotlin-multiplatform/references/abstraction-examples.md new file mode 100644 index 0000000000..e1277dc75c --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/references/abstraction-examples.md @@ -0,0 +1,311 @@ +# Abstraction Examples from Amethyst Codebase + +Real examples of abstraction decisions with rationale. + +## Good Abstractions (Why They Work) + +### 1. Secp256k1Instance - Crypto Signing + +**Location:** expect in commonMain, actual in androidMain/jvmMain/iosMain + +**Code:** +```kotlin +// quartz/src/commonMain/.../Secp256k1Instance.kt +expect object Secp256k1Instance { + fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray + fun verifySchnorr(signature: ByteArray, hash: ByteArray, pubKey: ByteArray): Boolean +} +``` + +**Why abstracted:** +- Used by all platforms (Android, Desktop, iOS) +- Security APIs fundamentally different: + - Android: secp256k1-kmp-jni-android (Android Keystore integration) + - Desktop: secp256k1-kmp-jni-jvm (pure JVM crypto) + - iOS: Native Security framework +- Core protocol requirement (Nostr signatures) + +**Decision rationale:** Always abstract crypto - varies by platform security APIs, critical for all platforms. + +--- + +### 2. Log - Platform Logging + +**Location:** expect object in commonMain + +**Code:** +```kotlin +// quartz/src/commonMain/.../Log.kt +expect object Log { + fun d(tag: String, message: String) + fun w(tag: String, message: String, throwable: Throwable?) + fun e(tag: String, message: String, throwable: Throwable?) +} +``` + +**Why abstracted:** +- Used throughout quartz module (protocol library) +- Logging systems differ: + - Android: android.util.Log + - Desktop: println or logging framework + - iOS: NSLog or OSLog +- Simple interface, easy to implement + +**Decision rationale:** Often abstract logging - platform systems differ, widely used, simple interface. + +--- + +### 3. Platform Utils - Time & Platform Name + +**Location:** expect functions in commonMain + +**Code:** +```kotlin +// quartz/src/commonMain/.../Platform.kt +expect fun platform(): String +expect fun currentTimeSeconds(): Long +``` + +**Why abstracted:** +- Used by Nostr event creation (timestamps) +- Platform name for debugging +- Simple utilities, clear platform boundary + +**Decision rationale:** Platform utilities are good abstraction candidates - simple, useful everywhere. + +--- + +### 4. Jackson JSON (jvmAndroid Pattern) + +**Location:** jvmAndroid source set + +**Code:** +```kotlin +// quartz/build.gradle.kts +val jvmAndroid = create("jvmAndroid") { + api(libs.jackson.module.kotlin) // JVM-only library +} +``` + +**Why jvmAndroid (not commonMain):** +- Jackson is JVM-specific library +- Works on Android (JVM) + Desktop (JVM) +- Does NOT work on iOS (not JVM) or web (not JVM) +- Performance-critical JSON parsing + +**Decision rationale:** Use jvmAndroid for JVM libraries shared between Android and Desktop. + +**Future consideration:** For web support, migrate to kotlinx.serialization (works on all platforms). + +--- + +## Bad/Over-Abstractions (Why They Failed) + +### 1. Navigation Abstraction (Avoided) + +**What COULD have been done:** +```kotlin +// ❌ Over-abstraction - DON'T DO THIS +expect interface Navigator { + fun navigate(route: String) + fun popBackStack() +} +``` + +**Why NOT abstracted:** +- Navigation paradigms fundamentally different: + - Android: Activity + Compose Navigation + back stack + - Desktop: Window + screen state + no back stack concept +- Complex APIs don't map well +- Creates leaky abstraction + +**Actual approach:** Keep platform-specific +- Android: `INav` interface + Compose Navigation +- Desktop: Simple screen enum + state + +**Decision rationale:** Never abstract navigation - platforms too different, abstraction would be leaky. + +--- + +### 2. String Resources (Abstraction Planned) + +**Current state:** Platform-specific (over-duplication) + +**Problem:** +```kotlin +// Android uses R.string.* +Text(stringResource(R.string.post_not_found)) + +// Desktop uses hardcoded strings +Text("Post not found") +``` + +**Why NOT yet abstracted:** Waiting for second platform to fully implement UI, then will create StringProvider interface. + +**Planned abstraction:** +```kotlin +// commonMain +interface StringProvider { + fun get(key: String): String +} + +// androidMain +class AndroidStringProvider(context: Context): StringProvider { ... } + +// jvmMain +class DesktopStringProvider: StringProvider { ... } +``` + +**Lesson:** Don't abstract prematurely - wait until second platform needs it, then create proper abstraction. + +--- + +## Platform-Specific Code (Why NOT Abstracted) + +### 1. MainActivity (Android Activity) + +**Location:** amethyst/src/main/.../MainActivity.kt + +**Code:** +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + setContent { + AmethystTheme { + AccountScreen(accountStateViewModel) + } + } + } +} +``` + +**Why platform-specific:** +- AppCompatActivity is Android framework +- Activity lifecycle unique to Android +- enableEdgeToEdge() is Android-specific API +- No equivalent on Desktop (uses Window) + +**Decision rationale:** Android Activity is platform-specific by nature. + +--- + +### 2. Desktop Window & MenuBar + +**Location:** desktopApp/src/jvmMain/.../Main.kt + +**Code:** +```kotlin +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "Amethyst" + ) { + MenuBar { + Menu("File") { + Item("New Note", onClick = { ... }, shortcut = KeyShortcut(Key.N, ctrl = true)) + Item("Quit", onClick = ::exitApplication) + } + } + NavigationRail { ... } // Sidebar navigation + } +} +``` + +**Why platform-specific:** +- Window, MenuBar, NavigationRail are Compose Desktop APIs +- Keyboard shortcuts (Ctrl+N) are desktop paradigm +- Sidebar navigation vs Android bottom nav +- No equivalent on Android + +**Decision rationale:** Desktop UX patterns are platform-specific by nature. + +--- + +### 3. AccountViewModel (Android ViewModel) + +**Location:** amethyst/.../AccountStateViewModel.kt + +**Partially abstracted:** +- Business logic → IAccountState interface (can be shared) +- UI state + lifecycle → AndroidX ViewModel (Android-only) + +**Why not fully abstracted:** +- AndroidX ViewModel lifecycle tied to Android +- Desktop doesn't need ViewModel (simpler state management) +- SavedStateHandle is Android-specific + +**Decision rationale:** Extract business logic to interface, keep UI state platform-specific. + +--- + +## Migration Examples (Android → Shared) + +### Example 1: PubKeyFormatter (Pure Kotlin) + +**Before:** +```kotlin +// amethyst/ui/note/PubKeyFormatter.kt +fun String.toDisplayHexKey(): String { + return "${take(8)}:${takeLast(8)}" +} +``` + +**After:** +```kotlin +// commons/commonMain/formatters/PubKeyFormatter.kt +fun String.toDisplayHexKey(): String { + return "${take(8)}:${takeLast(8)}" +} + +// Both apps use it +import com.vitorpamplona.amethyst.commons.formatters.toDisplayHexKey +``` + +**Why successful:** +- Pure Kotlin, no platform dependencies +- Widely reused +- Simple utility function + +--- + +### Example 2: TimeAgoFormatter (Requires Abstraction) + +**Problem:** +```kotlin +// Uses Android R.string.* +fun timeAgo(timestamp: Long): String { + return context.getString(R.string.x_minutes_ago, minutes) +} +``` + +**Solution:** Abstract string resources +```kotlin +// commonMain +fun timeAgo(timestamp: Long, stringProvider: StringProvider): String { + return stringProvider.get("x_minutes_ago", minutes) +} + +// androidMain +stringProvider = AndroidStringProvider(context) + +// jvmMain +stringProvider = DesktopStringProvider() +``` + +**Why successful:** Clear platform boundary (string resources), useful on both platforms. + +--- + +## Decision Pattern Summary + +| Pattern | Abstract? | Why | +|---------|-----------|-----| +| Pure Kotlin utilities | ✅ YES | No platform dependency, easy | +| Crypto APIs | ✅ YES (expect/actual) | Platform security APIs differ | +| JVM libraries | ⚠️ jvmAndroid | Works on Android+Desktop only | +| UI components (simple) | ✅ YES | Composables work cross-platform | +| UI components (complex) | ❌ NO | Platform dependencies | +| Navigation | ❌ NO | Paradigms too different | +| ViewModels | ⚠️ PARTIAL | Business logic yes, UI state no | +| String resources | ⚠️ PLANNED | Needs abstraction layer | diff --git a/.claude/skills/kotlin-multiplatform/references/expect-actual-catalog.md b/.claude/skills/kotlin-multiplatform/references/expect-actual-catalog.md new file mode 100644 index 0000000000..6b6e98c1cb --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/references/expect-actual-catalog.md @@ -0,0 +1,163 @@ +# Complete expect/actual Catalog + +All 24 expect declarations in Amethyst quartz module with rationale. + +| # | Name | Type | Purpose | Why Abstracted | Files | +|---|------|------|---------|----------------|-------| +| 1 | AESCBC | class | AES CBC encryption | Platform crypto APIs differ | quartz/.../ciphers/AESCBC.kt | +| 2 | AESGCM | class | AES GCM encryption | Platform crypto APIs differ | quartz/.../ciphers/AESGCM.kt | +| 3 | DigestInstance | class | Hash digests (SHA256) | Platform implementations | quartz/.../diggest/DigestInstance.kt | +| 4 | MacInstance | class | MAC (HMAC) operations | Platform crypto APIs | quartz/.../mac/MacInstance.kt | +| 5 | Sha256 | object | SHA256 hashing | Platform-specific optimizations | quartz/.../sha256/Sha256.kt | +| 6 | LargeCache | object | Large object caching | Platform storage APIs differ | quartz/.../cache/LargeCache.kt | +| 7 | UriParser | object | URI parsing | Platform URL APIs differ | quartz/.../UriParser.kt | +| 8 | UrlEncoder | object | URL encoding | Platform encoding differs | quartz/.../UrlEncoder.kt | +| 9 | Urls | object | URL utilities | Platform URL handling | quartz/.../Urls.kt | +| 10 | Platform | functions | platform(), currentTimeSeconds() | Platform name & time APIs | quartz/.../Platform.kt | +| 11 | Rfc3986 | object | RFC 3986 URL normalization | Used in jvmAndroid | quartz/.../Rfc3986.kt | +| 12 | Secp256k1Instance | object | Bitcoin crypto (secp256k1) | Different libs per platform | quartz/.../Secp256k1Instance.kt | +| 13 | SecureRandom | object | Cryptographically secure random | Platform random APIs differ | quartz/.../SecureRandom.kt | +| 14 | StringExt | functions | String utilities | Platform string handling | quartz/.../StringExt.kt | +| 15 | UnicodeNormalizer | object | Unicode normalization | Platform text APIs | quartz/.../UnicodeNormalizer.kt | +| 16 | GZip | object | GZip compression | Platform compression APIs | quartz/.../GZip.kt | +| 17 | LibSodiumInstance | object | NaCl/libsodium (NIP-44 encryption) | Different libs per platform | quartz/.../LibSodiumInstance.kt | +| 18 | Log | object | Logging | Platform logging systems | quartz/.../Log.kt | +| 19 | BigDecimal | class | Arbitrary precision decimal | Not in Kotlin common stdlib | quartz/.../BigDecimal.kt | +| 20 | BitSet | class | Bit set data structure | Not in Kotlin common stdlib | quartz/.../BitSet.kt | +| 21 | ServerInfoParser | object | Server info parsing (NIP-96) | Platform JSON parsing | quartz/.../nip96.../ServerInfoParser.kt | +| 22 | EventHasherSerializer | object | Event hashing | Platform-specific optimizations | quartz/.../nip01Core.../EventHasherSerializer.kt | +| 23 | OptimizedJsonMapper | object | JSON mapping | Platform JSON libraries | quartz/.../nip01Core.../OptimizedJsonMapper.kt | +| 24 | Address | data class | Address data structure | Platform-specific string handling | quartz/.../nip01Core.../Address.kt | + +## Pattern Analysis + +### Objects (Singletons) - 19 total +Most common pattern for platform-specific singletons: +- Crypto: Secp256k1Instance, LibSodiumInstance, Sha256 +- I/O: UriParser, UrlEncoder, GZip +- Utils: Log, Platform, SecureRandom + +### Classes (Instantiable) - 4 total +For objects that need to maintain state: +- AESCBC, AESGCM (cipher state) +- DigestInstance, MacInstance (hash/MAC state) +- BigDecimal, BitSet (data structures) + +### Functions - 2 total +Simple utilities: +- platform(), currentTimeSeconds() + +## Why Abstracted Categories + +### Crypto (8 items) +**Always abstract:** Security APIs fundamentally different across platforms +- Android: Android Keystore, secp256k1-android +- Desktop: JVM crypto, secp256k1-jvm +- iOS: Security framework, native crypto + +### I/O & Platform Utils (7 items) +**Often abstract:** File systems, URLs, compression differ +- Platform storage APIs +- URL handling varies +- Compression libraries differ + +### Data Structures (2 items) +**Abstract when missing:** Not available in Kotlin common stdlib +- BigDecimal, BitSet not in common + +### JSON/Parsing (3 items) +**Platform-specific optimization:** Uses platform JSON libraries +- Android/Desktop: Jackson (via jvmAndroid) +- iOS: Native parsers + +### Logging (1 item) +**Always abstract:** Platform logging systems differ +- Android: android.util.Log +- Desktop: println or logging framework +- iOS: NSLog or OSLog + +## Actual Implementation Examples + +### Simple Object Pattern + +```kotlin +// commonMain +expect object Log { + fun d(tag: String, message: String) +} + +// androidMain +actual object Log { + actual fun d(tag: String, message: String) { + android.util.Log.d(tag, message) + } +} + +// jvmMain +actual object Log { + actual fun d(tag: String, message: String) { + println("[$tag] $message") + } +} +``` + +### Complex Object with Dependencies + +```kotlin +// commonMain +expect object Secp256k1Instance { + fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray +} + +// androidMain - uses JNI bindings +actual object Secp256k1Instance { + actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray { + return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null) + } +} + +// jvmMain - different JNI library +actual object Secp256k1Instance { + actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray { + return fr.acinq.secp256k1.Secp256k1.signSchnorr(data, privKey, null) + } +} + +// iosMain - native iOS implementation +actual object Secp256k1Instance { + actual fun signSchnorr(data: ByteArray, privKey: ByteArray): ByteArray { + // Uses iOS Security framework or native lib + } +} +``` + +### Class Pattern + +```kotlin +// commonMain +expect class BigDecimal { + constructor(value: String) + fun add(other: BigDecimal): BigDecimal + override fun toString(): String +} + +// jvmAndroid (works on Android + Desktop) +actual typealias BigDecimal = java.math.BigDecimal + +// iosMain +actual class BigDecimal { + private val value: NSDecimalNumber + actual constructor(value: String) { + this.value = NSDecimalNumber(value) + } + // ... implementation +} +``` + +## Decision Patterns + +Ask for each declaration: +1. **Used by 2+ platforms?** → YES (otherwise platform-specific) +2. **Pure Kotlin possible?** → NO (otherwise commonMain) +3. **Varies by platform?** → YES (expect/actual) +4. **JVM-only library?** → NO (otherwise jvmAndroid) diff --git a/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md new file mode 100644 index 0000000000..ec09e00332 --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/references/source-set-hierarchy.md @@ -0,0 +1,332 @@ +# Source Set Hierarchy in Amethyst + +Visual guide to source set organization with concrete examples from the codebase. + +## Hierarchy Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ commonMain │ +│ Pure Kotlin, no platform APIs │ +│ Examples: │ +│ - Nostr event parsing (TextNoteEvent, MetadataEvent) │ +│ - Business logic (data validation, crypto algorithms) │ +│ - Data models (@Immutable data classes) │ +│ Dependencies: kotlin-stdlib, kotlinx-coroutines │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ┌────────────┴────────────┬───────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐ +│ jvmAndroid │ │ iosMain │ │ Future: │ +│ JVM libraries │ │ iOS common │ │ jsMain │ +│ Examples: │ │ Examples: │ │ wasmMain │ +│ - Jackson JSON │ │ - Platform API │ └──────────────┘ +│ - OkHttp HTTP │ │ - Actuals for │ +│ - url-detector │ │ crypto/I/O │ +│ Dependencies: │ │ Dependencies: │ +│ - Jackson │ │ - Platform libs │ +│ - OkHttp │ └───────┬───────────┘ +└────┬─────────┬───┘ │ + │ │ ├─→ iosX64Main (simulator Intel) + │ │ ├─→ iosArm64Main (device ARM64) + │ │ └─→ iosSimulatorArm64Main (Apple Silicon) + ▼ ▼ +┌──────────┐ ┌───────────┐ +│android │ │ jvmMain │ +│Main │ │ (Desktop) │ +│Examples: │ │ Examples: │ +│- Activity│ │- Window │ +│- ViewModel│ │- MenuBar │ +│- Android │ │- Desktop │ +│ APIs │ │ Compose │ +│Deps: │ │ Deps: │ +│- secp256k│ │- secp256k │ +│ 1-android│ │ 1-jvm │ +│- androidx│ │- Compose │ +│ │ │ Desktop │ +└──────────┘ └───────────┘ +``` + +## Dependency Flow + +``` +Code in commonMain + ↓ can use +Nothing (only Kotlin stdlib) + +Code in jvmAndroid + ↓ can use +commonMain + JVM libraries (Jackson, OkHttp) + +Code in androidMain + ↓ can use +commonMain + jvmAndroid + Android framework + +Code in jvmMain + ↓ can use +commonMain + jvmAndroid + JVM + Compose Desktop + +Code in iosMain + ↓ can use +commonMain + iOS platform APIs +``` + +## Real Examples from Amethyst + +### commonMain - Pure Kotlin + +**File:** `quartz/src/commonMain/.../TextNoteEvent.kt` + +```kotlin +@Immutable +class TextNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseThreadedEvent(...) { + // Pure Kotlin - works everywhere + override fun indexableContent() = "Subject: " + subject() + "\n" + content +} +``` + +**Why commonMain:** +- Pure Kotlin code +- No platform APIs +- Data class with business logic +- Needed by all platforms + +--- + +### jvmAndroid - JVM Libraries + +**File:** `quartz/build.gradle.kts` + +```kotlin +val jvmAndroid = create("jvmAndroid") { + dependsOn(commonMain.get()) + + dependencies { + // Normalizes URLs + api(libs.rfc3986.normalizer) + + // Performant Parser of JSONs into Events + api(libs.jackson.module.kotlin) + + // Parses URLs from Text + api(libs.url.detector) + + // Websockets API + implementation(libs.okhttp) + implementation(libs.okhttpCoroutines) + } +} + +jvmMain { dependsOn(jvmAndroid) } // Desktop gets Jackson, OkHttp +androidMain { dependsOn(jvmAndroid) } // Android gets Jackson, OkHttp +``` + +**Why jvmAndroid:** +- Jackson, OkHttp are JVM-only libraries +- Works on Android (JVM) and Desktop (JVM) +- Does NOT work on iOS (not JVM) or web (not JVM) + +**Usage in code:** +```kotlin +// Can use Jackson in jvmAndroid source set +val mapper = ObjectMapper() +val event = mapper.readValue(json, Event::class.java) +``` + +--- + +### androidMain - Android Platform + +**File:** `amethyst/src/main/.../MainActivity.kt` + +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() // Android API + super.onCreate(savedInstanceState) + + setContent { // Compose for Android + AmethystTheme { + val accountStateViewModel: AccountStateViewModel = viewModel() + AccountScreen(accountStateViewModel) + } + } + } +} +``` + +**Why androidMain:** +- AppCompatActivity is Android framework +- Activity lifecycle Android-specific +- AndroidX libraries (viewModel()) + +**Dependencies:** +```kotlin +androidMain { + dependsOn(jvmAndroid) // Gets Jackson, OkHttp + dependencies { + implementation(libs.androidx.core.ktx) + api(libs.secp256k1.kmp.jni.android) // Android crypto + } +} +``` + +--- + +### jvmMain - Desktop Platform + +**File:** `desktopApp/src/jvmMain/.../Main.kt` + +```kotlin +fun main() = application { + val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp + ) + + Window( // Compose Desktop API + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst" + ) { + MenuBar { // Desktop-specific + Menu("File") { + Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) + } + } + NavigationRail { ... } // Sidebar + } +} +``` + +**Why jvmMain:** +- Window, MenuBar, NavigationRail are Compose Desktop +- Keyboard shortcuts desktop paradigm +- Different UX from Android (sidebar vs bottom nav) + +**Dependencies:** +```kotlin +jvmMain { + dependsOn(jvmAndroid) // Gets Jackson, OkHttp + dependencies { + implementation(libs.secp256k1.kmp.jni.jvm) // Desktop crypto + implementation(compose.desktop.currentOs) + } +} +``` + +--- + +### iosMain - iOS Platform + +**File:** `quartz/build.gradle.kts` + +```kotlin +iosMain { + dependsOn(commonMain.get()) + dependencies { + // iOS platform dependencies + } +} + +val iosX64Main by getting { dependsOn(iosMain.get()) } +val iosArm64Main by getting { dependsOn(iosMain.get()) } +val iosSimulatorArm64Main by getting { dependsOn(iosMain.get()) } +``` + +**Why iosMain:** +- iOS platform APIs +- Native crypto (Security framework) +- Different from Android/Desktop + +**Architecture targets:** +- iosX64Main: Intel simulator +- iosArm64Main: Device (iPhone, iPad) +- iosSimulatorArm64Main: Apple Silicon simulator + +--- + +## Build Order Matters + +**CRITICAL:** jvmAndroid must be defined BEFORE androidMain and jvmMain: + +```kotlin +// ✅ CORRECT ORDER +val jvmAndroid = create("jvmAndroid") { ... } +jvmMain { dependsOn(jvmAndroid) } +androidMain { dependsOn(jvmAndroid) } + +// ❌ WRONG - Build error +androidMain { dependsOn(jvmAndroid) } // jvmAndroid not defined yet! +val jvmAndroid = create("jvmAndroid") { ... } +``` + +See comment in quartz/build.gradle.kts:131: +```kotlin +// Must be defined before androidMain and jvmMain +val jvmAndroid = create("jvmAndroid") { ... } +``` + +## Choosing the Right Source Set + +Decision flowchart: + +``` +Q: Where should this code go? + +├─ Pure Kotlin? (no platform APIs) +│ └─ commonMain +│ +├─ JVM library? (Jackson, OkHttp) +│ └─ jvmAndroid +│ +├─ Android API? (Activity, Context) +│ └─ androidMain +│ +├─ Desktop API? (Window, MenuBar) +│ └─ jvmMain +│ +└─ iOS API? (platform.posix, Security) + └─ iosMain +``` + +## Future: Web/wasm Source Sets + +**Not yet implemented**, but structure would be: + +``` +commonMain + ├─→ jsMain (JavaScript/Web) + │ └─ JS-specific: DOM APIs, fetch + │ + └─→ wasmMain (WebAssembly) + └─ wasm-specific: limited APIs +``` + +**Constraints:** +- Cannot use jvmAndroid (Jackson, OkHttp) +- Cannot use platform.posix +- Must use pure Kotlin or web-compatible libs (ktor, kotlinx.serialization) + +## Summary Table + +| Source Set | Extends | Can Use | Example Code | +|------------|---------|---------|--------------| +| commonMain | - | Kotlin stdlib only | TextNoteEvent, business logic | +| jvmAndroid | commonMain | JVM libs (Jackson, OkHttp) | JSON parsing, HTTP | +| androidMain | jvmAndroid | Android framework | Activity, ViewModel | +| jvmMain | jvmAndroid | JVM + Compose Desktop | Window, MenuBar | +| iosMain | commonMain | iOS platform | Security framework | +| iosX64Main | iosMain | Simulator (Intel) | Architecture-specific | +| iosArm64Main | iosMain | Device (ARM64) | Architecture-specific | +| jsMain | commonMain | JS/DOM | Web (future) | +| wasmMain | commonMain | wasm APIs | WebAssembly (future) | diff --git a/.claude/skills/kotlin-multiplatform/references/target-compatibility.md b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md new file mode 100644 index 0000000000..7ccf720fb8 --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/references/target-compatibility.md @@ -0,0 +1,345 @@ +# Target Compatibility Guide + +Current targets (Android, JVM/Desktop, iOS) and future targets (web, wasm) with constraints. + +## Current Primary Targets + +### Android (androidMain) + +**Status:** ✅ Mature, production-ready + +**Runtime:** JVM (Dalvik/ART) + +**Available:** +- Android framework (Activity, Context, Intent, etc.) +- AndroidX libraries (ViewModel, Navigation, etc.) +- JVM libraries via jvmAndroid (Jackson, OkHttp) +- Platform-specific crypto: secp256k1-kmp-jni-android + +**Constraints:** +- Mobile UX paradigms (bottom navigation, vertical scroll) +- Touch-first interaction +- Limited screen space +- Battery/performance constraints + +**Example code:** +```kotlin +// androidMain +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Android-specific lifecycle + } +} +``` + +--- + +### JVM / Desktop (jvmMain) + +**Status:** ✅ Active development, functional + +**Runtime:** JVM + +**Available:** +- Pure JVM libraries +- JVM libraries via jvmAndroid (Jackson, OkHttp) +- Compose Desktop (Window, MenuBar, etc.) +- Platform-specific crypto: secp256k1-kmp-jni-jvm + +**Constraints:** +- Desktop UX paradigms (sidebar, menus, keyboard shortcuts) +- Keyboard + mouse interaction +- Larger screen space +- Different navigation patterns (no back stack) + +**Example code:** +```kotlin +// jvmMain +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "Amethyst" + ) { + MenuBar { ... } // Desktop-specific + NavigationRail { ... } // Sidebar + } +} +``` + +--- + +### iOS (iosMain + architecture targets) + +**Status:** ⚠️ In development, framework configured + +**Runtime:** Native iOS + +**Source sets:** +- iosMain (common iOS code) +- iosX64Main (Intel simulator) +- iosArm64Main (device - iPhone/iPad) +- iosSimulatorArm64Main (Apple Silicon simulator) + +**Available:** +- iOS platform APIs (platform.posix, Foundation, etc.) +- Native crypto (Security framework) +- SwiftUI integration (via KMP framework) + +**NOT available:** +- JVM libraries (Jackson, OkHttp) +- jvmAndroid source set +- JVM-specific APIs + +**Constraints:** +- Mobile UX (similar to Android) +- Swift/Objective-C interop +- XCFramework distribution +- CocoaPods integration + +**Example code:** +```kotlin +// iosMain +actual object Secp256k1Instance { + actual fun signSchnorr(...): ByteArray { + // Use iOS Security framework + } +} +``` + +**XCFramework setup:** +```kotlin +// quartz/build.gradle.kts +kotlin { + listOf(iosX64(), iosArm64(), iosSimulatorArm64()) + .forEach { target -> + target.binaries.framework { + baseName = "quartz-kmpKit" + isStatic = true + } + } +} +``` + +--- + +## Future Targets + +### Web / JavaScript (jsMain) + +**Status:** ❌ Not implemented, consider for future + +**Runtime:** JavaScript (browser or Node.js) + +**Available:** +- Kotlin/JS stdlib +- JS/DOM APIs +- kotlinx.* libraries (serialization, coroutines, datetime) +- ktor-client (HTTP) + +**NOT available:** +- ❌ JVM libraries (Jackson, OkHttp) +- ❌ jvmAndroid source set +- ❌ platform.posix (no file system access like native) +- ❌ Blocking APIs (different async model - JS event loop) + +**Constraints:** +- Single-threaded event loop +- No blocking calls +- Different async patterns (Promises, async/await) +- Browser security (CORS, no file system) + +**Migration path from current code:** + +| Current (jvmAndroid) | Web-compatible alternative | +|---------------------|---------------------------| +| Jackson JSON | kotlinx.serialization | +| OkHttp HTTP | ktor-client | +| java.math.BigDecimal | Kotlin BigDecimal (coming) | +| Blocking I/O | Suspending functions | + +**Example migration:** +```kotlin +// Current: jvmAndroid +val mapper = ObjectMapper() +val event = mapper.readValue(json, Event::class.java) + +// Future: commonMain (works on web) +val json = Json { ignoreUnknownKeys = true } +val event = json.decodeFromString(jsonString) +``` + +--- + +### WebAssembly (wasmMain) + +**Status:** ❌ Not implemented, experimental Kotlin/Wasm + +**Runtime:** WebAssembly + +**Available:** +- Kotlin/Wasm stdlib +- Limited kotlinx.* libraries +- wasm-specific APIs + +**NOT available:** +- ❌ JVM libraries +- ❌ Full platform.posix +- ❌ Many kotlinx libraries (limited wasm support) + +**Constraints:** +- Even more limited than JS +- Experimental Kotlin support +- Limited library ecosystem + +**Recommendation:** Focus on web (jsMain) first, wasm later. + +--- + +## Cross-Target Compatibility Matrix + +| Feature | Android | JVM/Desktop | iOS | Web (JS) | wasm | +|---------|---------|-------------|-----|----------|------| +| Pure Kotlin | ✅ | ✅ | ✅ | ✅ | ✅ | +| kotlinx.coroutines | ✅ | ✅ | ✅ | ✅ | ⚠️ | +| kotlinx.serialization | ✅ | ✅ | ✅ | ✅ | ⚠️ | +| kotlinx.datetime | ✅ | ✅ | ✅ | ✅ | ⚠️ | +| ktor-client | ✅ | ✅ | ✅ | ✅ | ❌ | +| Jackson JSON | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ | +| OkHttp | ✅ (jvmAndroid) | ✅ (jvmAndroid) | ❌ | ❌ | ❌ | +| platform.posix | ❌ | ❌ | ✅ | ❌ | ⚠️ | +| Compose Multiplatform | ✅ | ✅ | ⚠️ (experimental) | ⚠️ (experimental) | ❌ | + +Legend: +- ✅ Full support +- ⚠️ Limited/experimental +- ❌ Not available + +--- + +## Future-Proofing Recommendations + +### For Web Compatibility + +**DO:** +- ✅ Use kotlinx.serialization instead of Jackson +- ✅ Use ktor-client instead of OkHttp +- ✅ Use kotlinx.datetime instead of java.time +- ✅ Use suspending functions (non-blocking) +- ✅ Keep business logic in commonMain + +**DON'T:** +- ❌ Put JVM libraries in commonMain +- ❌ Use platform.posix for critical features +- ❌ Use blocking I/O +- ❌ Depend on threading (use coroutines) + +**Example:** +```kotlin +// ❌ NOT web-compatible +// jvmAndroid +fun parseJson(json: String): Event { + val mapper = ObjectMapper() // Jackson - JVM only + return mapper.readValue(json, Event::class.java) +} + +// ✅ Web-compatible +// commonMain +@Serializable +data class Event(...) + +fun parseJson(json: String): Event { + return Json.decodeFromString(json) // Works everywhere +} +``` + +### Current Migration Priorities + +**High priority:** (Needed for web) +1. Migrate Jackson → kotlinx.serialization +2. Migrate OkHttp → ktor-client +3. Move business logic to commonMain + +**Medium priority:** (Nice to have) +1. Abstract date/time handling → kotlinx.datetime +2. Remove platform.posix usage where possible +3. Use suspending functions over blocking + +**Low priority:** (Future optimization) +1. wasm-specific optimizations +2. Platform-specific performance tuning + +--- + +## Platform-Specific Patterns + +### Android vs iOS Differences + +| Aspect | Android | iOS | +|--------|---------|-----| +| **Activity/ViewController** | Activity | UIViewController | +| **Navigation** | Compose Navigation | UINavigationController | +| **Lifecycle** | onCreate, onResume, etc. | viewDidLoad, viewWillAppear | +| **Permissions** | Runtime permissions | Info.plist + runtime | +| **Crypto** | secp256k1-android | Security framework | +| **Storage** | Room, SharedPreferences | Core Data, UserDefaults | + +### Desktop vs Mobile Differences + +| Aspect | Desktop | Mobile | +|--------|---------|--------| +| **Navigation** | Sidebar | Bottom nav | +| **Input** | Keyboard + mouse | Touch | +| **Screen** | Large, landscape | Small, portrait | +| **Windows** | Multi-window | Single app | +| **Shortcuts** | Keyboard shortcuts (Ctrl+N) | None | +| **Menus** | MenuBar | Bottom sheets | + +--- + +## Testing Strategy + +### Per-Target Testing + +**Android:** +- Unit tests: androidTest +- Instrumented: androidInstrumentedTest +- Device/emulator testing + +**Desktop:** +- Unit tests: jvmTest +- Manual desktop app testing + +**iOS:** +- Unit tests: iosTest (iosX64Test, iosArm64Test, etc.) +- Simulator/device testing + +**Web (future):** +- Unit tests: jsTest +- Browser testing (Selenium, Playwright) + +### Shared Testing + +**commonTest:** +- Business logic tests +- Pure Kotlin code +- Works on all platforms + +```kotlin +// commonTest +class EventParsingTest { + @Test + fun parseTextNoteEvent() { + // Tests run on all platforms + } +} +``` + +--- + +## Summary + +**Current Focus:** Android, JVM/Desktop, iOS (active development) + +**Future Considerations:** Web (requires migration from Jackson/OkHttp) + +**Key Decision:** Prefer kotlinx.* libraries over JVM-specific libs for future web compatibility. diff --git a/.claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh b/.claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh new file mode 100755 index 0000000000..5b07cd810a --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/scripts/suggest-kmp-dependency.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# Suggests KMP library alternatives for JVM-specific dependencies + +set -e + +PROJECT_ROOT="${1:-.}" +cd "$PROJECT_ROOT" + +echo "=== KMP Dependency Suggestions ===" +echo + +# Colors +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' + +SUGGESTIONS_FOUND=0 + +# Check for Jackson (suggest kotlinx.serialization) +echo "📦 Checking for Jackson JSON..." +if grep -r "jackson" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then + echo -e "${YELLOW}⚠ Found Jackson dependency${NC}" + echo " Current: Jackson (JVM-only)" + echo -e " ${GREEN}Suggest: kotlinx.serialization${NC} (works on all platforms)" + echo + echo " Migration:" + echo " // Remove:" + echo " api(libs.jackson.module.kotlin)" + echo + echo " // Add to commonMain:" + echo " implementation(libs.kotlinx.serialization.json)" + echo + echo " // Code change:" + echo " // Before (Jackson):" + echo " val mapper = ObjectMapper()" + echo " val event = mapper.readValue(json, Event::class.java)" + echo + echo " // After (kotlinx.serialization):" + echo " @Serializable" + echo " data class Event(...)" + echo " val event = Json.decodeFromString(json)" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using Jackson (or already using kotlinx.serialization)${NC}" +fi + +# Check for OkHttp (suggest ktor) +echo +echo "📦 Checking for OkHttp..." +if grep -r "okhttp" */build.gradle.kts 2>/dev/null | grep -q "implementation\|api"; then + echo -e "${YELLOW}⚠ Found OkHttp dependency${NC}" + echo " Current: OkHttp (JVM-only)" + echo -e " ${GREEN}Suggest: ktor-client${NC} (works on all platforms)" + echo + echo " Migration:" + echo " // Remove:" + echo " implementation(libs.okhttp)" + echo + echo " // Add to commonMain:" + echo " implementation(libs.ktor.client.core)" + echo " // Platform-specific engines:" + echo " // androidMain: implementation(libs.ktor.client.android)" + echo " // jvmMain: implementation(libs.ktor.client.cio)" + echo " // iosMain: implementation(libs.ktor.client.darwin)" + echo + echo " // Code change:" + echo " // Before (OkHttp):" + echo " val client = OkHttpClient()" + echo " val request = Request.Builder().url(url).build()" + echo " val response = client.newCall(request).execute()" + echo + echo " // After (ktor):" + echo " val client = HttpClient()" + echo " val response: String = client.get(url)" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using OkHttp (or already using ktor)${NC}" +fi + +# Check for java.time (suggest kotlinx.datetime) +echo +echo "📦 Checking for java.time usage..." +if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.time\." >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Found java.time imports${NC}" + echo " Current: java.time (JVM-only)" + echo -e " ${GREEN}Suggest: kotlinx.datetime${NC} (works on all platforms)" + echo + echo " Migration:" + echo " // Add to commonMain:" + echo " implementation(libs.kotlinx.datetime)" + echo + echo " // Code change:" + echo " // Before (java.time):" + echo " import java.time.Instant" + echo " val now = Instant.now()" + echo + echo " // After (kotlinx.datetime):" + echo " import kotlinx.datetime.Clock" + echo " val now = Clock.System.now()" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using java.time (or already using kotlinx.datetime)${NC}" +fi + +# Check for java.math.BigDecimal +echo +echo "📦 Checking for java.math.BigDecimal usage..." +if find */src -name "*.kt" 2>/dev/null | xargs grep -l "import java.math.BigDecimal" >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Found java.math.BigDecimal imports${NC}" + echo " Current: java.math.BigDecimal (JVM-only)" + echo -e " ${BLUE}Note:${NC} KMP BigDecimal not yet in stable kotlinx" + echo + echo " Options:" + echo " 1. Use expect/actual (current approach in quartz)" + echo " 2. Wait for kotlinx.decimal (proposal stage)" + echo " 3. Use third-party KMP library (e.g., bignum)" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using java.math.BigDecimal directly${NC}" +fi + +# Check for platform.posix usage +echo +echo "📦 Checking for platform.posix usage..." +if find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "import platform.posix\." >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Found platform.posix in commonMain${NC}" + echo " Current: platform.posix (native platforms only, not web)" + echo -e " ${GREEN}Suggest:${NC} Abstract file I/O with expect/actual" + echo + echo " For web compatibility:" + echo " - iOS/Native: platform.posix" + echo " - Web: Use kotlinx-io or ktor file APIs" + echo " - Create expect/actual for file operations" + echo + SUGGESTIONS_FOUND=$((SUGGESTIONS_FOUND + 1)) +else + echo -e "${GREEN}✓ Not using platform.posix in commonMain${NC}" +fi + +# Summary +echo +echo "=== Summary ===" +if [ "$SUGGESTIONS_FOUND" -eq 0 ]; then + echo -e "${GREEN}✓ No JVM-specific dependencies found!${NC}" + echo " Your code is ready for web/wasm targets." +else + echo -e "${YELLOW}Found $SUGGESTIONS_FOUND suggestion(s) for KMP alternatives${NC}" + echo + echo "Priority recommendations:" + echo " 1. ${GREEN}High:${NC} Jackson → kotlinx.serialization (enables web support)" + echo " 2. ${GREEN}High:${NC} OkHttp → ktor-client (enables web support)" + echo " 3. ${GREEN}Medium:${NC} java.time → kotlinx.datetime" + echo " 4. ${GREEN}Low:${NC} Consider web compatibility for platform.posix usage" + echo + echo "Resources:" + echo " - kotlinx.serialization: https://github.com/Kotlin/kotlinx.serialization" + echo " - ktor: https://ktor.io/docs/client.html" + echo " - kotlinx.datetime: https://github.com/Kotlin/kotlinx-datetime" +fi + +exit 0 diff --git a/.claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh b/.claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh new file mode 100755 index 0000000000..d8713bcd02 --- /dev/null +++ b/.claude/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh @@ -0,0 +1,126 @@ +#!/bin/bash +# Validates KMP source set structure and detects common issues + +set -e + +PROJECT_ROOT="${1:-.}" +cd "$PROJECT_ROOT" + +echo "=== Validating KMP Structure ===" +echo + +# Colors for output +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +ISSUES_FOUND=0 + +# Check 1: jvmAndroid defined before androidMain/jvmMain +echo "📋 Checking source set definition order..." +if [ -f "quartz/build.gradle.kts" ]; then + jvmandroid_line=$(grep -n "val jvmAndroid = create" quartz/build.gradle.kts | cut -d: -f1) + android_line=$(grep -n "androidMain {" quartz/build.gradle.kts | cut -d: -f1) + jvm_line=$(grep -n "jvmMain {" quartz/build.gradle.kts | cut -d: -f1) + + if [ -n "$jvmandroid_line" ] && [ -n "$android_line" ] && [ -n "$jvm_line" ]; then + if [ "$jvmandroid_line" -lt "$android_line" ] && [ "$jvmandroid_line" -lt "$jvm_line" ]; then + echo -e "${GREEN}✓${NC} jvmAndroid defined before androidMain and jvmMain" + else + echo -e "${RED}✗${NC} jvmAndroid must be defined BEFORE androidMain and jvmMain" + ISSUES_FOUND=$((ISSUES_FOUND + 1)) + fi + fi +fi + +# Check 2: Platform code in commonMain (Android imports) +echo +echo "📋 Checking for platform code in commonMain..." +android_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import android\." || true) +if [ -n "$android_imports_in_common" ]; then + echo -e "${RED}✗${NC} Found Android imports in commonMain:" + echo "$android_imports_in_common" | sed 's/^/ /' + echo " Fix: Move to androidMain or create expect/actual" + ISSUES_FOUND=$((ISSUES_FOUND + 1)) +else + echo -e "${GREEN}✓${NC} No Android imports in commonMain" +fi + +# Check 3: JVM libraries in commonMain (Jackson, OkHttp) +echo +echo "📋 Checking for JVM libraries in commonMain..." +jvm_imports_in_common=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^import com.fasterxml.jackson\|^import okhttp3\." || true) +if [ -n "$jvm_imports_in_common" ]; then + echo -e "${RED}✗${NC} Found JVM library imports in commonMain:" + echo "$jvm_imports_in_common" | sed 's/^/ /' + echo " Fix: Move to jvmAndroid or migrate to kotlinx.serialization/ktor" + ISSUES_FOUND=$((ISSUES_FOUND + 1)) +else + echo -e "${GREEN}✓${NC} No JVM library imports in commonMain" +fi + +# Check 4: Unmatched expect/actual declarations +echo +echo "📋 Checking expect/actual pairs..." +expect_files=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -l "^expect " || true) +if [ -n "$expect_files" ]; then + for file in $expect_files; do + # Extract declarations + expects=$(grep "^expect \(class\|object\|fun\|interface\)" "$file" | sed 's/expect //' | awk '{print $2}' | sed 's/[({].*$//') + + # Check for actuals in platform source sets + for expect_name in $expects; do + actual_count=0 + for platform in androidMain jvmMain iosMain; do + platform_dir=$(dirname "$file" | sed "s/commonMain/$platform/") + platform_file="${platform_dir}/$(basename "$file")" + if [ -f "$platform_file" ] && grep -q "actual.*$expect_name" "$platform_file"; then + actual_count=$((actual_count + 1)) + fi + done + + if [ "$actual_count" -eq 0 ]; then + echo -e "${YELLOW}⚠${NC} No actual implementations found for: $expect_name in $file" + echo " Check: androidMain, jvmMain, iosMain" + ISSUES_FOUND=$((ISSUES_FOUND + 1)) + fi + done + done +else + echo -e "${GREEN}✓${NC} No expect declarations to validate" +fi + +# Check 5: Duplicated business logic across platforms +echo +echo "📋 Checking for potential code duplication..." +# This is a heuristic check - look for similar function names in different platform source sets +common_functions=$(find */src/commonMain -name "*.kt" 2>/dev/null | xargs grep -h "^fun " | awk '{print $2}' | sed 's/[({<].*$//' | sort -u || true) +if [ -n "$common_functions" ]; then + for func in $common_functions; do + android_count=$(find */src/androidMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l) + jvm_count=$(find */src/jvmMain -name "*.kt" 2>/dev/null | xargs grep -l "^fun $func" | wc -l) + + if [ "$android_count" -gt 0 ] && [ "$jvm_count" -gt 0 ]; then + echo -e "${YELLOW}⚠${NC} Function '$func' found in both androidMain and jvmMain" + echo " Consider: Move to commonMain or jvmAndroid if truly shared" + fi + done +fi + +# Summary +echo +echo "=== Summary ===" +if [ "$ISSUES_FOUND" -eq 0 ]; then + echo -e "${GREEN}✓ All checks passed!${NC}" + exit 0 +else + echo -e "${RED}✗ Found $ISSUES_FOUND issue(s)${NC}" + echo + echo "Common fixes:" + echo " 1. Platform code in commonMain → Move to androidMain or create expect/actual" + echo " 2. JVM libraries in commonMain → Move to jvmAndroid or migrate to kotlinx.*" + echo " 3. Missing actual implementations → Implement in all target platforms" + echo " 4. Duplicated logic → Move to commonMain or jvmAndroid" + exit 1 +fi