<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Peter wang</title>
    <description>The latest articles on DEV Community by Peter wang (@wanghengwen).</description>
    <link>https://dev.to/wanghengwen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4124222%2F13a9d771-7b58-4abb-86ca-09ac3899b0c4.png</url>
      <title>DEV Community: Peter wang</title>
      <link>https://dev.to/wanghengwen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wanghengwen"/>
    <language>en</language>
    <item>
      <title>~40k LOC, Shared 90%: Building a Cross-Platform Shared E-bike Ops App</title>
      <dc:creator>Peter wang</dc:creator>
      <pubDate>Tue, 15 Sep 2026 05:32:15 +0000</pubDate>
      <link>https://dev.to/wanghengwen/40k-loc-shared-90-building-a-cross-platform-shared-e-bike-ops-app-1m5f</link>
      <guid>https://dev.to/wanghengwen/40k-loc-shared-90-building-a-cross-platform-shared-e-bike-ops-app-1m5f</guid>
      <description>&lt;p&gt;It's 7 a.m. An ops worker stands by the roadside, facing a row of shared e-bikes parked at odd angles.&lt;/p&gt;

&lt;p&gt;The job is concrete: open the app to see which bikes are low on battery, scan to unlock, swap in a fully charged battery, snap a photo to close the task, then move to the next stop. Along the way they might relocate a few illegally parked bikes, handle a user report, or file a repair ticket for a broken unit. Over a full day the app gets opened dozens of times — outdoors, one-handed, sometimes with gloves on.&lt;/p&gt;

&lt;p&gt;That's everyday life on the &lt;strong&gt;operations side&lt;/strong&gt; of a shared e-bike business. It isn't glamorous, but it's the ground force that keeps the operation running.&lt;/p&gt;

&lt;p&gt;On the engineering side, the question is simpler: &lt;strong&gt;do we build all of this twice — once for Android and once for iOS?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our answer is no. The whole project is roughly 40,000 lines of Kotlin. &lt;strong&gt;About 90% lives in a shared layer used by both platforms&lt;/strong&gt; (measured at 95.8% in source; see §3.5). The rest — roughly 5% per host shell — is what you rewrite when you add another platform. This article walks through how we got there: starting at 81%, pushing through two migration rounds to ~96% shared code, and how each round dismantled the excuses for "this can't move."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Source available&lt;/strong&gt;: &lt;a href="https://github.com/wanghengwen/ebike-go" rel="noopener noreferrer"&gt;https://github.com/wanghengwen/ebike-go&lt;/a&gt;, project under &lt;code&gt;ebike-OpsApp/&lt;/code&gt;. Every module and file name in this article maps to the repo. Licensed under Elastic License 2.0 — &lt;strong&gt;source-available&lt;/strong&gt; (self-host, modify, use internally), not OSI open source. The sole restriction: you cannot use it to offer hosted / managed operations services for sale to third parties.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. First, Understand What Makes This App Hard
&lt;/h2&gt;

&lt;p&gt;When people hear "cross-platform," their first instinct is to pick a framework. But the framework comes last. Step one is &lt;strong&gt;weighing the requirements&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We laid out the ~40 screens the ops app needs and counted. The picture turned out cleaner than expected:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Category 1: pure business UI.&lt;/strong&gt; Login, service-area selection, task lists, task-completion forms, warehouse in/out records, repair-type pickers, permission-driven workbench, my reports… These screens are essentially "forms + lists + state transitions." Interaction patterns are stable and mostly platform-agnostic. This category accounts for &lt;strong&gt;more than 80%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Category 2: must touch native SDKs.&lt;/strong&gt; Only five areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Maps&lt;/strong&gt; — vehicle pin rendering, clustering, geofences&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bluetooth&lt;/strong&gt; — near-field vehicle control, BLE radar for finding bikes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Camera / scanning&lt;/strong&gt; — scan vehicle IDs, scan controller IMEIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location&lt;/strong&gt; — arrival detection, track upload&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File upload&lt;/strong&gt; — completion photos, repair images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. Nothing else.&lt;/p&gt;

&lt;p&gt;This "80/20 split" drove every decision afterward. Skip this exercise and you fall into two traps:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 1: build everything natively, twice.&lt;/strong&gt; Forty form screens on Android, forty again on iOS — double the work, double the bugs, and worst of all &lt;strong&gt;business rules diverge&lt;/strong&gt;. The same "does completion require a photo?" check written twice will eventually disagree. Ops staff can finish a task on Android but not on iOS; debugging that is brutal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 2: build everything with a cross-platform UI framework.&lt;/strong&gt; Forms are cheaper, but maps and proprietary BLE SDKs grind you down in interop hell. Proprietary BLE SDKs especially — closed-source, shipped as aar/framework only — can make bridge layers feel endless.&lt;/p&gt;

&lt;p&gt;We didn't pick one or the other. We &lt;strong&gt;assigned technology by where the hard part actually lives&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Choosing a Stack: Four Paths, Why We Took the Fourth
&lt;/h2&gt;

