← Stacks

Kotlin Multiplatform

Platform: ios + android | kotlin

name: Kotlin Multiplatform
platform: ios + android
language: kotlin
ui_framework: compose_multiplatform
build: gradle_kotlin_dsl
project_gen: "Android Studio wizard (Compose Multiplatform template)"
package_manager: gradle_version_catalog
auth: none (manual, or shared-auth later)
payments: storekit2 (iOS) + google_play_billing (Android) via expect/actual
validation: "kotlinx.serialization + custom validation"
i18n: compose_resources (commonMain/composeResources/values/strings.xml, values-ru/)
linter: detekt (static analysis) + ktlint (style)
formatter: ktfmt (Google style)
testing: kotlin_test + compose_testing
pre_commit: lefthook (detekt + ktlint hooks)
key_packages:
  - Compose Multiplatform (shared UI)
  - Material3 (design system)
  - kotlinx.datetime (date handling)
  - kotlinx.serialization (JSON, validation)
  - detekt + ktlint (code quality)
optional_packages:
  networking:
    - "Ktor (HTTP client, multiplatform)"
  persistence:
    - "DataStore (Android prefs)"
    - "NSUserDefaults (iOS prefs, via expect/actual)"
    - "Room Multiplatform (local DB, if needed)"
    - "SQLDelight (multiplatform SQL, alternative to Room)"
  di:
    - "Koin (multiplatform DI, optional — manual DI for small projects)"
  image:
    - "Coil3 (multiplatform image loading)"
  navigation:
    - "Voyager or Decompose (multiplatform nav, optional — sealed class for <6 screens)"
example_projects:
  - name: kissmytask
    description: "KMP shared module (SQLDelight, Ktor, Supabase) + native iOS/Android UI"
    patterns_learned:
      - "SharedModuleWrapper.swift singleton for KMP↔Swift bridge"
      - "DatabaseDriverFactory expect/actual for SQLDelight"
      - "iOSAuthStateCallback — platform-specific callback pattern"
      - "Config object for environment variables (supabaseUrl, supabaseKey)"
    caveats:
      - "Uses native UI per platform (NOT Compose Multiplatform) — different pattern"
      - "Heavy Supabase dependency — skip for offline-first apps"
  - name: FaceAlarm
    description: "Kotlin Android + iOS Swift (separate, no KMP shared UI)"
    patterns_learned:
      - "Canvas polar math for circular layouts (SpiralCalendarView.swift)"
      - "Compose Canvas: relative sizing (canvasWidth * 0.65f, ovalWidth * 1.3f)"
      - "MVVM with @Observable @MainActor (iOS) / regular ViewModel (Android)"
    caveats:
      - "Not KMP — separate Kotlin + Swift codebases"
      - "Canvas patterns transferable to Compose Multiplatform Canvas"
deploy: app_store + play_store
infra: none (native store distribution)
ci_cd: github_actions (gradle + xcodebuild)
monitoring: posthog (analytics, posthog-android + manual iOS)
logs:
  android_adb: "adb logcat -s {name} --format=time 2>&1 | tail -50"
  android_crash: "adb logcat '*:E' --format=time 2>&1 | tail -30"
  ios_console: "xcrun simctl spawn booted log stream --style compact --timeout 10"
  local_build_android: "./gradlew :androidApp:assembleDebug 2>&1 | tail -30"
  local_build_ios: "./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 2>&1 | tail -30"
architecture: MVVM

