From c5522b1197682244943af5d1b09c129546761b7d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 19 Sep 2026 20:13:25 -0400 Subject: [PATCH] fix(groups): keep the picture on the form through the success hold Creating a public group with a picture showed the tile crossfade back to the empty camera icon a beat before the flow left for the chat, so the pick read as dropped at the moment it was accepted. The create's success path called discardPendingImage(), which deletes the re-encoded cache file and dispatches OnImageCleared. Navigation does not happen on that frame: dispatchSuccessThen holds the checkmark for 500ms before firing ChatCreated, and the form is on screen for all of it with its image state already reset. PhotoSelectionViewModel, which this view model's own documentation says it mirrors, leaves the pick alone on success and lets onCleared() delete the file. CreateGroupViewModel had that same onCleared() override as well as the inline call, so dropping the inline call is enough. The shared view model clears when the flow entry pops, and leaving state.image populated is what lets that cleanup find the uri. The failure paths that should drop the picture are untouched, since there the form stays on screen and the pick does have to go. --- .../tipping/internal/CreateGroupViewModel.kt | 7 +++- .../internal/CreateGroupViewModelErrorTest.kt | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index ff9fafea64..f9ad7c9b71 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -375,8 +375,11 @@ internal class CreateGroupViewModel @Inject constructor( ).onSuccess { chat -> // The chat exists, so the key has done its job — a later Create is a second group. attempt.clear() - // The cached local copy is redundant now that the blob is the chat's picture. - discardPendingImage() + // The cached local copy is redundant now that the blob is the chat's picture, but the + // form is still on screen for the length of the hold below and its tile is still + // drawing from that file. Discarding it here crossfaded the picture back to the empty + // camera tile a beat before the flow left for the chat, so the pick read as dropped at + // the moment it was accepted. `onCleared` deletes it once the flow entry has popped. dispatchSuccessThen(Event.UpdateProcessingState(success = true)) { dispatchEvent(Event.ChatCreated(chat)) dispatchEvent(Event.UpdateProcessingState()) diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt index da55e51811..aaa69ce648 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt @@ -28,6 +28,7 @@ import com.getcode.solana.keys.Mint import com.getcode.solana.keys.PublicKey import com.getcode.util.resources.ContentReader import com.getcode.util.resources.FakeResourceHelper +import com.getcode.view.SuccessHoldDuration import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -36,6 +37,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.After @@ -308,4 +310,43 @@ class CreateGroupViewModelErrorTest { assertEquals(2, keys.size) assertTrue(!keys[0].bytes.contentEquals(keys[1].bytes)) } + + /** + * The checkmark is held for half a second before the flow leaves for the chat, and the form is + * on screen for all of it. The create's own cache cleanup used to run at the top of that hold, + * which crossfaded the picture back to the empty camera tile a beat before the screen went + * away — the pick reading as dropped at the moment it was accepted. + * + * So the local copy outlives the screen that shows it, exactly as the photo-selection step + * leaves its own pick to `onCleared`: the flow entry pops, the shared view model clears, and + * the file goes with it. + */ + @Test + fun `a created group keeps its picture until the form is gone`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.completeDraft() + + val uri = mockk(relaxed = true) + every { contentReader.readBytes(uri) } returns ByteArray(8) + coEvery { blobStorage.upload(any(), any()) } returns + Result.success(BlobId(ByteArray(32) { 5 })) + vm.dispatchEvent(CreateGroupViewModel.Event.OnImageCached(uri, "image/jpeg")) + coEvery { chatCoordinator.create(any(), any()) } returns Result.success(created()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceTimeBy(SuccessHoldDuration / 2) + + // Mid-hold: the button is showing success and the picture is still under it. + assertTrue(vm.stateFlow.value.processingState.success) + assertEquals(uri, vm.stateFlow.value.image.dataOrNull) + // And the file it points at is still there — it is what the tile is drawing from. + coVerify(exactly = 0) { contentReader.removeFromCache(uri) } + + advanceUntilIdle() + + assertEquals(created(), vm.stateFlow.value.created) + assertEquals(uri, vm.stateFlow.value.image.dataOrNull) + } }