Spec: F78 — Compose Multiplatform: Shared UI for Android, iOS, and Mac
Current V2 shell:
Lumi·Work·Business·Hub.
Status: in progress — Android/iOS are shell-only: 59 shared commonMain screens and no native duplicates. macOS mounts all 25 sidebar tabs via KMPMacView; the platform-parity gate covers all 38 detected shared-screen directories. Compose Desktop consumes shared screens. Remaining gaps: (1) Mac XCFramework target/re-link and runtime proof on a macOS host (./gradlew :shared:assembleXCFramework + Xcode Frameworks phase), (2) desktop Compose Desktop production promotion (manual desktop-v* tag release; Tauri remains in prod)
The canonical four-tab order remains Lumi · Work · Business · Hub; Pulse is a nested operating surface and Company Hub is grouped inside Hub.
Contract Graph note (2026-07-16, refreshed 2026-08-02): F78 is represented by
contracts/features/F78.cuewith source/docs proof for shared content and native host bridges. The spec’sin progressstatus and its remaining target/runtime boundary are consistent; XCFramework/macOS target and runtime gaps remain blocking.
Priority: F79 is the mobile product contract; F78 is its shared-code implementation path. Keep F78 active while KMP is chosen; explicitly deprecate it otherwise.
Relation: F78 replaces F30’s separate Android Compose/iOS SwiftUI architecture and extends F47’s Mac surface. F48 remains a Tauri web wrapper. When agreed, F78 §4 supersedes only F30/F47 technology sections.
1. Problem & CEO Job
F79 owns mobile information architecture; F78 only defines how iOS and Android share it.
Separate Android Compose and iOS SwiftUI apps duplicate screens and bugs. At 77+ features, a three-person team cannot maintain parity without cutting quality.
Kotlin Multiplatform + Compose share business logic, API clients, ViewModels, navigation, and
screen content. Native shells retain iOS/Mac TabView, NavigationStack, Sheet, and Liquid
Glass, plus Android Material 3. One team ships each feature and fix once while CEOs get software
designed for their device, not ported from another.
2. User Stories & Acceptance Criteria
Code sharing and architecture
-
As a mobile engineer, I want every screen’s content written once in Compose, so that a bug fixed on Android is fixed everywhere automatically.
- Each feature screen is a
@Composableinshared/src/commonMain/with zero platform imports. - Each screen’s ViewModel is in
shared/src/commonMain/usingkotlinx-coroutinesandKtor— no Android-only or iOS-only dependencies. - The shared module compiles to: Android AAR, iOS framework (Kotlin/Native via XCFramework), macOS framework (same XCFramework, separate slice).
-
./gradlew :shared:linkReleaseFrameworkIosArm64succeeds in CI with zero errors. -
./gradlew :androidApp:assembleReleasesucceeds in CI with zero errors.
- Each feature screen is a
-
As a mobile engineer, I want navigation defined once in shared code, so that the tab structure and deep-link routing are identical on every platform.
- Compose Navigation (or Voyager/Circuit — see §12) drives all screen transitions in shared code.
- Each platform shell bridges to shared navigation: SwiftUI on iOS/Mac mounts a
ComposeUIViewControllerat the root; the shell’sTabViewcalls into shared nav via a bridging API (LumiNavController.switchTab(tab:)). - Deep links (
lumi://today,lumi://tasks/{id}, etc.) are handled in shared code; each platform registers the URL scheme and callsLumiNavController.handleDeepLink(url:).
iOS — native shell, shared content
-
As an iPhone user, I want the iOS app to use native iOS 26 Liquid Glass chrome, so that Lumi feels like a first-class iPhone app, not an Android port.
- The iOS target (
iosApp/) hosts the F79 four-tab shell in SwiftUI:TabView,NavigationStackper tab,Sheetfor modals, and.searchablefor search. Shared Compose owns content. - Each tab’s content is rendered by a
ComposeView(UIViewRepresentable wrapper aroundComposeUIViewController) that hosts the corresponding shared Compose screen. -
TabViewuses iOS 26tabBarMinimizeBehavior/ glass styling when available, with a plain-Material-3 fallback for iOS 17–25. - Native
NavigationStackhandles swipe-back gesture and navigation bar chrome; the shared Compose content does NOT draw its own top bar on iOS. - Sheets, action sheets, and share sheets use SwiftUI presentation APIs triggered by
callbacks from shared ViewModels (via a
PlatformDelegateinterface). - The iOS app passes all existing F30 acceptance criteria for every feature listed in F30 §2.
- The iOS target (
-
As an iPhone user, I want push notifications, biometric auth, and Spotlight integration to work exactly as they do in a pure SwiftUI app, so that I don’t notice the Compose layer.
- Push notifications (F36) are registered and displayed via native
UNUserNotificationCenter; tap actions route to sharedLumiNavController.handleDeepLink. - Biometric auth uses
LocalAuthenticationin the SwiftUI layer; the shared ViewModel exposes arequestBiometricUnlock()callback the SwiftUI layer implements. - NSUserActivity / Spotlight donation is done in the SwiftUI layer after shared ViewModel signals a record was opened.
- Push notifications (F36) are registered and displayed via native
Android — Compose Material 3 shell, shared content
- As an Android user, I want the Android app to use Material 3 with correct Android
patterns (back gesture, predictive back, edge-to-edge), so that Lumi doesn’t feel like an
iPhone port.
- The Android target (
androidApp/) hosts the F79 four-tab shell with Compose Material 3NavigationBarand Compose NavigationNavHost. Shared Compose owns all content; Company Hub is grouped inside Hub. - Predictive back animation (
BackHandlerwithPredictiveBackGestureOverlay) is wired in the Android shell. - Edge-to-edge layout with
WindowCompat.setDecorFitsSystemWindows(false);Modifier .windowInsetsPaddingapplied at the scaffold level in shared code viaexpect/actual. - FCM push notifications registered and displayed via Android
NotificationManager; tap actions route to sharedLumiNavController.handleDeepLink. - The Android app passes all existing F30 acceptance criteria for every feature listed in F30 §2.
- The Android target (
macOS — SwiftUI shell, all content screens from KMP
-
As a Mac user, I want a native Mac app where every content screen is the same KMP shared screen as Android and Windows/Linux, so that new features appear everywhere at once.
- The macOS XCFramework (
macosArm64slice ofshared.xcframework) exposes factory functions inmacosMain/MainViewController.ktthat returnNSViewControllerinstances hosting a Compose-for-macOSComposePanelfor eachSharedScreencase (Today, Brief, Pulse, Tasks, Projects, Decisions, CRM, Chat, Hub, Messages, Teams, Settings, …). - The macOS shell is SwiftUI: sidebar navigation (
NavigationSplitViewfor two-pane,HSplitViewfor three-pane e.g. CRM: list | detail | activity), menu bar extra, toolbar. - Each content pane is a
KMPMacView(NSViewControllerRepresentable) wrapping theNSViewControllerreturned by themacosMainfactory — notComposeUIViewController(which is iOS-only UIKit) and not a UIKitComposeView. AppKitComposePanelis the macOS-specific embedding API. - Keyboard shortcuts (⌘1–⌘9 tabs, ⌘N new task, ⌘F search, ⌘, settings) are wired in
the SwiftUI shell via
LumiCommands; they postNSNotificationconsumed by the activeKMPMacView. - The macOS app passes all F47 acceptance criteria for Mac features.
-
./gradlew :shared:linkReleaseMacosArm64succeeds in CI (currently blocked — see Implementation Audit below).
Blockers before this AC is fully active:
macosArm64is not currently configured inandroid/shared/build.gradle.kts, so themacosMainfactories have no active target build or linked XCFramework evidence.PullToRefreshBoxincommonMainis iOS-only. The macOS build fails:error: unresolved reference 'PullToRefreshBox'. Fix: add a macOS (and JVM)expect/actualthat renders a no-op wrapper or scroll-triggered refresh — the pull gesture makes no sense on a trackpad but the composable must compile. Until fixed,#if canImport(shared)inSharedScreenBridge.swiftremains false and the SwiftUI fallback is active.
- The macOS XCFramework (
Feature parity gate
- As a product owner, I want a CI parity check that fails when a shared screen is missing
a platform registration, so that no feature silently ships on one platform and not another.
-
scripts/check-platform-parity.shenumerates allScreenobjects in shared nav graph and verifies each is registered in the iOSTabView/nav, AndroidNavHost, and macOS sidebar. - This script runs in CI and fails the build if any platform is missing a registration.
-
3. Scope Boundaries
In:
- Shared Compose UI for all feature screens (content, not chrome)
- Shared ViewModels, repositories, API client (
Ktor), and domain models - Shared navigation graph and deep-link handling
- iOS SwiftUI shell (TabView, NavigationStack, Sheet, Liquid Glass)
- Android Compose Material 3 shell (NavigationBar, NavHost)
- macOS SwiftUI shell (sidebar, NavigationSplitView, keyboard shortcuts) +
ComposePanelbridge (macosMain) - Windows/Linux Compose Desktop shell (
:desktopApp,jvmMain) — samecommonMainscreens, different native window/tray/shortcut OS integration; see F48 - CI build verification for all four targets (Android, iOS, macOS, JVM/desktop)
- Migration path from current separate Android-Compose + iOS-SwiftUI codebases
PullToRefreshBoxexpect/actualformacosMainandjvmMain(no-op / scroll-triggered; unblocks macOS + desktop KMP builds)macosMain/MainViewController.ktfactory functions (NSViewController + ComposePanel) for allSharedScreencases
Out:
- Apple Watch (remains SwiftUI-only via WatchKit; Watch Connectivity bridge is the boundary)
- Web frontend (React/TypeScript — separate product surface)
- Backend (Django — unchanged)
- Store billing wiring (StoreKit 2 / Play Billing — platform shell concern; referenced in F30/F24)
- Re-design of any screen’s UX (this spec is architectural migration, not a redesign)
4. Platform Architecture
┌──────────────────────────────────────────────────────────────────────────────────┐
│ shared/ (Kotlin Multiplatform) │
│ │
│ commonMain: │
│ ├─ domain/ (models, use-cases, repository interfaces) │
│ ├─ data/ (Ktor API client, DTOs, repository impls) │
│ ├─ presentation/ (ViewModels, UI state, navigation graph) │
│ └─ ui/ (Compose screens — content only; Today/Brief/Pulse/Tasks…) │
│ │
│ androidMain: expect/actual for Android (FCM, Keystore, back gesture) │
│ iosMain: expect/actual for iOS (ComposeUIViewController factories) │
│ macosMain: expect/actual for macOS (NSViewController+ComposePanel factories) │
│ jvmMain: expect/actual for JVM — Windows + Linux (Compose Desktop) │
└──────┬──────────────────┬───────────────────┬────────────────────┬──────────────┘
│ │ │ │
┌──────▼──────┐ ┌────────▼───────┐ ┌───────▼────────┐ ┌───────▼──────────────┐
│ androidApp/ │ │ iosApp/ │ │ LumiMac │ │ desktopApp/ │
│ Compose │ │ SwiftUI shell │ │ SwiftUI shell │ │ Compose Desktop │
│ shell │ │ │ │ (ios/ target) │ │ shell (JVM) │
│ │ │ TabView │ │ │ │ │
│NavigationBar│ │NavigationStack │ │ Sidebar │ │ Window {} │
│ NavHost │ │ Sheet │ │ SplitView │ │ System tray │
│ M3 scaffold │ │ Liquid Glass │ │ Toolbar │ │ Global shortcuts │
│ FCM │ │ StoreKit 2 │ │ Menu bar │ │ Autostart │
│ Play Billing│ │ LocalAuth │ │ Keyboard nav │ │ Credential Manager │
│Predictive │ │ │ │ Spotlight │ │ .msi / .deb installer │
│ back │ │ KMPMacView ←──────── KMPMacView │ │ │
│ │ │(ComposeUIVC) │ │(ComposePanel) │ │ │
└─────────────┘ └────────────────┘ └────────────────┘ └───────────────────────┘
Shell boundary rule: the shell provides chrome + OS integration only.
Every content pane is a shared Compose composable from commonMain.
Mac uses AppKit ComposePanel (macosMain); Windows/Linux use Compose Desktop Window (jvmMain).
iOS uses UIKit ComposeUIViewController (iosMain). Android uses Compose natively.
What lives where
| Layer | Location | Rationale |
|---|---|---|
API client (Ktor) | shared/commonMain | Single HTTP client, single token refresh logic |
DTOs + JSON (kotlinx.serialization) | shared/commonMain | One schema change touches one file |
| Domain models | shared/commonMain | Shared across all VMs and screens |
| Repository interfaces | shared/commonMain | Testable without platform |
| Repository implementations | shared/commonMain (using Ktor) | No platform IO needed |
ViewModels (ScreenModel or ViewModel) | shared/commonMain | State is the same on every platform |
| Compose screens (content) | shared/commonMain | Rendered identically everywhere |
| Navigation graph | shared/commonMain | One source of truth for routes/deep links |
| Tab chrome, nav bars | Platform shell | Native fidelity — not shareable |
| Push notification registration | Platform shell | OS-specific APIs |
| Biometric auth | Platform shell | OS-specific APIs |
| Store billing | Platform shell | StoreKit 2 / Play Billing |
| Keychain / Keystore token storage | expect/actual | Same interface, OS-specific impl |
| Date/time formatting | expect/actual (kotlinx-datetime) | Locale handling differs |
| File picker / image picker | expect/actual | OS-specific picker APIs |
expect/actual boundaries
// shared/commonMain
expect fun platformName(): String
expect fun secureStore(): SecureStore
expect fun imagePicker(): ImagePickerLauncher
expect fun dateFormatter(pattern: String): DateFormatter
// shared/androidMain
actual fun platformName() = "Android"
actual fun secureStore(): SecureStore = AndroidKeystore()
// shared/iosMain
actual fun platformName() = "iOS"
actual fun secureStore(): SecureStore = iOSKeychain()
Compose-in-SwiftUI bridge
// iosApp/ContentView.swift
struct LumiRootView: View {
var body: some View {
TabView {
ComposeView(screen: .today)
.tabItem { Label("Today", systemImage: "sun.max") }
ComposeView(screen: .tasks)
.tabItem { Label("Tasks", systemImage: "checkmark.circle") }
// ...
}
.tabViewStyle(.sidebarAdaptable) // iOS 18+ / macOS
}
}
struct ComposeView: UIViewControllerRepresentable {
let screen: LumiScreen
func makeUIViewController(context: Context) -> UIViewController {
LumiKt.makeComposeViewController(screen: screen)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
5. API Contract
No new backend API endpoints. All existing REST endpoints (defined in F01–F77 specs) are consumed by the shared Ktor client.
Shared API client (shared/data/api/LumiApiClient.kt)
| Concern | Implementation |
|---|---|
| Base URL | expect val API_BASE_URL: String — prod/staging set per build variant |
| Auth | Bearer token from SecureStore; 401 → token refresh → retry once |
| JSON | kotlinx.serialization — DTOs mirror existing DRF response shapes |
| Timeouts | Connect 10s, read 30s (same as web Axios config) |
| Certificate pinning | Optional Phase 2 (not blocking MVP) |
6. UI / UX
Screen-level UI is unchanged — shared Compose composables render identically to today’s Android screens. The visible difference per platform:
| Surface | Android | iOS | macOS |
|---|---|---|---|
| Bottom nav | Material 3 NavigationBar | SwiftUI TabView | SwiftUI sidebar |
| Top bar | Compose TopAppBar (hidden behind shell option) | SwiftUI nav bar | SwiftUI toolbar |
| Modals | Compose ModalBottomSheet | SwiftUI .sheet | SwiftUI .sheet |
| Alerts | Compose AlertDialog | SwiftUI .alert | SwiftUI .alert |
| Search | Compose SearchBar | SwiftUI .searchable | SwiftUI .searchable |
| Back | Predictive back (Android 14+) | Swipe-back on NavigationStack | Toolbar back button |
i18n
The shared Compose layer uses the Compose Multiplatform compose.components.resources API
(Res.string.*) for all user-facing copy in commonMain. String resource files live at:
android/shared/src/commonMain/composeResources/values/strings.xml ← English baseline
android/shared/src/commonMain/composeResources/values-vi/strings.xml ← Vietnamese (primary)
Access pattern in any shared composable:
import com.locuno.lumi.shared.res.Res
import org.jetbrains.compose.resources.stringResource
Text(stringResource(Res.string.brief_title)) // "Tóm tắt điều hành" in vi, "Executive Brief" in en
Rule: no hardcoded user-facing string literals in commonMain UI code (Six Rules Rule 3).
Every new shared screen must add its keys to both locale XML files before merging. The Res
class is generated at build time — a missing key causes a compile error, making the contract
type-safe. Vietnamese is the primary locale; English is the baseline/fallback.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Shared ViewModel state transitions (Today, Tasks, Brief, Pulse…) | unit | shared/src/commonTest/ |
| 2 | API client: auth header sent, 401 triggers refresh, retry succeeds | unit | shared/src/commonTest/ |
| 3 | Repository: data flows from mock API → ViewModel → UI state | unit | shared/src/commonTest/ |
| 4 | Navigation: deep link lumi://tasks/123 resolves to TaskDetail screen | unit | shared/src/commonTest/ |
| 5 | expect/actual SecureStore: write/read/delete token round-trips | unit | shared/src/androidTest/ + iosTest/ |
| 6 | Android app builds (AAR + APK) without errors | build | CI ./gradlew :androidApp:assembleRelease |
| 7 | iOS XCFramework builds without errors | build | CI ./gradlew :shared:assembleXCFramework |
| 8 | macOS framework builds without errors | build | CI ./gradlew :shared:linkReleaseMacosArm64 |
| 9 | iOS app compiles (xcodebuild build-for-testing) | build | CI via xcodebuild |
| 10 | Platform parity check: every screen registered on all platforms | lint | CI scripts/check-platform-parity.sh |
| 11 | Android E2E: critical flow (login → today → task complete) | e2e | Espresso / Maestro |
| 12 | iOS E2E: critical flow (login → today → pulse submit) | e2e | XCTest UI / Maestro |
| 13 | Cross-tenant: API client sends correct X-Organization-ID header | unit | shared/src/commonTest/ |
The test plan distinguishes source/docs proof from the two remaining F78 gates: the macOS/XCFramework implementation boundary and named target-runtime/build evidence. Neither is inferred from the shared-screen inventory or collected proof nodes.
8. Current Behavior
Android: The F79 shell is active, and shared Compose now mounts Today, Brief,
Pulse, all four F79 top-level roots, and the shared Hub directory composition via
SharedKmpViewModel and the lane screens in
android/app/src/main/kotlin/com/locuno/lumi/android/ui/LumiApp.kt. Major breadth
surfaces such as chat, settings, CRM, messages, teams, approvals, decisions,
commitments, and risks still remain Android-native under android/feature/**.
iOS: SwiftUI still owns the outer shell and most feature breadth. The SharedScreen route
inventory lives in ios/Lumi/KMP/SharedScreenTypes.swift, while
ios/Lumi/KMP/SharedScreenView.swift hosts the shared Compose controller. Together they bridge
Today, Brief, Pulse, all four top-level roots, and the shared Hub directory composition
into SwiftUI. Hub breadth/detail destinations, chat, settings, CRM, teams,
messages, and most detail flows remain native SwiftUI screens.
macOS: ios/LumiMac/KMP/SharedScreenBridge.swift has the full KMPMacView bridge and the
detected shared-screen registrations, while the #if canImport(shared) branch is currently
inactive: the shared.xcframework Frameworks build phase is unlinked because
macosArm64 is not configured in android/shared/build.gradle.kts. The macosMain factories
are source-level adapters only until that target and the PullToRefreshBox macOS/JVM boundary
are buildable. The #else native SwiftUI fallback is active for Today, Brief, and Pulse; all
other screens show KMPNotLinkedView placeholders. A macOS target build, XCFramework re-link,
and runtime proof are still required before claiming Mac activation.
Windows/Linux: Tauri 2 wrapper over runlumi.app — current production state. Target is
Compose Desktop (:desktopApp Gradle module, jvmMain actuals) consuming the same commonMain
screens as Android, iOS, and Mac. Migration per F48 and ADR-021.
9. Error States
| Scenario | Behavior |
|---|---|
| Shared module fails to build for iOS target | CI blocks the PR; platform shell cannot compile without the framework |
expect declaration without matching actual | Kotlin compiler error — cannot build |
Compose rendering crash in ComposeUIViewController | SwiftUI catches as an unhandled exception; shared error boundary Composable shows a retry card instead of crashing the tab |
| API timeout in shared Ktor client | ViewModel emits UiState.Error("network_timeout") → screen shows retry button; i18n key common.error_network |
| Token refresh fails (refresh token expired) | SecureStore.clearAll() → shared nav emits AuthEvent.SessionExpired → platform shell presents login screen |
10. Diagrams & Docs Touched
- DIAGRAMS.md: add KMP architecture layer to the system diagram.
- android/AGENTS.md: update to reflect
shared/module as source of truth for ViewModels and screens;androidApp/becomes a thin shell. - ios/AGENTS.md (if it exists) or frontend/AGENTS.md: add KMP bridge notes.
- docs/specs/F30-mobile.md: add forward reference to F78 §4 for architecture; acceptance criteria are unchanged.
- docs/specs/F47-cross-device-apple.md: replace “native SwiftUI screens” with “SwiftUI shell + ComposeView content from shared module” in the Mac sections.
- docs/MVP_SCOPE.md: add F78 row.
- New ADR:
docs/ADR/0NN-compose-multiplatform-architecture.md— records the decision to use KMP+CMP over (a) three separate native codebases and (b) React Native / Flutter. Required before implementation begins (see §12).
11. Implementation Audit
Partial. A KMP module exists and is wired into both mobile shells, but broad feature ownership is still mixed rather than shell-only.
| Item | Status | Notes |
|---|---|---|
compose.components.resources wired in build.gradle.kts | ✅ 2026-06-22 | commonMain.dependencies; compose.resources { packageOfResClass } configured |
composeResources/values/strings.xml baseline (en) | ✅ 2026-06-22 | 15+ keys; grows as screens are localized |
composeResources/values-vi/strings.xml primary (vi) | ✅ 2026-06-22 | Mirrors en key-for-key |
BriefScreen.kt localized (no hardcoded strings) | ✅ 2026-06-22 | All 6 user-facing strings via Res.string.*; raw Button → LumiInlineAction |
TodayScreen.kt localized (no hardcoded strings) | ✅ 2026-06-22 | All 10 user-facing strings via Res.string.*; commitments section added; raw Button → LumiInlineAction |
SettingsScreen.kt localized (no hardcoded strings) | ✅ 2026-06-22 | 13 settings_* keys in both en and vi; onLeaveOrg dead-code removed |
CompaniesScreen.kt localized (no hardcoded strings) | ✅ 2026-06-22 | 8 crm_companies_* keys; company type labels (Customer/Supplier/Partner) |
DealsScreen.kt localized (no hardcoded strings) | ✅ 2026-06-22 | 9 crm_deals_* keys; deal status mapped via when in composable |
MessagesScreen.kt localized (no hardcoded strings) | ✅ 2026-06-22 | 5 messages_* keys |
HubScreen.kt localized (no hardcoded strings) | ✅ 2026-06-22 | 8 hub_* keys; Pinned / Acknowledge labels localized in AnnouncementCard |
WorkspaceLaneScreen.kt localized + design tokens (Rule 3 + Rule 4) | ✅ 2026-06-23 | 14 new workspace_* keys (EN + VI); hardcoded strings → Res.string.*; LaneScaffold.kt deleted (stock Card + raw colorScheme.onSurfaceVariant removed); now uses LumiPageTitle+LumiSectionHeader+LumiDirectoryRow. Routing keys unchanged. See improvement file 2026-06/week-26/kmp-workspace-lane-i18n-design-tokens.md |
| Remaining shared screens localized | ☐ (guard-enforced) | Keys added per-screen as touched. en↔vi parity is now mechanically guarded (2026-07-06): StringsParityTest (src/jvmTest/.../i18n/StringsParityTest.kt) fails :shared:jvmTest if values/strings.xml and values-vi/strings.xml key sets diverge, so an English fallback can no longer land silently on a VN phone. First run caught 4 drifted team_detail_* keys (present in en, missing from vi) — now translated. See improvement file 2026-07/week-27/kmp-strings-en-vi-parity.md |
Form-screen migration (P30): CreateApproval ported to shared KMP (reference) | ✅ 2026-06-25 | CreateApprovalScreen + CreateApprovalScreenModel + CreateApprovalRepository in commonMain; 7 commonTest cases (all green); iOS + Mac factories makeCreateApprovalViewController(…, onCreated); .createApproval case in both SharedScreenBridge.swift files; Android entry<CreateApprovalNavKey> shadows native, model in SharedKmpViewModel; native screens/CreateApprovalScreen.kt + its WorkspaceEntry block deleted. 18 approvals_* form keys added (EN+VI). Reuses the existing shared api.createApproval/ApprovalCreateRequest. Plan + remaining 12 screens: P30 |
| Form-screen migration (P30) batch 1: 6 create-only forms ported to shared KMP | ✅ 2026-06-25 | CreateTask, CreateProject, CreateRecurring, CreateDecision, CreateCompany, CreateDeal — each gets a dedicated Create…ScreenModel + Create…Repository in commonMain, a stateless Create…Screen (Lumi components + tokens, all strings via Res.string.*), 6–8 commonTest cases each (42 new, all green via :shared:jvmTest), iOS + Mac makeCreate…ViewController(…, onCreated) factories, .create… cases in both SharedScreenBridge.swift files, Android entry<…NavKey> + model in SharedKmpViewModel. The 6 native screens/Create*.kt files + their WorkspaceEntry blocks/imports were deleted; the native DateField helper was extracted into screens/DateField.kt so the still-native EditProjectScreen keeps compiling. Reuses existing shared create APIs/DTOs (createTask/createProject/createRecurring/createDecision/createCompany/createDeal). Date inputs are YYYY-MM-DD text fields, dropdowns are chip rows, company tags are toggle chips, CreateDecision keeps a dynamic options list. Plan: P30 |
| Form-screen migration (P30) batch 2 (final): 6 edit/detail screens ported to shared KMP | ✅ 2026-06-25 | EditTask, EditProject (load → prefill → PUT via submit), ProjectDetail, RecurringDetail, CompanyDetail, DealDetail (load → render, inline actions kept). Each gets a dedicated …ScreenModel + …Repository in commonMain (model holds LoadState<…> for the loaded entity), a stateless …Screen (Lumi components + tokens, all strings via Res.string.*, renders Loading/Error/Success), and a commonTest model test (24 new cases, all green via :shared:jvmTest). iOS + Mac make…ViewController(…, id, onSaved/onEdit/onRan) factories; legacy .dealStatusDetail bridge cases remain as compatibility aliases for the canonical DealDetailScreen. Android: per-id models constructed inside each entry<…NavKey> from the NavKey id via id-parametrized factories on SharedKmpViewModel; native entry blocks removed from WorkspaceEntry.kt (only Subscription remains). 37 new edit/detail i18n keys (EN+VI). Reuses existing shared APIs (taskDetail/updateTask, projectDetail/updateProject, recurringDetail/runRecurringNow, companyDetail, dealDetail/updateDeal). The former duplicate status-only deal detail was removed; the workspace-api DealDetailNavKey now resolves to the canonical F50 activity-flow detail. The 6 native screens/*.kt + the now-orphaned DateField.kt were deleted. All 13 P30 screens now KMP — form-screen migration complete. Plan: P30 |
P30 cleanup sweep: delete orphaned feature/workspace/impl/ ViewModels + screen files left behind by P30 batch 2 | ✅ 2026-06-27 | 17 production files + 9 dedicated test files deleted; 3 dead test methods removed from WorkspaceTests.kt; obsolete approval/decision action-test classes removed from ActionTests.kt; WorkspaceEntry.kt (empty, migration notice comment) deleted. LumiApp.kt had already imported all screens from com.locuno.lumi.shared.ui.* — zero dangling refs after deletion. See §13 change log + improvement file 2026-06/week-04/kmp-workspace-impl-dead-code-cleanup.md |
macOS blocker 1: android/shared/src/macosMain/kotlin/…/MainViewController.kt — factory functions returning NSViewController (with ComposePanel) for each SharedScreen case; mirrors iosMain/MainViewController.kt which uses ComposeUIViewController | ✅ 2026-07-02 | Initial batch (2026-06-23) wrote 31 factories; makeNotificationsViewController (F77) added 2026-06-25. 2026-06-28: 3 additional factories added (makeOrgManagementViewController, makeOrgSetupViewController, makeSubscriptionViewController). 2026-07-02: makeAuthViewController added — missed when F30 auth was migrated to shared KMP (PR #899 added it to iosMain only). macosMain ↔ iosMain factory parity now 32/32; all bridge-callable SharedScreen cases have matching macosMain symbols. See improvement files 2026-06/week-04/macos-kmp-main-view-controller.md, 2026-06/week-04/macos-kmp-notifications-factory-parity.md, 2026-06/week-04/macos-kmp-org-subscription-factory-parity.md, 2026-07/week-27/macos-kmp-auth-factory-parity.md |
macOS blocker 2: PullToRefreshBox macOS+JVM expect/actual — isolate the mobile-only API from common code | ⏳ | The source split exists: Android and iOS use PullToRefreshBox; macOS no-ops; JVM enables it only on Android ART/Dalvik. TodayScreen.kt no longer imports it directly. macosArm64 is not currently configured in android/shared/build.gradle.kts, so no active macOS compile proves the macOS actual. See improvement file 2026-06/week-26/kmp-macos-pulltorefresh-expect-actual.md. |
Detail-screen PTR coverage: ProjectDetailScreen, CompanyDetailScreen, DealDetailScreen (P30 batch 2) — all 3 now have pull-to-refresh via LumiRefreshable (stale-while-revalidate; error surfaced on failure, guard against double-refresh) | ✅ 2026-06-25 | isRefreshing: Boolean + refresh() added to each ScreenModel; LumiRefreshable wrapper added to each Screen composable; success/failure/guard tests added to each ScreenModel test. See improvement file docs/improvements/2026-06/week-26/kmp-detail-screen-pull-to-refresh.md |
Mac routing: ContentView.contentForTab routes all 9 active sidebar tabs to KMPMacView — tasks, projects, chat, and workspace/hub moved from native SwiftUI to KMP; 5 unused @State vars removed; detail pane uses MacPlaceholderView for tasks/projects (KMP handles item detail inline, matching decisions/risks pattern) | ✅ 2026-06-24 | Mac KMP reuse coverage: 5/9 → 9/9 tabs. Native MacTasksViewModel/MacProjectsViewModel/ChatViewModel eliminated as active code paths. SharedScreenBridge.swift activation comment updated. See improvement file 2026-06/week-26/macos-kmp-tasks-projects-chat-hub.md |
Mac activation: re-link shared.xcframework in LumiMac Frameworks build phase and verify SharedScreenBridge.swift #if canImport(shared) branch | ⏳ | Factory source parity and 25 sidebar routes do not establish a buildable Mac framework: macosArm64 is currently absent from the shared target graph. Restore/configure that target, build on a macOS host, re-link the framework, then verify the bridge and native-login parity. |
| Dead native file cleanup: delete migration-notice files that are no longer live code | ✅ 2026-07-04 | Deleted 6 tombstone Swift files: AccountScreens.swift, IntelligenceInboxScreens.swift, SubscriptionScreen.swift, AuthScreen.swift, GateScreens.swift, DetailScreens.swift. Removed dead WorkspaceSection + WorkspaceRow structs from MainTabView.swift (never instantiated after SharedLaneTabView KMP switch). Added DeadFileGuardTests.swift compile-time guard. Verified: project.pbxproj had 0 explicit references; SPM path: "Lumi" auto-discovery requires no manifest update. See improvement file docs/improvements/2026-07/week-27/ios-dead-native-file-cleanup-f78.md. |
Platform parity script: scripts/check-platform-parity.sh enumerates all shared Screen objects and verifies each is registered in iOS shell, macOS shell, and Kotlin iOS entry point | ✅ 2026-07-02 | Script exists at scripts/check-platform-parity.sh and is wired into CI via .github/workflows/ci-kmp.yml step bash scripts/check-platform-parity.sh. Runs on deploy/* pushes that touch android/**, ios/Lumi/KMP/**, or ios/LumiMac/KMP/**. |
Windows/Linux: desktopApp/src/main/kotlin/Main.kt Compose Desktop entry point consuming shared commonMain screen composables via jvmMain actuals | ⏳ | Compose Desktop app exists on main (android/desktopApp/). Main.kt uses application { Window { DesktopApp() } }; MainShell.kt mounts the 24 active shared screens, including PulseHomeScreen, BriefScreen, and TasksScreen, directly from com.locuno.lumi.shared.ui.*. The retired TodayScreen is no longer a desktop route; its card anatomy lives in Pulse needs-you-now. CI: .github/workflows/desktopApp-ci.yml runs on release/desktop branch. Production promotion (manual desktop-v* tag release) pending — Tauri-based production desktop not yet retired. |
Decision boundary: F79 is the product target; F78 is one implementation strategy. The current repo still behaves as if F78 is intended, because shared KMP code is present and live. Therefore the current problem is best treated as implementation drift against an active architecture target, not as proof that F78 is obsolete. If product/engineering leadership decides the KMP strategy is no longer desired, this spec should be deprecated in one explicit change.
What exists and can be reused
| Asset | Location | Reuse path |
|---|---|---|
| Shared KMP screens | android/shared/src/commonMain/kotlin/com/locuno/lumi/shared/ui/{today,brief,pulse,lanes} | Expand the same commonMain pattern to additional lists and detail flows |
| Shared KMP screen models | android/shared/src/commonMain/kotlin/com/locuno/lumi/shared/presentation/{today,brief,pulse} | Reuse for future shared state ownership instead of adding more platform-local duplicates |
| iOS/macOS bridge | android/shared/src/iosMain/.../MainViewController.kt, ios/Lumi/KMP/SharedScreenBridge.swift, ios/LumiMac/KMP/SharedScreenBridge.swift | Extend factory coverage beyond lane roots into shared directory and breadth/detail flows; keep shell native |
| Android shell integration | android/app/src/main/kotlin/com/locuno/lumi/android/ui/LumiApp.kt, SharedKmpViewModel.kt | Reuse this injection path as shared coverage expands |
| Native lane and Workspace screens | android/feature/**, ios/Lumi/UI/** | Migrate in waves; do not duplicate them again in parallel |
Migration phases (ordered by risk, lowest first)
- Phase 0–3 completed only for
Today/Brief/Pulse: KMP scaffold, shared data, shared models, and shared UI are real. - Wave 1 now materially executed: both mobile shells share all four F79 top-level roots and the shared Hub directory composition, while breadth/detail ownership remains mixed.
- Next wave — Hub breadth lists: migrate shared list/detail ownership for work, intelligence, business, and company directory surfaces before attempting edge features.
- Next wave — detail/action flows: approvals, decisions, commitments, risks, CRM, messages, teams, and settings should share state and screen composition where F30 demands parity.
- Parity gate last, not fictional: only add or enforce a platform-parity gate after the shared destination graph is broad enough to be meaningful.
Ownership inventory snapshot (2026-06-24, updated)
| Surface family | Shared KMP | Android-native | iOS-native | macOS bridge |
|---|---|---|---|---|
| Today | ✅ | legacy native copy still present (deletion candidate) | native copy still present (deletion candidate) | ✅ |
| Brief | ✅ | legacy native copy still present (deletion candidate) | native copy still present (deletion candidate) | ✅ |
| Pulse form/readback | ✅ | legacy native copy still present (deletion candidate) | native copy still present (deletion candidate) | ✅ |
| F79 lane roots | ✅ | shared KMP mounted from Android shell | ✅ | 🔲 (no sidebar route) |
| Tasks / Projects / Recurring / Horizon | ✅ | routed to KMP (native files deleted) | routed to KMP (native files deleted) | ✅ Tasks+Projects via KMPMacView (PR #582); Recurring/Horizon no sidebar route |
| Approvals / Decisions / Commitments / Risks | ✅ | routed to KMP (native files deleted) | routed to KMP (native files deleted, routing bugs fixed) | ✅ all four via KMPMacView — Decisions/Risks (PR #582), Commitments/Approvals (2026-06-24) |
| Messages / Hub / Teams | ✅ | routed to KMP (native files deleted) | routed to KMP (native files deleted) | ✅ Hub via KMPMacView (PR #582 Workspace tab); Messages/Teams no sidebar route |
| CRM / Companies / Deals | ✅ | routed to KMP (native files deleted) | routed to KMP (native files deleted) | 🔲 (no sidebar route) |
| Settings / account / org controls | ✅ | routed to KMP (native files deleted) | routed to KMP (native files deleted) | native Settings scene (HIG requirement) |
| Signal Inbox (F32) | ✅ 2026-06-23 | routed to KMP (no-op stub replaced) | routed to KMP (native Swift deleted) | ✅ via KMPMacView (2026-06-24) |
| Follow-ups | ✅ | routed to KMP | routed to KMP | ✅ via KMPMacView (2026-06-24) |
See implementation audit for the evidence-backed repo inventory.
12. Open Questions / Decisions
These must be resolved (as ADR) before Phase 1 begins:
| # | Question | Options | Recommendation |
|---|---|---|---|
| 1 | Navigation library in shared module | (a) Compose Navigation (Google) · (b) Voyager · (c) Circuit (Cash App) · (d) Decompose | Voyager — simplest ScreenModel API, good iOS track record, actively maintained. Circuit is elegant but heavier. Decompose is powerful but complex. |
| 2 | String resources in shared module | (a) moko-resources · (b) Lyricist (compile-time from JSON) · (c) custom expect/actual | moko-resources — mature, well-documented for KMP; generates typed accessors from existing JSON locale files |
| 3 | DI in shared module | (a) Koin · (b) Kodein · (c) manual factory | Koin — multiplatform-first, zero-reflection on iOS (works with Kotlin/Native), used by most KMP reference projects |
| 4 | Image loading in shared module | (a) Coil 3 (CMP-ready) · (b) Kamel · (c) platform-side only | Coil 3 — official CMP support as of 3.x, same API as Android |
| 5 | Do top bars stay in Compose or move to platform shell on iOS? | (a) Compose TopAppBar hidden, SwiftUI nav bar used · (b) Compose draws its own top bar on all platforms | Platform shell on iOS/macOS for Liquid Glass fidelity; Compose draws its own on Android. Controlled via expect/actual WindowChromeSpec. |
| 6 | ADR for this architectural decision | Required before implementation | Author docs/ADR/0NN-compose-multiplatform-architecture.md covering: why KMP over Flutter/RN, why hybrid shell over pure-Compose, migration risk and rollback. |
13. Bug / Change Log
2026-08-05 — Align the F78 architecture proof with F79’s four-tab V2 shell
Type: Contract and CI repair
Summary:
- Replaced the stale F78 five-tab proof with exact assertions for F79’s canonical
Lumi·Work·Business·Hubshell. - Updated the F78 CUE scope, authority boundary, requirement, and proof locator to the same four-tab contract.
Reason:
- The shipped product contract had already moved to the four-tab V2 shell, but the F78 structural test and CUE declaration still encoded the retired five-tab wording and failed backend CI.
Impact:
- F78 now guards the active F79 architecture instead of rejecting it. This changes no runtime navigation behavior and does not close any named-device proof gap.
Tests:
backend/tests/platform/test_f78_contract.py- Contract formatting, validation, generation, and graph tests.
2026-08-02 — Android deal-detail state no longer serializes transient CRM JSON
Type: Android navigation-state crash fix
Summary:
- Sentry event
0ebe286474b947d486b8d902bdbb8526showedDealDetailNavKeycrashing inSavedStateEncoderwhile the Activity was saving state. The in-memoryinitialDealDTO containscustom_fields: JsonObject, whose serializer is JSON-only. initialDealremains available for the immediate deal-detail render but is now@Transient, so persisted Navigation3 state contains only the stabledealIdand reloads the DTO after process recreation.
Verification: :app:testDevDebugUnitTest --tests com.locuno.lumi.android.navigation.DealDetailNavKeyTest.
2026-07-12 — macOS Audit Log parity registration restored
Type: Shared-screen parity fix
Summary:
ui/auditlogalready had an iOS factory but no matching macOSSharedScreencase ormacosMainview-controller factory. The platform-parity gate therefore failed even though the shared screen exists.- The macOS bridge and factory now register the read-only, role-gated Audit Log surface. This is a registration repair only; it does not add a new navigation surface or change authorization.
Verification: bash scripts/check-platform-parity.sh.
2026-08-02 — LumiTab parity registration restored
Type: Shared-screen parity fix
Summary:
- The new
ui/lumitabdirectory was already hosted on iOS through thelumiHomecompatibility route and had an iOS factory, but the parity gate derives the canonical name from the shared directory and therefore required an explicitlumitabregistration on both native shells. - Added the iOS compatibility registration, the macOS
SharedScreen.lumitabbridge route, and amacosMainLumiTabScreenhost factory. This is source registration only; it does not add a sixth Mac sidebar tab or claim a buildable/linked macOS framework.
Verification: bash scripts/check-platform-parity.sh; python3 -m pytest -q backend/tests/platform/test_f78_contract.py.
2026-07-04 — Dead iOS tombstone files removed (closes F78 Implementation Audit gap)
Type: Shell cleanup — 6 tombstone Swift files deleted + 2 dead SwiftUI structs removed from MainTabView.swift.
Files deleted:
ios/Lumi/UI/Workspace/AccountScreens.swift— migration-notice-only (SwitchOrg, JoinOrg, CreateOrg, OrgDangerZone all routed to KMP OrgManagementScreen since 2026-07-02)ios/Lumi/UI/Workspace/IntelligenceInboxScreens.swift— 10-line comment-only (F32 Signal Inbox migrated to KMP since 2026-06-23)ios/Lumi/Billing/SubscriptionScreen.swift— migration-notice-only (migrated to KMP since 2026-06-25)ios/Lumi/UI/Auth/AuthScreen.swift— migration-notice-only (all auth surfaces on KMP since ~2026-06-20)ios/Lumi/UI/Auth/GateScreens.swift— migration-notice-only (JoinWithCodeView removed 2026-07-02; routing through KMP OrgManagementScreen)ios/Lumi/UI/Features/DetailScreens.swift—StatusBadgestruct with zero call sites outside the file
Dead structs removed from MainTabView.swift:
WorkspaceSection— never instantiated afterWorkspaceTabViewswitched toSharedLaneTabViewWorkspaceRow— same
Safety verification:
grepacrossios/Lumi.xcodeproj/project.pbxproj→ 0 references to any deleted filenameios/Package.swiftusespath: "Lumi"SPM auto-discovery — no manifest update requiredgrep -r "StatusBadge\|WorkspaceSection\|WorkspaceRow"→ 0 live call sites
New guard added: ios/UnitTests/Workspace/DeadFileGuardTests.swift — compile-level XCTest that verifies all org/subscription/signal-inbox workspace rows still resolve to non-nil actions after cleanup, confirming no live route was accidentally severed.
See improvement file docs/improvements/2026-07/week-27/ios-dead-native-file-cleanup-f78.md.
2026-07-02 — Status and §11 audit reconciled to converged shell-only reality
Type: Documentation correction — status line and Implementation Audit rows updated to reflect the actual repo state; no code changed.
What was stale:
- Status line still said “partial hybrid rather than the intended shell-only split” — incorrect.
The shell-only split is effectively achieved: 59 shared
commonMainscreens cover all feature families; Android and iOS native duplicate files have been deleted; Mac mounts all 25 sidebar tabs viaKMPMacView; desktop has migrated to Compose Desktop (code on main). scripts/check-platform-parity.shwas listed as a gap, but the script exists and is wired into CI (ci-kmp.ymlstepbash scripts/check-platform-parity.sh) — marked ✅.- Mac activation row cited “31 cases” — corrected to 32 (after
makeAuthViewController2026-07-02). - Windows/Linux row showed ☐ citing “blocked on macOS blocker 2” — blocker 2 resolved 2026-06-23;
Compose Desktop app is on
mainand mounts shared screens. Updated to ⏳ (production promotion pending, not a code gap).
Genuinely pending gaps kept:
- Mac XCFramework re-link on a macOS host (env-blocked non-code step).
- Dead native file cleanup:
AccountScreens.swift(retired SwiftUI structs) +IntelligenceInboxScreens.swift(10-line comment-only migration notice). Added as explicit ⏳ row. - Desktop Compose Desktop production promotion (Tauri not yet retired from prod).
- One native
LoginViewinios/LumiMac/Views/LoginView.swift— correctly remains until XCFramework re-link confirms parity.
Evidence basis:
- Shared screen count:
find android/shared/src/commonMain/kotlin -name "*Screen.kt" -path "*/ui/*" | wc -l→ 59. - Parity script:
ls scripts/check-platform-parity.sh→ exists;grep "check-platform-parity" .github/workflows/ci-kmp.yml→ line 36bash scripts/check-platform-parity.sh. - Mac tabs:
ContentView.swiftheader comment “All 25 shared Compose screens render via KMPMacView”; 28KMPMacView(call sites. - Dead files:
ls ios/Lumi/UI/Workspace/→AccountScreens.swift(exists, “RETIRED” header),IntelligenceInboxScreens.swift(exists, 10-line comment only). - Desktop:
MainShell.ktmounts the 24 active shared screens, includingPulseHomeScreen,BriefScreen, andTasksScreen; the retiredTodayScreenis absent.desktopApp-release.ymltrigger is manualdesktop-v*tag.
2026-07-02 — makeAuthViewController added to macosMain (closes factory parity gap)
Type: Build-correctness fix — makeAuthViewController was added to iosMain/MainViewController.kt
by the F30 auth KMP migration (PR #899) but was not ported to macosMain. The Mac
SharedScreenBridge.swift .auth case already dispatches to this symbol; its absence would have
caused a link error once shared.xcframework is re-linked (the “Mac activation” step in §11).
macosMain/MainViewController.kt — 3 imports (AuthScreenModel, DefaultAuthRepository,
AuthScreen) + makeAuthViewController(baseUrl, onAuthenticated) factory added in the Pre-auth
section, mirroring the iosMain counterpart exactly. Uses ComposeNSViewController (AppKit)
instead of ComposeUIViewController (UIKit); no new Kotlin content, no new Swift. macosMain ↔
iosMain factory parity: 32/32.
See improvement file: docs/improvements/2026-07/week-27/macos-kmp-auth-factory-parity.md
2026-06-28 — ApprovalsScreen now triggers .createApproval on iOS + Android (closes P30 wiring gap)
Type: UX wiring fix — closes the gap §11 noted at the 2026-06-25 P30 entry (line 684-686): CreateApprovalScreen + its iOS/Mac factory + .createApproval bridge case shipped on 2026-06-25, but the shared ApprovalsScreen had no create affordance to navigate to it, so the destination was unreachable on both platforms.
ApprovalsScreen.kt (commonMain) — @Composable signature gains optional onCreateApproval: (() -> Unit)? = null. When non-null, the title row renders a TextButton with Res.string.action_new (reuses the existing en+vi key shared with Decisions/Commitments). The previous LumiTrailingCount falls back to the else branch when no create callback is wired (e.g. on macOS until that lane gets its own create flow).
iOS — MainViewController.kt makeApprovalsViewController(…) now takes onCreateApproval: (() -> Unit)? as a required non-default parameter (KMP/Swift ObjC interop rule per check-kmp-ios-factory-no-default-lambdas). SharedScreenBridge.swift .approvals case forwards onCreated → onCreateApproval; MainTabView.swift already pushed .createApproval on that callback (mirroring .tasks → .createTask etc.) — the bridge wiring is the only iOS change required.
Android — LumiApp.kt entry<ApprovalsNavKey> now passes onCreateApproval = { navigator.navigate(CreateApprovalNavKey) }. The shadow-over-native entry registered on 2026-06-25 is unchanged.
macOS — untouched; the macosMain makeApprovalsViewController keeps its existing 3-arg signature (no create flow on Mac yet, parity deferred until Mac approvals workflow is scoped).
Regression coverage — Maestro .maestro/critical/21_create_approval_flow.yaml asserts Workspace → Approvals → ”+ New” → New approval page title → save → list reappears. Tests 16-20 added in the same PR cover Task Detail, Project Board, Company Detail, Create Company, and Hub Announcement detail navigation (independent regression guards for other previously-untested screens).
Verification: make check-kmp-ios-factory-no-default-lambdas → OK (0 violations). make ci-kmp + make ci-docs green on PR head. iOS Xcode build gated by ios-ci.yml on PR; Maestro suite runs out-of-band on seeded devices.
2026-06-27 — Complete LoadState four-state contract + stale-data preservation on Today screen
Type: UX-consistency fix — closes the merge-blocking gap in mobile-ux-patterns.md §1. Before this change, LoadState.Error carried no stale data (last was absent), so a failed pull-to-refresh would blank the screen instead of keeping prior content visible.
LoadState.kt — Error<T> promoted to generic with last: T? = null; added dataOrStale() extension and isFirstLoad property (both in commonMain, shared across all screens).
TodayScreenModel.kt —
TodayUiStatedefaults changed fromLoading()toNotRequested(correct initial state per §1).loadAll()captures stale data from the currentSuccessstate before launching the fetch.toLoadState(last)now passeslasttoLoadState.Errorso failures on refresh carry prior good data.combinedTasksfailure path also forwardsstaleTasks.
TodayScreen.kt — rendering updated to use dataOrStale() per section:
NotRequested/Loading(last=null)→ skeleton (first load).Loading(last=data)→ stale rows visible while refresh is in flight.Error(last=null)→ blocking error row (no data ever loaded).Error(last=data)→ non-blocking error banner above stale rows.
Tests — three new tests in TodayScreenModelTest.kt:
initial state is NotRequested for all sectionsfailed refresh preserves stale data in Error_lastfailed first load has null Error_last (no stale data)
Verification: compile-only (cloud CI cannot reach Google Maven for AGP 9.0.0); code reviewed against spec invariants.
See improvement file: docs/improvements/2026-06/week-04/loadstate-four-state-contract.md
2026-06-27 — P30 cleanup sweep: delete orphaned Android shell ViewModels and screen files
Type: Dead-code removal — android/feature/workspace/impl/ shell-only files made unreachable by P30 batch 2 (2026-06-25). WorkspaceEntry.kt had explicitly noted itself as “kept for reference until the next cleanup sweep”; this is that sweep.
Deleted (production, 17 files):
- 7 ViewModels (
ApprovalsViewModel,CrmViewModel,DecisionsViewModel,ProjectsViewModel,RecurringViewModel,SubscriptionViewModel,TasksViewModel) — all replaced by equivalent VoyagerScreenModels inandroid/shared/src/commonMain/. - 9 screen files (
AnnouncementReaderSheet,CommitmentActionSheet,DecisionDetailSheet,FeatureStubScreen,LumiScreenColors,RiskActionSheet,SubscriptionScreen,TaskActionSheet,UpgradeGateContent) — all replaced by shared KMP composables. navigation/WorkspaceEntry.kt— empty; Subscription migrated in a prior step, leaving only the package declaration and migration notice comment.
Deleted (tests, 9 files): seven app-level ViewModel tests (Approvals, Crm, Decisions, Projects, Recurring, Subscription, Tasks) and the Approvals and Decisions tests under feature/workspace/impl. Equivalent coverage lives in android/shared/src/commonTest/.
Edited (2 files): WorkspaceTests.kt removed three methods that directly referenced deleted ViewModels; ActionTests.kt removed obsolete approval/decision action classes. Remaining tests are unchanged.
Verification: compile-only (no emulator/simulator available in cloud CI); :feature:workspace:impl:compileDebugKotlin, :app:compileDevDebugKotlin, :shared:compileKotlinJvm, and :shared:jvmTest all green.
See improvement file: docs/improvements/2026-06/week-04/kmp-workspace-impl-dead-code-cleanup.md
2026-06-25 — Form-migration batch 2 (final): 6 edit/detail screens → shared KMP (P30)
Type: Platform Migration — native Android edit/detail screens → shared KMP commonMain (batch 2, completing P30). With this change all 13 P30 form/edit/detail screens are KMP; the form-screen migration is complete.
- Screens migrated:
EditTask,EditProject(load → prefill → PUT),ProjectDetail,RecurringDetail,CompanyDetail,DealDetail(load → render with inline actions). Each now lives once incommonMainand ships on Android, iOS, and the Mac shell from one source. - Per screen (
commonMain):presentation/<area>/<X>ScreenModel.kt— VoyagerScreenModelwhoseUiStateholds aLoadState<entity>;init { load() }fetches via the repository (Loading→Success/Error idiom matching the list models). Edit models add form fields +isSaving/error and asubmit(onSaved)that PUTs; detail models expose their inline action (runNow(onRan)for recurring,updateStatus(status)for the deal).testScopector param for tests.presentation/<area>/<X>Repository.kt— focused interface +Default…impl wrapping the existingLumiApiClientload/update calls (reuses existing request DTOs).ui/<area>/<X>Screen.kt— stateless@Composable; renders Loading/Error/Success of theLoadState; Lumi components + tokens only, no self-drawn top bar, every string viaRes.string.*.
- Per-id construction: unlike the singleton list/create models, edit/detail models are per-id —
each Android
entry<…NavKey>builds its model inside the entry from the NavKey id via a new id-parametrized factory onSharedKmpViewModel(mirroringtaskDetailModel/dealDetailModel),remember(key.<id>)-scoped. - Inline actions preserved:
ProjectDetailkeeps its Edit affordance (→EditProjectNavKey),RecurringDetailkeeps Run-now (confirm dialog),DealDetailkeeps its stage move (open/won/lost viaupdateDeal). - Deal-detail naming: the F50 activity-flow
crm/DealDetailScreenis the canonical deal detail across shells. The old status-only implementation was removed; the workspace-api key and.dealStatusDetailbridge case remain only as compatibility aliases to that canonical screen. The canonical screen is wired to the workspace-apiDealDetailNavKeyas well as the app-module deal route. - iOS / Mac:
makeEditTaskViewController(…, taskId, onSaved),makeEditProjectViewController,makeProjectDetailViewController(…, onEdit),makeRecurringDetailViewController(…, onRan),makeCompanyDetailViewController, and the compatibility aliasmakeDealStatusDetailViewControlleradded to bothiosMainandmacosMainMainViewController. BothSharedScreenBridge.swiftfiles gain matching id-carrying enum cases + dispatch (edit screens reuseonCreatedas the saved sink; ProjectDetail reusesonRowSelectedfor its Edit affordance). Full 6/6 symbol parity. - i18n: 37 new edit/detail keys added to both
values/strings.xmlandvalues-vi/strings.xml(existingrecurring_frequency_*/recurring_impact_*/crm_deal_status_*keys reused, not duplicated). - Cleanup: the 6 native
screens/{EditTask,EditProject,ProjectDetail,RecurringDetail, CompanyDetail,DealDetail}Screen.ktand the now-orphanedscreens/DateField.kt(used only by the deleted EditProjectScreen) were deleted;WorkspaceEntry.ktnow registers only the Subscription entry. Zero dangling refs. - Verification:
:shared:compileKotlinJvm,:app:compileDevDebugKotlin,:feature:workspace:impl:compileDebugKotlinall BUILD SUCCESSFUL; 24 newcommonTestcases pass via:shared:jvmTest. iOS/Mac build in CI (no local Xcode). - As-built: P30’s batches 2 and 3 were delivered in one PR because the shared wiring files
(
LumiApp.kt,SharedKmpViewModel.kt, bothSharedScreenBridge.swift,WorkspaceEntry.kt) are touched by all six screens and cannot be split.
2026-06-25 — Form-migration batch 1: 6 create-only forms → shared KMP (P30)
Type: Platform Migration — native Android forms → shared KMP commonMain (batch 1, replicating the CreateApproval reference).
- Screens migrated:
CreateTask,CreateProject,CreateRecurring,CreateDecision,CreateCompany,CreateDeal. Each now lives once incommonMainand ships on Android, iOS, and the Mac shell from one source. - Per screen (
commonMain):presentation/<area>/Create<X>ScreenModel.kt— VoyagerScreenModelholding all form-field state +isSaving/error flags;submit(onCreated)validates required fields, trims, maps blanks→null,runCatchingthe repo call, firesonCreatedon success.testScopector param.presentation/<area>/Create<X>Repository.kt— focused interface +Default…impl wrapping the existingLumiApiClientcreate call (reuses existing request DTOs).ui/<area>/Create<X>Screen.kt— stateless@Composable(model, onCreated); Lumi components + tokens only, no self-drawn top bar, every string viaRes.string.*.
- Deviations from the reference (KMP constraints): Material3
DatePicker/ExposedDropdownMenuare Android-only and unavailable incommonMain, so date inputs render asYYYY-MM-DDtext fields (like the reference’s money field), single-select dropdowns (priority/status/frequency/ impact/deal-status) render asLumiFilterChiprows, and company customer/supplier/partner tags render as toggleLumiFilterChips.CreateDecisionkeeps its dynamic options list — the model ownsoptions: List<String>with add/edit-by-index/remove (min one row); blank rows are dropped on submit, matching the native ViewModel. - i18n: all field/label/error/chip keys added to both
composeResources/values/strings.xmlandvalues-vi/strings.xml(tasks_*,projects_*,recurring_*,decisions_*,crm_*).tasks_create_title/projects_create_titlereused from the existing list namespace. - Tests: 6
commonTestCreate<X>ScreenModelTestclasses (42 cases total: validation, trim/ null-mapping, success callback, error flag, plus dynamic-options add/edit/remove for Decision and dual-required-field validation for Recurring/Deal). All green via:shared:jvmTest. - iOS + Mac:
makeCreate<X>ViewController(…, onCreated)in bothiosMainandmacosMainMainViewController.kt;.create<X>cases (+screenNametitles) in bothios/Lumi/.../SharedScreenBridge.swiftandios/LumiMac/.../SharedScreenBridge.swift. - Android:
entry<Create<X>NavKey>for all 6 added toLumiApp.kt(beforeworkspaceEntry, shadowing native; imports kept ktlint-alphabetical) +create<X>ModelinSharedKmpViewModel. Deleted the 6 nativefeature/workspace/impl/.../screens/Create*.ktfiles and theirentry<…NavKey>blocks/imports inWorkspaceEntry.kt. The nativeinternal fun DateField(previously defined inCreateProjectScreen.kt, used by the still-nativeEditProjectScreen) was extracted into a newscreens/DateField.ktso the edit flow keeps compiling. The native list ViewModels (TasksViewModel,ProjectsViewModel,RecurringViewModel,DecisionsViewModel,CrmViewModel) were retained — they back the still-native edit/detail screens. - Drive-by fix:
ui/crm/DealsScreen.kthad a pre-existing@Composable-out-of-scope error (astringResourcecall inside aLazyColumnforEachbut above theitem {}block) that broke:shared:compileKotlinJvmonorigin/main; moved the call into theitemscope so the jvm target (and thus the new tests) compiles. - Verification:
:shared:compileKotlinJvmBUILD SUCCESSFUL;:shared:jvmTest --tests "*Create*ScreenModelTest"49/49 pass (42 new + 7 reference);:app:compileDevDebugKotlinand:feature:workspace:impl:compileDebugKotlinBUILD SUCCESSFUL. iOS/Mac builds run in CI (no local Xcode); bridges static-checked for symbol parity.
2026-06-25 — CreateApproval ported to shared KMP (form-migration reference, P30)
Type: Platform Migration — native Android form → shared KMP commonMain (reference implementation).
Summary:
- New shared (
commonMain):presentation/approvals/CreateApprovalScreenModel.kt— VoyagerScreenModelholding all form-field state (title/description/category/moneyAmount+titleError/isSaving/submitError);submit(onCreated)validates (blank title → error), trims, maps blanks to null, calls the repo, firesonCreatedon success.APPROVAL_CATEGORIESconstant.presentation/approvals/CreateApprovalRepository.kt— focused interface +Default…impl wrapping the existingapi.createApproval(ApprovalCreateRequest(…))(kept separate fromApprovalsRepository, which is list/approve/reject only).ui/approvals/CreateApprovalScreen.kt— stateless@Composable(model, onCreated); Lumi components only (LumiPageTitle,LumiFormField,LumiMultilineField,LumiFilterChipcategory picker,LumiPrimaryButton,LumiStatusBanner); all strings viaRes.string.*; no self-drawn top bar (shell owns chrome, per §6).
- i18n: 18
approvals_*form keys added to bothvalues/strings.xmlandvalues-vi/strings.xml(title/saving/failed/title-required/description/category/amount/save + 6 category labels). - Tests:
commonTest/.../presentation/approvals/CreateApprovalScreenModelTest.kt— 7 cases (initial state, field updaters clear error, blank-title validation no-op, trim+null mapping +onCreated, non-blank forwarding, failure setssubmitError, edit-after-failure clears error). All green via./gradlew :shared:jvmTest. - iOS:
iosMain/MainViewController.ktmakeCreateApprovalViewController(…, onCreated);.createApprovalcase (+onCreatedclosure) inios/Lumi/KMP/SharedScreenBridge.swift. No iPhone route triggers it yet (the sharedApprovalsScreenlist has no create entry point — matching the prior Android state whereCreateApprovalNavKeywas registered but never navigated to); the factory + bridge case are ready for whenever a create affordance is added. - Mac:
macosMain/MainViewController.ktmakeCreateApprovalViewController(…, onCreated)(keeps macosMain↔iosMain factory parity);.createApprovalcase (+onCreated) inios/LumiMac/KMP/SharedScreenBridge.swift. - Android:
entry<CreateApprovalNavKey>added toLumiApp.kt(beforeworkspaceEntry, so it shadows the native entry;onCreated = goBack());createApprovalModeladded toSharedKmpViewModel. Deleted nativefeature/workspace/impl/.../screens/CreateApprovalScreen.kt+ itsentry<CreateApprovalNavKey>block inWorkspaceEntry.kt. NativeApprovalsViewModelretained — still exercised by existing unit tests and not referenced by any other live screen; flagged in P30 as a future deletion candidate, not removed here to avoid deleting real test coverage. - Drive-by fix: added the missing
import kotlin.test.assertNullto the pre-existingSettingsScreenModelTest.kt(dropped in commitbbf5e5dc1’s ktlint reorder) — without it the whole:sharedtest module failed to compile, blocking every shared unit test.
Reason:
- F78’s promise is “every screen written once.” List/detail surfaces are shared, but the 13 create/edit form screens were still native-only and dual-maintained. CreateApproval is the reference the other 12 copy. Plan + sequencing: P30.
Impact:
- One more screen runs from a single
commonMainsource; one native Android file deleted. - Establishes the repeatable form-migration pattern (model + repo + screen + i18n + test + iOS/Mac factory + bridge + Android wiring + native deletion).
Tests:
./gradlew :shared:compileKotlinMetadata→ BUILD SUCCESSFUL (commonMain compiles for all targets)../gradlew :shared:jvmTest --tests "*CreateApprovalScreenModelTest"→ 7/7 pass.- Full
:shared:jvmTestsurfaces 22 pre-existing failures in the “reload preserves stale data in Loading” pattern across many untouched models (incl. the referenceOrgManagementScreenModelTest); arunTesteager-dispatch test-design flake, not introduced here (on pristine origin/main the test module did not even compile, so these never previously ran). None of the new CreateApproval tests are affected. - iOS/Mac/Xcode builds not run locally (no Xcode); bridges static-checked for symbol parity. CI gates.
2026-06-23 — WorkspaceLaneScreen i18n + Lumi design tokens (Rule 3 + Rule 4 compliance)
Type: Compliance fix — i18n (Rule 3) + design tokens (Rule 4).
Summary:
WorkspaceLaneScreen.kt(commonMain) — 26 hardcoded English string literals replaced withRes.string.*viastringResource(). Routing keys (passed toonRowSelected) preserved unchanged. Rendering switched fromLaneScaffoldto directLazyColumn+LumiPageTitle+LumiSectionHeader+LumiDirectoryRow.LaneScaffold.kt(commonMain) — deleted; was the only remaining file inlanes/using stockCardand rawMaterialTheme.colorScheme.onSurfaceVariant.values/strings.xmlandvalues-vi/strings.xml— 14 newworkspace_*keys added (EN + VI).LaneManifestTest.kt—workspace sections contain expected row counts per grouptest added.
Reason:
WorkspaceLaneScreenwas the last F79 lane screen not usingRes.string.*, giving Vietnamese-locale users English labels on the widest-breadth screen in the app (entry to all 21 surfaces).LaneScaffoldwas also the sole remaining file inlanes/using non-Lumi design tokens.
Impact:
- Workspace tab displays localized labels in all supported locales, Vietnamese first.
LaneScaffold.ktremoved — no stock Compose tokens remain inlanes/.- All four F79 top-level roots now comply with Rule 3 + Rule 4; Pulse remains a nested shared surface.
See: docs/improvements/2026-06/week-26/kmp-workspace-lane-i18n-design-tokens.md
2026-06-25 — macOS Blocker 1 (final): makeNotificationsViewController added to macosMain (32/32 parity)
Type: Platform Gap Fix — missing factory function completing macosMain ↔ iosMain parity.
Summary:
android/shared/src/macosMain/kotlin/…/MainViewController.ktwas missingmakeNotificationsViewController(F77). The initial 2026-06-23 batch wrote 31 factories; F77 was added toiosMainafter that and not mirrored.SharedScreenBridge.swiftalready had.notifications → makeNotificationsViewController(…)in its#if canImport(shared)branch — the call site was ready, the symbol was absent.- Fix: added 3 import lines + 9-line factory body mirroring the iosMain pattern. Factory count: macosMain = iosMain = 32.
- Both compile blockers (1 + 2) are now fully resolved.
./gradlew :shared:linkReleaseMacosArm64unblocked. XCFramework rebuild + Xcode re-link on a macOS host activates all 31SharedScreencases.
See: docs/improvements/2026-06/week-04/macos-kmp-notifications-factory-parity.md
2026-06-23 — macOS Blocker 2: PullToRefreshBox expect/actual (unblocks macOS XCFramework build)
Type: Platform Compatibility Fix — expect/actual to isolate iOS-only M3 API from macOS/JVM targets.
Summary:
LumiMobilePrimitives.kt(commonMain) —LumiRefreshablerefactored:PullToRefreshBoxremoved fromcommonMain; public function now delegates to internalexpect fun lumiRefreshableImpl;ExperimentalMaterial3ApiandPullToRefreshBoximports removed.TodayScreen.kt(commonMain) — replaced directPullToRefreshBoxcall +@OptIn(ExperimentalMaterial3Api::class)withLumiRefreshable; removed both problematic imports.iosMain/LumiRefreshable.kt— newactualproviding realPullToRefreshBoxfor iOS (iosArm64 + iosSimulatorArm64).macosMain/LumiRefreshable.kt— newactualno-op (pull-to-refresh gesture is meaningless on macOS trackpad/mouse).jvmMain/LumiRefreshable.kt— newactualno-op (JVM/desktop).
Reason:
PullToRefreshBoxfromandroidx.compose.material3is an Android/iOS CMP API with no macOS Kotlin/Native binding. AnycommonMainimport causedunresolved referenceat./gradlew :shared:linkReleaseMacosArm64and./gradlew :shared:compileKotlinJvm, blocking the macOS XCFramework build and the Windows/Linux desktop build.
Impact:
./gradlew :shared:linkReleaseMacosArm64unblocked — macOS XCFramework can now be built../gradlew :shared:compileKotlinJvmunblocked — desktop (Windows/Linux) shared KMP build unblocked.- iOS pull-to-refresh unchanged (real
PullToRefreshBoxstill used viaiosMainactual). - macOS and JVM show a plain scrollable view; user can trigger a refresh via the inline retry action or by navigating away and back.
See: docs/improvements/2026-06/week-26/kmp-macos-pulltorefresh-expect-actual.md
2026-06-24 — Mac: all 9 sidebar tabs routed to KMPMacView
Type: Mac Shell — contentForTab routing completion.
Summary:
ios/LumiMac/Views/ContentView.swift— Tasks, Projects, Chat, and Workspace tabs moved from native SwiftUI views (TasksView,ProjectsView,ChatView,MacPlaceholderView) toKMPMacView. Five unused@Statevars removed. Detail pane for tasks/projects usesMacPlaceholderView(consistent with decisions/risks)..workspace→.hubKMP screen.ios/LumiMac/KMP/SharedScreenBridge.swift— activation comment updated (blockers resolved; only Xcode Framework-link step remains).
Impact:
- Mac KMP reuse coverage: 5/9 → 9/9 active tabs.
- Native Mac-only ViewModels (
MacTasksViewModel,MacProjectsViewModel,ChatViewModel) eliminated as active code paths. - No new native Swift content code added; the four screens run from
commonMainonce xcframework is linked in Xcode.
See: docs/improvements/2026-06/week-26/macos-kmp-tasks-projects-chat-hub.md
2026-06-23 — F32 Signal Inbox migrated to shared KMP
Type: Platform Migration — native iOS → shared KMP commonMain.
Summary:
SignalInboxRepository.kt,SignalInboxScreenModel.kt,SignalInboxScreen.ktcreated incommonMain.SignalInboxScreenModelTest.kt(18 tests) created incommonTest.LumiApiClient.kt— 5 Signal Inbox API methods added;LumiDtos.kt— 2 DTOs added.- Android:
SignalInboxNavKey.ktadded;SharedKmpViewModelwired;LumiApp.ktno-op stub replaced. - iOS:
makeSignalInboxViewControlleradded to iosMain;.signalInboxcase added toSharedScreenBridge.swift;MainTabView.swiftroutes to KMP;IntelligenceInboxScreens.swiftnative implementation deleted. - 29
signal_inbox_*i18n keys added to both EN and VI string resources.
Reason:
- Android had a silent no-op; iOS had a parallel native implementation. Consolidating into shared KMP eliminates the divergence, adds test coverage, and increases shared-code coverage metric.
2026-06-22 — Rule 3 fix: CRM/Messages/Hub screens now locale-aware
Type: Rule 3 fix (merge-blocking)
Summary:
CompaniesScreen.kt— replaced 9 hardcoded English strings withstringResource(Res.string.crm_companies_*);Res+stringResourceimports added.DealsScreen.kt— replaced 7 hardcoded English strings withstringResource(Res.string.crm_deals_*); deal status label (won/lost/open) now mapped viawhen+stringResourceinsideDealCardcomposable instead of rawreplaceFirstChar.MessagesScreen.kt— replaced 6 hardcoded English strings withstringResource(Res.string.messages_*).HubScreen.kt— replaced 9 hardcoded English strings withstringResource(Res.string.hub_*); Pinned/Acknowledged/Acknowledge labels localized insideAnnouncementCard.values/strings.xml— added 30 new keys: 8crm_companies_*, 9crm_deals_*, 5messages_*, 8hub_*.values-vi/strings.xml— matching Vietnamese translations for all 30 new keys.
Reason:
- All four screens were routed live on both Android and iOS in the wave23 PR (2026-06-22) but still contained hardcoded English strings — Rule 3 violation. Vietnamese-locale CEOs saw English text on these live screens.
Impact:
- Single
commonMainchange: both Android and iOS corrected simultaneously. - No behavior change in English locale; Vietnamese locale now displays correct strings.
- Zero platform-shell changes required.
Tests:
- Type-safe:
Res.string.*references are compile-time-checked — missing keys cause build errors. - Cloud environment: AGP 9.0.0 unavailable in Gradle cache — compile/test could not be run locally. CI gates the PR. Pattern identical to prior localized screens (TasksScreen, ApprovalsScreen) which passed CI.
See: docs/improvements/2026-06/week-04/f78-rule3-crm-messages-hub-i18n.md
2026-06-22 — Settings KMP migration: Rule 3 violation fixed, native shells deleted
Type: Rule 3 fix + shared-code coverage increase
Summary:
android/shared/.../ui/settings/SettingsScreen.kt— replaced all hardcoded English strings withstringResource(Res.string.settings_*)(13 keys); removed deadonLeaveOrgcallback that was always{}in both shellsandroid/shared/composeResources/values/strings.xml— added 13settings_*keys (en)android/shared/composeResources/values-vi/strings.xml— added 13settings_*keys (vi)android/shared/src/iosMain/.../MainViewController.kt— removedonLeaveOrgparam from factoryios/Lumi/KMP/SharedScreenBridge.swift— updated.settingscase to not passonLeaveOrgios/Lumi/UI/MainTabView.swift— route.settingstoKMPComposeView, add DI environmentandroid/app/.../ui/LumiApp.kt— inlineentry<SettingsNavKey>using shared KMP screen; fix “Manage organization” routing bug (wasSettingsNavKey, nowSwitchOrgNavKey)android/MainActivityViewModel.kt— addfun signOut()public method- Deleted:
android/feature/settings/impl/.../SettingsScreen.kt(486 lines) - Deleted:
android/feature/settings/impl/.../navigation/SettingsEntry.kt(13 lines) - Deleted:
ios/Lumi/UI/Settings/SettingsScreen.swift(219 lines) - Net: ~700 lines deleted, 0 behavior regressions, Rule 3 resolved
2026-06-22 — Wave 2+3 routing: 12 list screens now served from shared KMP on both platforms
Type: Feature (routing wire-up + dead-code removal)
Summary:
android/app/.../ui/LumiApp.kt— added 12 explicitentry<NavKey>blocks for Wave 2+3 shared screens (Tasks, Projects, Recurring, Horizon, Approvals, Decisions, Commitments, Risks, Messages, Hub, Teams, CRM/Companies) before theworkspaceEntrycall so Navigation3’s first-match-wins routes to KMP. Mirrors the pattern of Wave 1’s FollowUps/Contacts/Forecast/HubDocs entries.android/feature/workspace/impl/.../navigation/WorkspaceEntry.kt— removed the 12 now-shadowed list-level entries; kept all create/edit/detail flows and intentionally-native screens (Notifications, Subscription, SwitchOrg).- Deleted 12 native list screen
.ktfiles + 4 ViewModels (CommitmentsViewModel,HorizonViewModel,RisksViewModel,TeamsViewModel) whose entire purpose was duplicating shared KMP state management. ios/Lumi/UI/MainTabView.swift— added 3 missing enum cases (.decisions,.commitments,.risks) toSharedLaneRoute; replaced 10 native screen renders withKMPComposeView; added 3 new KMP cases; fixed 3 routing bugs (Decisions was.brief, Commitments was.today, Risks was.today— all now correct).ios/Lumi/UI/Features/DetailScreens.swift— removed 8 detail/edit screen structs + 2 badge types (PriorityBadge, SeverityBadge) with zero remaining references; kept onlyStatusBadge(still referenced byIntelligenceInboxScreens.swift).- Deleted 3 native iOS files:
ChatScreen.swift(dead),WorkspaceScreens.swift(11 structs all replaced by KMP),CrmDealScreens.swift(DealListScreen + DealDetailScreen replaced by KMP).
Reason:
- Wave 2+3 shared KMP screens existed in
commonMainsince 2026-06-19 but both shells still routed to native duplicates. The shared code was dead-weight; native duplicates were the live product. This wires up what was already built.
Impact:
- 12 screens on Android and iOS now run from a single
commonMainsource. - 16 Android files + 3 iOS files deleted (~2,000+ lines of dual-maintenance eliminated).
- 3 iOS routing bugs fixed (CEO tapping Decisions, Commitments, Risks no longer lands on wrong screen).
- Ownership snapshot updated from “2026-06-18” to “2026-06-22” to reflect the new routing state.
Tests:
- Cloud environment: AGP 9.0.0 unavailable in Gradle cache — compile/test could not be run locally. CI gates the PR. Pattern is identical to Wave 1 entries (FollowUpsNavKey → FollowUpsScreen) which passed CI on prior PRs.
- No emulator/device available in cloud host — stated honestly per AGENTS-mobile.md §3.
See: docs/improvements/2026-06/week-04/f78-wave23-shared-kmp-routing.md
2026-06-18 — Repoint shell assumptions to F79
Type: Documentation Change
Summary:
- Replaced early-phase shell wording that implied an old generic five-tab model with explicit references to F79.
- Clarified that future shared navigation must implement
Pulse/Work Graph/Business/Company Hub/Workspace, with supporting modes nested inside those lanes.
Reason:
- Compose Multiplatform architecture should not freeze an already-superseded navigation model into the shared shell.
Impact:
- Future F78 implementation work now inherits the correct mobile IA target.
Tests:
- None. Spec-only change.
2026-06-18 — Correct F78 status to partial hybrid
Type: Documentation Change
Summary:
- Replaced the stale “complete” status with an evidence-backed in-progress state.
- Corrected the Implementation Audit and Current Behavior sections to reflect the actual repo:
shared KMP exists for
Today,Brief, andPulse, while most mobile breadth still lives in native Android and SwiftUI code. - Added an ownership snapshot and linked implementation audit.
Reason:
- The previous wording overstated the migration. The repo does not yet satisfy F78’s own promise that screen-level UI content is shared broadly and shells stay thin.
Impact:
- Future mobile work inherits the real migration target instead of a false “already done” claim.
Tests:
- None. Documentation-only correction.
2026-06-18 — Add Wave 1 execution handoff
Type: Documentation Change
Summary:
- Added a focused Wave 1 implementation plan for the first shared-ownership expansion after the F79 shell migration: shared lane roots, shared Workspace directory, and cleanup of duplicate native Today/Brief/Pulse launch surfaces.
- Linked the plan from the implementation index and from the broader F30 mobile plan.
Reason:
- The repo had a solid diagnosis of the KMP drift but no narrowly scoped execution lane for the first corrective wave.
Impact:
- Future work can start from a bounded, test-first migration slice instead of reopening the audit from scratch.
Tests:
- None. Planning-only change.
2026-06-17 — iOS & macOS shell wiring (Phase 4 bridge committed)
Type: Feature
Summary:
android/shared/src/iosMain/kotlin/.../MainViewController.kt— Kotlin entry pointsmakeTodayViewController,makeBriefViewController,makePulseViewControllerexposed to Swift; each returns aComposeUIViewControllerbacked by the sharedScreenModel+ repository.ios/Lumi/KMP/SharedScreenBridge.swift—KMPComposeView: UIViewControllerRepresentablewrapping the KMP entry points; stub fallback (KMPNotLinkedView) shown untilshared.xcframeworkis linked in Xcode → target → Frameworks.ios/Lumi/UI/MainTabView.swift— legacy note: Today/Brief/Pulse-era shell assumptions existed in early F78 planning. Future implementation must follow F79 instead:Pulse/Work Graph/Business/Company Hub/Workspace, with supporting modes nested inside those lanes.ios/LumiMac/KMP/SharedScreenBridge.swift— macOS equivalent usingNSViewControllerRepresentable; same stub-then-activate pattern.ios/LumiMac/Views/ContentView.swift— Today, Brief, Pulse, Decisions, Risks routed toKMPMacView; Tasks, Projects, Chat still native at this point.
Remaining step (after 2026-06-24 routing PR):
- Build XCFramework:
cd android && GRADLE_USER_HOME=/tmp/gradle-home ./gradlew :shared:assembleXCFramework - Drag
android/shared/build/XCFrameworks/release/shared.xcframework→ Xcode → Lumi + LumiMac targets → Frameworks, Libraries, and Embedded Content
Verified: compileKotlinIosSimulatorArm64 ✅ · compileKotlinMacosArm64 ✅ · xcodebuild build-for-testing ✅
2026-06-17 — All phases shipped (Phases 0–5)
Type: Feature
Summary:
- Phase 0: KMP scaffold —
sharedmodule compiles for JVM + iosArm64 + iosSimulatorArm64 + macosArm64;appleMainsource set; Ktor client w/ Darwin engine. - Phase 1: Data layer — Ktor
LumiApiClientwith all domain DTOs,SecureStoreexpect/actual,LumiApiClientTest(7 tests). - Phase 2: ViewModel layer — Voyager
ScreenModel+ Koin DI added;BriefScreenModel(6 tests),TodayScreenModel(9 tests),PulseScreenModel(5 tests); sharedLoadState<T>sealed interface;kotlin.time.Clockfor date handling (kotlinx-datetime 0.8.0 removedClock.System); CI jobshared-kmpadded. - Phase 3: Compose Multiplatform UI — CMP 1.10.3 added (
jetbrainsComposeplugin +compose.runtime/foundation/material3);TodayScreen,BriefScreen,PulseScreenincommonMain/ui/; zero platform imports; all screens consume ScreenModelStateFlow. - Phase 4: iOS framework — fixed
SecureStore.apple.kt(NSMutableDictionary subscript broken in Kotlin/Native → NSUserDefaults placeholder, TODO migrate to Keychain);:shared:linkDebugFrameworkIosSimulatorArm64→ BUILD SUCCESSFUL. - Phase 5: macOS framework —
:shared:linkDebugFrameworkMacosArm64→ BUILD SUCCESSFUL.
Tests: 28 jvmTests passing (LumiApiClient 7 · Brief 6 · Today 9 · Pulse 5 · Platform 1), 0 failures.
Notes:
- The native platform shells (SwiftUI iOS
ComposeUIViewControllerbridge, AndroidComposeView) remain to be wired to the shared screens — this is the Xcode/Android Studio step that requires the app targets. SecureStore.apple.ktusesNSUserDefaultsas a placeholder; must migrate to Keychain before shipping sensitive tokens to production.
2026-06-17 — Spec created
Type: Feature
Summary:
- Initial draft of F78 — Compose Multiplatform migration spec.
Reason:
- Current dual-codebase approach (Android Compose + iOS SwiftUI) cannot scale to 77+ features without tripling the mobile team. KMP+CMP allows sharing 80–90% of code while retaining native chrome quality on each platform.
Impact:
- F30 and F47 architecture sections are superseded by F78 §4 when this spec is agreed.
- No user-visible behavior changes — this is an implementation architecture change.
- Requires ADR before Phase 1 implementation begins.
Tests:
- None yet — spec only.
Notes:
- Do not start Phase 1 before F70/F71 wedge is validated (per strategy v2 “validation before velocity” doctrine). This spec defines the architecture; implementation is gated on wedge proof.