&lt;p&gt;We asked only two questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Can business UI be written once?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can strong platform capabilities be plugged in, instead of being locked to a UI framework?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Running the candidates through those questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Upside&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native UI on each platform&lt;/td&gt;
&lt;td&gt;Most direct platform integration&lt;/td&gt;
&lt;td&gt;~40 form screens duplicated; business rules fork&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full cross-platform UI (Flutter / RN)&lt;/td&gt;
&lt;td&gt;One UI stack&lt;/td&gt;
&lt;td&gt;High bridge cost for maps &amp;amp; proprietary BLE; breaks existing Kotlin assets&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared network layer only, native UI&lt;/td&gt;
&lt;td&gt;Easiest to start&lt;/td&gt;
&lt;td&gt;Saves the least valuable part; UI still duplicated&lt;/td&gt;
&lt;td&gt;❌ Not enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KMP business core + CMP shared UI + H5 dashboards + platform capabilities as interfaces&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Business &amp;amp; UI written once; SDKs swappable; dashboards ship independently&lt;/td&gt;
&lt;td&gt;Must draw clear boundaries: shared UI / host / H5&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The final split:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business UI (login, tasks, warehouse, repair, workbench…)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Compose Multiplatform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stable interaction patterns; Material3 is enough; best ROI, lowest risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations / revenue dashboards&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;H5 (Vue3)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Different release cadence from field work; needs independent shipping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maps · Bluetooth · scan · location · upload&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Interfaces + expect/actual&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Many closed SDKs; permission/compliance differs per platform — define contracts, not implementations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login, permissions, task state machines, signed requests&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;KMP shared core&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Must not fork; easiest to unit-test&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three sentences:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Share business UI. Interface-ize platform capabilities. Web-ize data dashboards.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The second point deserves expansion. For maps and Bluetooth, the right move isn't "find a cross-platform framework with a built-in map widget." It's &lt;strong&gt;define contracts in the shared layer and let hosts fill implementations&lt;/strong&gt;. An unexpected benefit: without a map key, without a real device, without a proprietary BLE SDK, drop in a simulator — every flow except real vehicle control still runs. Development and demos aren't blocked by hardware. Critical for the open-source drop, since proprietary SDKs can't live in the repo.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Final Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxsg0im7gyk29j7pbyvrf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxsg0im7gyk29j7pbyvrf.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Four Layers, Bottom to Top
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Bottom: backend gateway and H5 sites.&lt;/strong&gt; Network calls hit the business gateway; two dashboard sites are independent Vue3 static deployments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third layer: &lt;code&gt;shared&lt;/code&gt;, business core, Kotlin Multiplatform.&lt;/strong&gt; Four areas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Feature&lt;/code&gt; — per-domain state flows and intent functions; exposes state to UI, not repositories&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Domain&lt;/code&gt; — permission codes, task state machines, vehicle-control policy, validation rules&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Data&lt;/code&gt; — API definitions, DTOs, mappers, repositories&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;i18n&lt;/code&gt; — string keys and multilingual catalogs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Second layer: &lt;code&gt;sharedUi&lt;/code&gt;, main UI, Compose Multiplatform.&lt;/strong&gt; Login, workbench, tasks, warehouse, repair, report entry screens — all in &lt;code&gt;commonMain&lt;/code&gt;, one codebase for both platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Top: two host shells.&lt;/strong&gt; This layer is easy to misunderstand; worth its own section.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2 What the Top "Shell" Actually Is
&lt;/h3&gt;

&lt;p&gt;It is &lt;strong&gt;not another UI layer&lt;/strong&gt;. It's two thin shells — one Android, one iOS. Each does exactly three things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bootstrap&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Android &lt;code&gt;MainActivity&lt;/code&gt; / &lt;code&gt;OpsApplication&lt;/code&gt;, iOS &lt;code&gt;OpsAppViewController&lt;/code&gt; — mount shared UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Implement platform capabilities&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tencent Map view, CameraX preview, foreground location service, permission flows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Inject&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Push those implementations into shared-layer contracts via &lt;code&gt;CompositionLocal&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;No business rules and no business UI in the shell.&lt;/strong&gt; Completion conditions, permission checks, state transitions — not a single line belongs here. That's our hardest line. Android host &lt;code&gt;MainActivity&lt;/code&gt; is 62 lines total: the three jobs above, nothing else. Analogy: shared layer is engine and transmission; the shell is bodywork and the ignition key.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Dependency Direction
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fba6wdj9o25t5b2wb9yrg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fba6wdj9o25t5b2wb9yrg.png" alt=" " width="800" height="488"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note the &lt;strong&gt;cyan dashed line&lt;/strong&gt;: the shell isn't "calling into" the core. It's &lt;strong&gt;filling holes&lt;/strong&gt; — shared layer defines interfaces; the shell plugs in real SDKs. Reverse that direction and the architecture stops being reusable.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Module Layout
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ebike-OpsApp
├── shared/          # KMP: Feature · Domain · Data · platform contracts · i18n
├── sharedUi/        # Compose Multiplatform: business UI (nav shell, login, 4 tabs, scan screen)
├── androidApp/      # Android host: bootstrap · permissions · SDK adapters (1,639 LOC, no business UI)
├── iosApp/          # iOS host: links SharedUi.framework, 27 lines Swift
├── webH5/           # Vue3: operations dashboard, revenue dashboard
├── config/          # Multi-tenant config (demo only in repo; secrets supplied locally)
└── docs/            # Architecture, migration, i18n, BLE, open-source boundaries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.5 How We Calculated "Shared ≈ 90%"
&lt;/h3&gt;