patterns:
  modules: |
    shared/        — commonMain, androidMain, iosMain (all business logic + UI)
    androidApp/    — thin Android entry point (MainActivity)
    iosApp/        — thin iOS entry point (iOSApp.swift + ContentView.swift)
  viewmodel: |
    Regular class with Compose State (mutableStateOf, StateFlow).
    No @Observable (Swift), no ObservableObject — it's Kotlin.
    Keep VMs in shared/commonMain/kotlin/ui/viewmodel/.
    Use constructor injection for dependencies.
  expect_actual: |
    expect/actual for platform-specific code:
      - Storage: DataStore (Android) / NSUserDefaults (iOS)
      - Billing: Google Play Billing (Android) / StoreKit 2 (iOS)
      - Haptics: Vibrator (Android) / UIImpactFeedbackGenerator (iOS)
      - Screenshots: View.drawToBitmap (Android) / UIGraphicsImageRenderer (iOS)
      - Links: Intent(ACTION_VIEW) (Android) / UIApplication.shared.open (iOS)
    Define interface in commonMain, implement in androidMain/iosMain.
  navigation: |
    For <6 screens: sealed class Screen + when() in root App composable.
    No navigation library needed. Simple and testable.
  models: |
    Immutable data classes in commonMain for game/domain state.
    sealed class for enums with data (Cell types, outcomes).
    Pure functions for state transitions — easy to test.
  di: |
    For small projects (<5 dependencies): manual DI via factory object.
    For larger: Koin multiplatform.
    Platform-specific factory in androidMain/iosMain creates dependencies.
  directory_structure: |
    shared/src/
      commonMain/kotlin/
        domain/          — models, game logic, pure functions
        data/            — storage interfaces, repositories
        ui/
          theme/         — AppTheme, Colors, Typography
          screens/       — Screen composables
          components/    — Reusable UI components
          viewmodel/     — ViewModels (State + logic)
        di/              — Manual DI factory
      commonTest/kotlin/
        domain/          — Unit tests for game logic
      androidMain/kotlin/
        data/            — Android storage (DataStore)
        platform/        — Android-specific (billing, haptics, screenshots)
        di/              — Android DI factory
      iosMain/kotlin/
        data/            — iOS storage (NSUserDefaults)
        platform/        — iOS-specific (StoreKit, haptics, screenshots)
        di/              — iOS DI factory
    androidApp/src/main/
      kotlin/            — MainActivity, Application class
      res/               — Android resources (if needed beyond Compose)
    iosApp/
      iosApp/            — iOSApp.swift, ContentView.swift
  canvas_layouts: |
    For circular/radial layouts (game boards, calendars, charts):
      val angleStep = 2 * PI / cellCount
      val x = center.x + radius * cos(angle)
      val y = center.y + radius * sin(angle)
    Use relative sizing: cellSize = canvasWidth * 0.08f
    Glow effects: drawCircle() with alpha + larger radius
    Animations: animateFloatAsState for position, InfiniteTransition for pulse
    Reference: FaceAlarm SpiralCalendarView.swift (polar math pattern)
  ios_bridge: |
    iOSApp.swift is a thin wrapper:
      @main struct iOSApp: App {
          var body: some Scene {
              WindowGroup { ComposeView().ignoresSafeArea() }
          }
      }
    ComposeView wraps shared Compose UI via UIViewControllerRepresentable.
    Platform APIs (StoreKit, UIKit) called from iosMain via expect/actual.
    No SharedModuleWrapper needed for Compose Multiplatform (unlike KMP with native UI).
  concurrency: |
    Coroutines for async work (commonMain).
    MainScope for UI updates.
    No Combine, no RxJava — pure coroutines.

gradle_required:
  android_config:
    namespace: "<org_domain>.<name>"
    application_id: "<org_domain>.<name>"
    compileSdk: 35
    targetSdk: 35
    minSdk: 26
    versionCode: 1
    versionName: "1.0.0"
  ios_config:
    deployment_target: "16.0"
    framework_name: "shared"
  signing:
    android:
      storeFile: "keystore.jks"
      keyAlias: "release"
    ios:
      development_team: "<apple_dev_team>"
      code_sign_style: "Automatic"
  release_commands:
    android: |
      ./gradlew :androidApp:bundleRelease
      # → androidApp/build/outputs/bundle/release/androidApp-release.aab
      # Upload .aab to Google Play Console
    ios: |
      cd iosApp
      xcodebuild archive -scheme iosApp -destination 'generic/platform=iOS' -archivePath build/iosApp.xcarchive
      open build/iosApp.xcarchive  # → Distribute App

visual_testing:
  android:
    type: emulator
    boot: "emulator -avd $(emulator -list-avds | head -1) -no-window -no-audio &"
    wait: "adb wait-for-device && adb shell getprop sys.boot_completed | grep -q 1"
    screenshot: "adb exec-out screencap -p > /tmp/emu-screenshot.png"
    install: "adb install -r"
    launch: "adb shell am start"
    checks:
      - "Build debug APK and install on emulator"
      - "Take screenshot after launch, verify main activity renders"
  ios:
    type: simulator
    boot: "xcrun simctl boot 'iPhone 16' 2>/dev/null || true"
    screenshot: "xcrun simctl io booted screenshot /tmp/sim-screenshot.png"
    checks:
      - "Build iOS framework: ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64"
      - "Open iosApp/iosApp.xcodeproj in Xcode, run on simulator"
      - "Take screenshot after launch, verify main screen renders"

notes: |
  - Compose Multiplatform for shared UI (Canvas, animations work on both platforms)
  - expect/actual for platform APIs (billing, haptics, storage, screenshots)
  - Manual DI for small projects, Koin for larger
  - Sealed class navigation for <6 screens (no library)
  - Immutable state + pure functions for game/domain logic
  - kotlinx.datetime for date handling (no java.time dependency)
  - kotlinx.serialization for JSON (no Gson/Moshi)
  - Test iOS each phase — catch KMP issues early
  - Gradle version catalog (libs.versions.toml) for dependency management
  - English first, then localize via Compose Resources
  - StoreKit 2 (iOS) + Google Play Billing (Android) behind interface
  - lefthook for pre-commit hooks (detekt + ktlint)
  - Canvas for custom layouts (polar coordinates, game boards, charts)
  - No Combine, no RxJava — pure Kotlin coroutines
  - Thin platform entry points (MainActivity, iOSApp.swift)
Sources

Related