Skip to content

Migrate MoveNodeFragment speech bubble content to Compose (commonMain)#6797

Merged
westnordost merged 7 commits intostreetcomplete:masterfrom
michaelabon:compose/move-node-form
Apr 10, 2026
Merged

Migrate MoveNodeFragment speech bubble content to Compose (commonMain)#6797
westnordost merged 7 commits intostreetcomplete:masterfrom
michaelabon:compose/move-node-form

Conversation

@michaelabon
Copy link
Copy Markdown
Contributor

@michaelabon michaelabon commented Mar 30, 2026

Summary

Addresses #6796

  • Extract MoveNodeFragment's speech bubble content (title, description, cancel/OK buttons) into a MoveNodeForm composable in commonMain
  • Move MeasureDisplayUnit to commonMain (replace String.format with the project's NumberFormatter expect/actual for locale-aware formatting on both platforms)
  • Fragment wrapper in androidMain keeps arrow drawable, pin positioning, map interfaces, survey confirmation, and edit submission

Approach

The split follows the bottom-up pattern from PR #6022 (BuildingLevelsForm): portable UI in commonMain, platform wrapper in androidMain. The Fragment now passes a MoveNodeDistanceState sealed interface to the composable instead of imperatively updating TextViews and toggling popIn/popOut on the OK button.

No changes to MainActivity, MoveNodeFragment.Listener, or DI wiring.

Test plan

  • Android: ./gradlew assembleDebug compiles cleanly
  • iOS: ./gradlew :app:compileKotlinIosSimulatorArm64 compiles cleanly (with fix branches merged)
  • Manual: open move-node bottom sheet on a POI, verify title updates as map is dragged, OK button appears/disappears at correct distances, cancel and OK actions work as before

Again, I'm very happy to change the approach or anything here, based on your feedback.

MoveNodeFragment's UI is split between Android-specific map interaction
(arrow drawing, pin positioning, screen coordinate conversion) and
portable speech bubble content (title, description, buttons). Only the
latter needs to differ per platform, so extracting it to a shared
composable lets the upcoming iOS port reuse the same UI without
duplicating the layout logic.

MeasureDisplayUnit moves to commonMain because the composable's caller
needs it to format distances. The file is pure Kotlin (kotlin.math +
kotlinx.serialization) with one exception: String.format is unavailable
in Kotlin/Native, so it's replaced with a small formatFixed helper that
produces identical output for the 0/1/2 decimal cases this class uses.

The Fragment wrapper keeps all its existing responsibilities (arrow
drawable, pin marker, IsCloseableBottomSheet/IsMapPositionAware
interfaces, survey confirmation, edit submission) and passes a
MoveNodeDistanceState to the composable instead of imperatively updating
TextViews. No changes to MainActivity or the Listener interface.

Addresses streetcomplete#6796
Copy link
Copy Markdown
Member

@westnordost westnordost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you test this? From the code, it looks like the OK button might be at a different place now than it was before. Now, it looks like it is in a column below the other content.

You might have noticed that due to the bottom-up method of the migration, the button bars and OK button of all the quest forms have not been migrated yet. (This is due soon, because I am almost done with migrating all the quest form content, currently I am on the note stuff).
Now, MoveNodeFragment does not inherit from the quest forms, so already migrating the button bar and OK button is fine, however, next step would be to generalize the button bar and OK button composables and outsource it into common ui components that can be used for the other places as well (e.g. ButtonBar.kt, FloatingOkButton). Ideally, this could happen right away in this PR.

(Alternatively, it would also be fine to keep the OK button and button bar in the layout XML for now and migrate that later.)


Due to an error in GitHub, I can't comment on MeasureDisplayUnit.kt. In line 43, you seem to have changed a figure space into a normal white space. Could you revert that?

The figure space was accidentally replaced with a regular space when
moving the file from androidMain to commonMain.
Reusable animated FAB with check icon, matching the View-based
popIn/popOut animations (scale 0.5-1.0, fade, 100ms). Will be used
by MoveNodeFragment and eventually all quest form bottom sheets.
Simple Row-based button bar for bottom sheet button panels. Callers
add VerticalDivider between buttons when multiple are present.
The OK button was inside the speech bubble content, but it should be a
sibling at the SlidingRelativeLayout level (matching the original XML).

Also simplifies the Fragment to pass raw Float distance instead of a
sealed state class, with the composable handling display logic.

Uses the new FloatingOkButton and ButtonBar composables.
@michaelabon
Copy link
Copy Markdown
Contributor Author

I had tested this functionally in an emulator, to make sure that everything still worked. You're right, I had inadvertently changed the positioning of the OK button. In my day job, I have built the habit of including before and after screenshots so that I and reviewers have an easier time observing those kinds of changes. I'll do that here.

Screenshots
Before After
move-it-further move-it-further
moved-by-metres moved-by-metres
moved-too-far moved-too-far

Styling

You'll note that the font family is different, the colour of the body text is different, and the Cancel button text is no longer red, it is now blue. Based on my digging through the issues and codebase, I believe that these styling changes/gaps are project-wide thus far, and that I shouldn't address them in this PR. Please let me know if that's an incorrect decision.

I extracted FloatingOkButton.kt and ButtonBar.kt into ui/common/ as reusable composables, and moved the OK button back to its correct position as a sibling of the speech bubble content container.

move the logic that decides which message to display within the composable

Done in 983eead. I changed the Fragment to just pass a raw Float distance via mutableFloatStateOf. I also moved the message logic into MoveNodeForm.kt. The composable now has the when block that decides between "not far enough", "too far", and the formatted distance message.

Ideally, this could happen right away in this PR.

Also done.

you seem to have changed a figure space into a normal white space. Could you revert that?

I had not realized that those were not normal whitespaces. I have configured my editor to highlight non-standard whitespace now. And I have also learned what a figure space is, so thank you!

Again, I am very open to feedback on this. I'm happy to keep editing if there are big or small things that you would like changed. If you're happy with it, then please feel free to tell me what to pick up next, if you have an idea.

Copy link
Copy Markdown
Member

@westnordost westnordost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! A few small nitpicky things

Comment thread app/src/commonMain/composeResources/drawable/ic_check_48.xml
Comment thread app/src/commonMain/kotlin/de/westnordost/streetcomplete/ui/common/ButtonBar.kt Outdated
@westnordost
Copy link
Copy Markdown
Member

westnordost commented Apr 9, 2026

If you're happy with it, then please feel free to tell me what to pick up next, if you have an idea.

I have a few. They are, (for me,) a bit in the "huff... ehh, let's do it later..." zone (Not because they are awkward, but rather because I don't have a Mac machine I like to work and test with). So, ideal for you to pick up:

  1. mentioned in another ticket: Implement the TODO() for isImeVisible on iOS

  2. the logs filter screen (About → Show logs → Filter) requires a date and a time picker dialog. Unfortunately, in Material 2 (the UI theme library we are using), it is not implemented and unlikely to be ever implemented, as Google wants everyone to move to Material 3. Hence, currently, we use the Android built-in view-based date and time pickers. There are two possible solutions to this:

    1. Migrate from Material 2 to Material 3. I'd be fine with this. Earlier, I was dismissive of Material3, but in the end, I guess we could customize the parts that are not good by default. Plus, Google recommends to move off Material 2, which means that sooner or later, we might be soft-forced to migrate anyway because we find that more and more things we need are not possible with M2. (Already now, there are a few minor things, such as ScrollableAlertDialog.kt, well, and missing date + time pickers). However, just the migration is a lot of grunt work (replacing all those imports...), and a lot of testing and checking before/after.
      Then, finally, we use the date picker and time picker from M3

    2. Implement a simple DatePicker based on a WheelPicker (with wheelpicker I mean something like this). We already have a simple wheelpicker-based TimePicker because we needed it for the opening hours quest. For locale aware display of month names, we also already have Month.getDisplayName, so, implementation will be straightforward. Of course, implementing this solution doesn't foreclose an eventual migration to M3, it just makes it not imperative for iOS/multiplatform purposes.

  3. the FeatureDictionary and the CountryBoundaries class need to be fed a directory and a file, respectively, to be initialized. On Android, the files in composeResources/files/ are never unpacked from the APK (file in which the app is delivered on Android, it is essentially a ZIP file, quite similar to iOS apps), so these files are not actually in the file system. This is why on Android, one needs to use the AssetManager to access those files, which provides a simplified "file system like" API. On iOS, on the other hand, I read that asset files are accessible via the normal file system APIs. However, I don't know what exactly the base path for assets specified in composeResources/files/ would be on iOS. Inspecting the source code of de.westnordost.streetcomplete.resources.Res::readBytes and following the call chain into the compose multiplatform source code to ResourceReader.ios.kt will provide the answer, alternatively, one could also inspect the resulting IPA file.
    So, in androidMain, there is a file named MetadataModule.kt, in which these two classes are initialized. We need a MetadataModule.kt in iosMain that creates these two classes by feeding it the appropriate directory/file, wherever it is located exactly.
    Note that the other (file system) overload for FeatureDictionary.create and CountryBoundaries.deserializeFrom both use APIs from kotlinx-io (multiplatform I/O library)

    In case you ask yourself: Why can this not be done in a completely multiplatform way, using the Compose Multiplatform Resources API (Res.getBytes etc.)?, there are several reasons for this: 1. there is neither an API for checking if a file exists, nor to get a directory listing, 2. one can only get the bytes as a ByteArray, whilst it would be more performant to parse the data while streaming the bytes (Source from kotlinx-io is the multiplatform replacement for InputStream on Java), no API for that exists, however and 3. Any API dependent on Compose Multiplatform Resources thus needs that as a dependency, but neither the osmfeatures library providing FeatureDictionary nor the countryboundaries library provider CountryBoundaries should depend on compose.

@michaelabon
Copy link
Copy Markdown
Contributor Author

Thank you for the detailed suggestions! I can pick up any/all of these, but I think I should start with option 2.ii, the WheelPicker-based DatePicker.

I'll open an issue with the details before starting, so you can sanity-check the approach.

For the M2 to M3 migration, I agree it'll need to happen eventually, but tackling it now feels like a little too much for my... 4th PR. I don't mind doing grunt work at all, but I'll get settled in a bit with the codebase first.

1. Consolidate move-node files into a `move_node/`
   subpackage under `screens/main/bottom_sheet`.
2. Remove my redundant nested Column.
3. Move content padding out of the composable
   and back into the ComposeView.
4. Apply hint text style to the description text.
5. Rename ic_check_48dp.xml to ic_check_48.xml,
   following the updated naming convention for icons.
6. Fix ButtonBar KDoc.
7. Fix FloatingOKButton KDoc.
8. Add FloatingOKButton contentDescription.
@westnordost westnordost merged commit 0eb13b2 into streetcomplete:master Apr 10, 2026
@riQQ riQQ added the iOS necessary for iOS port label Apr 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

iOS necessary for iOS port

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants