* 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>
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:appas a normal project dependency.iosX64(),iosArm64(),iosSimulatorArm64()— packaged as a staticSharedframework for the iOS app.
Source sets:
commonMain— code that runs on every target (no Android/JVM-only APIs).androidMain/iosMain—actualimplementations ofexpectdeclarations.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
:sharedKMP module with Android + iOS targets and an iOS framework. - Migrated the platform-agnostic,
kotlinx.serializationDTOs intocommonMain(same package names, so:appimports are unchanged):HistoricalData,RepoCommit,SuggestionsNet,Trending,YahooQuoteResponse. - Migrated the pure-Kotlin
FetchResultwrapper intocommonMain. - Migrated
FetchExceptionintocommonMainvia anexpect/actualwrapper; the Androidactualextendsjava.io.IOExceptionso existing handling is unchanged. - Migrated the
Suggestionmodel intocommonMain(it only depends on the already sharedSuggestionNet). ItsParcelable/@Parcelizewas dropped because it was never used as aParcelable(never put in aBundle/Intent/nav argument). - Added an
expect/actualPlatformabstraction and acommonTestserialization test. - Added a reusable
expect/actualParcelableabstraction (CommonParcelable+CommonParcelize, incom.github.premnirmal.shared). On Android thesetypealiastoandroid.os.Parcelable/kotlinx.parcelize.Parcelize(thekotlin-parcelizeplugin is applied to:shared); on iOSCommonParcelableis an empty marker andCommonParcelizeis an@OptionalExpectationwith no actual. - Migrated
Position/Holding/HoldingSumintocommonMainusing that abstraction — these are genuinely parceled (e.g.Positionis passed through anIntentbetweenHoldingsActivityandQuoteDetailActivity), so they keep theirParcelableon Android. - Migrated
PropertiesintocommonMainusing that abstraction. It only depended onParcelable+kotlinx.serialization(no Compose /AppPreferences), so it moves as-is and keeps itsParcelableon Android viaCommonParcelable/@CommonParcelize. - Migrated
AppClockintocommonMainon the Kotlin stdlib multiplatform time API (kotlin.time.Clock/Instant).elapsedRealtime()is backed by anexpect/actual(SystemClock.elapsedRealtime()on Android,NSProcessInfo.systemUptimeon iOS). Android scheduling/notification code keeps itsjava.timearithmetic viatodayZoned()/todayLocal()extensions (in:app) that derivejava.timevalues from the clock. - Migrated
PriceFormatintocommonMain. Its only blocker was the JVM-onlyAppPreferences.SELECTED_DECIMAL_FORMAT(java.text.DecimalFormat), now replaced by anexpect/actualDecimalFormatter(Androidjava.text.DecimalFormat, iOSNSNumberFormatter) and a sharedAppNumberFormatholding the two formats and theroundToTwoDecimalPlacesselection flag (kept in sync from:app'sAppPreferences). This also lays the groundwork forQuote, whose…String()helpers use the same formats. - Migrated
QuoteintocommonMain. It now uses the sharedCommonParcelable/@CommonParcelizeabstraction (it is genuinely parceled — e.g. passed through anIntenttoQuoteDetailActivity) and the sharedAppNumberFormatfor its…String()helpers (replacingAppPreferences.SELECTED_DECIMAL_FORMAT/DECIMAL_FORMAT_2DP). Its only Compose dependency, thechangeColourgetter (androidx.compose.ui.graphics.Color+ColourPalette), was factored out into an Android-only@Composableextension (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 theGoogleNewsApi/YahooFinanceNewsApinews feeds).:appstays Ktor-free:androidMaincreateXxxApi(baseUrl, okHttpClient)factories build the Ktor client over the existing (@Named("yahoo")-authenticated)OkHttpClient, andNetworkModuleproviders call those factories. The last Retrofit consumer — the unusedGithubApicompare-tags interface, obsoleted by the network-free sharedCommitsProvider(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-streamersymbol parser incommonMain(YahooFinanceMostActiveApi), removingjsoupand the scalars converter from:app. - Replaced the SimpleXML RSS models with
kotlinx.serialization+ xmlutil incommonMain(NewsArticle/NewsRssFeed), removing the SimpleXML converter from:app. HTML sanitization is anexpect/actual(sanitizeHtml:android.text.Htmlon Android, a portable stripper on iOS), andpubDateparsing/formatting moved to a dependency-free multiplatformArticleDate(RFC-1123 + ISO-8601), replacingjava.time.commonTestcovers 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/Acceptheaders, thecrumbquery parameter and theYahooFinanceCookiescookie jar) into an engine-agnostic Ktor configuration incommonMain(YahooAuth,CrumbProvider,createYahooHttpClient/createXxxApi(baseUrl, crumbProvider)). Cookies use the multiplatformHttpCookiesplugin, and the crumb is supplied through the platform-neutralCrumbProviderabstraction, so Yahoo's authenticated endpoints now work on iOS (Darwin engine) as well as Android.commonTest(YahooAuthTest, via KtorMockEngine) covers the forced headers, crumb query parameter and cookie persistence. Android still authenticates through its existing@Named("yahoo")OkHttp stack; iOS provides aCrumbProviderviaUserDefaultsPreferences(see "Done — Phase 2 (iOS implementations)"). - Moved the
StocksApiorchestrator (Yahoo quotes/crumb-bootstrap/suggestions → sharedQuote/FetchResultmodel) from:appintocommonMain. It no longer depends onTimber(now the multiplatformAppLogger,expect/actual: Timber on Android,NSLogon iOS),Dispatchers.IO(now theexpect/actualioDispatcher),AppPreferences(now theCrumbStore : CrumbProviderread/write abstraction implemented byAppPreferences) or Hilt/javax.inject(it is now plain and declared in the shared KoinsharedModule). The public contract is unchanged.commonTest(StocksApiTest, via KtorMockEngine) 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
NewsProvideraggregator (Google News + Yahoo Finance news feeds, plus the Yahoo "most active"/ApeWisdom trending-stocks flow → sharedNewsArticle/Quote/FetchResultmodel) from:appintocommonMain. LikeStocksApiit no longer depends onTimber(now the multiplatformAppLogger, extended withw/dlevels),Dispatchers.IO(nowioDispatcher) or Hilt/javax.inject(it is now plain and declared in the shared KoinsharedModule). The public contract is unchanged so the:appview models keep working.commonTest(NewsProviderTest, via KtorMockEngine) 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:appintocommonMain. LikeStocksApi/NewsProviderit is now a plain, Android-free class. The changelog itself — derived from the local git history at build time (previously only:app'sBuildConfig.CHANGE_LOG) — is now generated into a sharedcommonMainconstant (ChangelogBuildConfig.CHANGE_LOG) by the:sharedgenerateChangelogGradle task (reusing the existingGitHelpers), so Android and iOS show the same changelog from a single source.CommitsProviderdefaults to that constant (no platform input needed),NetworkModule.provideCommitsProviderjust constructsCommitsProvider(), and:app'sBuildConfig.CHANGE_LOGfield was dropped. The public contract (loadWhatsNew()→FetchResult<List<String>>, filtering the version-bump/F-droid bot commits) is unchanged, andcommonTest(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
commonMainas a new plainSuggestionsProviderover the sharedStocksApi. It turns the raw YahooSuggestionNetresults into the UISuggestionmodel and always appends the upper-cased raw query as a selectable symbol (when not already present), logic that previously lived inline in Android'sSearchViewModel. 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 KoinsharedModulealongside the other orchestrators, so the future iOS / shared presentation layer binds to the same flow.commonTest(SuggestionsProviderTest, via KtorMockEngine) 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
commonMainas a new plainPortfolioSerializer. 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 thekotlinx.serializationJSON of aQuoteportfolio (serializePortfolio/deserializePortfolio) — that previously lived inline in Android'sTickersExporter/PortfolioExporterandTickersImportTask/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 KoinsharedModule(taking the app-providedJsonas 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 thecommonMainQuoteDaocarried the JVM-only@JvmSuppressWildcardsannotation, 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 surfacedDispatchers.IObeing non-public on Kotlin/Native — theioDispatcheriOSactualnow usesDispatchers.Default(a multi-threaded worker pool on Native) instead. All iOS targets (iosX64/iosArm64/iosSimulatorArm64) now run KSP, compilecommonMain/commonTestand link theSharedframework.
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 sharedUserPreferencesandCrumbStore/CrumbProvider, mirroring AndroidAppPreferenceskeys/defaults, over a sharedPreferenceStorenow backed by the DataStore Multiplatform store (DataStorePreferenceStore, replacing the bespokeNSUserDefaultsSettingsStoreas the iOS preferences backend). The update window is exposed through the sharedUserPreferencescontract as a platform-neutralTime(hour, minute)+ ISO day-set the scheduler consumes. - Background refresh + scheduler (
BackgroundRefreshScheduler). Implements the sharedRefreshScheduler; a faithful port ofAlarmScheduler's update-window math usingkotlinx-datetime. The actual OS submission is delegated to anBackgroundTaskSchedulerinterface, implemented by the SwiftStockTickerBackgroundSchedulerviaBGTaskScheduler(BGAppRefreshTaskRequest/BGProcessingTaskRequest). - Stocks provider (
StocksProvider). Implements the sharedIStocksProviderover the sharedStocksApi/StocksStorage/BackgroundRefreshScheduler/FetchEventLogger, replacing the AndroidWidgetDataProvidercoupling with anonQuotesUpdatedhook (wired to WidgetKit timeline reloads). - Analytics (
AnalyticsImpl). Implements the sharedAnalyticsinterface, logging the sharedAnalyticsEvent/ClickEvent/GeneralEventthroughAppLoggerand forwards them to anAnalyticsSink(the SwiftStockTickerAnalyticsSinkforwards to Firebase when linked, elseNSLog). - DI + entry point (
iosModule/initKoinIos/KoinHelper). The iOS counterpart of:app'snetworkModule/appModule— contributes the Darwin-engine Ktor clients, the Room-backedStocksStorage, theJsoninstance and the appCoroutineScope, plus the iOS implementations above.initKoinIos(...)starts Koin fromStockTickerApp.swift;KoinHelperexposes the provider/scheduler and aobservePortfolioflow 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 (seeiosApp/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 theTopBarwrapper (its Android@Previewdropped), both inticker.ui. - The per-ticker editor ViewModels —
NotesViewModel,DisplaynameViewModelandAlertsViewModel(ticker.portfolio) — using the multiplatformandroidx.lifecycleViewModel(lifecycle-viewmodelKMP). They now depend on the sharedIStocksProvider+QuoteStorageinterfaces rather than the AndroidStocksProvider/StocksStorageconcretes;:appbinds those interfaces to the Android implementations inappModule(single<IStocksProvider> { get<StocksProvider>() }/single<QuoteStorage> { get<StocksStorage>() }). - The per-ticker editor screens —
NotesScreen,DisplaynameScreenandAlertsScreen(ticker.portfolio, inPortfolioEditScreens.kt) — bound to those shared ViewModels. The Android-only inputs are hoisted as parameters: the localised strings asStrings, the back/done icons asPainters, the snackbar as aSnackbarHostState, and thefinish()/setResult()navigation side effects asonBack/onDonecallbacks. The alerts editor's locale-aware number parsing stays on the host via anonSavecallback that returns the above/below error flags. The decimal-input helpers it relies on (DecimalFormatter/DecimalInputVisualTransformation) also moved intocommonMain, with the locale separator behind anexpect/actuallocaleDecimalSeparator()(Androidandroid.icu.text.DecimalFormatSymbols, iOSNSNumberFormatter).NotesActivity/DisplaynameActivity/AlertsActivityare 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 sharedIStocksProviderlike the other editor ViewModels) and theAddPositionScreen(PortfolioEditScreens.kt). As with the other editors the localised strings are hoisted asStrings, the back/remove icons asPainters, the snackbar as aSnackbarHostState, and the navigation side effects asonBackcallbacks; the holdings number formatting is delegated to aformatNumberlambda and the locale-aware parse/validate/persist of the entered shares/price to anonAddcallback (returning the price/shares error flags), so the platformNumberFormat/snackbar stays on the host. The Android-only adaptive two-pane layout (AccompanistTwoPane) is hoisted as an optionaltwoPaneslot (nullrenders the single-column layout).HoldingsActivityis 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 ViewModel —
NewsFeedViewModel(ticker.news) — moved intocommonMainalongside its already-shared dependencies (NewsProvider,FetchResult,NewsFeedItem). It uses the multiplatformandroidx.lifecycleViewModel; the package and public contract are unchanged, so the:appNewsFeedScreenand Koin registration are untouched. - The trending/news-feed screen —
NewsFeedScreen(ticker.news) — and the shared UI-state helpers (EmptyState/ErrorState/ProgressState,ticker.ui). The Android-coupled inputs are hoisted as parameters: the localised labels asStrings, theQuoteCard/NewsCardas composable slots (they still pull in the not-yet-shared image loading + theme), theRuntimeShader-basedfadingEdgesas a(ScrollableState) -> Modifierlambda, the navigationrememberScrollToTopActionregistration as aregisterScrollToTopslot, and the quote tap asonQuoteClick. A thinNewsFeedScreenHost.ktin:appresolves the Koin ViewModel + the above and delegates to the shared screen. - The search/trending screen —
SearchScreen(ticker.portfolio.search) — moved intocommonMainusing 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 asStrings, theic_closeclear icon as aPainter, theQuoteCard/SuggestionItem/AddSymbolDialogas composable slots, theRuntimeShader-basedfadingEdgesas a(ScrollableState) -> Modifierlambda, the navigationrememberScrollToTopActionregistration as aregisterScrollToTopslot, and the adaptive AccompanistTwoPanelayout as an optionaltwoPaneslot (null = single column; the news second pane stays in:app). A thinSearchScreenHost.ktin:appresolves the KoinSearchViewModel/NewsFeedViewModel+ the above and delegates to the shared screen.SearchViewModelstays in:appfor now (it still depends on the not-yet-sharedWidgetDataProvider/AppMessaging). - The home watchlist screen —
WatchlistContent(ticker.home) — and the pureCollapsingTopBarScrollConnection(the collapsing-header nested-scroll connection) moved intocommonMainusing a fully stateless (state-hoisting) design. Rather than taking theHomeViewModel, 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-backedWidgetDatais abstracted behind a small sharedWatchlistWidgetinterface (name + reactivestocks+rearrange/setAutoSort/removeStock), and theHomeViewModel.TotalGainLossdata class became the sharedTotalGainLoss. The remaining Android-coupled inputs are hoisted too: the localised app name as aString, theic_moneyicon as aPainter, the theme-aware header background as a nullablePainter(null = no image, e.g. in the dual-pane list), theQuoteCardandTotalHoldingsPopupas composable slots, theRuntimeShader- basedfadingEdgesas a(ScrollableState) -> Modifierlambda, and the two navigationrememberScrollToTopActionregistrations asregisterResetScroll/registerWidgetScrollslots. The reorderable drag (sh.calvin.reorderable, a Compose-Multiplatform library) moved from:appinto:shared. A thinWatchlistContentHost.ktin:appcollects the ViewModel flows, adapts eachWidgetDatatoWatchlistWidgetand supplies the resources/slots. - The settings screen —
SettingsScreen(ticker.settings) — and the preference UI components (SettingsText,CheckboxPreference,ListPreference,MultiSelectListPreference,TimeSelectorPreference, inticker.ui.Preferences.kt) moved intocommonMainusing a fully stateless (state-hoisting) design. TheSettingsDatadata class (the snapshot of preferences) moved tocommonMain(Parceling dropped — it is only held in-memory by the ViewModel'sStateFlow). The preference dialogs (ListPreference,MultiSelectListPreference,TimeSelectorPreference) were reimplemented with Compose Multiplatformmaterial3.AlertDialogandmaterial3.TimePicker/TimePickerState, removing the AndroidAlertDialog/TimePickerDialog/LocalContextdependencies. The Android-coupled inputs are hoisted as parameters: the localised labels/string-arrays asString/Array<String>values, the three dialog confirm/dismiss labels, theDivideras a composable slot (it lives in:app), the alarm-permission banner as a composable slot, theAlegreya/Boldfonts as nullableFontFamilyparams, all user actions as callback lambdas (file pickers, Custom Tabs links, notification-permission flow, version-tap easter egg), theRuntimeShader-basedfadingEdgesas a(ScrollableState) -> Modifierlambda, and the navigationrememberScrollToTopActionregistration as aregisterScrollToTopslot. A thinSettingsScreenHost.ktin:appresolves the KoinSettingsViewModel, collects the settings flow, owns therememberLauncherForActivityResultfile pickers, the accompanist notification permission, theAlarmPermissionBanner, theOnVersionTapeaster egg, and supplies the resources/slots. TheSettingsViewModelstays in:app(it depends onWidgetDataProvider,NotificationsHandler, and Android file I/O). - The quote-detail price chart —
PriceChartView(ticker.detail, inPriceChart.kt) — moved intocommonMainby swapping the Android-only MPAndroidChartLineChart(AndroidView) for the multiplatform Vico chart (com.patrykandpatrick.vico:multiplatform). The date/number axis and marker formatting is hoisted to:appvia plain functions (ui/AxisFormatters.kt) and passed in asxAxisFormatter/yAxisFormatter/markerFormatterparameters. - The quote-detail ViewModel —
QuoteDetailViewModel(ticker.news) — moved intocommonMainusing the multiplatformandroidx.lifecycleViewModel. It now depends on the sharedIStocksProvider/UserPreferencescontracts (rather than the AndroidStocksProvider/AppPreferencesconcretes) plus the already-sharedNewsProvider/HistoryProvider;:appbindsUserPreferencestoAppPreferencesinappModule. The genuinely Android-coupled, localised "quote details" list (the@StringReslabels +Context-based number/date formatting) is hoisted out of the ViewModel into a:apphelper (detail/QuoteDetails.kt,buildQuoteDetails), and the news-fetch error snackbar is exposed as a sharedmessagesflow the host collects instead of coupling to the AndroidAppMessaging. - The quote-detail screen —
QuoteDetailScreen(ticker.detail) — and its cards (QuoteDetailCard,PositionDetailCard,AlertsCard,EditSectionHeader) moved intocommonMainusing a fully stateless (state-hoisting) design building on the already-sharedQuoteDetailViewModel/PriceChartView. The localisedQuoteDetailrow model became a sharedQuoteDetailItem(resolvedtitle: String+data: String); the Android-onlybuildQuoteDetails(detail/QuoteDetails.kt) now resolves the@StringRestitles toStrings and returnsList<QuoteDetailItem>, so the shared grid has no@StringRes/Context. Every Android-coupled input is hoisted as a parameter: the localised labels as aQuoteDetailStringsholder and the pre-formatted alert values asStrings, theic_refresh/ic_add_to_list/ic_editicons asPainters, the resolved change/up/downColourPalettecolours asColors (theQuote.changeColour/ChartData.changeColourCompose theming stays in:app), the chart axis/marker formatters as lambdas, theAppMessagingbottom-sheet card-tap as anonCardClick(title, data)lambda, the per-sectionHoldings/Alerts/Notes/Displaynameediting asonEdit*callbacks plus the displayedposition/alert/notes/displaynamestate values, theAppCardcontainer (it lives in:app),NewsCard,AddSymbolDialogand the websiteLinkTextas composable slots, theRuntimeShader-basedfadingEdgesas a(ScrollableState) -> Modifierlambda, theSnackbarHostState, and the adaptive AccompanistTwoPane/WindowWidthSizeClass/ContentTypelayout as an optionaltwoPane(first, second)slot (null = single column). A thinQuoteDetailScreenHost.ktin:app(namedQuoteDetailScreen, keeping the samewidthSizeClass/contentType/displayFeatures/quotesignature soQuoteDetailActivity,WatchlistScreenandRootGraphare unchanged) resolves the KoinQuoteDetailViewModel/AppPreferences, collects the ViewModel state, owns therememberLauncherForActivityResultactivity-result wiring, theloadQuote/fetchAll/fetchQuoteInRealTime/resetDisposableEffectlifecycle, the range-change chart fetch and theviewModel.messagessnackbar collection, and supplies the resources/colours/slots. - The widgets settings screen —
WidgetsScreen(ticker.widget) — and theSpinner(ticker.ui) moved intocommonMainusing a fully stateless (state-hoisting) design. The Glance/SharedPreferences-backedWidgetDatais abstracted behind a small sharedWidgetSettingsinterface (a reactiveprefs: StateFlow<WidgetPrefs>snapshot + thesetXxxmutators), and the preference values rendered by the screen became the sharedWidgetPrefsdata class (the Android-only@DrawableRes/@ColorResfields used to actually paint the widget stay onWidgetData.Prefs). The Android-coupled inputs are hoisted as parameters: the localised labels + string-arrays as aWidgetSettingsStringsholder, theic_arrow_down/ic_doneicons asPainters, the genuinely Android-only GlanceWidgetPreviewas awidgetPreviewcomposable slot, theDivideras a slot, theRuntimeShader-basedfadingEdgesas a(ScrollableState) -> Modifierlambda, the navigationrememberScrollToTopActionregistration as aregisterScrollToTopslot, the adaptive AccompanistTwoPanelayout as an optionaltwoPaneslot (null = single column), and the widget selection (widgetNames/selectedIndex/onWidgetSelected) + theAddStockstap as parameters. A thinWidgetsScreenHost.ktin:app(namedWidgetsScreen, keeping the samewidthSizeClass/displayFeatures/selectedWidgetId/showSpinnersignature soWidgetSettingsActivityandHomeNavigationare unchanged) resolves the KoinWidgetsViewModel, collects the widget list/fetch state, adapts eachWidgetDatatoWidgetSettingsand supplies the resources/slots. - Image loading was migrated from Coil 2 to Coil 3 (
io.coil-kt.coil3, with thecoil-network-okhttpfetcher) in:app— the multiplatform-capable image loader. Coil 3 is now pinned to3.4.0(built with Kotlin2.3.10+ Compose Multiplatform1.9.3) so its Kotlin/Native klibs are ABI-compatible with the project's Kotlin2.3.21, lettingcoil-composebe consumed from:sharedcommonMain. - The Coil-backed news card —
NewsCard(ticker.news) — moved intocommonMain, using the multiplatformcoil3.compose.AsyncImagefor the article thumbnail. The Android-coupled inputs are hoisted as parameters: the article tap (theCustomTabsCustom Tab open) as anonClickcallback, theColourPaletteimage placeholder gradient colour as aplaceholderColor: Color, and theAppCardcontainer (it lives in:app) as acardcomposable slot. A thin:appNewsCard.ktoverload (NewsCard(item), keeping the same signature soNewsFeedScreenHost,QuoteDetailScreenHostandSearchScreenHostare unchanged) supplies the Custom Tab open, theColourPaletteplaceholder colour and theAppCardslot. - Navigation was migrated from the Android Jetpack
androidx.navigation:navigation-composeto the Compose Multiplatform navigation library (org.jetbrains.androidx.navigation:navigation-compose2.10.0-alpha02, whose Kotlin/Native klibs are ABI-compatible with the project's Kotlin2.3.21), and the navigation graph moved into:sharedcommonMainusing the established stateless-screen + thin-:app-host pattern. The navigation enums (NavigationType/ContentType/NavigationContentPosition+LocalContentType,ticker.ui) moved tocommonMain(theFoldingFeature-basedDevicePosture/isBookPosture/isSeparatingstay in:app). Inticker.navigationthe shared pieces are the route constants (Graph/HomeRoute), theNavigationViewModel(scroll-to-top flow), theBottomNavigationBar/HomeNavigationRail(with aHomeBottomNavDestinationthat carries resolvedPaintericons +Stringlabels instead of Android resource IDs), theHomeNavigationActions(the per-tab navigate-with-side-effect via anonNavigatedcallback), the statelessHomeNavHost(the 5 tabs as composable slots), the statelessRootNavigationGraph(home + quote-detail as slots, reading thesymbolarg via the multiplatformSavedState), and the statelessHomeScaffold(bottom-nav vs rail, taking aSnackbarHostState). Thin:apphosts supply the Android-coupled inputs:RootNavigationGraphHostprovides theLocalNavGraphViewModelStoreOwner+ theHomeListDetail/QuoteDetailScreenslots,HomeNavHostWrapperresolveskoinViewModel()+ the screen*Hosts + the URL-encoded quote navigation, andHomeListDetailbuilds the destinations withpainterResource/stringResourceand computes the window-size-class →NavigationType/ContentTypemapping (calculateContentAndNavigationType, which uses the AndroidFoldingFeature, stays in:app). The Android runtime still resolves the Jetpackandroidx.navigation2.8.5artifacts (forced via aresolutionStrategy, since the CMP library's transitive Android artifacts target a newercompileSdk), with the CMP wrapper klib on top. - The remaining shared UI building blocks moved into
commonMain: the platform-neutral in-app message model (AppMessage, inticker.ui, with itsBottomSheetMessage/BannerMessagesubtypes — the AndroidAppMessagingdispatcher that needs aContextto resolve string resources stays in:appand emits these); the bottom-sheet message UI (BottomSheetWithMessage/ModalBottomSheetWithMessage, its Android@Previewdropped); the bottom-sheet message collector (CollectBottomSheetMessage) — now a plain composable that takes thebottomSheets: Flow<BottomSheetMessage>as a parameter (hoisted offLocalAppMessaging) and queues with a multiplatformArrayDequeinstead ofjava.util.LinkedList, withBaseActivitysupplyingappMessaging.bottomSheets; the Compose-awareQuote.changeColour/ChartData.changeColourextensions (ticker.network.data, now readingSharedColoursrather than the Android-onlyColourPalette); and the navigationrememberScrollToTopAction+LocalNavGraphViewModelStoreOwner(ticker.navigation), built on the already-sharedNavigationViewModel.
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; AndroidAppPreferencesmigration offSharedPreferencesremains), 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 sharedStocksApiorchestrator, and the sharedRefreshSchedulerinterface (the common background-refresh contract —canScheduleExactAlarm/isCurrentTimeWithinScheduledUpdateTime/msToNextAlarmand the periodic refresh/cleanup enqueue operations — implemented on Android byAlarmScheduler; the platform-specificAlarmManager/WorkManagerenqueueing and the exact-alarm/daily-summary scheduling stay on the concrete implementation, and iOS provides aBGTaskScheduler/WidgetKitimplementation viaBackgroundRefreshScheduler+BackgroundTaskScheduler). The persistence layer also has a sharedQuoteStorageinterface (the common contract for persisting tickers/quotes/holdings/ properties, in already-sharedcommonMainmodels); the Room engine itself now lives incommonMainvia Room KMP —QuotesDB/QuoteDao/*Row/QuoteWithHoldingsand the 8 schema migrations moved into:shared(exported schema v9 unchanged, so installed Android databases migrate transparently), behind agetQuotesDBBuilder()expect/actual(AndroidContext, iOSNSDocumentDirectory) plus the bundled-SQLite driver. TheStocksStorageimplementation ofQuoteStorageis also shared; iOS now gets a real Room-backed implementation rather than a stub. The settings layer likewise has a sharedUserPreferencesinterface (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, incommonMain) implemented on Android byAppPreferences; the configured update window is now part of that shared contract, expressed with the platform-neutralTimevalue and ISO day-of-week numbers (replacing the formerjava.time/Parcelable Timeboundary), and the theme settings are now shared too — theNightModemapping and theSelectedThemeselection live incommonMain(with asupportsSystemNightMode()expect/actual; Android mapsNightModetoAppCompatDelegate). The two platform key/value stores have been unified behind a sharedPreferenceStorecontract, implemented by a DataStore Multiplatform store (DataStorePreferenceStore, incommonMain) that both platforms now use as their preferences backend: iOS in place of the bespokeNSUserDefaultsSettingsStore, and AndroidAppPreferencesin place ofSharedPreferences(a one-shotAppPreferencesDataMigrationimports the legacySharedPreferencesvalues on first run). The central data provider likewise has a sharedIStocksProviderinterface (the common contract for the observable watchlist/portfolio state and the add/remove/fetch/schedule operations, expressed in the already-sharedQuote/Position/Holding/FetchResultmodels, incommonMain) implemented on Android byStocksProvider; the platform wiring (Context/SharedPreferences,AlarmScheduler,WidgetDataProvider, the Room-backedStocksStorage) stays on the concrete implementation, while the observablefetchState/FetchStateflow is now part of the sharedIStocksProvidercontract — itsjava.time-formatted display string was decoupled into aformatFetchTime()expect/actual(Androidjava.time, iOSkotlinx-datetime). iOS provides its ownStocksProviderimplementation (with anonQuotesUpdatedWidgetKit hook in place of the AndroidWidgetDataProvidercoupling). The diagnostic fetch-event logging is fully shared: theFetchLoggerinterface (the commonlog(source, event, detail)contract) and itsFetchEventLoggerimplementation now both live incommonMain. LikeStocksApi/NewsProvider/HistoryProviderit is a plain class (noTimber/Dispatchers.IO/Hilt — the multiplatformAppLoggerandioDispatcher, declared in the Koin graph) that persists each entry through the now-shared Room-backedStocksStorage.addFetchLog, so Android and iOS share the same sink; the app-providedCoroutineScopeis the only platform input (supplied by the Koin binding in:app'sappModule).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), theAnalyticsinterface (itstrackScreenViewnow takes a screen-nameStringrather than anandroid.app.Activity) andGeneralProperties(which takes the sharedIStocksProviderplus a widget-count lambda) all live incommonMain; the per-flavor AndroidAnalyticsImplreports the events through Firebase (prod) or no-ops (purefoss/dev), and iOS provides its ownAnalyticsImplover anAnalyticsSink(Firebase when linked, elseNSLog). The iOS-backedCrumbProvider/CrumbStoreis provided byUserDefaultsPreferences. The news-feed list model (NewsFeedItem— the article vs trending-stocks carousel entry, depending only on the already-sharedNewsArticle/Quote) also moved intocommonMain(sameticker.newspackage), 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 Financeinterval/rangequery-param mapping) also moved intocommonMain(ticker.model, decoupled fromjava.timeby storing a plaindurationDays), so iOS shares the same range options and param mapping. Building on that, the chart fetch itself (HistoryProvider→ChartData) also moved intocommonMain(ticker.model): it is now a plain class (noTimber/Dispatchers.IO/Hilt —AppLogger/ioDispatcher, declared in the shared KoinsharedModule) over the already-sharedChartApi, andChartData's…String()helpers use the sharedAppNumberFormat(its ComposechangeColouris an Android-only extension in:app, likeQuote.changeColour). TheDataPointcandle that blocked this is nowexpect/actual:commonMain/iOS see a plain, MPAndroidChart-free value ordered by its timestamp, while the Androidactualstill extends MPAndroidChart'sCandleEntry(and staysParcelable/Serializable) so the Android chart UI (LineDataSet/TextMarkerView) renders it unchanged; MPAndroidChart is therefore a:sharedandroidMain-only dependency.commonTest(HistoryProviderTest, via KtorMockEngine) 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 acommonMainsharedModule(reused by every platform), while:appprovides the Android leaf bindings inappModule/networkModule/viewModelModule(the former Hilt@Providesfunctions became Koinsingle { … },@HiltViewModelbecameviewModel { … }, the legacyEntryPoint/Injectorfield-injection of widgets/receivers/workers becameKoinComponent+by inject(), and@Named("yahoo")became a Koinnamed("yahoo")qualifier).StocksAppnow callsstartKoin { … }; Hilt, its Gradle plugin and KSP compiler are removed. A RobolectricKoinModulesTestresolves 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 (
DataStorePreferenceStorebehind the sharedPreferenceStorecontract) just like iOS, with a one-shotAppPreferencesDataMigrationimporting the legacySharedPreferencesvalues; and the previously platform-typed surfaces have moved intocommonMain— theNightMode/SelectedThemetheme settings onUserPreferences, thefetchState/FetchStateflow onIStocksProvider(its display string decoupled via aformatFetchTime()expect/actual), and theAnalyticsinterface (trackScreenView(String)) +GeneralProperties. Thejava.time-based update window had already been decoupled intocommonMain(sharedTimevalue + ISO day-of-week numbers onUserPreferences). -
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@Composablescreens intocommonMain, swap Android-only UI libraries for multiplatform equivalents (Coil 3, CMP navigation; Koin DI is already adopted in Phase 2), and repoint:appto host the shared Compose UI. Keep Glance widget + Firebase on Android. -
Phase 5 (complete): Add the
iosAppXcode project — a thin SwiftUI shell that hosts the shared Compose UI in aUIViewController, 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 aComposeUIViewControllerthat the SwiftUI shell hosts via aUIViewControllerRepresentable(ComposeView/ContentView), replacing the Phase 2 SwiftUI watchlist placeholder. An iOS Material 3 theme (IosAppTheme, mirroring the Android brand palette/shapes withoutandroid.os.Builddynamic colour) wraps the iOS host. The host has since grown from the singleWatchlistScreeninto the full shared navigation graph: an iOSHomeScreen(shared/src/iosMain, rendered byMainViewController) now hosts the sharedRootNavigationGraphover a root Compose MultiplatformNavHostController. The graph'shomeContentslot is the home navigation chrome (HomeScaffold+BottomNavigationBar+HomeNavHostover a nestedNavHostController), so the five home tabs (Watchlist/Trending/Search/Widgets/Settings) switch via bottom navigation on the simulator; itsquoteDetailContentslot is an iOSQuoteDetailScreen(shared/src/iosMain) reached by tapping a watchlist row, which navigates the root controller to the sharedquote_detail_graph/{symbol}destination. The Watchlist tab renders the sharedWatchlistScreen; 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 intocom.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 sharedappTypography()(commonMaintickerwidget.ui.theme) builds the Material 3 type scale from them, so the AndroidAppThemeand the iOSIosAppThemerender the same fonts (the duplicate AndroidAppTypography.ktwas removed; theapp/res/fontfiles remain only for the legacy XML themes). The colour scheme is now shared too: the brand Material 3 palette and the light/darkColorSchemes (brandLightColorScheme/brandDarkColorScheme), theappShapes, and a single cross-platformSharedAppThemecomposable all live incommonMain(tickerwidget.ui.theme).SharedAppThemeresolves dark/light from the sharedSelectedThemeand applies the brand scheme +appTypography()+appShapes, taking an optionalcolorSchemeOverride. Both platform themes are now thin wrappers over it: the iOSIosAppThemedelegates with no override (so it uses the brand scheme), and the AndroidAppThemesupplies a Material You dynamicColorSchemeoverride on Android 12+ (falling back to the same shared brand scheme otherwise); the duplicated AndroidAppColours/ThemePref/AppShapesand the iOS colour definitions were removed. The iOS quote-detail screen is now real: thequoteDetailContentQuoteDetailScreen(shared/src/iosMain) drives the sharedQuoteDetailViewModel(resolved from the iOS Koin graph:IStocksProvider/NewsProvider/HistoryProvider/UserPreferences) and renders the shared multiplatformPriceChartView(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 withNSDateFormatterand the prices with the sharedAppNumberFormat. The iOS Trending tab is now real too: an iOSTrendingScreen(shared/src/iosMain) drives the sharedNewsFeedViewModel(built from the iOS Koin graph'sNewsProvider) through the sharedNewsFeedScreen, supplying iOS-native Material 3 card slots — a lightweight quote card and the shared Coil-backedNewsCard(itscardslot a Material 3Card); tapping a news article opens its URL viaUIApplication.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 iOSSearchScreen(shared/src/iosMain) drives anIosSearchViewModel(built from the iOS Koin graph'sSuggestionsProvider/NewsProvider/IStocksProvider) through the sharedSearchScreen, 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 sharedic_closeCompose resource); tapping a result navigates to the shared quote-detail destination. An iOSSettingsScreen(shared/src/iosMain) drives anIosSettingsViewModelover the sharedUserPreferences/IStocksProviderthrough the sharedSettingsScreen— 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 (hasWidgetsis alwaysfalseon iOS); the external links open viaUIApplication.openURL. The chosen theme is now applied live:MainViewControllerobserves the sharedthemePrefFlowand passes the resolvedSelectedThemetoIosAppTheme. The iOSWidgetsScreen(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: theQuoteDetailScreen(shared/src/iosMain) shows the latest news articles (the sharedQuoteDetailViewModel.fetchNewspopulates them and they render through the shared Coil-backedNewsCard, opening in the browser viaUIApplication.openURL) and, for portfolio symbols, a holdings summary (shares / equity value / average price / gain-loss / day-change from the sharedQuotehelpers). Its editors are the same shared Compose Multiplatform screens the Android app uses, presented full-screen and persisted through the shared view models: positions viaAddPositionScreen+AddPositionViewModel, price alerts viaAlertsScreen+AlertsViewModel, notes viaNotesScreen+NotesViewModel, and the per-ticker display name viaDisplaynameScreen+DisplaynameViewModel(the editors reuse the sharedic_close/ic_doneCompose resources and the sharedDecimalFormatterfor input parsing). Remaining: none — the iOS portfolio share/import/export now drive native document pickers (UIDocumentPickerViewController/UIActivityViewController) via the sharedIosPortfolioExchange+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 inStockTickerApp.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) viaAlarmManager/WorkManager. The iOS app now has an equivalentUNUserNotificationCenter-backedLocalNotificationsHandler(shared/src/iosMain/.../notifications/LocalNotificationsHandler.kt): it ports Android'scheckAlerts()(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 configuredendTime(). It is driven off the sharedIStocksProvider.fetchStateflow (so a check runs after every refresh, including theBGTaskSchedulerbackground refresh viaStockTickerBackgroundScheduler.handleRefresh), gated on the samenotificationAlerts()/updateDays()preferences. The iOS app starts the observer and requests notification permission viaKoinHelper.initializeNotifications()inStockTickerApp.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 iOSStockTickerWidget(iosApp/StockTickerWidget/StockTickerWidget.swift) is now anAppIntentConfigurationdriven by a per-widgetStockTickerConfigurationIntent(iosApp/StockTickerWidget/StockTickerWidgetIntent.swift): each placed widget keeps its own watchlist selection — aWatchlistSymbolEntity/WatchlistSymbolQueryoffers the symbols read from the shared App GroupWidgetSnapshotStoresnapshot — 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 iOSWidgetsScreen.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.kt→HomeViewModel.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 sharedtutorialShown()/setTutorialShown()preference (shared/src/commonMain/.../UserPreferences.kt).HomeScreen.ktshows it once on first launch (showIfFirstRun()), the Settings "Tutorial" row re-opens it (onTutorial→controller.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 iOSonVersionTapis 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'sSKStoreReviewController.requestReviewInScene(...). Like Android'sHomeActivity, it is triggered when the user opens a quote detail (the iOSHomeScreen.ktobserves the root nav back stack for theQUOTE_DETAILroute) and is gated on the same sharedUserPreferences.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 anIosDbViewerViewModelthat reads the shared Room-backedQuoteDaoand renders the quotes/holdings/properties tables plus recent fetch logs as HTML in a nativeWKWebView(JavaScript disabled), hosted via Compose Multiplatform'sUIKitViewinterop. It is reached the same way as Android — tapping the Settings version label five times (onVersionTap→VersionTapCounter) opens it. iOS has no Glance widgets orWorkManager, so the widget/scheduled-work sections are omitted. - Background fetch scheduling. (Done.) The iOS
BGTaskSchedulerbridge (StockTickerBackgroundScheduler.swift) and the sharedBackgroundRefreshSchedulerupdate-window math already existed, but nothing enqueued the periodic refresh/cleanup on launch. The iOSHomeScreen.ktnow callsstocksProvider.schedule()once on first composition (mirroring Android'sHomeActivity.onCreate), which arms the next update and submits the recurringBGAppRefreshTaskRequest/BGProcessingTaskRequestwork.
- Local notifications (price alerts + daily summary). (Done.) Android delivers price-alert
notifications and a scheduled daily-summary notification (
-
Phase 6: (Done.) CI for Android + the iOS framework/app (macOS runner) and
commonTeston the simulator. The Android build/test/detekt jobs already run on ubuntu (.github/workflows/build.yml,unit-tests.yml,detekt.yml);unit-tests.ymlnow also runs the shared module's Android-target unit tests (:shared:testDebugUnitTest, which executescommonTestagainst the JVM/Android target). A new.github/workflows/ios.ymlruns on a pinnedmacos-15runner with Xcode 16.4 (pinned viamaxim-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 iOSSharedframework for both the simulator and device targets (:shared:linkDebugFrameworkIosSimulatorArm64/linkDebugFrameworkIosArm64), runs the sharedcommonTestsuite on the iOS simulator (:shared:iosSimulatorArm64Test, uploading the results as an artifact), and then builds the iOS app itself. TheiosAppXcode project is still not committed — instead it is generated reproducibly from a declarativeiosApp/project.yml(XcodeGen) spec: CI installs XcodeGen, runsxcodegen generate, andxcodebuild builds theiosAppscheme for the iOS simulator withCODE_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 appInfo.plist/Assets.xcassetsare now committed underiosApp/iosApp/. Producing a signed.ipafor TestFlight/App Store is intentionally out of scope (it needs code-signing secrets + anxcodebuild archive/-exportArchiveor fastlane step); seeiosApp/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 toshared/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 iOSWatchlistScreen/SearchScreennow call the shared card; the Androiddetail/QuoteCard.ktand the bespoke iOS cards were deleted.AppCard— moved toshared/src/commonMain/.../tickerwidget/ui/AppCard.kt(it only used Material3Card, 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 toshared/src/commonMain/.../tickerwidget/ui/Divider.kt(thin Material3HorizontalDividerwrapper, same package so the Android call sites are unchanged).SuggestionItem— moved toshared/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 iOSSuggestionRowand AndroidSuggestionItemduplicates were deleted.TotalHoldingsPopup— moved toshared/src/commonMain/.../home/TotalHoldingsPopup.ktusing the sharedtotal_holdingsstring andSharedColours(the Android-onlyexcludeFromSystemGesturepopup property was dropped).AddSymbolDialog— the stateless dialog body moved toshared/src/commonMain/.../portfolio/search/AddSymbolDialog.kt(AddSymbolDialogContent, with theSuggestionState/SuggestionWidgetDataStateholders). The AndroidAddSymbolDialogHostresolves the KoinSuggestionViewModeland delegates to it; the labels use the sharedselect_widget/savestrings and theic_add_circle/ic_remove_circledrawables.NewsCard— now inlines the (shared)AppCardandSharedColours.ImagePlaceHolderGray, so it only hoists the article tap asonClick; the Androidcard/placeholder slots and the iOSArticleCardwere removed.LinkText— moved toshared/src/commonMain/.../ui/LinkText.kt. The link action is hoisted as anonLinkClick(annotation)lambda (Android passes Chrome Custom Tabs, iOS its in-app browser) because the URL opener is platform specific.- Shared colours —
SharedColours(shared/src/commonMain/.../tickerwidget/ui/theme/SharedColours.kt) holds the change/gain/loss colours and the image placeholder grey; the AndroidColourPalettedelegates to it andQuoteCarddropped 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
iosArm64binary require a macOS host with Xcode, so do that on a macOS runner.