&lt;p&gt;No hand-waving — we counted lines (&lt;code&gt;.kt&lt;/code&gt; / &lt;code&gt;.swift&lt;/code&gt;, excluding tests and blank lines):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Shared both platforms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;shared&lt;/code&gt; (business core)&lt;/td&gt;
&lt;td&gt;196&lt;/td&gt;
&lt;td&gt;22,989&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sharedUi&lt;/code&gt; (CMP UI)&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;14,833&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;androidApp&lt;/code&gt; (Android host)&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;1,639&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;iosApp&lt;/code&gt; (iOS host, Swift)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;276&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;39,488&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95.8% shared&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Also: &lt;code&gt;commonTest&lt;/code&gt; 2,571 lines (149 test cases on the shared layer) and &lt;code&gt;webH5&lt;/code&gt; 5,070 lines.&lt;/p&gt;

&lt;p&gt;Of &lt;code&gt;sharedUi&lt;/code&gt;'s 14,833 lines, only 246 sit in &lt;code&gt;androidMain&lt;/code&gt; / &lt;code&gt;iosMain&lt;/code&gt; (WebView body, icon resource mapping, Chinese sorting); the rest is &lt;code&gt;commonMain&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Figures and titles in this article use &lt;strong&gt;Shared ≈ 90% / Android ≈ 5% / iOS ≈ 5%&lt;/strong&gt; as a round illustration: measured shared code is ~95.8%, Android host ~4%. After iOS platform adapters (maps / camera / WebView / HUD) catch up, both host shells should land at similar scale — closer to "90% shared, ~5% thin shell per platform."&lt;/p&gt;

&lt;h3&gt;
  
  
  3.6 From 81% to 96%: Two Rounds of "Can't Move" Excuses
&lt;/h3&gt;

&lt;p&gt;This section matters on its own — it's the architecture's strongest proof point. The real win isn't two percentage points. It's this: &lt;strong&gt;every time we said "this can't move," the blocker wasn't technical. We were missing a contract.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Round 1: 81% → 88%, Map Screens
&lt;/h4&gt;

&lt;p&gt;When we started this article, the Android host had &lt;strong&gt;7,404 lines&lt;/strong&gt; — far too heavy for a "thin shell." My explanation then: "Any screen embedding a native map view must stay in the host." Sounds reasonable.&lt;/p&gt;

&lt;p&gt;We counted file by file. That explanation didn't hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only two files actually embed &lt;code&gt;AndroidView&lt;/code&gt;: &lt;code&gt;TencentMapView&lt;/code&gt; (321 lines) and &lt;code&gt;ReturnCarScatterMapView&lt;/code&gt; (164 lines).&lt;/li&gt;
&lt;li&gt;The five "map screens" — task map, relocation map, battery-swap map, vehicle condition distribution, return distribution — had &lt;strong&gt;zero Android-specific code&lt;/strong&gt;: no &lt;code&gt;android.*&lt;/code&gt; imports, no Material2, no &lt;code&gt;ui.res&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;They lived in the host for one reason: &lt;strong&gt;they called those two Composables directly&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;More ironic: &lt;code&gt;SimulatorMapView&lt;/code&gt; (155 lines) is pure Compose &lt;code&gt;Canvas&lt;/code&gt;, using projection and clustering from &lt;code&gt;shared&lt;/code&gt;, zero platform deps — it belonged in the shared layer but stayed on Android because it sat in the same folder as Tencent Map.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the issue wasn't "technically impossible to move." &lt;strong&gt;We lacked a cross-platform map container to call.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Filling that gap took a bit over a hundred lines. Same pattern as the repo's proven &lt;code&gt;H5Screen&lt;/code&gt;: contract in shared layer, implementation injected by host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sharedUi/commonMain — params are all shared-layer types, so the contract lives in commonMain&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;OpsMapSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MapPin&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;selectedCarId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fencePolygons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FencePolygon&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;trackPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TrackPoint&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="c1"&gt;// Viewport control via incrementing nonce — don't leak SDK camera objects into shared layer&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fitNonce&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OpsMapRenderer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;Pins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OpsMapSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Vendors without scatter support fall back to normal pin map&lt;/span&gt;
    &lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;Scatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OpsScatterMapSpec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* default impl */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Default is pure Compose canvas — runs both sides; host injects real SDK when key is available&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;LocalOpsMapRenderer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;staticCompositionLocalOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OpsMapRenderer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;SimulatorMapRenderer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The host side shrank to 49 lines of glue mapping spec → Tencent Map; we also added &lt;code&gt;LocalOpsToast&lt;/code&gt; so UI says "show this message" and the host picks the widget.&lt;/p&gt;

