iOS/KMP-utvikling
Denne guiden dekker iOS-utvikling i Impulse AI: KMP shared module, SwiftUI-moonstre, design-tokens og lokalisering.
KMP shared module
heading.anchorLabelKotlin Multiplatform (KMP) deler forretningslogikk mellom iOS og Android. Shared-modulen inneholder domenemodeller, store-er, API-klient og i18n.
shared/src/commonMain/kotlin/com/im/shared/├── domain/ # Domenemodeller (Impulse, Entry, Insight, etc.)├── store/ # FlowMVI stores (HomeStore, InsightsStore, etc.)├── data/ # Repository-implementasjoner + API-klient├── i18n/ # Strings.kt (moko-wrappere)└── util/ # Delte verktoyBygg-kommandoer
heading.anchorLabel# Rask kompileringssjekk (13s) -- bruk dette for valideringcd apps/mobile-kmp && ./gradlew :shared:compileKotlinIosSimulatorArm64
# Full shared-modul build (3+ min)cd apps/mobile-kmp && ./gradlew :shared:build
# Rens og bygg pa nyttcd apps/mobile-kmp && ./gradlew cleanByggeytelse
heading.anchorLabelGradle-daemon er aktivert og gir 10-15s raskere bygg. Incremental compilation er konfigurert i gradle.properties:
| Byggemodus | Tid |
|---|---|
| Forste build | ~15-20s |
| Inkrementelt | ~8-10s |
| Shared-kode | ~10-12s |
Device-testing mot staging/production
heading.anchorLabeliOS-appen bruker DEVELOPMENT (localhost) som standard. For a teste mot staging eller production fra fysisk enhet eller simulator, bruk Xcode scheme environment variables.
Oppsett
heading.anchorLabel- Xcode → Product → Scheme → Edit Scheme (⌘<)
- Run → Arguments → Environment Variables
- Legg til:
| Name | Value | Resultat |
|---|---|---|
APP_ENVIRONMENT | STAGING | api-stg.impulseai.app + Supabase staging |
APP_ENVIRONMENT | PRODUCTION | api.impulseai.app + Supabase production |
APP_ENVIRONMENT | DEVELOPMENT | Tilbake til localhost (fjerner override) |
- Bygg og kjor (⌘R)
Slik fungerer det
heading.anchorLabelValget persisteres i UserDefaults — appen husker miljoet selv etter at du kobler fra Xcode eller stopper debuggeren. Du trenger kun a sette variabelen en gang.
Tilbakestilling: Sett APP_ENVIRONMENT=DEVELOPMENT og kjor en gang, eller slett appen fra enheten.
Fil: ImpulseApp.swift (init) — leser ProcessInfo → fallback til UserDefaults.
Miljo-oversikt
heading.anchorLabel| Miljo | API URL | Supabase |
|---|---|---|
| Development | http://localhost:3001 | Lokal (localhost:54331) |
| Staging | https://api-stg.impulseai.app | conxpuqktkeiphnnpfiv.supabase.co |
| Production | https://api.impulseai.app | jksqfeutntcvukrisrno.supabase.co |
Forutsetninger for staging-autentisering
heading.anchorLabelFor at Apple Sign In skal fungere mot staging:
-
Supabase staging → Authentication → Providers → Apple:
- Client ID:
com.digiteers.impulseai - Secret Key: Gyldig Apple JWT (samme som production)
- Callback URL:
https://conxpuqktkeiphnnpfiv.supabase.co/auth/v1/callback
- Client ID:
-
Doppler stg ma ha:
SUPABASE_URL— Supabase staging URLSUPABASE_SECRET_KEY— Service role keyAPI_CLIENT_KEY— Matcher verdien iEnvironment.ktNODE_ENV=production— Aktiverer API-key middleware
-
Railway staging custom domain aktiv:
api-stg.impulseai.app
SwiftUI + @Observable-regler
heading.anchorLabelKritisk regel: aldri .map KMP-typer
heading.anchorLabelBruk KMP-typer (shared.Impulse, shared.Entry, etc.) direkte i SwiftUI views. Aldri transformer med .map { SwiftWrapper(...) } — dette bryter @Observable-tracking og forhindrer UI-oppdateringer.
// FEIL -- bryter observation:private var entries: [EntryDisplayData] { entriesStore.entries.map { EntryDisplayData(...) }}
// RIKTIG -- observation fungerer:private var entries: [shared.Entry] { entriesStore.entries}Tillatte operasjoner pa KMP-typer:
.filter { }— returnerer samme objekter.prefix()/.suffix()— OK.sorted { }— OK- Direkte tilgang til store-properties — OK
Forbudte operasjoner:
.map { NewType(...) }— bryter observation- Wrapper-typer som
EntryDisplayData— unnga
KMP-typer med id-property trenger extension for sheet(item:):
extension shared.Entry: Identifiable {}State Sync Pattern
heading.anchorLabelNar en view holder en lokal @State-kopi av et store-objekt, synkroniser med en enkelt onChange:
// EN onChange -- updatedAt endres ved ALLE server-side felt-mutasjoner.onChange(of: storeObject?.updatedAt) { _, _ in if let updated = storeObject { localCopy = updated }}Aldri legg til separate onChange-handlere for individuelle felter. updatedAt-timestampet dekker alle mutasjoner.
Derived View State Pattern
heading.anchorLabelNar en SwiftUI-views rendering avhenger av flere @Observable-stores, bruk en computed enum — aldri if-else-kjeder i view body.
Monster (2 filer per skjerm)
heading.anchorLabel// 1. FooState.swift -- enum med alle mulige display-tilstanderenum FooContentState { case loading case empty case content case error(String)}
// 2. FooView.swift -- computed property utleder tilstand fra storesprivate var contentState: FooContentState { if store.isLoading && !store.hasData { return .loading } if let error = store.error, !store.hasData { return .error(error) } if !store.hasData { return .empty } return .content}
// Exhaustive switch -- kompilatoren hjelperswitch contentState {case .loading: ProgressView()case .empty: EmptyState()case .content: ContentView()case .error(let msg): ErrorView(msg)}Never-Fetched = Loading-konvensjonen
heading.anchorLabelif store.isLoading || (!store.hasData && store.lastFetched == nil) { return .loading}lastFetched == nil betyr at data aldri ble hentet — vis spinner, ikke tom tilstand. KMP RetryableLoadingState-grensesnittet gir lastFetched: Long? pa tvers av alle stores.
Eksisterende bruk
heading.anchorLabel| View | Enum | Fil |
|---|---|---|
| ImpulseDetailView | SessionSectionState | Features/Impulses/SessionSectionState.swift |
| InsightsView | InsightsContentState | Features/Insights/InsightsContentState.swift |
| JourneyView | JourneyContentState | Features/Journey/JourneyContentState.swift |
| UnifiedSessionView | SessionLayer | Definert inline |
Accent Surface Token System
heading.anchorLabelDesignsystemet bygger pa en aksent-farge med fire opasitetsniva:
Fargehjelpere (Colors.swift)
heading.anchorLabelextension Color { func accentSurface() -> Color { self.opacity(0.08) } // Kort-bakgrunner func accentInteractive() -> Color { self.opacity(0.10) } // Ghost CTA-er func accentBadge() -> Color { self.opacity(0.15) } // Status-piller, tags // 1.00 -- direkte farge for tekst/ikoner}ViewModifiers (AccentSurface.swift)
heading.anchorLabel| Modifier | Funksjon |
|---|---|
.imCard(accent:, padding:, cornerRadius:) | Kort med padding + maxWidth + accentSurface-bakgrunn. nil accent -> backgroundSecondary |
.imGhostCTA(accent:) | Ghost-CTA med foregroundColor + accentInteractive-bakgrunn |
.imBadge(accent:) | Status-pille med accentBadge-bakgrunn |
.imSheet(dismiss:) | Sheet med backgroundPrimary + toolbar xmark.circle.fill |
Aksentfarge-valg
heading.anchorLabel| Kontekst | Aksent |
|---|---|
| Kontraksjons-impuls/sesjon | .classContraction |
| Ekspansjons-impuls/sesjon | .classExpansion |
| Brand/noytral (insights, stats, home) | .brand |
| Ingen kontekst (profil, tab bar) | nil -> backgroundSecondary |
CTA-hierarki (2 niva)
heading.anchorLabel| Tier | Stil | Jobb | Bruk |
|---|---|---|---|
| Invite | .imGhostCTA(accent:) | Brukerhandlinger | Start sesjon, fang impuls, fortsett, los |
| Progress | Solid SessionColors.primaryText | Sesjonsnavigasjon | Sesjon-intro, steg-knapper, Done |
Toolbar Close-knapper
heading.anchorLabelAlle sheets og modale skjermer bruker:
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .foregroundColor(.textMetadata)}Eksklusjonsliste (forblir backgroundSecondary)
heading.anchorLabel- ProfileView list-rader
- CustomTabBar
- JourneyFilterChips / GroupingToggle
- Editor-overflater (ContractionEditor, ExpansionEditor, NoteEditor)
- AIClassificationLoadingView gradient
- HomeEmptyStates
Session Design Tokens
heading.anchorLabelSesjons-spesifikke tokens bygger pa accent surface-systemet:
SessionColors.cardBackground(for:)— returnerer aksenttintet kortfarge basert pa sesjonsklassifiseringSessionColors.backgroundGradient— sesjonsgradient-bakgrunn- Sesjonskomponenter mottar en
accent: Color-parameter (standard:.brand)
Fil: iosApp/Features/Sessions/Shared/SessionDesignTokens.swift
Store-kommunikasjonsarkitektur
heading.anchorLabelAppen bruker tre distinkte kommunikasjonsmekanismer:
| Mekanisme | Jobb | Retning | Eksempel |
|---|---|---|---|
| @Observable state | Reaktive UI-oppdateringer | Store -> View (automatisk) | Loading, lister, feil |
| Callbacks | Navigasjonsside-effekter | Store -> View (imperativ) | Apne fullScreenCover, dismiss |
| AppEventBus | Cross-store-koordinering | Store -> Store (KMP) | “Analyse ferdig” -> HomeStore refresher |
Callback-regler
heading.anchorLabelCallbacks pa store-wrappere er strengt for navigasjonseffekter som ikke kan uttrykkes via @Observable-state.
- Tilordne i onAppear, rydd i onDisappear — forhindre foreldede callbacks
- Aldri legg til callbacks for state-endringer — bruk @Observable store-properties
- Singleton = siste skriver vinner — kun en view kan eie en callback om gangen
Gold standard: UnifiedSessionView.setupStoreCallbacks() + cleanupIfNeeded().
Moko Strings — trippel oppdatering
heading.anchorLabelNy lokalisert streng krever oppdatering i 3 filer:
1. strings.xml (base + nb) -- moko source of truth (snake_case)2. Strings.kt -- KMP camelCase wrapper3. LocalizedString.swift -- iOS Swift wrapper (L.propertyName)Filer
heading.anchorLabel| Fil | Sti |
|---|---|
| Engelsk | shared/src/commonMain/moko-resources/base/strings.xml |
| Norsk | shared/src/commonMain/moko-resources/nb/strings.xml |
| KMP-wrapper | shared/src/commonMain/kotlin/com/im/shared/i18n/Strings.kt |
| iOS-wrapper | iosApp/Utilities/LocalizedString.swift |
// iOSL.sessionHelloWorld
// AndroidmokoStringResource(MR.strings.session_hello_world)Nøkkelfiler
heading.anchorLabel| Beskrivelse | Sti |
|---|---|
| Fargetokens | iosApp/Theme/Colors.swift |
| Typografi | iosApp/Theme/Typography.swift |
| Spacing | iosApp/Theme/Spacing.swift |
| ViewModifiers | iosApp/Components/Modifiers/AccentSurface.swift |
| Press-effekt | iosApp/Components/Modifiers/PressEffect.swift |
| Sesjonstokens | iosApp/Features/Sessions/Shared/SessionDesignTokens.swift |
| Lokalisering | strings.xml -> Strings.kt -> LocalizedString.swift |
| Store-wrappere | iosApp/Stores/ |
| KMP store provider | iosApp/Stores/KMPStoreProvider.swift |
SKIE og SwiftUI Preview
heading.anchorLabelSKIE (Swift/Kotlin Interface Enhancer) genererer forskjellige Swift-typer for simulator vs device:
| Target | Kotlin Double? | Kotlin String i ForEach |
|---|---|---|
| Simulator (arm64-simulator) | Double? | Fungerer direkte |
| Device (arm64) | KotlinDouble? | Kan gi generics-feil |
Regler
heading.anchorLabel1. #Preview ma wrappes i simulator-guard:
#if targetEnvironment(simulator)#Preview { MyView(data: shared.SomeKMPType(...))}#endifUten dette feiler device-builds fordi preview-data bruker simulator-spesifikke typer.
2. ForEach med KMP-lister — bruk enumerated():
// Robust — fungerer pa tvers av targetsForEach(Array(items.enumerated()), id: \.offset) { _, item in ItemView(item: item)}
// Fragilt — kan feile pa deviceForEach(items, id: \.self) { item in ... }3. KotlinDouble i preview-data:
// Simulator: bruk plain Doubleshared.AreaAssignment(relevanceScore: 0.95)
// Device: krever KotlinDoubleshared.AreaAssignment(relevanceScore: KotlinDouble(double: 0.95))
// Losning: wrap preview i #if targetEnvironment(simulator)