Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
19
Section 19 · follows 18

Rebuilding Compare from the APK's layout_compare.xml + smali

Written
2026-05-17

The previous post got the palette right. This one rebuilds the Compare tab to actually match the APK’s structure and behavior, working from two source files in the decoded APK: res/layout/layout_compare.xml for the shape, and smali_classes2/com/nakedlabs/.../CompareCardsAdapter.smali + .../managers/CompareStatsManager*.smali for the row-tap-to-model-zoom binding.

§ 19.1

The structural shape

Stripped of ConstraintLayout boilerplate, the APK Compare is this:

┌────────────────────────────────────────┐│ model_view (height = 360dp, fixed) │ ← two avatars side-by-side├────────────────┬───────────────────────┤│ ▼ date_before │ ▼ date_after │ ← two date dropdowns├────────────────┴───────────────────────┤│ stats_recycler (vertical scroll) ││ ┌──────────┬───────────┬─────────┐ ││ │ 76.0 cm │ WAIST ▼ │ 78.1 cm │ │ ← 3 equal-weight columns│ │ │ +2.1 cm │ │ ││ └──────────┴───────────┴─────────┘ ││ ... │└────────────────────────────────────────┘

height_container_compare_view = 360.0dp is in res/values/dimens.xml. Picked up as a literal .frame(height: 360) on iOS: the model row is fixed-height, not flexible, and the stats list fills the rest. This is one of those details you’d never guess from screenshots; you have to read the XML.