&lt;p&gt;After the move:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sharedUi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9,779&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12,668&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;androidApp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7,404&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,587&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share rate&lt;/td&gt;
&lt;td&gt;81.5%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;88.5%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Eight screens moved to shared; host slimmed 38%. &lt;strong&gt;We also wrote less code&lt;/strong&gt;: each map screen had duplicated &lt;code&gt;if (isTencent) TencentMapView(…) else SimulatorMapView(…)&lt;/code&gt; — six copies gone.&lt;/p&gt;

&lt;p&gt;Two details worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: after &lt;code&gt;git mv&lt;/code&gt;, most files needed no edits.&lt;/strong&gt; &lt;code&gt;sharedUi&lt;/code&gt; and &lt;code&gt;androidApp&lt;/code&gt; share the same package prefix. &lt;code&gt;com.luopingtech.ebike.ops.ui.task.ChangeBatteryMapScreen&lt;/code&gt; kept its FQN after the move — host imports unchanged. Moving UI across modules that lightly is a dividend from early package planning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: verification is mechanical, not honor-based.&lt;/strong&gt; After migration, &lt;code&gt;:sharedUi:compileCommonMainKotlinMetadata&lt;/code&gt; passing means those eight screens don't sneak platform APIs into &lt;code&gt;commonMain&lt;/code&gt;. More reliable than human review saying "looks fine."&lt;/p&gt;

&lt;h4&gt;
  
  
  Round 2: 88% → 96%, Moving the Nav Shell Too
&lt;/h4&gt;

&lt;p&gt;After round 1 the host still had 4,587 lines; &lt;code&gt;MainActivity&lt;/code&gt; alone was 3,108. The excuse: "It's the nav shell — login, four tabs, scan overlay. It needs Activity context; can't move."&lt;/p&gt;

&lt;p&gt;Same method, even flimsier excuse. Of 3,108 lines, only these touch the platform:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform coupling&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Why in host&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Activity&lt;/code&gt; class itself&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;Truly host-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location / notification permission requests&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rememberLauncherForActivityResult&lt;/code&gt; is Android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 camera previews&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;Direct CameraX Composable calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2× &lt;code&gt;R.drawable&lt;/code&gt; (torch, manual entry)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Direct Android resource IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6× &lt;code&gt;Toast&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Direct &lt;code&gt;android.widget.Toast&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;137 lines total.&lt;/strong&gt; The rest is pure Compose business UI.&lt;/p&gt;

&lt;p&gt;We also found &lt;strong&gt;1,063 lines of dead code&lt;/strong&gt;: 432 lines in &lt;code&gt;MainActivity&lt;/code&gt; were old task UI replaced by redesign plus two unused Composables; &lt;code&gt;sharedUi&lt;/code&gt; had two old map screens from round 1 that nothing referenced (377 + 254 lines). They compiled but never rendered. Deleted. So the &lt;code&gt;sharedUi&lt;/code&gt; delta below is "moved in minus deleted."&lt;/p&gt;

&lt;p&gt;Same recipe for new contracts — two this time. Camera preview mirrors &lt;code&gt;OpsMapRenderer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sharedUi/commonMain: shared layer only needs "a surface that emits codes"&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OpsScanPreview&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Composable&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;Preview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;torchOn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// When host has no camera, don't show a black box — say scanning isn't available on this device&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;LocalOpsScanPreview&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;staticCompositionLocalOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OpsScanPreview&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;UnavailableScanPreview&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Permissions were more interesting — they forced a split between &lt;strong&gt;business rules&lt;/strong&gt; and &lt;strong&gt;platform flows&lt;/strong&gt;. Android needs two dialogs (foreground location + notification first, then nudge for "always allow" — combined prompts get rejected); iOS has whenInUse / always tiers — that flow can't be shared. But &lt;strong&gt;"once granted, start uploading tracks" is a business rule&lt;/strong&gt; and must live in shared code. The contract is one question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sharedUi/commonMain: null = OK to start upload; String = user-facing denial message&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;OpsTrackPermissionGate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage in MainShell: who asks for permission doesn't matter; start upload on grant stays in shared layer&lt;/span&gt;
&lt;span class="nc"&gt;LaunchedEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;homeState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;homeState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trackUploadFeature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@LaunchedEffect&lt;/span&gt;
    &lt;span class="n"&gt;trackPermissionGate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;trackPermissionHint&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;denied&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;denied&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trackUploadFeature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we split &lt;code&gt;MainActivity&lt;/code&gt; into seven files under &lt;code&gt;sharedUi/ui/shell/&lt;/code&gt;, 2,689 lines total: &lt;code&gt;OpsAppRoot&lt;/code&gt; (login / set password / pick service area), &lt;code&gt;MainShell&lt;/code&gt; (switching among ~40 full screens), &lt;code&gt;MapTab&lt;/code&gt;, &lt;code&gt;TasksTab&lt;/code&gt;, &lt;code&gt;AnalysisTab&lt;/code&gt;, &lt;code&gt;WorkbenchTab&lt;/code&gt;, &lt;code&gt;ScanOverlay&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After the move:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;After round 1&lt;/th&gt;
