mit einem Klick
migrate-screen
// Migrate an Android screen from MVP (Fragment/Presenter/ViewBinder) to Compose + ViewModel with UDF. Triggers on "migrate screen", "migrate X to compose", "rewrite X screen", "convert X to compose".
// Migrate an Android screen from MVP (Fragment/Presenter/ViewBinder) to Compose + ViewModel with UDF. Triggers on "migrate screen", "migrate X to compose", "rewrite X screen", "convert X to compose".
Deploy the Android app to the Play Store via GitHub Actions. Runs pre-flight checks, creates a version tag, and pushes to trigger the deploy pipeline.
Build the debug APK, install it on a connected device/emulator via ADB, and launch the app.
| name | migrate-screen |
| description | Migrate an Android screen from MVP (Fragment/Presenter/ViewBinder) to Compose + ViewModel with UDF. Triggers on "migrate screen", "migrate X to compose", "rewrite X screen", "convert X to compose". |
| user_invocable | true |
Orchestrates the full migration of an MVP screen to Compose + ViewModel with unidirectional data flow. Follows the established architecture in docs/architecture/compose-viewmodel-udf.md and test strategy in docs/architecture/testing-strategy.md.
The user provides a screen name (e.g., "album detail", "genre detail", "playlist detail"). If ambiguous, ask which screen they mean.
Read these docs — they are the source of truth:
docs/architecture/compose-viewmodel-udf.md — all 14 UDF principlesdocs/architecture/testing-strategy.md — test approach, fakes, robotsCLAUDE.md — build commands, code style, project structureCheck if a per-screen migration prompt exists in docs/architecture/prompts/. If it does, read it for screen-specific guidance — but this skill's process takes precedence over the prompt's structure.
These are non-negotiable. Violating any of these is a bug in the migration:
mockk — use fakes at system boundariesAndroidViewModel — no Context, Application, or Android resources in ViewModels@Stable / @Immutable — Kotlin 2.x strong skipping handles thiscombine().stateIn() — never a separate MutableStateFlow<UiState> to emit intocollectAsStateWithLifecycle() — never collectAsState()UiEvent sealed interface, Fragment resolves stringsWhileSubscribed(5_000) — not Eagerly, not LazilyLoadingState enum inside, not a sealed interfaceGoal: Understand the old screen completely before touching any code. Produce a written spec and reference screenshots.
Search for the screen's MVP classes:
grep -r "class <Screen>Fragment" or glob **/<Screen>Fragment.kt**/<Screen>Presenter.kt (includes the Contract interface)*Binder classesfragment_<screen>.xml or similarRead every file found above. Document:
| What | Details |
|---|---|
| States | Loading, scanning, empty, ready — and what triggers each |
| Data displayed | Per-item fields (name, subtitle, artwork, counts, durations) |
| Layout modes | List only, or grid/list toggle? |
| User interactions | Click, long click, swipe, drag-to-reorder |
| Context menu items | Every overflow/popup menu item and what it does |
| Toolbar menu items | Options menu items |
| Multi-select | Yes/no, and what bulk operations are available |
| Navigation | Where clicks navigate to, with what arguments |
| Dialogs | Confirmation dialogs, edit dialogs, pickers |
| Sort order | Options, persistence mechanism, default |
| Dependencies | Repositories, managers, preferences the Presenter injects |
Ensure a device or emulator is available:
# Check for connected device
adb devices | grep -w "device"
If no device is found, start an emulator:
# List available AVDs
emulator -list-avds
# Start the first available AVD in background
emulator @<avd-name> -no-window &
# Wait for boot
adb wait-for-device && adb shell getprop sys.boot_completed | grep -q 1
Build and install the current app:
./gradlew :android:app:assembleDebug && ./gradlew :android:app:installDebug
Launch the app and navigate to the screen. Take screenshots of every state you can reach:
Save screenshots to docs/migration-specs/<screen>/before/ with descriptive names.
Write the analysis to docs/migration-specs/<screen>-spec.md. This is the source of truth for all subsequent phases.
Commit: "Add migration spec for "
Goal: Identify dependency gaps before writing tests. Extract interfaces and create fakes where needed.
From the Presenter's constructor and the spec, list every dependency the new ViewModel will need. For each one:
| Dependency | Is it an interface? | Fake exists? | Action needed |
|---|---|---|---|
e.g. AlbumRepository | Yes | FakeAlbumRepository | None |
e.g. GeneralPreferenceManager | No (concrete) | No | Extract <Screen>Preferences interface |
Before creating anything new, check what already exists:
Existing fakes (android/app/src/test/java/com/simplecityapps/fakes/):
FakeSongRepository, FakeGenreRepository, FakeAlbumRepository, FakeAlbumArtistRepository, FakePlaylistRepositoryFakeSongImportStateProviderFakeSortPreferencesFakePlaybackManager, FakeQueueManagerFakeArtistListPreferences, FakeAlbumListPreferencesExisting interfaces:
PlaybackOperations, QueueOperations (playback module)SongImportStateProvider (mediaprovider core)SortPreferences, ArtistListPreferences, AlbumListPreferencesShared composables (ui/common/components/):
SelectionMark — selection overlayFastScroller / FastScrollableState — fast scroll for LazyList and LazyGridLoadingStatusIndicator, LinearProgressIndicatorWithTextIf the ViewModel will depend on a concrete class that has no interface:
ArtistListPreferences / FakeArtistListPreferencesDo NOT extract interfaces for:
PlaybackManager / QueueManager — these already have PlaybackOperations / QueueOperationsLook at already-migrated screens for UI patterns this screen shares:
SongListItem, AlbumListItemAlbumGridItem, AlbumArtistGridItemHeroImage composable)SectionHeader composable exists or should be createdPrinciple: Only extract a shared component if it's already duplicated across 2+ screens. Don't pre-extract on speculation. If this is the first screen with a pattern, implement it inline. If it's the second, consider extraction. If it's the third, extract.
Review the old Presenter's action methods. For each one, decide whether to keep it inline in the ViewModel or extract it into a use case class (see compose-viewmodel-udf.md principle #8a).
Extract when:
Leave inline when:
playbackManager.addToQueue(songs) + emit eventrepository.setExcluded(songs, true)Check for existing use cases first. As migrations progress, use cases accumulate. Common ones:
PlaySongs — set queue, load, play, handle errors (shared across most screens)ShuffleSongs — shuffle, load, play (shared across most screens)If a use case already exists for the action, the ViewModel just injects and calls it. If it doesn't, create it in the same package as the ViewModel. If a second ViewModel needs it later, promote it to a shared package.
Use case shape:
class PlaySongs @Inject constructor(
private val queueManager: QueueOperations,
private val playbackManager: PlaybackOperations,
) {
sealed interface Result {
data object Success : Result
data class Failure(val message: String?) : Result
}
suspend operator fun invoke(songs: List<Song>, position: Int = 0): Result { ... }
}
operator fun invoke — suspend for one-shot, Flow for observableResult sealed interface for operations that can failPlaySongs, ShuffleAlbums, ResolveSongsForAlbumCommit (if any interfaces or use cases were extracted): "Extract <Interface/UseCase> for "
Goal: Define the screen's contract and write failing tests that specify the migration target.
Create <Screen>UiState.kt:
data class <Screen>UiState(
// Fields derived from the spec
val loadingState: LoadingState = LoadingState.Loading,
) {
enum class LoadingState { Loading, Scanning, Ready, Empty }
// Only include Scanning if the screen depends on media import
}
Create the UiEvent sealed interface for one-off effects (toasts, navigation triggers, dialogs).
Create <Screen>.kt with the composable function signature, accepting UiState and individual callback lambdas. Leave the body as Box {} or similar placeholder.
Create <Screen>Robot.kt following the pattern in SongListRobot.kt or GenreListRobot.kt:
setContent(uiState) — renders composable with callback capturesassertTextDisplayed(), assertTextNotDisplayed()openContextMenu(), clickText(), clickMenuItem()Create <Screen>Scenarios.kt with top-level factory functions:
ready<Screen>(...) — ready state with sensible defaultsempty<Screen>() — empty stateloading<Screen> — loading val (no parameters)scanning<Screen>(progress) — only if screen shows scan progressCreate <Screen>Test.kt with test cases derived from the spec:
If creationFunctions.kt doesn't have a factory for this screen's model type, add one with sensible defaults.
Run tests — they should all fail (red):
./gradlew :android:app:testDebugUnitTest --tests "com.simplecityapps.shuttle.ui.screens.<package>.<Screen>*"
Commit: "Add Compose UI tests (red — not yet implemented)"
Goal: Make the tests pass, then wire everything up.
Build the UI to make the characterisation tests pass. Follow existing patterns:
LazyColumn with FastScrollerLazyVerticalGrid with FastScroller via rememberFastScrollableState(gridState)LazyColumn with hero image as first itemSelectionMark from shared componentskotlinx.collections.immutable.toImmutableList() at call sitesRun tests after implementation:
./gradlew :android:app:testDebugUnitTest --tests "com.simplecityapps.shuttle.ui.screens.<package>.<Screen>*"
Commit: "Implement Composable"
Add setContentWithViewModel() to the Robot. Write integration tests that:
setContentWithViewModel(viewModel)These test the full combine().stateIn() chain through the UI.
Use cases first (identified in Phase 2e):
operator fun invokeThen the ViewModel:
combine().stateIn() for state derivation@HiltViewModel
class <Screen>ViewModel @Inject constructor(
private val repository: <Entity>Repository,
private val playSongs: PlaySongs, // use case
private val shuffleSongs: ShuffleSongs, // use case
sortPreferenceManager: SortPreferences,
mediaImportObserver: SongImportStateProvider,
) : ViewModel() {
// combine().stateIn() for state
// Action methods delegate to use cases
}
Run all tests:
./gradlew :android:app:testDebugUnitTest --tests "com.simplecityapps.shuttle.ui.screens.<package>.<Screen>*"
Commit: "Implement ViewModel"
The Fragment becomes a thin lifecycle host:
setContent {} with the ComposablecollectAsStateWithLifecycle() for UiStaterepeatOnLifecycle(STARTED) for eventsCommit: "Rewrite Fragment to use Compose + ViewModel"
Commit: "Remove old MVP classes for "
Goal: Confirm the migration is correct — functionally, visually, and architecturally.
# New tests
./gradlew :android:app:testDebugUnitTest --tests "com.simplecityapps.shuttle.ui.screens.<package>.<Screen>*"
# All existing tests still pass
./gradlew :android:app:testDebugUnitTest
# Build
./gradlew :android:app:assembleDebug
# Lint
support/scripts/lint
Install the new build on the device/emulator. Navigate to the migrated screen and take screenshots of the same states as Phase 1. Save to docs/migration-specs/<screen>/after/.
Compare before/after screenshots. Flag any visual differences:
Verify against the hard constraints listed at the top of this skill. Specifically check:
mockk in any test fileAndroidViewModel or Context in ViewModelcombine().stateIn() pattern in ViewModelcollectAsStateWithLifecycle() in FragmentReview the implementation for:
Do NOT refactor other screens or extract speculative abstractions.
Every migration produces this commit sequence:
Add migration spec for <Screen> (Phase 1)Extract <Interface> for <Screen> testability (Phase 2, only if needed)Add <Screen> Compose UI tests (red — not yet implemented) (Phase 3)Implement <Screen> Composable (Phase 4a)Implement <Screen>ViewModel (Phase 4b+4c)Rewrite <Screen>Fragment to use Compose + ViewModel (Phase 4d)Remove old MVP classes for <screen> (Phase 4e)Push directly to main after all verification passes.