The per-row layout (item_stat_compare.xml) uses android:weightSum="3.0" with three children each android:layout_weight="1.0". SwiftUI’s Spacer()-driven equal-distribution via .frame(maxWidth: .infinity) on each column gives the same behavior. Inside the middle column, the stat name (15sp, ALL CAPS, with a drawableEnd="@drawable/ic_arrow_down" chevron) sits above a delta value+unit row in @color/gray_compare_difference (which is #8014131b, half-opacity bottom_bar).

§ 19.2

The smali find

I’d assumed the Compare zoom was scroll-driven: scroll the stats list, the centered row’s region animates the models. The smali tells a different story:

Fig. 19-1
smali
.class public interface abstract Lcom/nakedlabs/.../CompareCardsAdapter$CompareItemListener; # virtual methods.method public abstract onItemClick(Landroid/view/View;I)V.end method
Fig. 19-2
smali
.class public interface abstract Lcom/nakedlabs/.../managers/CompareStatsManager$CompareManagerListener; # virtual methods.method public abstract onUserBandChanged(Ljava/lang/String;)V.end method

That’s the entire interface surface for the Compare→model interaction:

Fig. 19-3
sequenceDiagram
    participant Tap as Tap on stat row
    participant Adapter as CompareCardsAdapter
    participant Manager as CompareStatsManager
    participant Activity as BodyModelActivity
    Tap->>Adapter: onItemClick(view, position)
    Adapter->>Manager: look up band name at position
    Manager->>Activity: onUserBandChanged("WAIST")
    Activity->>Activity: update both model_view cameras to focus region

No scroll listener. No fling detection. Just onItemClick.

The scroll-feel I remembered is probably the SnappingRecyclerView (a custom class used elsewhere in the app for the bottom MEASURE cards in the Body tab: bottom_cards is a com.nakedlabs.android.nakedfit.ui.components.SnappingRecyclerView). Inside the Compare layout stats_recycler is a plain androidx.recyclerview.widget.RecyclerView with a plain LinearLayoutManager, no snapping. So the impression was from a different surface in the same app.

The iOS port is simpler than the original instinct: a LazyVStack of CompareStatRows, each with .onTapGesture. Tapping sets a shared @State selected: CompareRowKind?, which both BodyModelView instances read as their selectedPart, and the existing camera animation I’d built for the Body tab’s MEASURE sub-tab does the rest.

Fig. 19-4
sequenceDiagram
    participant Row as Stat row (tap)
    participant State as @State selected
    participant ModelA as BodyModelView (before)
    participant ModelB as BodyModelView (after)
    Row->>State: onTapGesture sets selected = CompareRowKind
    State->>ModelA: selectedPart updated
    State->>ModelB: selectedPart updated
    Note over ModelA,ModelB: existing MEASURE camera animation<br/>eases into the region centroid

Zero new geometry code, zero new SceneKit work. Reusing what’s already there is the whole point of porting carefully.

§ 19.3

The enum

CompareStatType has 17 cases in the APK:

BODY_FAT, WEIGHT, LEAN_MASS, FAT_MASS, WAIST_TO_HIP,WAIST, HIPS, CHEST, STOMACH, SHOULDER,UPPER_ARM_LEFT, UPPER_ARM_RIGHT,UPPER_THIGH_LEFT, UPPER_THIGH_RIGHT,LOWER_THIGH_LEFT, LOWER_THIGH_RIGHT,CALF_LEFT, CALF_RIGHT

iOS already had a BodyPart enum with all the circumference regions. The composition stats (BODY_FAT, WEIGHT, LEAN_MASS, FAT_MASS) live on Scan directly. WAIST_TO_HIP is the only derived one (waist_circumference / hip_circumference) and gets its own row when both numbers are available.

So the iOS model is CompareRowKind:

Fig. 19-5
swift
enum CompareRowKind: Hashable { case bodyFat, weight, leanMass, fatMass, waistToHip case region(BodyPart) /// Which region the dual model camera should focus on for this row. var regionForFocus: String? { switch self { case .bodyFat, .weight, .leanMass, .fatMass: return nil // no specific body region case .waistToHip: return BodyPart.waist.rawValue // emphasize the waist ring case .region(let part): return part.rawValue } }}

A CompareRow wraps the kind with before / after values, a format closure, the unit label, and a DeltaDirection enum (.lowerIsBetter / .higherIsBetter / .neutral) that drives the green / red / gray delta-text coloring. Body fat dropping is green. Lean mass going up is green. Weight is neutral (no green/red, losing weight isn’t categorically “better” for a body-comp app). Limb circumferences are neutral. Waist/hips/stomach circumferences are .lowerIsBetter because the typical user goal is dropping them, but this is opinionated and easy to flip.

§ 19.4

The @MainActor papercut

The factory methods on CompareRow call Units.weightValue(_:) and Units.circumferenceValue(_:). Both read from ProfileStore.shared.profile?.unitSystem, which is @MainActor-isolated. Swift Concurrency requires every static factory that calls them to also be @MainActor:

Fig. 19-6
swift
@MainActor static func weight(before: Double, after: Double) -> CompareRow { ... }

The compiler error is loud and useful (“call to main actor-isolated static method ‘weightUnit()’ in a synchronous nonisolated context”), but the fix annotation is unergonomic. There’s no equivalent of @inheritActor for static methods, so each factory needs its own @MainActor. Calling code (orderedRows() inside a SwiftUI View) is already @MainActor by SwiftUI default, so once the factories are annotated, the call site Just Works.

The cleaner alternative would be to pre-format the strings at row-construction time and store them as let beforeText: String, afterText: String instead of let before: Double?, after: Double? plus format: (Double) -> String. Less actor surface, less work per render. Phase C can swap that out if it becomes a hotspot.

§ 19.5

The History tab compare-sheet plumbing

The History tab has a “Compare first to latest” button that opens ScanComparisonView as a sheet, pre-bound to the oldest and newest scans. The new ScanComparisonView signature takes allScans + two Binding<String?> for the IDs, not two Scan objects directly, because the dropdowns let the user re-pick within the sheet. A small bridge view HistoryComparePresenter adapts the old “open with these two scans” call site to the new “here are all scans, start with these two IDs” shape:

Fig. 19-7
swift
.sheet(item: $comparePair) { pair in HistoryComparePresenter(scans: scans, initialBeforeId: pair.a.id, initialAfterId: pair.b.id)}

The presenter holds the @State for the two IDs and seeds them from the pair .onAppear. Once the sheet is open the user can swap either side via the date dropdowns. The History “first to latest” button just provides the starting selection.

§ 19.6

What this commit does NOT do

A handful of intentional omissions worth naming, so they’re not surprises in Phase C+:

  • No snapping on the stats list. Plain LazyVStack scroll. The APK doesn’t snap here either (per the smali: LinearLayoutManager, no SnapHelper). If the feel ends up off, a ScrollViewReader.scrollTo after row-tap would add center-anchoring without changing the underlying interaction.
  • No expanded-row variant. The APK has item_bottom_double_card_expanded.xml for an “expand the card to see a history graph” pattern on the Body tab. Compare doesn’t have it: the per-row dropdown chevron in the center column is a hint, but tapping it just toggles selection (and model focus); it doesn’t expand a sub-panel. Adding that would be Phase C territory.
  • No multi-scan-per-day picker. If the user has 3 scans on the same day the date dropdown shows three identically-labeled entries with different times. APK has layout_mutiple_scan_picker.xml for a richer picker; iOS just lists them with time suffixes for now.
  • No heatmap. The APK doesn’t have a per-vertex distance heatmap either. That was an aspirational iOS-only feature noted in earlier comments. Removed the “Phase 6 polish” comment; it’s not what the APK does.

Phase C polishes the Body tab itself: KEY STATS gets inline sparklines, MEASURE gets expanded cards, symmetric regions (arms, thighs, calves) get L/R split cards. Phase D rebuilds the scan flow. Phase E is the first-scan walkthrough. Phase F is profile/settings/history polish. Six phases total; A and B are in, four to go.

§ 19.7
§ 19.8
Agent prompts Procedure 19-A as prompts for a coding agent · 6 prompts

Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.

1
Read layout XML before coding
Before writing any SwiftUI for the Compare screen, read the decoded APK's `res/layout/layout_compare.xml` and the row layout it includes (`item_stat_compare.xml`) to get the structural shape: a fixed-height model row, two date dropdowns, and a 3-column equal-weight stats list. Check `res/values/dimens.xml` for literal constants referenced by the layout (here, `height_container_compare_view = 360.0dp`) and use them as literal `.frame(height:)` values rather than guessing from screenshots.
2
Read smali for interaction model
Do not assume the Compare screen's tap/scroll interaction model from a demo video. Decompile and read the smali interface definitions for the relevant adapter and manager classes (e.g. `smali_classes2/.../CompareCardsAdapter.smali` and `.../managers/CompareStatsManager*.smali`). Identify the actual callback methods declared (e.g. `onItemClick(Landroid/view/View;I)V`, `onUserBandChanged(Ljava/lang/String;)V`) to confirm whether the interaction is tap-driven or scroll/fling-driven before implementing the SwiftUI gesture handling.
3
Verify RecyclerView snap behavior
Check whether the APK's `RecyclerView` for this screen (here, `stats_recycler` inside `layout_compare.xml`) actually uses a `SnapHelper` or just a plain `LinearLayoutManager`. If there's no `SnapHelper` attached, do not build snap-to-center scroll behavior in the SwiftUI port — implement a plain `LazyVStack`/`ScrollView` instead, and only add `ScrollViewReader.scrollTo` anchoring if a later UX issue specifically calls for it.
4
Reuse existing camera/animation code
Before writing new SceneKit/model-focus geometry for the Compare tab's tap-to-zoom behavior, check whether an equivalent camera animation already exists elsewhere in the app for a similar region-focus interaction (e.g. the Body tab's MEASURE sub-tab). Wire the new tap gesture to set the same shared `selectedPart`/`selected` state that the existing `BodyModelView` camera animation already observes, instead of duplicating the animation logic.
5
Isolate @MainActor factories
For any row/view-model type whose factory methods read from an `@MainActor`-isolated singleton (e.g. `ProfileStore.shared.profile?.unitSystem` via `Units.weightValue`/`Units.circumferenceValue`), mark each factory method `@MainActor` individually rather than letting the isolation requirement propagate through unrelated call sites. Confirm the SwiftUI call site (already `@MainActor` by default) compiles once the factories are annotated.
6
Document deliberate scope omissions
In the commit/PR description or a code comment near the new feature, explicitly list what was deliberately not ported in this phase (e.g. no RecyclerView snapping, no expanded-row variant, no multi-scan-per-day picker, no heatmap) so later phases don't rediscover these as surprises or bugs.