&lt;th&gt;After round 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sharedUi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12,668&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14,833&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;androidApp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4,587&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,639&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MainActivity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,108&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;62&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share rate&lt;/td&gt;
&lt;td&gt;88.5%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95.8%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The remaining 1,639 host lines are clean: &lt;strong&gt;1,442 lines are platform capability implementations&lt;/strong&gt; (Tencent Map 534 · camera scan 358 · location &amp;amp; foreground service 262 · permission flow 82 · photo capture 107 · upload 67 · reverse geocode 32), &lt;strong&gt;197 lines bootstrap &amp;amp; injection&lt;/strong&gt; (&lt;code&gt;OpsApplication&lt;/code&gt; 135 + &lt;code&gt;MainActivity&lt;/code&gt; 62).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MainActivity&lt;/code&gt; now — that's all of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;setContent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OpsTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;branding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;branding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;CompositionLocalProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;LocalOpsMapRenderer&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="nf"&gt;opsMapRendererFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;LocalOpsScanPreview&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="nc"&gt;AndroidScanPreview&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;LocalOpsTrackPermissionGate&lt;/span&gt; &lt;span class="n"&gt;provides&lt;/span&gt; &lt;span class="nf"&gt;rememberTrackPermissionGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;LocalOpsToast&lt;/span&gt; &lt;span class="nf"&gt;provides&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LENGTH_SHORT&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Surface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxSize&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;OpsAppRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// all UI in shared layer&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interestingly, after round 1 we estimated "truly non-movable platform code ~1,400 lines." Actual count: 1,442. That match suggests &lt;strong&gt;"what must stay in the host" can be estimated upfront&lt;/strong&gt; — you don't need gut feel.&lt;/p&gt;

&lt;h4&gt;
  
  
  What This Round Means for iOS
&lt;/h4&gt;

&lt;p&gt;This is the point. After the move, "what does the iOS host do?" became a ** countable checklist**: one entry function plus four injection points.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sharedUi/iosMain — same job as Android setContent&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;OpsAppViewController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OpsApp&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;UIViewController&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ComposeUIViewController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OpsTheme&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;branding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;branding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Surface&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillMaxSize&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;OpsAppRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swift side: 27 lines, one &lt;code&gt;UIViewControllerRepresentable&lt;/code&gt;, done. &lt;strong&gt;Not a single screen rewritten in SwiftUI.&lt;/strong&gt; &lt;code&gt;SharedUi.framework&lt;/code&gt; &lt;code&gt;export(project(":shared"))&lt;/code&gt;, so &lt;code&gt;import SharedUi&lt;/code&gt; brings &lt;code&gt;OpsApp&lt;/code&gt; along — link one framework.&lt;/p&gt;

&lt;p&gt;More important: &lt;strong&gt;unfilled holes don't block bootstrapping&lt;/strong&gt;. No camera → "this host has no camera"; no map → shared Canvas renderer; H5 screen → placeholder. iOS todo went from "rewrite ~40 Android screens" to four concrete items: AVFoundation preview, map renderer, &lt;code&gt;WKWebView&lt;/code&gt;, HUD. Those four should land near Android's 1,442-line host scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.7 Interface-ized Capability Inventory
&lt;/h3&gt;

&lt;p&gt;Shared core knows capabilities, not vendors:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contract&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Implemented by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MapCapability&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether map is ready, which vendor&lt;/td&gt;
&lt;td&gt;Tenant config + key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OpsMapRenderer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pin / scatter maps&lt;/td&gt;
&lt;td&gt;Tencent Map / shared Canvas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BleTransport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Near-field control, BLE radar&lt;/td&gt;
&lt;td&gt;Proprietary BLE SDK / simulator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CodeScanner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Standalone scan page, one code&lt;/td&gt;
&lt;td&gt;CameraX + ML Kit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OpsScanPreview&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;In-page camera preview&lt;/td&gt;
&lt;td&gt;CameraX / iOS AVFoundation TBD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LocationTracker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Arrival check, track upload, vehicle re-location&lt;/td&gt;
&lt;td&gt;System location&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OpsTrackPermissionGate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ask "can we start uploading now?"&lt;/td&gt;
&lt;td&gt;Per-platform permission UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MediaUploader&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Completion photos, repair images&lt;/td&gt;
&lt;td&gt;Multipart upload / demo stub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlatformWebView&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;WebView body for H5 screens&lt;/td&gt;
&lt;td&gt;Android WebView / iOS &lt;code&gt;WKWebView&lt;/code&gt; TBD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LocalOpsToast&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One-shot lightweight toast&lt;/td&gt;
&lt;td&gt;Android Toast / iOS HUD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Business layer uses these for "scan once, upload a photo, beep" — UI only binds state: loading, error copy, button enabled.&lt;/p&gt;

&lt;p&gt;Pattern: &lt;strong&gt;contract parameters must not include vendor types&lt;/strong&gt;. &lt;code&gt;OpsMapRenderer&lt;/code&gt; lives in &lt;code&gt;commonMain&lt;/code&gt; because it takes &lt;code&gt;MapPin&lt;/code&gt;, &lt;code&gt;FencePolygon&lt;/code&gt;, etc. Even "zoom in one level" is an incrementing &lt;code&gt;zoomInNonce&lt;/code&gt;, not an SDK camera object. One &lt;code&gt;LatLng&lt;/code&gt; in the signature breaks the layer.&lt;/p&gt;

