Merge main into feat-150-battery-deeplink
This commit is contained in:
@@ -120,6 +120,7 @@ fun MailboxScreen(
|
||||
val searchActive by viewModel.searchActive.collectAsStateWithLifecycle()
|
||||
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
|
||||
val isSyncingFolder by viewModel.isSyncingFolder.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val selectedIds by viewModel.selectedIds.collectAsStateWithLifecycle()
|
||||
val pendingConfirm by viewModel.pendingConfirm.collectAsStateWithLifecycle()
|
||||
@@ -330,8 +331,16 @@ fun MailboxScreen(
|
||||
}
|
||||
} else if (messages.isEmpty()) {
|
||||
item {
|
||||
// Hold the empty state back while the folder's initial background
|
||||
// sync is still in flight, so opening an uncached folder doesn't
|
||||
// flash "No messages to display" before the fetch has a chance to
|
||||
// populate anything (issue #149).
|
||||
if (searchActive && searchQuery.isNotBlank()) {
|
||||
NoResultsState(Modifier.fillParentMaxSize())
|
||||
} else if (isSyncingFolder) {
|
||||
Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
NoMessagesState(Modifier.fillParentMaxSize())
|
||||
}
|
||||
|
||||
@@ -186,6 +186,22 @@ class MailboxViewModel @Inject constructor(
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
/**
|
||||
* True while [selectFolder]'s background sync for the currently selected folder is in flight,
|
||||
* so the screen can hold the empty state back until the initial fetch either populates the
|
||||
* cache or confirms the folder really is empty (issue #149).
|
||||
*/
|
||||
private val _isSyncingFolder = MutableStateFlow(false)
|
||||
val isSyncingFolder: StateFlow<Boolean> = _isSyncingFolder.asStateFlow()
|
||||
|
||||
/**
|
||||
* (accountId, folder) of the most recent [selectFolder] call. Used only so a completing sync
|
||||
* can tell whether it's stale — superseded by a newer folder selection — before clearing
|
||||
* [_isSyncingFolder], so rapid folder switching can't let a stale sync hide the spinner for
|
||||
* whichever folder is actually selected now.
|
||||
*/
|
||||
private var latestFolderSelection: Pair<String, String>? = null
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
@@ -365,7 +381,18 @@ class MailboxViewModel @Inject constructor(
|
||||
_selectedAccountId.value = accountId
|
||||
explicitDrawerAccountId.value = accountId
|
||||
_selectedFolder.value = folderFullName
|
||||
viewModelScope.launch { mailSyncer.syncFolder(accountId, folderFullName) }
|
||||
val key = accountId to folderFullName
|
||||
latestFolderSelection = key
|
||||
_isSyncingFolder.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
mailSyncer.syncFolder(accountId, folderFullName)
|
||||
} finally {
|
||||
// Only clear if this is still the most recent selection — a stale sync superseded
|
||||
// by another selectFolder() must not clobber the newer one's in-flight spinner.
|
||||
if (latestFolderSelection == key) _isSyncingFolder.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns to the unified inbox across all accounts. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -108,6 +109,95 @@ class MailboxViewModelTest {
|
||||
coVerify { syncer.syncFolder("imap:a", "Archive") }
|
||||
}
|
||||
|
||||
// Issue #149: opening a per-account folder with no cached messages yet must show a spinner
|
||||
// (via isSyncingFolder) rather than "No messages to display" until the background sync settles.
|
||||
@Test
|
||||
fun `isSyncingFolder is true while the initial folder sync is in flight, then clears`() = runTest(testDispatcher) {
|
||||
val syncer = mockk<MailSyncer>()
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { syncer.syncFolder("imap:a", "Archive") } coAnswers {
|
||||
gate.await()
|
||||
Result.success(0)
|
||||
}
|
||||
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
|
||||
backgroundScope.launch { vm.messages.collect {} }
|
||||
|
||||
vm.isSyncingFolder.test {
|
||||
assertEquals(false, awaitItem())
|
||||
|
||||
vm.selectFolder("imap:a", "Archive")
|
||||
assertEquals(true, awaitItem())
|
||||
|
||||
gate.complete(Unit)
|
||||
assertEquals(false, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// A failed sync must still clear the flag (try/finally), so a folder that errors out doesn't
|
||||
// spin forever. Syncer.syncFolder reports failure via Result.failure (see refresh()'s
|
||||
// onFailure handling below), not a thrown exception, so the stub mirrors that contract.
|
||||
@Test
|
||||
fun `isSyncingFolder clears even when the sync fails`() = runTest(testDispatcher) {
|
||||
val syncer = mockk<MailSyncer>()
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
coEvery { syncer.syncFolder("imap:a", "Archive") } coAnswers {
|
||||
gate.await()
|
||||
Result.failure(IllegalStateException("boom"))
|
||||
}
|
||||
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
|
||||
backgroundScope.launch { vm.messages.collect {} }
|
||||
|
||||
vm.isSyncingFolder.test {
|
||||
assertEquals(false, awaitItem())
|
||||
|
||||
vm.selectFolder("imap:a", "Archive")
|
||||
assertEquals(true, awaitItem())
|
||||
|
||||
gate.complete(Unit)
|
||||
assertEquals(false, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #149: switching folders before the previous folder's sync resolves must not let that
|
||||
// stale completion clear the spinner for the folder actually selected now.
|
||||
@Test
|
||||
fun `rapidly switching folders keeps the spinner for the folder actually selected`() = runTest(testDispatcher) {
|
||||
val syncer = mockk<MailSyncer>()
|
||||
val archiveGate = CompletableDeferred<Unit>()
|
||||
val sentGate = CompletableDeferred<Unit>()
|
||||
coEvery { syncer.syncFolder("imap:a", "Archive") } coAnswers {
|
||||
archiveGate.await()
|
||||
Result.success(0)
|
||||
}
|
||||
coEvery { syncer.syncFolder("imap:a", "Sent") } coAnswers {
|
||||
sentGate.await()
|
||||
Result.success(0)
|
||||
}
|
||||
val vm = createViewModel(accounts = listOf(alice), messages = emptyList(), syncer = syncer)
|
||||
backgroundScope.launch { vm.messages.collect {} }
|
||||
|
||||
vm.isSyncingFolder.test {
|
||||
assertEquals(false, awaitItem())
|
||||
|
||||
vm.selectFolder("imap:a", "Archive")
|
||||
assertEquals(true, awaitItem())
|
||||
|
||||
// Switch away before Archive's sync resolves; still syncing (now Sent's own fetch).
|
||||
vm.selectFolder("imap:a", "Sent")
|
||||
expectNoEvents()
|
||||
assertEquals(true, vm.isSyncingFolder.value)
|
||||
|
||||
// Archive's now-stale sync finishing must not clear the flag out from under Sent.
|
||||
archiveGate.complete(Unit)
|
||||
expectNoEvents()
|
||||
assertEquals(true, vm.isSyncingFolder.value)
|
||||
|
||||
// Only Sent's own completion (the folder actually selected now) clears it.
|
||||
sentGate.complete(Unit)
|
||||
assertEquals(false, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `folders expose the drawer account's folders in order`() = runTest(testDispatcher) {
|
||||
val vm = createViewModel(
|
||||
|
||||
Reference in New Issue
Block a user