Files
com.github.premnirmal.ticke…/MULTIPLATFORM.md
T
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>premnirmal
096b19ce06 Add iOS App Store link to README (#479)
* Update docs to reflect completed KMP migration; add iOS app note + screenshots

Co-authored-by: premnirmal <1255689+premnirmal@users.noreply.github.com>

* Add iOS App Store link to README next to Google Play badge

Co-authored-by: premnirmal <1255689+premnirmal@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: premnirmal <1255689+premnirmal@users.noreply.github.com>
2026-06-27 09:26:25 +01:00

65 KiB

Kotlin Multiplatform migration

This document records the (now complete) migration of StockTicker from an Android-only app to a Kotlin Multiplatform (KMP) project with a shared core and shared Compose Multiplatform UI, plus a thin native iOS shell and a native iOS widget.

The migration was deliberately incremental: the Android app kept building and all existing tests kept passing at every step. Large rewrites (Glance widget → WidgetKit, Retrofit → Ktor, Room → Room KMP, and adopting Compose Multiplatform for the shared screens) were broken into separate, independently reviewable changes. The status section below is kept as a historical record of how the work was sequenced.

Module layout

Module Type Contents
:shared Kotlin Multiplatform library Platform-agnostic code shared by Android and iOS
:app Android application Android entry point, Glance widget, Firebase, WorkManager, Android theming/resources
iosApp Xcode project SwiftUI shell + WidgetKit extension, hosts shared Compose UI

The in-app screens are shared via Compose Multiplatform (see "UI strategy" below), so the bulk of the Compose UI lives in :shared (commonMain) and is hosted by both :app (Android) and iosApp (inside a UIViewController).

:shared declares the following Kotlin targets:

  • androidTarget() — consumed by :app as a normal project dependency.
  • iosX64(), iosArm64(), iosSimulatorArm64() — packaged as a static Shared framework for the iOS app.

Source sets:

  • commonMain — code that runs on every target (no Android/JVM-only APIs).
  • androidMain / iosMainactual implementations of expect declarations.
  • commonTest — multiplatform unit tests (e.g. DTO serialization round-trips).

UI strategy: shared UI with Compose Multiplatform (Option A)

The app is already written entirely in Jetpack Compose, so the in-app screens are shared using Compose Multiplatform (CMP) rather than rewritten natively per platform. The shared @Composable screens live in :shared commonMain and are hosted by the Android app and by the iOS app (inside a UIViewController).

What is shared via CMP:

  • The in-app Compose screens (watchlists, quote detail, search/suggestions, settings).
  • Navigation (Compose Multiplatform navigation).
  • Presentation/state (shared ViewModels and UI state — see Phase 3).

What stays platform-native (cannot be shared):

  • The home-screen widget. Android uses Glance; iOS requires a native WidgetKit + SwiftUI extension running in a separate process. This is a per-platform rewrite.
  • The platform entry point / app shell, plus platform integrations (Firebase, background scheduling) wired per platform.

Android-only UI libraries are replaced with multiplatform equivalents as part of this work: Coil → Coil 3 (multiplatform), and Android-only Compose artifacts (activity.compose, runtime.liveData, windowSizeClass, Glance previews) swapped for CMP equivalents or expect/actual shims. DI has already moved off Hilt to Koin in Phase 2 (see "Done — Phase 2"), so the shared modules are reused directly here.

Status

The migration is complete: all phases below (the shared core, the shared Compose Multiplatform UI, and the native iOS app + WidgetKit widget) have shipped. Both Android and iOS build and run from the shared :shared module. The phase-by-phase breakdown that follows is retained as a historical record of how the work was sequenced.

Done — Phase 0/1 (foundation)

  • Added the :shared KMP module with Android + iOS targets and an iOS framework.
  • Migrated the platform-agnostic, kotlinx.serialization DTOs into commonMain (same package names, so :app imports are unchanged): HistoricalData, RepoCommit, SuggestionsNet, Trending, YahooQuoteResponse.
  • Migrated the pure-Kotlin FetchResult wrapper into commonMain.
  • Migrated FetchException into commonMain via an expect/actual wrapper; the Android actual extends java.io.IOException so existing handling is unchanged.
  • Migrated the Suggestion model into commonMain (it only depends on the already shared SuggestionNet). Its Parcelable/@Parcelize was dropped because it was never used as a Parcelable (never put in a Bundle/Intent/nav argument).
  • Added an expect/actual Platform abstraction and a commonTest serialization test.
  • Added a reusable expect/actual Parcelable abstraction (CommonParcelable + CommonParcelize, in com.github.premnirmal.shared). On Android these typealias to android.os.Parcelable / kotlinx.parcelize.Parcelize (the kotlin-parcelize plugin is applied to :shared); on iOS CommonParcelable is an empty marker and CommonParcelize is an @OptionalExpectation with no actual.
  • Migrated Position/Holding/HoldingSum into commonMain using that abstraction — these are genuinely parceled (e.g. Position is passed through an Intent between HoldingsActivity and QuoteDetailActivity), so they keep their Parcelable on Android.
  • Migrated Properties into commonMain using that abstraction. It only depended on Parcelable + kotlinx.serialization (no Compose / AppPreferences), so it moves as-is and keeps its Parcelable on Android via CommonParcelable/@CommonParcelize.
  • Migrated AppClock into commonMain on the Kotlin stdlib multiplatform time API (kotlin.time.Clock/Instant). elapsedRealtime() is backed by an expect/actual (SystemClock.elapsedRealtime() on Android, NSProcessInfo.systemUptime on iOS). Android scheduling/notification code keeps its java.time arithmetic via todayZoned() / todayLocal() extensions (in :app) that derive java.time values from the clock.
  • Migrated PriceFormat into commonMain. Its only blocker was the JVM-only AppPreferences.SELECTED_DECIMAL_FORMAT (java.text.DecimalFormat), now replaced by an expect/actual DecimalFormatter (Android java.text.DecimalFormat, iOS NSNumberFormatter) and a shared AppNumberFormat holding the two formats and the roundToTwoDecimalPlaces selection flag (kept in sync from :app's AppPreferences). This also lays the groundwork for Quote, whose …String() helpers use the same formats.
  • Migrated Quote into commonMain. It now uses the shared CommonParcelable/@CommonParcelize abstraction (it is genuinely parceled — e.g. passed through an Intent to QuoteDetailActivity) and the shared AppNumberFormat for its …String() helpers (replacing AppPreferences.SELECTED_DECIMAL_FORMAT/DECIMAL_FORMAT_2DP). Its only Compose dependency, the changeColour getter (androidx.compose.ui.graphics.Color + ColourPalette), was factored out into an Android-only @Composable extension (Quote.changeColour) that stays in :app.

Done — Phase 2 (networking)

  • Migrated the networking layer from Retrofit/OkHttp + Jsoup + SimpleXML to Ktor in commonMain. Each endpoint is a shared client (SuggestionApi, ApeWisdom, YahooFinanceApi/YahooCrumbApi/YahooFinanceInitialLoadApi, ChartApi, YahooFinanceMostActiveApi, and the GoogleNewsApi/YahooFinanceNewsApi news feeds). :app stays Ktor-free: androidMain createXxxApi(baseUrl, okHttpClient) factories build the Ktor client over the existing (@Named("yahoo")-authenticated) OkHttpClient, and NetworkModule providers call those factories. The last Retrofit consumer — the unused GithubApi compare-tags interface, obsoleted by the network-free shared CommitsProvider (the changelog is now baked into the build) — has been removed, so Retrofit is no longer a dependency of :app.
  • Replaced Jsoup most-active HTML scraping with a dependency-free fin-streamer symbol parser in commonMain (YahooFinanceMostActiveApi), removing jsoup and the scalars converter from :app.
  • Replaced the SimpleXML RSS models with kotlinx.serialization + xmlutil in commonMain (NewsArticle/NewsRssFeed), removing the SimpleXML converter from :app. HTML sanitization is an expect/actual (sanitizeHtml: android.text.Html on Android, a portable stripper on iOS), and pubDate parsing/formatting moved to a dependency-free multiplatform ArticleDate (RFC-1123 + ISO-8601), replacing java.time. commonTest covers RSS parsing for both the Yahoo (media:content thumbnails, RFC-1123) and Google (CDATA description) feed shapes.
  • Ported the Yahoo Finance authentication (previously Android-only OkHttp interceptors: browser User-Agent/Accept headers, the crumb query parameter and the YahooFinanceCookies cookie jar) into an engine-agnostic Ktor configuration in commonMain (YahooAuth, CrumbProvider, createYahooHttpClient/createXxxApi(baseUrl, crumbProvider)). Cookies use the multiplatform HttpCookies plugin, and the crumb is supplied through the platform-neutral CrumbProvider abstraction, so Yahoo's authenticated endpoints now work on iOS (Darwin engine) as well as Android. commonTest (YahooAuthTest, via Ktor MockEngine) covers the forced headers, crumb query parameter and cookie persistence. Android still authenticates through its existing @Named("yahoo") OkHttp stack; iOS provides a CrumbProvider via UserDefaultsPreferences (see "Done — Phase 2 (iOS implementations)").
  • Moved the StocksApi orchestrator (Yahoo quotes/crumb-bootstrap/suggestions → shared Quote/FetchResult model) from :app into commonMain. It no longer depends on Timber (now the multiplatform AppLogger, expect/actual: Timber on Android, NSLog on iOS), Dispatchers.IO (now the expect/actual ioDispatcher), AppPreferences (now the CrumbStore : CrumbProvider read/write abstraction implemented by AppPreferences) or Hilt/javax.inject (it is now plain and declared in the shared Koin sharedModule). The public contract is unchanged. commonTest (StocksApiTest, via Ktor MockEngine) covers the success, ordering, failure and the 401 → crumb-refresh → retry paths, so the orchestration is verified on iOS as well as Android.
  • Moved the NewsProvider aggregator (Google News + Yahoo Finance news feeds, plus the Yahoo "most active"/ApeWisdom trending-stocks flow → shared NewsArticle/Quote/FetchResult model) from :app into commonMain. Like StocksApi it no longer depends on Timber (now the multiplatform AppLogger, extended with w/d levels), Dispatchers.IO (now ioDispatcher) or Hilt/javax.inject (it is now plain and declared in the shared Koin sharedModule). The public contract is unchanged so the :app view models keep working. commonTest (NewsProviderTest, via Ktor MockEngine) covers the merged market-news feeds, the trending-stocks ApeWisdom fallback and the news-query failure path, so the aggregation is verified on iOS as well as Android.
  • Moved the CommitsProvider ("what's new" changelog reader) from :app into commonMain. Like StocksApi/NewsProvider it is now a plain, Android-free class. The changelog itself — derived from the local git history at build time (previously only :app's BuildConfig.CHANGE_LOG) — is now generated into a shared commonMain constant (ChangelogBuildConfig.CHANGE_LOG) by the :shared generateChangelog Gradle task (reusing the existing GitHelpers), so Android and iOS show the same changelog from a single source. CommitsProvider defaults to that constant (no platform input needed), NetworkModule.provideCommitsProvider just constructs CommitsProvider(), and :app's BuildConfig.CHANGE_LOG field was dropped. The public contract (loadWhatsNew()FetchResult<List<String>>, filtering the version-bump/F-droid bot commits) is unchanged, and commonTest (CommitsProviderTest) covers the line splitting, bot-commit filtering and the shared default, so it is verified on iOS as well as Android.
  • Moved the symbol-search suggestions orchestration into commonMain as a new plain SuggestionsProvider over the shared StocksApi. It turns the raw Yahoo SuggestionNet results into the UI Suggestion model and always appends the upper-cased raw query as a selectable symbol (when not already present), logic that previously lived inline in Android's SearchViewModel. The view model now delegates to it (keeping only the UI concerns — the debounce delay and the error snackbar), and it is declared in the shared Koin sharedModule alongside the other orchestrators, so the future iOS / shared presentation layer binds to the same flow. commonTest (SuggestionsProviderTest, via Ktor MockEngine) covers the append-when-missing, the no-duplicate-when-present, the empty-query short-circuit and the request-failure paths, so it is verified on iOS as well as Android.
  • Moved the portfolio/tickers import & export serialization into commonMain as a new plain PortfolioSerializer. It owns the pure, shared transformations between the in-memory models and their on-disk text — the comma+space separated tickers list (serializeTickers/parseTickers) and the kotlinx.serialization JSON of a Quote portfolio (serializePortfolio/deserializePortfolio) — that previously lived inline in Android's TickersExporter/PortfolioExporter and TickersImportTask/PortfolioImportTask. Those Android tasks now keep only the platform IO (ContentResolver/Uri/FileOutputStream) and delegate the format to the shared serializer, which is declared in the Koin sharedModule (taking the app-provided Json as a leaf dependency), so a file exported on one platform imports cleanly on the other. commonTest (PortfolioSerializerTest) covers the tickers round-trip, the trailing-separator handling and the portfolio (with positions/holdings) JSON round-trip, so it is verified on iOS as well as Android.
  • Fixed the iOS/Kotlin-Native build of the shared persistence + IO layers, which previously only compiled on Android. The Room KSP processor failed on every iOS target (:shared:kspKotlinIos*[MissingType]: Element 'QuoteDao' references a type that is not present) because the commonMain QuoteDao carried the JVM-only @JvmSuppressWildcards annotation, which Native KSP cannot resolve; the annotation was unnecessary because Room KMP generates Kotlin (no Java wildcards to suppress), so it was removed. With KSP unblocked, the Native compile then surfaced Dispatchers.IO being non-public on Kotlin/Native — the ioDispatcher iOS actual now uses Dispatchers.Default (a multi-threaded worker pool on Native) instead. All iOS targets (iosX64/iosArm64/iosSimulatorArm64) now run KSP, compile commonMain/commonTest and link the Shared framework.

Done — Phase 2 (iOS implementations)

The deferred iOS concrete implementations of the shared Phase 2 interfaces are now implemented in :shared iosMain (Kotlin/Native), wired through a Koin iosModule, and hosted by a thin SwiftUI shell under iosApp/. This closes the "iOS will provide its own implementation once it exists" items above:

  • Preferences + Crumb store (UserDefaultsPreferences). Implements the shared UserPreferences and CrumbStore/CrumbProvider, mirroring Android AppPreferences keys/defaults, over a shared PreferenceStore now backed by the DataStore Multiplatform store (DataStorePreferenceStore, replacing the bespoke NSUserDefaults SettingsStore as the iOS preferences backend). The update window is exposed through the shared UserPreferences contract as a platform-neutral Time(hour, minute) + ISO day-set the scheduler consumes.
  • Background refresh + scheduler (BackgroundRefreshScheduler). Implements the shared RefreshScheduler; a faithful port of AlarmScheduler's update-window math using kotlinx-datetime. The actual OS submission is delegated to an BackgroundTaskScheduler interface, implemented by the Swift StockTickerBackgroundScheduler via BGTaskScheduler (BGAppRefreshTaskRequest/BGProcessingTaskRequest).
  • Stocks provider (StocksProvider). Implements the shared IStocksProvider over the shared StocksApi/StocksStorage/BackgroundRefreshScheduler/FetchEventLogger, replacing the Android WidgetDataProvider coupling with an onQuotesUpdated hook (wired to WidgetKit timeline reloads).
  • Analytics (AnalyticsImpl). Implements the shared Analytics interface, logging the shared AnalyticsEvent/ClickEvent/GeneralEvent through AppLogger and forwards them to an AnalyticsSink (the Swift StockTickerAnalyticsSink forwards to Firebase when linked, else NSLog).
  • DI + entry point (iosModule / initKoinIos / KoinHelper). The iOS counterpart of :app's networkModule/appModule — contributes the Darwin-engine Ktor clients, the Room-backed StocksStorage, the Json instance and the app CoroutineScope, plus the iOS implementations above. initKoinIos(...) starts Koin from StockTickerApp.swift; KoinHelper exposes the provider/scheduler and a observePortfolio flow bridge to Swift.
  • iOS app shell (iosApp/). A minimal SwiftUI host (StockTickerApp, ContentView, StockTickerBackgroundScheduler, StockTickerAnalyticsSink, WidgetCenterReloader) that wires the above into a running app. The Xcode project itself is generated on macOS (see iosApp/README.md) since iOS builds cannot run in the Linux CI.

iosTest covers the pure logic (BackgroundRefreshSchedulerTest, UserDefaultsPreferencesTest, UserDefaultsTickersStoreTest, AnalyticsTest); all iOS targets compile iosMain/iosTest and link the Shared framework.

Done — Phase 4 (shared Compose UI)

Compose Multiplatform (org.jetbrains.compose 1.11.0, paired with the Kotlin compose-compiler plugin) is now applied to :shared, so commonMain can host @Composable UI shared by Android and iOS. commonMain gains the compose.runtime/foundation/material3/ui (+ components.resources) dependencies, and the iosX64 target was dropped because Compose Multiplatform no longer publishes Intel-simulator artifacts (the iOS targets are iosArm64() + iosSimulatorArm64()).

Migrated into commonMain so far:

  • The small shared building blocks: AppTextField (the rounded text-field colours/shape) and the TopBar wrapper (its Android @Preview dropped), both in ticker.ui.
  • The per-ticker editor ViewModelsNotesViewModel, DisplaynameViewModel and AlertsViewModel (ticker.portfolio) — using the multiplatform androidx.lifecycle ViewModel (lifecycle-viewmodel KMP). They now depend on the shared IStocksProvider + QuoteStorage interfaces rather than the Android StocksProvider/StocksStorage concretes; :app binds those interfaces to the Android implementations in appModule (single<IStocksProvider> { get<StocksProvider>() } / single<QuoteStorage> { get<StocksStorage>() }).
  • The per-ticker editor screensNotesScreen, DisplaynameScreen and AlertsScreen (ticker.portfolio, in PortfolioEditScreens.kt) — bound to those shared ViewModels. The Android-only inputs are hoisted as parameters: the localised strings as Strings, the back/done icons as Painters, the snackbar as a SnackbarHostState, and the finish()/setResult() navigation side effects as onBack/onDone callbacks. The alerts editor's locale-aware number parsing stays on the host via an onSave callback that returns the above/below error flags. The decimal-input helpers it relies on (DecimalFormatter/DecimalInputVisualTransformation) also moved into commonMain, with the locale separator behind an expect/actual localeDecimalSeparator() (Android android.icu.text.DecimalFormatSymbols, iOS NSNumberFormatter). NotesActivity/DisplaynameActivity/AlertsActivity are now thin hosts that supply the resources/callbacks and call the shared screen.
  • The add-position / holdings editor — its AddPositionViewModel (ticker.portfolio, now on the shared IStocksProvider like the other editor ViewModels) and the AddPositionScreen (PortfolioEditScreens.kt). As with the other editors the localised strings are hoisted as Strings, the back/remove icons as Painters, the snackbar as a SnackbarHostState, and the navigation side effects as onBack callbacks; the holdings number formatting is delegated to a formatNumber lambda and the locale-aware parse/validate/persist of the entered shares/price to an onAdd callback (returning the price/shares error flags), so the platform NumberFormat/snackbar stays on the host. The Android-only adaptive two-pane layout (Accompanist TwoPane) is hoisted as an optional twoPane slot (null renders the single-column layout). HoldingsActivity is now a thin host that collects the ViewModel flows, owns the activity-result wiring and supplies the resources/callbacks/two-pane slot.
  • The trending/news-feed ViewModelNewsFeedViewModel (ticker.news) — moved into commonMain alongside its already-shared dependencies (NewsProvider, FetchResult, NewsFeedItem). It uses the multiplatform androidx.lifecycle ViewModel; the package and public contract are unchanged, so the :app NewsFeedScreen and Koin registration are untouched.
  • The trending/news-feed screenNewsFeedScreen (ticker.news) — and the shared UI-state helpers (EmptyState/ErrorState/ProgressState, ticker.ui). The Android-coupled inputs are hoisted as parameters: the localised labels as Strings, the QuoteCard/NewsCard as composable slots (they still pull in the not-yet-shared image loading + theme), the RuntimeShader-based fadingEdges as a (ScrollableState) -> Modifier lambda, the navigation rememberScrollToTopAction registration as a registerScrollToTop slot, and the quote tap as onQuoteClick. A thin NewsFeedScreenHost.kt in :app resolves the Koin ViewModel + the above and delegates to the shared screen.
  • The search/trending screenSearchScreen (ticker.portfolio.search) — moved into commonMain using a fully stateless (state-hoisting) design: rather than taking a ViewModel, it receives the state it renders (searchResults/trendingStocks/isRefreshing) and the events it raises (onQueryChange/onRefresh/onQuoteClick) as plain parameters. The Android-coupled inputs are hoisted too: the localised labels as Strings, the ic_close clear icon as a Painter, the QuoteCard/SuggestionItem/AddSymbolDialog as composable slots, the RuntimeShader-based fadingEdges as a (ScrollableState) -> Modifier lambda, the navigation rememberScrollToTopAction registration as a registerScrollToTop slot, and the adaptive Accompanist TwoPane layout as an optional twoPane slot (null = single column; the news second pane stays in :app). A thin SearchScreenHost.kt in :app resolves the Koin SearchViewModel/NewsFeedViewModel + the above and delegates to the shared screen. SearchViewModel stays in :app for now (it still depends on the not-yet-shared WidgetDataProvider/AppMessaging).
  • The home watchlist screenWatchlistContent (ticker.home) — and the pure CollapsingTopBarScrollConnection (the collapsing-header nested-scroll connection) moved into commonMain using a fully stateless (state-hoisting) design. Rather than taking the HomeViewModel, the shared screen receives the state it renders (hasWidgets/subtitle/ isRefreshing/hasHoldings/totalGainLoss) and the events it raises (onRefresh/onQuoteClick) as parameters. The Glance/SharedPreferences-backed WidgetData is abstracted behind a small shared WatchlistWidget interface (name + reactive stocks + rearrange/setAutoSort/removeStock), and the HomeViewModel.TotalGainLoss data class became the shared TotalGainLoss. The remaining Android-coupled inputs are hoisted too: the localised app name as a String, the ic_money icon as a Painter, the theme-aware header background as a nullable Painter (null = no image, e.g. in the dual-pane list), the QuoteCard and TotalHoldingsPopup as composable slots, the RuntimeShader- based fadingEdges as a (ScrollableState) -> Modifier lambda, and the two navigation rememberScrollToTopAction registrations as registerResetScroll/registerWidgetScroll slots. The reorderable drag (sh.calvin.reorderable, a Compose-Multiplatform library) moved from :app into :shared. A thin WatchlistContentHost.kt in :app collects the ViewModel flows, adapts each WidgetData to WatchlistWidget and supplies the resources/slots.
  • The settings screenSettingsScreen (ticker.settings) — and the preference UI components (SettingsText, CheckboxPreference, ListPreference, MultiSelectListPreference, TimeSelectorPreference, in ticker.ui.Preferences.kt) moved into commonMain using a fully stateless (state-hoisting) design. The SettingsData data class (the snapshot of preferences) moved to commonMain (Parceling dropped — it is only held in-memory by the ViewModel's StateFlow). The preference dialogs (ListPreference, MultiSelectListPreference, TimeSelectorPreference) were reimplemented with Compose Multiplatform material3.AlertDialog and material3.TimePicker/TimePickerState, removing the Android AlertDialog/TimePickerDialog/ LocalContext dependencies. The Android-coupled inputs are hoisted as parameters: the localised labels/string-arrays as String/Array<String> values, the three dialog confirm/dismiss labels, the Divider as a composable slot (it lives in :app), the alarm-permission banner as a composable slot, the Alegreya/Bold fonts as nullable FontFamily params, all user actions as callback lambdas (file pickers, Custom Tabs links, notification-permission flow, version-tap easter egg), the RuntimeShader-based fadingEdges as a (ScrollableState) -> Modifier lambda, and the navigation rememberScrollToTopAction registration as a registerScrollToTop slot. A thin SettingsScreenHost.kt in :app resolves the Koin SettingsViewModel, collects the settings flow, owns the rememberLauncherForActivityResult file pickers, the accompanist notification permission, the AlarmPermissionBanner, the OnVersionTap easter egg, and supplies the resources/slots. The SettingsViewModel stays in :app (it depends on WidgetDataProvider, NotificationsHandler, and Android file I/O).
  • The quote-detail price chartPriceChartView (ticker.detail, in PriceChart.kt) — moved into commonMain by swapping the Android-only MPAndroidChart LineChart (AndroidView) for the multiplatform Vico chart (com.patrykandpatrick.vico:multiplatform). The date/number axis and marker formatting is hoisted to :app via plain functions (ui/AxisFormatters.kt) and passed in as xAxisFormatter/yAxisFormatter/markerFormatter parameters.
  • The quote-detail ViewModelQuoteDetailViewModel (ticker.news) — moved into commonMain using the multiplatform androidx.lifecycle ViewModel. It now depends on the shared IStocksProvider/UserPreferences contracts (rather than the Android StocksProvider/ AppPreferences concretes) plus the already-shared NewsProvider/HistoryProvider; :app binds UserPreferences to AppPreferences in appModule. The genuinely Android-coupled, localised "quote details" list (the @StringRes labels + Context-based number/date formatting) is hoisted out of the ViewModel into a :app helper (detail/QuoteDetails.kt, buildQuoteDetails), and the news-fetch error snackbar is exposed as a shared messages flow the host collects instead of coupling to the Android AppMessaging.
  • The quote-detail screenQuoteDetailScreen (ticker.detail) — and its cards (QuoteDetailCard, PositionDetailCard, AlertsCard, EditSectionHeader) moved into commonMain using a fully stateless (state-hoisting) design building on the already-shared QuoteDetailViewModel/PriceChartView. The localised QuoteDetail row model became a shared QuoteDetailItem (resolved title: String + data: String); the Android-only buildQuoteDetails (detail/QuoteDetails.kt) now resolves the @StringRes titles to Strings and returns List<QuoteDetailItem>, so the shared grid has no @StringRes/Context. Every Android-coupled input is hoisted as a parameter: the localised labels as a QuoteDetailStrings holder and the pre-formatted alert values as Strings, the ic_refresh/ic_add_to_list/ic_edit icons as Painters, the resolved change/up/down ColourPalette colours as Colors (the Quote.changeColour/ChartData.changeColour Compose theming stays in :app), the chart axis/marker formatters as lambdas, the AppMessaging bottom-sheet card-tap as an onCardClick(title, data) lambda, the per-section Holdings/Alerts/Notes/Displayname editing as onEdit* callbacks plus the displayed position/alert/notes/displayname state values, the AppCard container (it lives in :app), NewsCard, AddSymbolDialog and the website LinkText as composable slots, the RuntimeShader-based fadingEdges as a (ScrollableState) -> Modifier lambda, the SnackbarHostState, and the adaptive Accompanist TwoPane/WindowWidthSizeClass/ContentType layout as an optional twoPane(first, second) slot (null = single column). A thin QuoteDetailScreenHost.kt in :app (named QuoteDetailScreen, keeping the same widthSizeClass/contentType/displayFeatures/quote signature so QuoteDetailActivity, WatchlistScreen and RootGraph are unchanged) resolves the Koin QuoteDetailViewModel/ AppPreferences, collects the ViewModel state, owns the rememberLauncherForActivityResult activity-result wiring, the loadQuote/fetchAll/fetchQuoteInRealTime/reset DisposableEffect lifecycle, the range-change chart fetch and the viewModel.messages snackbar collection, and supplies the resources/colours/slots.
  • The widgets settings screenWidgetsScreen (ticker.widget) — and the Spinner (ticker.ui) moved into commonMain using a fully stateless (state-hoisting) design. The Glance/SharedPreferences-backed WidgetData is abstracted behind a small shared WidgetSettings interface (a reactive prefs: StateFlow<WidgetPrefs> snapshot + the setXxx mutators), and the preference values rendered by the screen became the shared WidgetPrefs data class (the Android-only @DrawableRes/@ColorRes fields used to actually paint the widget stay on WidgetData.Prefs). The Android-coupled inputs are hoisted as parameters: the localised labels + string-arrays as a WidgetSettingsStrings holder, the ic_arrow_down/ic_done icons as Painters, the genuinely Android-only Glance WidgetPreview as a widgetPreview composable slot, the Divider as a slot, the RuntimeShader-based fadingEdges as a (ScrollableState) -> Modifier lambda, the navigation rememberScrollToTopAction registration as a registerScrollToTop slot, the adaptive Accompanist TwoPane layout as an optional twoPane slot (null = single column), and the widget selection (widgetNames/selectedIndex/onWidgetSelected) + the AddStocks tap as parameters. A thin WidgetsScreenHost.kt in :app (named WidgetsScreen, keeping the same widthSizeClass/displayFeatures/selectedWidgetId/showSpinner signature so WidgetSettingsActivity and HomeNavigation are unchanged) resolves the Koin WidgetsViewModel, collects the widget list/fetch state, adapts each WidgetData to WidgetSettings and supplies the resources/slots.
  • Image loading was migrated from Coil 2 to Coil 3 (io.coil-kt.coil3, with the coil-network-okhttp fetcher) in :app — the multiplatform-capable image loader. Coil 3 is now pinned to 3.4.0 (built with Kotlin 2.3.10 + Compose Multiplatform 1.9.3) so its Kotlin/Native klibs are ABI-compatible with the project's Kotlin 2.3.21, letting coil-compose be consumed from :shared commonMain.
  • The Coil-backed news cardNewsCard (ticker.news) — moved into commonMain, using the multiplatform coil3.compose.AsyncImage for the article thumbnail. The Android-coupled inputs are hoisted as parameters: the article tap (the CustomTabs Custom Tab open) as an onClick callback, the ColourPalette image placeholder gradient colour as a placeholderColor: Color, and the AppCard container (it lives in :app) as a card composable slot. A thin :app NewsCard.kt overload (NewsCard(item), keeping the same signature so NewsFeedScreenHost, QuoteDetailScreenHost and SearchScreenHost are unchanged) supplies the Custom Tab open, the ColourPalette placeholder colour and the AppCard slot.
  • Navigation was migrated from the Android Jetpack androidx.navigation:navigation-compose to the Compose Multiplatform navigation library (org.jetbrains.androidx.navigation:navigation-compose 2.10.0-alpha02, whose Kotlin/Native klibs are ABI-compatible with the project's Kotlin 2.3.21), and the navigation graph moved into :shared commonMain using the established stateless-screen + thin-:app-host pattern. The navigation enums (NavigationType/ContentType/ NavigationContentPosition + LocalContentType, ticker.ui) moved to commonMain (the FoldingFeature-based DevicePosture/isBookPosture/isSeparating stay in :app). In ticker.navigation the shared pieces are the route constants (Graph/HomeRoute), the NavigationViewModel (scroll-to-top flow), the BottomNavigationBar/HomeNavigationRail (with a HomeBottomNavDestination that carries resolved Painter icons + String labels instead of Android resource IDs), the HomeNavigationActions (the per-tab navigate-with-side-effect via an onNavigated callback), the stateless HomeNavHost (the 5 tabs as composable slots), the stateless RootNavigationGraph (home + quote-detail as slots, reading the symbol arg via the multiplatform SavedState), and the stateless HomeScaffold (bottom-nav vs rail, taking a SnackbarHostState). Thin :app hosts supply the Android-coupled inputs: RootNavigationGraphHost provides the LocalNavGraphViewModelStoreOwner + the HomeListDetail/QuoteDetailScreen slots, HomeNavHostWrapper resolves koinViewModel() + the screen *Hosts + the URL-encoded quote navigation, and HomeListDetail builds the destinations with painterResource/stringResource and computes the window-size-class → NavigationType/ContentType mapping (calculateContentAndNavigationType, which uses the Android FoldingFeature, stays in :app). The Android runtime still resolves the Jetpack androidx.navigation 2.8.5 artifacts (forced via a resolutionStrategy, since the CMP library's transitive Android artifacts target a newer compileSdk), with the CMP wrapper klib on top.
  • The remaining shared UI building blocks moved into commonMain: the platform-neutral in-app message model (AppMessage, in ticker.ui, with its BottomSheetMessage/BannerMessage subtypes — the Android AppMessaging dispatcher that needs a Context to resolve string resources stays in :app and emits these); the bottom-sheet message UI (BottomSheetWithMessage/ModalBottomSheetWithMessage, its Android @Preview dropped); the bottom-sheet message collector (CollectBottomSheetMessage) — now a plain composable that takes the bottomSheets: Flow<BottomSheetMessage> as a parameter (hoisted off LocalAppMessaging) and queues with a multiplatform ArrayDeque instead of java.util.LinkedList, with BaseActivity supplying appMessaging.bottomSheets; the Compose-aware Quote.changeColour/ChartData.changeColour extensions (ticker.network.data, now reading SharedColours rather than the Android-only ColourPalette); and the navigation rememberScrollToTopAction + LocalNavGraphViewModelStoreOwner (ticker.navigation), built on the already-shared NavigationViewModel.

Phase 4 (shared Compose UI) is complete: the home/watchlist, trending/news-feed, search, settings, widgets and quote-detail screens, their ViewModels, the shared building blocks, the Vico price chart, the Coil-backed news card and the navigation graph all live in :shared, with thin :app hosts for the Android-coupled wiring.

Roadmap (all phases complete)

All phases below have shipped. This section is retained as a historical record of the migration plan and how each phase was delivered.

  • Phase 1 (cont.): Move more pure logic into commonMain.

  • Phase 2 (cont.): Replace the remaining Android-only infrastructure with KMP equivalents — persistence (Room → Room KMP), preferences (DataStore Multiplatform — adopted on iOS via the shared PreferenceStore/DataStorePreferenceStore; Android AppPreferences migration off SharedPreferences remains), DI (Hilt → Koin), background refresh (WorkManager + a common scheduler interface). Done so far: the shared Yahoo auth layer (YahooAuth/CrumbProvider), a multiplatform logger (AppLogger) and IO dispatcher (ioDispatcher), the shared StocksApi orchestrator, and the shared RefreshScheduler interface (the common background-refresh contract — canScheduleExactAlarm/isCurrentTimeWithinScheduledUpdateTime/msToNextAlarm and the periodic refresh/cleanup enqueue operations — implemented on Android by AlarmScheduler; the platform-specific AlarmManager/WorkManager enqueueing and the exact-alarm/daily-summary scheduling stay on the concrete implementation, and iOS provides a BGTaskScheduler/WidgetKit implementation via BackgroundRefreshScheduler + BackgroundTaskScheduler). The persistence layer also has a shared QuoteStorage interface (the common contract for persisting tickers/quotes/holdings/ properties, in already-shared commonMain models); the Room engine itself now lives in commonMain via Room KMPQuotesDB/QuoteDao/*Row/QuoteWithHoldings and the 8 schema migrations moved into :shared (exported schema v9 unchanged, so installed Android databases migrate transparently), behind a getQuotesDBBuilder() expect/actual (Android Context, iOS NSDocumentDirectory) plus the bundled-SQLite driver. The StocksStorage implementation of QuoteStorage is also shared; iOS now gets a real Room-backed implementation rather than a stub. The settings layer likewise has a shared UserPreferences interface (the common contract for the platform-neutral user settings — update interval, the boolean toggles, the theme preference, the refresh/tooltip flows and the configured update window, in commonMain) implemented on Android by AppPreferences; the configured update window is now part of that shared contract, expressed with the platform-neutral Time value and ISO day-of-week numbers (replacing the former java.time/Parcelable Time boundary), and the theme settings are now shared too — the NightMode mapping and the SelectedTheme selection live in commonMain (with a supportsSystemNightMode() expect/actual; Android maps NightMode to AppCompatDelegate). The two platform key/value stores have been unified behind a shared PreferenceStore contract, implemented by a DataStore Multiplatform store (DataStorePreferenceStore, in commonMain) that both platforms now use as their preferences backend: iOS in place of the bespoke NSUserDefaults SettingsStore, and Android AppPreferences in place of SharedPreferences (a one-shot AppPreferencesDataMigration imports the legacy SharedPreferences values on first run). The central data provider likewise has a shared IStocksProvider interface (the common contract for the observable watchlist/portfolio state and the add/remove/fetch/schedule operations, expressed in the already-shared Quote/Position/Holding/FetchResult models, in commonMain) implemented on Android by StocksProvider; the platform wiring (Context/SharedPreferences, AlarmScheduler, WidgetDataProvider, the Room-backed StocksStorage) stays on the concrete implementation, while the observable fetchState/FetchState flow is now part of the shared IStocksProvider contract — its java.time-formatted display string was decoupled into a formatFetchTime() expect/actual (Android java.time, iOS kotlinx-datetime). iOS provides its own StocksProvider implementation (with an onQuotesUpdated WidgetKit hook in place of the Android WidgetDataProvider coupling). The diagnostic fetch-event logging is fully shared: the FetchLogger interface (the common log(source, event, detail) contract) and its FetchEventLogger implementation now both live in commonMain. Like StocksApi/NewsProvider/HistoryProvider it is a plain class (no Timber/Dispatchers.IO/Hilt — the multiplatform AppLogger and ioDispatcher, declared in the Koin graph) that persists each entry through the now-shared Room-backed StocksStorage.addFetchLog, so Android and iOS share the same sink; the app-provided CoroutineScope is the only platform input (supplied by the Koin binding in :app's appModule). commonTest (FetchEventLoggerTest) covers the persisted fields, the clock timestamp and the detail truncation, so it is verified on iOS as well as Android. The analytics layer is now fully shared: the platform-neutral event model (AnalyticsEvent/GeneralEvent/ClickEvent — an event name plus an accumulating string property map), the Analytics interface (its trackScreenView now takes a screen-name String rather than an android.app.Activity) and GeneralProperties (which takes the shared IStocksProvider plus a widget-count lambda) all live in commonMain; the per-flavor Android AnalyticsImpl reports the events through Firebase (prod) or no-ops (purefoss/dev), and iOS provides its own AnalyticsImpl over an AnalyticsSink (Firebase when linked, else NSLog). The iOS-backed CrumbProvider/CrumbStore is provided by UserDefaultsPreferences. The news-feed list model (NewsFeedItem — the article vs trending-stocks carousel entry, depending only on the already-shared NewsArticle/Quote) also moved into commonMain (same ticker.news package), so the shared news view models / Compose Multiplatform UI in later phases can bind to it directly. The chart range selection (Range — the One Day…Max options plus their Yahoo Finance interval/range query-param mapping) also moved into commonMain (ticker.model, decoupled from java.time by storing a plain durationDays), so iOS shares the same range options and param mapping. Building on that, the chart fetch itself (HistoryProviderChartData) also moved into commonMain (ticker.model): it is now a plain class (no Timber/Dispatchers.IO/Hilt — AppLogger/ioDispatcher, declared in the shared Koin sharedModule) over the already-shared ChartApi, and ChartData's …String() helpers use the shared AppNumberFormat (its Compose changeColour is an Android-only extension in :app, like Quote.changeColour). The DataPoint candle that blocked this is now expect/actual: commonMain/iOS see a plain, MPAndroidChart-free value ordered by its timestamp, while the Android actual still extends MPAndroidChart's CandleEntry (and stays Parcelable/Serializable) so the Android chart UI (LineDataSet/TextMarkerView) renders it unchanged; MPAndroidChart is therefore a :shared androidMain-only dependency. commonTest (HistoryProviderTest, via Ktor MockEngine) covers the mapping, timestamp sorting, the missing-value filtering and the failure path, so the chart fetch is verified on iOS as well as Android. DI has moved off Hilt to Koin: the shared services are declared in a commonMain sharedModule (reused by every platform), while :app provides the Android leaf bindings in appModule/networkModule/viewModelModule (the former Hilt @Provides functions became Koin single { … }, @HiltViewModel became viewModel { … }, the legacy EntryPoint/Injector field-injection of widgets/receivers/workers became KoinComponent + by inject(), and @Named("yahoo") became a Koin named("yahoo") qualifier). StocksApp now calls startKoin { … }; Hilt, its Gradle plugin and KSP compiler are removed. A Robolectric KoinModulesTest resolves the graph at runtime to replace Hilt's compile-time graph validation.

    Phase 2 settings/provider unification — now complete: Android preferences are now backed by the unified DataStore Multiplatform store (DataStorePreferenceStore behind the shared PreferenceStore contract) just like iOS, with a one-shot AppPreferencesDataMigration importing the legacy SharedPreferences values; and the previously platform-typed surfaces have moved into commonMain — the NightMode/SelectedTheme theme settings on UserPreferences, the fetchState/FetchState flow on IStocksProvider (its display string decoupled via a formatFetchTime() expect/actual), and the Analytics interface (trackScreenView(String)) + GeneralProperties. The java.time-based update window had already been decoupled into commonMain (shared Time value + ISO day-of-week numbers on UserPreferences).

  • Phase 3: Share ViewModels / presentation logic in commonMain (state + logic the shared Compose UI binds to).

  • Phase 4 (shared UI): Adopt Compose Multiplatform in :shared. Move the in-app @Composable screens into commonMain, swap Android-only UI libraries for multiplatform equivalents (Coil 3, CMP navigation; Koin DI is already adopted in Phase 2), and repoint :app to host the shared Compose UI. Keep Glance widget + Firebase on Android.

  • Phase 5 (complete): Add the iosApp Xcode project — a thin SwiftUI shell that hosts the shared Compose UI in a UIViewController, plus a native WidgetKit home-screen widget (Swift Charts where the widget needs charts); Firebase iOS SDK (or no-op for FOSS). Started: the Compose Multiplatform UI now runs on iOS. MainViewController() (shared/src/iosMain) builds a ComposeUIViewController that the SwiftUI shell hosts via a UIViewControllerRepresentable (ComposeView/ContentView), replacing the Phase 2 SwiftUI watchlist placeholder. An iOS Material 3 theme (IosAppTheme, mirroring the Android brand palette/shapes without android.os.Build dynamic colour) wraps the iOS host. The host has since grown from the single WatchlistScreen into the full shared navigation graph: an iOS HomeScreen (shared/src/iosMain, rendered by MainViewController) now hosts the shared RootNavigationGraph over a root Compose Multiplatform NavHostController. The graph's homeContent slot is the home navigation chrome (HomeScaffold + BottomNavigationBar + HomeNavHost over a nested NavHostController), so the five home tabs (Watchlist/Trending/Search/Widgets/Settings) switch via bottom navigation on the simulator; its quoteDetailContent slot is an iOS QuoteDetailScreen (shared/src/iosMain) reached by tapping a watchlist row, which navigates the root controller to the shared quote_detail_graph/{symbol} destination. The Watchlist tab renders the shared WatchlistScreen; the other tabs are lightweight placeholders until their view models can be resolved on iOS. The tab icons come from new shared Compose Multiplatform drawable resources (shared/src/commonMain/composeResources/drawable, generated into com.github.premnirmal.shared.resources.Res), the first shared resources in the project. The typography is now shared too: the brand Ubuntu / Alegreya / Raleway fonts moved into shared Compose resources (shared/src/commonMain/composeResources/font) and a shared appTypography() (commonMain tickerwidget.ui.theme) builds the Material 3 type scale from them, so the Android AppTheme and the iOS IosAppTheme render the same fonts (the duplicate Android AppTypography.kt was removed; the app/res/font files remain only for the legacy XML themes). The colour scheme is now shared too: the brand Material 3 palette and the light/dark ColorSchemes (brandLightColorScheme/brandDarkColorScheme), the appShapes, and a single cross-platform SharedAppTheme composable all live in commonMain (tickerwidget.ui.theme). SharedAppTheme resolves dark/light from the shared SelectedTheme and applies the brand scheme + appTypography() + appShapes, taking an optional colorSchemeOverride. Both platform themes are now thin wrappers over it: the iOS IosAppTheme delegates with no override (so it uses the brand scheme), and the Android AppTheme supplies a Material You dynamic ColorScheme override on Android 12+ (falling back to the same shared brand scheme otherwise); the duplicated Android AppColours/ThemePref/AppShapes and the iOS colour definitions were removed. The iOS quote-detail screen is now real: the quoteDetailContent QuoteDetailScreen (shared/src/iosMain) drives the shared QuoteDetailViewModel (resolved from the iOS Koin graph: IStocksProvider/NewsProvider/HistoryProvider/UserPreferences) and renders the shared multiplatform PriceChartView (Vico) historical price chart with a range selector (1D/2W/1M/3M/1Y/5Y/Max), the same presentation logic the Android app uses; the axis/marker date labels are formatted with NSDateFormatter and the prices with the shared AppNumberFormat. The iOS Trending tab is now real too: an iOS TrendingScreen (shared/src/iosMain) drives the shared NewsFeedViewModel (built from the iOS Koin graph's NewsProvider) through the shared NewsFeedScreen, supplying iOS-native Material 3 card slots — a lightweight quote card and the shared Coil-backed NewsCard (its card slot a Material 3 Card); tapping a news article opens its URL via UIApplication.openURL, and tapping a trending quote navigates to the shared quote-detail destination. The iOS Search, Settings and Widgets tabs are now real too: an iOS SearchScreen (shared/src/iosMain) drives an IosSearchViewModel (built from the iOS Koin graph's SuggestionsProvider/NewsProvider/IStocksProvider) through the shared SearchScreen, debouncing symbol queries, loading the trending stocks and — since iOS has a single watchlist rather than Android's per-Glance-widget lists — toggling a symbol's membership of the shared portfolio directly from the suggestion row's add/remove button (the clear icon is a new shared ic_close Compose resource); tapping a result navigates to the shared quote-detail destination. An iOS SettingsScreen (shared/src/iosMain) drives an IosSettingsViewModel over the shared UserPreferences/IStocksProvider through the shared SettingsScreen — the theme, update interval, update window (start/end times and days), round-to-two-decimals and notification-alerts toggles read and write the shared preferences (hasWidgets is always false on iOS); the external links open via UIApplication.openURL. The chosen theme is now applied live: MainViewController observes the shared themePrefFlow and passes the resolved SelectedTheme to IosAppTheme. The iOS WidgetsScreen (shared/src/iosMain) is an informational WidgetKit-guidance screen, because iOS widgets are configured from the home screen rather than in-app like Android's Glance widgets. The iOS quote-detail extras are now real too: the QuoteDetailScreen (shared/src/iosMain) shows the latest news articles (the shared QuoteDetailViewModel.fetchNews populates them and they render through the shared Coil-backed NewsCard, opening in the browser via UIApplication.openURL) and, for portfolio symbols, a holdings summary (shares / equity value / average price / gain-loss / day-change from the shared Quote helpers). Its editors are the same shared Compose Multiplatform screens the Android app uses, presented full-screen and persisted through the shared view models: positions via AddPositionScreen + AddPositionViewModel, price alerts via AlertsScreen + AlertsViewModel, notes via NotesScreen + NotesViewModel, and the per-ticker display name via DisplaynameScreen + DisplaynameViewModel (the editors reuse the shared ic_close/ic_done Compose resources and the shared DecimalFormatter for input parsing). Remaining: none — the iOS portfolio share/import/export now drive native document pickers (UIDocumentPickerViewController/UIActivityViewController) via the shared IosPortfolioExchange + PortfolioDocumentBridge, and a native WidgetKit home-screen widget (iosApp/StockTickerWidget) renders the watchlist from a shared App Group snapshot (WidgetSnapshotStore), with a Swift Charts bar chart on the large family; Firebase iOS is wired in StockTickerApp.configureFirebase() (no-op without the SDK/GoogleService-Info.plist).

  • Phase 5.1 (feature gaps — to do before Phase 6): Phase 5 stood the iOS app up end to end, but a number of Android features are still missing, stubbed or simplified on iOS. Close these gaps before moving on to CI:

    • Local notifications (price alerts + daily summary). (Done.) Android delivers price-alert notifications and a scheduled daily-summary notification (app/.../notifications/ NotificationsHandler.kt, DailySummaryNotificationReceiver.kt) via AlarmManager/ WorkManager. The iOS app now has an equivalent UNUserNotificationCenter-backed LocalNotificationsHandler (shared/src/iosMain/.../notifications/LocalNotificationsHandler.kt): it ports Android's checkAlerts() (above/below alerts, the generic ≥ 8 % move alert with the same 24 h per-symbol rate limit, and clearing an alert once it fires) and delivers a once-per- update-day movers summary after the configured endTime(). It is driven off the shared IStocksProvider.fetchState flow (so a check runs after every refresh, including the BGTaskScheduler background refresh via StockTickerBackgroundScheduler.handleRefresh), gated on the same notificationAlerts()/updateDays() preferences. The iOS app starts the observer and requests notification permission via KoinHelper.initializeNotifications() in StockTickerApp.swift.
    • Home-screen widget configuration & customisation. (Done.) Android supports multiple Glance widgets, each with its own watchlist and per-widget options (auto-sort, layout, size, background/text colour, bold text, header visibility, currency display, refresh button), configured in-app (app/.../widget/). The iOS StockTickerWidget (iosApp/StockTickerWidget/StockTickerWidget.swift) is now an AppIntentConfiguration driven by a per-widget StockTickerConfigurationIntent (iosApp/StockTickerWidget/StockTickerWidgetIntent.swift): each placed widget keeps its own watchlist selection — a WatchlistSymbolEntity/WatchlistSymbolQuery offers the symbols read from the shared App Group WidgetSnapshotStore snapshot — plus appearance options (sort by change, show header, show change amount, bold change), applied on the render side. The widget family still chooses the layout/size (the equivalent of Android's layout/size prefs). The iOS WidgetsScreen.kt (shared/src/iosMain/.../ui) explains how to add a widget and edit each one (touch & hold → Edit Widget).
    • Onboarding tutorial. (Done.) Android shows a first-run tutorial gated on the shared tutorialShown() preference (app/.../home/HomeActivity.ktHomeViewModel.checkShowTutorial()). The iOS app now presents an equivalent onboarding flow: OnboardingScreen.kt (shared/src/iosMain/.../ui) is a multi-step Compose Multiplatform modal (OnboardingController + OnboardingTutorial) driven by the same shared tutorialShown()/setTutorialShown() preference (shared/src/commonMain/.../UserPreferences.kt). HomeScreen.kt shows it once on first launch (showIfFirstRun()), the Settings "Tutorial" row re-opens it (onTutorialcontroller.show()), and dismissing it persists the preference. The iOS pages are tailored to iOS (watchlist, search, quote detail, Home Screen WidgetKit widget).
    • In-app review / version-tap. (Done.) Android triggers the Play in-app review flow (app/.../home/IAppReviewManager.kt) and a functional version-tap handler (app/.../settings/SettingsScreenHost.kt). The iOS onVersionTap is wired (five quick taps open the debug DB viewer — see below). The in-app review prompt is now wired too: AppReviewPrompter (shared/src/iosMain/.../review/AppReviewPrompter.kt) requests an App Store rating via StoreKit's SKStoreReviewController.requestReviewInScene(...). Like Android's HomeActivity, it is triggered when the user opens a quote detail (the iOS HomeScreen.kt observes the root nav back stack for the QUOTE_DETAIL route) and is gated on the same shared UserPreferences.shouldPromptRate() plus a once-per-session guard; the system itself decides whether to actually show the rating sheet and rate-limits it.
    • Debug database viewer. (Done.) Android exposes a DB viewer from settings (app/.../debug/DbViewerActivity.kt). The iOS app now has an equivalent: DbViewerScreen.kt (shared/src/iosMain/.../ui) with an IosDbViewerViewModel that reads the shared Room-backed QuoteDao and renders the quotes/holdings/properties tables plus recent fetch logs as HTML in a native WKWebView (JavaScript disabled), hosted via Compose Multiplatform's UIKitView interop. It is reached the same way as Android — tapping the Settings version label five times (onVersionTapVersionTapCounter) opens it. iOS has no Glance widgets or WorkManager, so the widget/scheduled-work sections are omitted.
    • Background fetch scheduling. (Done.) The iOS BGTaskScheduler bridge (StockTickerBackgroundScheduler.swift) and the shared BackgroundRefreshScheduler update-window math already existed, but nothing enqueued the periodic refresh/cleanup on launch. The iOS HomeScreen.kt now calls stocksProvider.schedule() once on first composition (mirroring Android's HomeActivity.onCreate), which arms the next update and submits the recurring BGAppRefreshTaskRequest/BGProcessingTaskRequest work.
  • Phase 6: (Done.) CI for Android + the iOS framework/app (macOS runner) and commonTest on the simulator. The Android build/test/detekt jobs already run on ubuntu (.github/workflows/build.yml, unit-tests.yml, detekt.yml); unit-tests.yml now also runs the shared module's Android-target unit tests (:shared:testDebugUnitTest, which executes commonTest against the JVM/Android target). A new .github/workflows/ios.yml runs on a pinned macos-15 runner with Xcode 16.4 (pinned via maxim-lobanov/setup-xcode; macos-15/Xcode 16.4 is required because Compose Multiplatform's Kotlin/Native artifacts are built against the iOS 18.5 simulator SDK): it compiles the shared common code (:shared:compileKotlinMetadata), links the iOS Shared framework for both the simulator and device targets (:shared:linkDebugFrameworkIosSimulatorArm64 / linkDebugFrameworkIosArm64), runs the shared commonTest suite on the iOS simulator (:shared:iosSimulatorArm64Test, uploading the results as an artifact), and then builds the iOS app itself. The iosApp Xcode project is still not committed — instead it is generated reproducibly from a declarative iosApp/project.yml (XcodeGen) spec: CI installs XcodeGen, runs xcodegen generate, and xcodebuild builds the iosApp scheme for the iOS simulator with CODE_SIGNING_ALLOWED=NO (so no signing secrets are required). The generated project wires a Gradle run-script phase (:shared:embedAndSignAppleFrameworkForXcode) that builds the shared Kotlin/Native framework the app + widget link against, and the previously-missing app Info.plist / Assets.xcassets are now committed under iosApp/iosApp/. Producing a signed .ipa for TestFlight/App Store is intentionally out of scope (it needs code-signing secrets + an xcodebuild archive/-exportArchive or fastlane step); see iosApp/README.md.

Shared widget views (hoisting the per-platform composables)

Phase 4 shared the screens but left several reusable widgets (cards, rows, popups) as @Composable slot parameters, supplied by the Android :app hosts and re-implemented by hand on iOS. That slot pattern is the reason the iOS watchlist card drifted out of sync with Android. The goal of this follow-up work is to move those widgets into commonMain so both platforms render one implementation.

Done so far:

  • QuoteCard — moved to shared/src/commonMain/.../detail/QuoteCard.kt (Instrument + Position variants, the overflow/"three-dot" remove menu and the change colours). Both the Android hosts (WatchlistContentHost/SearchScreenHost/NewsFeedScreenHost) and the iOS WatchlistScreen/SearchScreen now call the shared card; the Android detail/QuoteCard.kt and the bespoke iOS cards were deleted.
  • AppCard — moved to shared/src/commonMain/.../tickerwidget/ui/AppCard.kt (it only used Material3 Card, so it was portable as-is).
  • Added shared string resources (shared/src/commonMain/composeResources/values/strings.xml: remove/holdings/gain/loss/change_percent/change_amount/day_change_amount) and shared drawables (ic_more, ic_remove_circle) so the shared card needs no per-platform resources.
  • Divider — moved to shared/src/commonMain/.../tickerwidget/ui/Divider.kt (thin Material3 HorizontalDivider wrapper, same package so the Android call sites are unchanged).
  • SuggestionItem — moved to shared/src/commonMain/.../portfolio/search/SuggestionItem.kt. The trailing add/remove affordance (icon/tint/content-description) is hoisted as parameters so Android (widget picker, ic_add_to_list) and iOS (watchlist toggle, ic_add/ic_remove) configure it; the iOS SuggestionRow and Android SuggestionItem duplicates were deleted.
  • TotalHoldingsPopup — moved to shared/src/commonMain/.../home/TotalHoldingsPopup.kt using the shared total_holdings string and SharedColours (the Android-only excludeFromSystemGesture popup property was dropped).
  • AddSymbolDialog — the stateless dialog body moved to shared/src/commonMain/.../portfolio/search/AddSymbolDialog.kt (AddSymbolDialogContent, with the SuggestionState/SuggestionWidgetDataState holders). The Android AddSymbolDialogHost resolves the Koin SuggestionViewModel and delegates to it; the labels use the shared select_widget/save strings and the ic_add_circle/ic_remove_circle drawables.
  • NewsCard — now inlines the (shared) AppCard and SharedColours.ImagePlaceHolderGray, so it only hoists the article tap as onClick; the Android card/placeholder slots and the iOS ArticleCard were removed.
  • LinkText — moved to shared/src/commonMain/.../ui/LinkText.kt. The link action is hoisted as an onLinkClick(annotation) lambda (Android passes Chrome Custom Tabs, iOS its in-app browser) because the URL opener is platform specific.
  • Shared coloursSharedColours (shared/src/commonMain/.../tickerwidget/ui/theme/SharedColours.kt) holds the change/gain/loss colours and the image placeholder grey; the Android ColourPalette delegates to it and QuoteCard dropped its private colour copies.

All six remaining hoisting candidates below are now done.

Remaining hoisting candidates (each is currently an Android :app composable passed as a slot and/or duplicated on iOS):

View Android source iOS duplicate Blockers before hoisting
Divider done — shared/.../tickerwidget/ui/Divider.kt
SuggestionItem / SuggestionRow done — shared/.../portfolio/search/SuggestionItem.kt
TotalHoldingsPopup done — shared/.../home/TotalHoldingsPopup.kt
AddSymbolDialog done — shared/.../portfolio/search/AddSymbolDialog.kt (+ Android AddSymbolDialogHost)
NewsCard done — inlines shared AppCard + placeholder colour
LinkText done — shared/.../ui/LinkText.kt (link action hoisted as onLinkClick)

Not hoistable (genuinely platform-specific), keep as slots:

  • widgetPreview — Android Glance widget renderer.
  • listFadingEdges — Android-13+ RuntimeShader-based modifier (needs an iOS-friendly fallback).
  • twoPane — Accompanist adaptive layout supplied per platform.

Building

Android (unchanged):

./gradlew :app:assembleDevDebug

Shared module checks:

./gradlew :shared:compileKotlinMetadata             # common code
./gradlew :shared:testDebugUnitTest                 # android unit tests for shared
./gradlew :shared:compileKotlinIosSimulatorArm64    # iOS compile (Kotlin/Native, runs Room KSP)
./gradlew :shared:iosSimulatorArm64Test             # run iOS tests (requires macOS + Xcode)

Note: the iOS targets use the Kotlin/Native toolchain. Compiling them (and running Room's KSP processor) works on Linux, but running the iOS tests and linking a device iosArm64 binary require a macOS host with Xcode, so do that on a macOS runner.