&lt;p&gt;Side note on vehicle control: &lt;code&gt;VehicleControlPolicy&lt;/code&gt; falls back to network commands when BLE is unavailable. Rule lives in &lt;code&gt;shared&lt;/code&gt; — both platforms behave the same. Write it twice and "when to fall back" almost certainly diverges.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.8 Embedding H5 in CMP
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sharedUi&lt;/code&gt; has cross-platform &lt;code&gt;H5Screen&lt;/code&gt;: common layer handles title, load-failure retry, back stack; &lt;code&gt;PlatformWebView&lt;/code&gt; in androidMain / iosMain (back-key semantics differ — implement separately). Dashboard URLs built from tenant config + login session — field app and data dashboards ship independently.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. i18n: Change Once, Both Platforms Update
&lt;/h2&gt;

&lt;p&gt;Ops apps often serve domestic and overseas tenants — i18n isn't optional.&lt;/p&gt;

&lt;p&gt;We didn't scatter strings across &lt;code&gt;strings.xml&lt;/code&gt; and &lt;code&gt;Localizable.strings&lt;/code&gt; — one new sentence, two files, no compile-time guard for misses. Instead: &lt;strong&gt;type-safe keys + multilingual catalogs in the shared layer; UI and network share one resolver.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 End-to-End Chain
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Str (enum keys — missing keys caught at compile / in tests)
   │
   ▼
StringCatalogs (ZH_CN / EN maps)
   │
   ▼
OpsI18n.t(key, args…)
   │
   ├── Strings global delegate → Feature / Repository get copy without Compose
   └── LocaleContext.acceptLanguage → HTTP Accept-Language header
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Business layer uses the same &lt;code&gt;Strings.t()&lt;/code&gt;.&lt;/strong&gt; Toasts, API error mapping, demo fake data stay language-consistent with UI — no "English UI, Chinese errors." A test guards it: every &lt;code&gt;Str&lt;/code&gt; key must exist in both ZH and EN catalogs.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 The Moment Language Switches
&lt;/h3&gt;

&lt;p&gt;User taps English in settings:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;OpsI18n.setLanguage(...)&lt;/code&gt; updates in-memory language&lt;/li&gt;
&lt;li&gt;Write to &lt;code&gt;SecureStore&lt;/code&gt; (persist choice)&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;LocaleContext.acceptLanguage&lt;/code&gt; (next request carries new language)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Strings.install(this)&lt;/code&gt; — process-wide resolver points at new catalog&lt;/li&gt;
&lt;li&gt;UI &lt;code&gt;collectAsState(languageFlow)&lt;/code&gt; — copy &lt;strong&gt;refreshes immediately&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No app restart. No re-navigation.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.3 Default Language: Follow System
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;First launch follows device language; after manual choice, user wins forever.&lt;/strong&gt; Two priorities only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lang&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNullOrBlank&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;OpsLanguage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// user chose — user wins&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;OpsLanguage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromSystemLanguage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;systemLanguage&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nf"&gt;platformLanguageTag&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading device language is itself &lt;code&gt;expect/actual&lt;/code&gt; — another "interface-ize platform capability" exercise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// commonMain&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;platformLanguageTag&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

&lt;span class="c1"&gt;// androidMain&lt;/span&gt;
&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;platformLanguageTag&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDefault&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLanguageTag&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// iosMain&lt;/span&gt;
&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;platformLanguageTag&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NSLocale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preferredLanguages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;firstOrNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4.4 Adding a New Language
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Add enum entry and &lt;code&gt;acceptLanguage&lt;/code&gt; on &lt;code&gt;OpsLanguage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add full catalog in &lt;code&gt;StringCatalogs&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add option on settings language list&lt;/li&gt;
&lt;li&gt;(Optional) Append &lt;code&gt;lang&lt;/code&gt; to H5 dashboard URLs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;No&lt;/strong&gt; Android / iOS resource files to touch — UI already consumes shared &lt;code&gt;t()&lt;/code&gt;. That's "change once, both platforms update."&lt;/p&gt;

&lt;p&gt;Easily overlooked: login country codes. Going overseas means international phone numbers — built-in calling-codes catalog and picker; submit normalized as &lt;code&gt;+{code}-{number}&lt;/code&gt;. Independent from UI language, same "going global" bundle.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. What's Shipped So Far
&lt;/h2&gt;

&lt;p&gt;Architecture done — here's what actually runs on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8nd1elitm5p6qtn5ewwz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8nd1elitm5p6qtn5ewwz.png" alt=" " width="471" height="917"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Account &amp;amp; field prep&lt;/strong&gt;: password / SMS login, multi-tenant branch selection, service-area picker, permission-code-driven workbench entries, EN/ZH switch, international dialing codes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find &amp;amp; control vehicles&lt;/strong&gt;: list and map, ops-state and alarm filters, scan parsing, lock/unlock, ring, battery compartment open/close (network or BLE per policy), battery SN binding, vehicle re-location.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tasks &amp;amp; work orders&lt;/strong&gt;: battery swap / relocation / inspection / repair — claim, execute, photo completion, review results; self-service relocation and batch manual relocation; legacy inspection/repair ledgers (list, claim, complete).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repair &amp;amp; reports&lt;/strong&gt;: configurable repair types, out-of-service option, photo submit, my report history; user reports with last-order validation, multi-select types, optional photos, pending revoke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warehouse &amp;amp; production&lt;/strong&gt;: coded / codeless in-out scanning and records, vehicle inspection, controller bind/unbind, shelf up/down, unlocked-vehicle checks, BLE radar find, ops track upload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data dashboards&lt;/strong&gt;: one tap from workbench to operations / revenue H5.&lt;/p&gt;

&lt;p&gt;Extra win: &lt;strong&gt;behavior alignment&lt;/strong&gt;. Legacy rules hid in details: which status code defines "low battery," whether "all" includes sold-out bikes, offline alarms via &lt;code&gt;alarmState&lt;/code&gt; vs connection state, 200 m gate on relocation completion, max repair plate length… We aligned each rule in shared code with tests. &lt;strong&gt;Rules exist once&lt;/strong&gt; — no "Android correct, iOS wrong."&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Want to Run It?
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/wanghengwen/ebike-go" rel="noopener noreferrer"&gt;https://github.com/wanghengwen/ebike-go&lt;/a&gt;, directory &lt;code&gt;ebike-OpsApp/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;ebike-OpsApp
&lt;span class="c"&gt;# JDK 17 or 21 (25 breaks — current Gradle Kotlin DSL can't parse it)&lt;/span&gt;
&lt;span class="c"&gt;# Windows / Linux: shared-layer check + tests + Android APK&lt;/span&gt;
gradlew.bat :sharedUi:compileCommonMainKotlinMetadata :shared:testAndroidHostTest :androidApp:assembleDebug

&lt;span class="c"&gt;# macOS can also link iOS framework (unverified by us — see §6)&lt;/span&gt;
&lt;span class="c"&gt;# ./gradlew :sharedUi:linkDebugFrameworkIosSimulatorArm64 &amp;amp;&amp;amp; cd iosApp &amp;amp;&amp;amp; xcodegen generate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open &lt;code&gt;ebike-OpsApp&lt;/code&gt; in Android Studio, run &lt;strong&gt;androidApp&lt;/strong&gt; Debug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backend required&lt;/strong&gt;: empty &lt;code&gt;api.baseUrl&lt;/code&gt; → Demo mode — login, tasks, warehouse, repair on local fake data, including simulated BLE ring. For real gateway, copy &lt;code&gt;androidApp/src/main/assets/tenant.json.example&lt;/code&gt;, fill URL and secrets (file is gitignored).&lt;/li&gt;
&lt;li&gt;Map key in local &lt;code&gt;local.properties&lt;/code&gt;, not in VCS.&lt;/li&gt;
&lt;li&gt;Multi-tenant config in &lt;code&gt;config/{tenant}_{mode}.json&lt;/code&gt;; only demo checked in.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kotlin</category>
      <category>mobile</category>
    </item>
    <item>
      <title>How We Use Go for Device Connectivity and Geofencing in a Shared E-Bike Platform</title>
      <dc:creator>Peter wang</dc:creator>
      <pubDate>Mon, 14 Sep 2026 09:30:09 +0000</pubDate>
      <link>https://dev.to/wanghengwen/how-we-use-go-for-device-connectivity-and-geofencing-in-a-shared-e-bike-platform-1oc2</link>
      <guid>https://dev.to/wanghengwen/how-we-use-go-for-device-connectivity-and-geofencing-in-a-shared-e-bike-platform-1oc2</guid>
      <description>&lt;p&gt;I’d like to share &lt;strong&gt;ebike-go&lt;/strong&gt;, a source-available collection of Go services for shared e-bike and pedal-assist bike operations:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/wanghengwen/ebike-go" rel="noopener noreferrer"&gt;https://github.com/wanghengwen/ebike-go&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The project grew from real operational requirements for city bike-sharing programs and tourist attractions. From a rider’s perspective, the process is simple: scan, unlock, ride, and return. On the backend, however, every trip involves live device connections, location updates, parking validation, helmet checks, pricing, promotions, payments, and operational reporting.&lt;/p&gt;

&lt;p&gt;This type of system has turned out to be a good match for Go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Go fits in a shared e-bike system
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Maintaining connections with a large number of bikes
&lt;/h3&gt;

&lt;p&gt;Each bike has an IoT controller that reports information such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GPS location&lt;/li&gt;
&lt;li&gt;Battery level and BMS data&lt;/li&gt;
&lt;li&gt;Lock and riding status&lt;/li&gt;
&lt;li&gt;Faults and alarms&lt;/li&gt;
&lt;li&gt;Smart helmet status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The backend also needs to send commands for unlocking, locking, powering the bike, finding a bike, or opening the battery compartment.&lt;/p&gt;

&lt;p&gt;In ebike-go, the device gateway is implemented as a separate Go service. Go’s lightweight goroutines and networking support make it practical to manage many long-lived device connections without requiring a complicated threading model.&lt;/p&gt;

&lt;p&gt;Device messages can arrive through TCP or MQTT. After protocol decoding, events are passed through Kafka to consumer and worker services. Redis is used for frequently accessed device state, while business events continue to the order and operations services.&lt;/p&gt;

&lt;p&gt;The simplified flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bike / IoT controller
        |
     TCP or MQTT
        |
Go device gateway and protocol decoder
        |
      Kafka
        |
Go consumers and workers
        |
Redis, MySQL, and business services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation keeps connection handling and binary protocol decoding away from slower business operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Processing bursts of device events
&lt;/h3&gt;

&lt;p&gt;Bike traffic is not evenly distributed. Morning commutes, tourist opening hours, promotions, and large return events can create sudden traffic spikes.&lt;/p&gt;

&lt;p&gt;Go works well here because the services can process independent messages concurrently while keeping the code relatively straightforward. Device ingestion, command delivery, state updates, and asynchronous tasks are split into independently deployable services, so the busiest part can be scaled without scaling the entire platform.&lt;/p&gt;

&lt;p&gt;Kafka provides buffering between live device traffic and downstream processing. This is important when bikes continue reporting data while a database or business service is temporarily slow.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fast geofencing during unlock and return
&lt;/h3&gt;

&lt;p&gt;Parking is one of the hardest operational problems in shared mobility.&lt;/p&gt;

&lt;p&gt;Before unlocking or returning a bike, the platform may need to determine whether the current location is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inside the service area&lt;/li&gt;
&lt;li&gt;Inside an approved parking zone&lt;/li&gt;
&lt;li&gt;Inside a no-parking or no-riding area&lt;/li&gt;
&lt;li&gt;Close enough to a designated station&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These checks happen on the user-facing path, so latency matters. The geofencing service is written in Go and kept separate from general business APIs. This lets us optimize and scale geometry-heavy checks independently.&lt;/p&gt;

&lt;p&gt;GPS alone is not always accurate enough. The business flow can also combine geofencing with Bluetooth beacons or RFID tags to confirm that a bike is parked in the correct place.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Keeping business domains independent
&lt;/h3&gt;

&lt;p&gt;The repository is organized as multiple Go modules rather than one large application. The main service areas include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API gateway and authentication&lt;/li&gt;
&lt;li&gt;Customer and operator APIs&lt;/li&gt;
&lt;li&gt;Device gateway and protocol handling&lt;/li&gt;
&lt;li&gt;Device state consumers and background workers&lt;/li&gt;
&lt;li&gt;Geofencing&lt;/li&gt;
&lt;li&gt;Order, pricing, and promotion integration&lt;/li&gt;
&lt;li&gt;Operational analytics&lt;/li&gt;
&lt;li&gt;Notifications&lt;/li&gt;
&lt;li&gt;Open APIs for external systems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most HTTP services use Gin. The supporting infrastructure includes Nacos, Redis, Kafka, MySQL, Docker, and Kubernetes.&lt;/p&gt;

&lt;p&gt;Using multiple services does add deployment and observability work, so this structure is not intended as a recommendation for every Go project. In this case, the boundaries follow workloads that behave differently: long-lived connections, CPU-heavy geofencing, user-facing APIs, and asynchronous event processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Building small deployment units
&lt;/h3&gt;

&lt;p&gt;Go’s static binaries and relatively fast startup are useful for containerized deployment. Each service can be built, deployed, restarted, and scaled independently.&lt;/p&gt;

&lt;p&gt;This is particularly helpful for shared mobility, where traffic varies by region and time of day. A device gateway may need more capacity during a fleet rollout, while customer-facing APIs may peak during commuting hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Business capabilities built on top
&lt;/h2&gt;

&lt;p&gt;The Go services support more than bike connectivity. The platform also covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The complete scan, unlock, ride, and return process&lt;/li&gt;
&lt;li&gt;GPS geofences, Bluetooth parking beacons, and RFID parking points&lt;/li&gt;
&lt;li&gt;Smart helmet pairing, status checks, and return rules&lt;/li&gt;
&lt;li&gt;Configurable fares for different regions and vehicle types&lt;/li&gt;
&lt;li&gt;Ride passes, coupons, memberships, referrals, and promotional campaigns&lt;/li&gt;
&lt;li&gt;Multi-tenant operations&lt;/li&gt;
&lt;li&gt;Merchant accounts, revenue-sharing records, refunds, and revenue reports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is to provide a practical foundation for organizations operating their own shared e-bike service, while keeping device communication and operational rules under their control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’d like feedback on
&lt;/h2&gt;

&lt;p&gt;I’m especially interested in hearing from Go developers who have worked on IoT, mobility, or real-time systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How do you structure connection ownership and command delivery across multiple gateway instances?&lt;/li&gt;
&lt;li&gt;Which approaches have worked well for high-frequency geospatial checks in Go?&lt;/li&gt;
&lt;li&gt;Where would you draw service boundaries in a system that combines IoT traffic and transactional business flows?&lt;/li&gt;
&lt;li&gt;Which parts of this project would be most useful to document or provide as standalone examples?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Issues and pull requests are welcome, especially for bug fixes, documentation, observability, and clean example configurations.&lt;/p&gt;

&lt;p&gt;Repository: &lt;strong&gt;&lt;a href="https://github.com/wanghengwen/ebike-go" rel="noopener noreferrer"&gt;https://github.com/wanghengwen/ebike-go&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>opensource</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
