Add drafts: save-for-later and resume

Composing now auto-saves a draft when you leave with anything entered, and sending
deletes it.

- New `drafts` Room table (entity + DAO + Draft model + MIGRATION_5_6, DB v6), with
  repository observe/get/save/delete.
- ComposeViewModel loads a draft by id (resume), saves/updates one on exit (or deletes
  it when emptied), and deletes it after sending; the screen closes via a finished event
  so the save completes before navigating away.
- New Drafts screen (list with per-row delete, resume on tap); the inbox shows a
  "Drafts (N)" entry when any exist. Compose gains a draft nav arg.
- assemble/test/lint green; verified on the Android 17 emulator — the v5->v6 migration
  kept existing mail, a backed-out compose saved a draft, the draft listed and reopened
  pre-filled, and sending it removed the draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 16:24:15 -05:00
co-authored by Claude Opus 4.8
parent 85ba9d9846
commit dc3147a138
20 changed files with 386 additions and 8 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ experience with power-user features tucked under an **Advanced Settings** group.
> rendered in a hardened WebView (JavaScript off, remote images blocked by default),
> with mark-read, star, and delete; and **composing** — a compose screen with device-
> contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued
> and retried), plus reply; **on-device new-mail
> and retried), plus reply and **drafts** saved for later; **on-device new-mail
> notifications** (no push service) with persisted settings; **instant push** via a
> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a
> system viewer; **multiple accounts** — a unified inbox with per-account filtering; and
@@ -6,11 +6,13 @@ import androidx.room.RoomDatabase
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.CredentialEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.OutboxEntity
@@ -21,8 +23,9 @@ import org.libremail.data.local.entity.OutboxEntity
CredentialEntity::class,
AttachmentEntity::class,
OutboxEntity::class,
DraftEntity::class,
],
version = 5,
version = 6,
exportSchema = false,
)
abstract class LibreMailDatabase : RoomDatabase() {
@@ -31,4 +34,5 @@ abstract class LibreMailDatabase : RoomDatabase() {
abstract fun credentialDao(): CredentialDao
abstract fun attachmentDao(): AttachmentDao
abstract fun outboxDao(): OutboxDao
abstract fun draftDao(): DraftDao
}
@@ -3,10 +3,12 @@ package org.libremail.data.local
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.domain.model.Account
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
@@ -98,5 +100,25 @@ internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = Atta
sizeBytes = sizeBytes,
)
internal fun DraftEntity.toDomain(): Draft = Draft(
id = id,
accountId = accountId,
to = toAddresses,
cc = ccAddresses,
subject = subject,
body = body,
updatedAt = updatedAt,
)
internal fun Draft.toEntity(): DraftEntity = DraftEntity(
id = id,
accountId = accountId,
toAddresses = to,
ccAddresses = cc,
subject = subject,
body = body,
updatedAt = updatedAt,
)
private fun String.toMailSecurity(): MailSecurity =
runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS)
@@ -37,3 +37,15 @@ val MIGRATION_4_5 = object : Migration(4, 5) {
)
}
}
/** v5 -> v6: add the drafts table for saved unsent mail (preserves existing data). */
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `drafts` (" +
"`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, " +
"`ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, " +
"`updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
)
}
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.DraftEntity
@Dao
interface DraftDao {
@Query("SELECT * FROM drafts ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<DraftEntity>>
@Query("SELECT COUNT(*) FROM drafts")
fun observeCount(): Flow<Int>
@Query("SELECT * FROM drafts WHERE id = :id LIMIT 1")
suspend fun getById(id: String): DraftEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(draft: DraftEntity)
@Query("DELETE FROM drafts WHERE id = :id")
suspend fun delete(id: String)
}
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/** A composed-but-unsent message saved for later editing. */
@Entity(tableName = "drafts")
data class DraftEntity(
@PrimaryKey val id: String,
val accountId: String?,
val toAddresses: String,
val ccAddresses: String,
val subject: String,
val body: String,
val updatedAt: Long,
)
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.OutboxEntity
@@ -21,6 +22,7 @@ import org.libremail.data.sync.MailConnectionFactory
import org.libremail.data.sync.SendScheduler
import org.libremail.domain.model.Account
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.MailRepository
@@ -34,6 +36,7 @@ class MailRepositoryImpl @Inject constructor(
private val accountDao: AccountDao,
private val attachmentDao: AttachmentDao,
private val outboxDao: OutboxDao,
private val draftDao: DraftDao,
private val imapClient: ImapClient,
private val connectionFactory: MailConnectionFactory,
private val sendScheduler: SendScheduler,
@@ -104,6 +107,15 @@ class MailRepositoryImpl @Inject constructor(
sendScheduler.sendNow()
}
override fun observeDrafts(): Flow<List<Draft>> =
draftDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override suspend fun getDraft(id: String): Draft? = draftDao.getById(id)?.toDomain()
override suspend fun saveDraft(draft: Draft) = draftDao.upsert(draft.toEntity())
override suspend fun deleteDraft(id: String) = draftDao.delete(id)
/** Writes downloaded bytes to a private cache file that the FileProvider can share. */
private fun saveToCache(attachment: DownloadedAttachment): File {
val dir = File(context.cacheDir, "attachments").apply { mkdirs() }
@@ -13,9 +13,11 @@ import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.MIGRATION_2_3
import org.libremail.data.local.MIGRATION_3_4
import org.libremail.data.local.MIGRATION_4_5
import org.libremail.data.local.MIGRATION_5_6
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
@@ -27,7 +29,7 @@ object DatabaseModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, "libremail.db")
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
// Safety net for unforeseen schema jumps during early development.
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -46,4 +48,7 @@ object DatabaseModule {
@Provides
fun provideOutboxDao(database: LibreMailDatabase): OutboxDao = database.outboxDao()
@Provides
fun provideDraftDao(database: LibreMailDatabase): DraftDao = database.draftDao()
}
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
data class Draft(
val id: String,
val accountId: String?,
val to: String,
val cc: String,
val subject: String,
val body: String,
val updatedAt: Long,
)
@@ -4,6 +4,7 @@ package org.libremail.domain.repository
import java.io.File
import kotlinx.coroutines.flow.Flow
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutgoingMessage
@@ -30,4 +31,10 @@ interface MailRepository {
suspend fun deleteMessage(id: String): Result<Unit>
suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit>
/** Drafts: composed-but-unsent messages saved for later. */
fun observeDrafts(): Flow<List<Draft>>
suspend fun getDraft(id: String): Draft?
suspend fun saveDraft(draft: Draft)
suspend fun deleteDraft(id: String)
}
@@ -22,6 +22,7 @@ import org.libremail.R
import org.libremail.ui.accountsetup.AccountSetupScreen
import org.libremail.ui.accountsetup.ManualSetupScreen
import org.libremail.ui.compose.ComposeScreen
import org.libremail.ui.drafts.DraftsScreen
import org.libremail.ui.mailbox.MailboxScreen
import org.libremail.ui.navigation.Routes
import org.libremail.ui.reader.ReaderScreen
@@ -45,6 +46,7 @@ fun LibreMailApp() {
MailboxScreen(
onOpenMessage = { id -> navController.navigate(Routes.reader(id)) },
onCompose = { navController.navigate(Routes.compose()) },
onOpenDrafts = { navController.navigate(Routes.DRAFTS) },
onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) },
onSelectTab = navController::navigateTab,
)
@@ -64,6 +66,7 @@ fun LibreMailApp() {
navArgument(Routes.COMPOSE_ARG_TO) { type = NavType.StringType; defaultValue = "" },
navArgument(Routes.COMPOSE_ARG_SUBJECT) { type = NavType.StringType; defaultValue = "" },
navArgument(Routes.COMPOSE_ARG_FROM) { type = NavType.StringType; defaultValue = "" },
navArgument(Routes.COMPOSE_ARG_DRAFT) { type = NavType.StringType; defaultValue = "" },
),
) {
ComposeScreen(onBack = navController::popBackStack)
@@ -95,6 +98,12 @@ fun LibreMailApp() {
},
)
}
composable(Routes.DRAFTS) {
DraftsScreen(
onBack = navController::popBackStack,
onOpenDraft = { id -> navController.navigate(Routes.composeDraft(id)) },
)
}
}
}
@@ -3,6 +3,7 @@ package org.libremail.ui.compose
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
@@ -52,6 +53,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collect
import org.libremail.R
import org.libremail.domain.model.Account
@@ -75,7 +77,8 @@ fun ComposeScreen(
PackageManager.PERMISSION_GRANTED
if (granted) viewModel.onContactsPermission(true) else permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
LaunchedEffect(state.sent) { if (state.sent) onBack() }
LaunchedEffect(Unit) { viewModel.finished.collect { onBack() } }
BackHandler { viewModel.onExit() }
LaunchedEffect(state.error) {
state.error?.let {
snackbarHostState.showSnackbar(it)
@@ -88,7 +91,7 @@ fun ComposeScreen(
TopAppBar(
title = { Text(stringResource(R.string.title_compose)) },
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = viewModel::onExit) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
@@ -5,18 +5,22 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.contacts.ContactSuggestion
import org.libremail.contacts.ContactsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.Draft
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
@@ -31,7 +35,6 @@ data class ComposeUiState(
val suggestions: List<ContactSuggestion> = emptyList(),
val contactsAllowed: Boolean = false,
val sending: Boolean = false,
val sent: Boolean = false,
val error: String? = null,
)
@@ -43,6 +46,9 @@ class ComposeViewModel @Inject constructor(
private val contactsRepository: ContactsRepository,
) : ViewModel() {
private val draftId: String? =
savedStateHandle.get<String>(Routes.COMPOSE_ARG_DRAFT)?.takeIf { it.isNotBlank() }
private val _state = MutableStateFlow(
ComposeUiState(
to = savedStateHandle.get<String>(Routes.COMPOSE_ARG_TO).orEmpty(),
@@ -55,8 +61,30 @@ class ComposeViewModel @Inject constructor(
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
/** Emitted when the screen should close — after the draft is saved/deleted, or after sending. */
private val _finished = Channel<Unit>(Channel.BUFFERED)
val finished = _finished.receiveAsFlow()
private var searchJob: Job? = null
init {
if (draftId != null) {
viewModelScope.launch {
mailRepository.getDraft(draftId)?.let { draft ->
_state.update {
it.copy(
to = draft.to,
cc = draft.cc,
subject = draft.subject,
body = draft.body,
fromAccountId = draft.accountId ?: it.fromAccountId,
)
}
}
}
}
}
fun onToChange(value: String) {
_state.update { it.copy(to = value) }
searchContacts(value)
@@ -89,6 +117,33 @@ class ComposeViewModel @Inject constructor(
}
}
/** Leaving the screen: keep a draft if there's anything worth keeping, then close. */
fun onExit() {
viewModelScope.launch {
saveOrDeleteDraft()
_finished.send(Unit)
}
}
private suspend fun saveOrDeleteDraft() {
val s = _state.value
val hasContent = s.to.isNotBlank() || s.cc.isNotBlank() || s.subject.isNotBlank() || s.body.isNotBlank()
when {
hasContent -> mailRepository.saveDraft(
Draft(
id = draftId ?: UUID.randomUUID().toString(),
accountId = s.fromAccountId,
to = s.to,
cc = s.cc,
subject = s.subject,
body = s.body,
updatedAt = System.currentTimeMillis(),
),
)
draftId != null -> mailRepository.deleteDraft(draftId) // an existing draft was emptied out
}
}
fun send() {
val s = _state.value
val account = accounts.value.firstOrNull { it.id == s.fromAccountId } ?: accounts.value.firstOrNull()
@@ -100,7 +155,11 @@ class ComposeViewModel @Inject constructor(
mailRepository.sendMessage(
OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body),
).fold(
onSuccess = { _state.update { it.copy(sending = false, sent = true) } },
onSuccess = {
draftId?.let { mailRepository.deleteDraft(it) }
_state.update { it.copy(sending = false) }
_finished.send(Unit)
},
onFailure = { e -> _state.update { it.copy(sending = false, error = e.message ?: "Could not send") } },
)
}
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.drafts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.domain.model.Draft
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DraftsScreen(
onBack: () -> Unit,
onOpenDraft: (String) -> Unit,
viewModel: DraftsViewModel = hiltViewModel(),
) {
val drafts by viewModel.drafts.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.drafts_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
)
},
) { padding ->
if (drafts.isEmpty()) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text(
stringResource(R.string.drafts_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(drafts, key = { it.id }) { draft ->
DraftRow(
draft = draft,
onClick = { onOpenDraft(draft.id) },
onDelete = { viewModel.deleteDraft(draft.id) },
)
HorizontalDivider()
}
}
}
}
}
@Composable
private fun DraftRow(draft: Draft, onClick: () -> Unit, onDelete: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(start = 16.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = draft.subject.ifBlank { stringResource(R.string.draft_no_subject) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = draft.to.ifBlank { stringResource(R.string.draft_no_recipient) },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (draft.body.isNotBlank()) {
Text(
text = draft.body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(Modifier.width(8.dp))
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.draft_delete))
}
}
}
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.drafts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.domain.model.Draft
import org.libremail.domain.repository.MailRepository
@HiltViewModel
class DraftsViewModel @Inject constructor(
private val repository: MailRepository,
) : ViewModel() {
val drafts: StateFlow<List<Draft>> = repository.observeDrafts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun deleteDraft(id: String) {
viewModelScope.launch { repository.deleteDraft(id) }
}
}
@@ -66,6 +66,7 @@ import org.libremail.domain.model.Message
fun MailboxScreen(
onOpenMessage: (String) -> Unit,
onCompose: () -> Unit,
onOpenDrafts: () -> Unit,
onAddAccount: () -> Unit,
onSelectTab: (org.libremail.ui.TopDest) -> Unit,
viewModel: MailboxViewModel = hiltViewModel(),
@@ -74,6 +75,7 @@ fun MailboxScreen(
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val selectedAccountId by viewModel.selectedAccountId.collectAsStateWithLifecycle()
val hasAccounts by viewModel.hasAccounts.collectAsStateWithLifecycle()
val draftCount by viewModel.draftCount.collectAsStateWithLifecycle()
val searchActive by viewModel.searchActive.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
@@ -144,6 +146,10 @@ fun MailboxScreen(
onSelect = viewModel::selectAccount,
)
}
if (draftCount > 0 && !searchActive) {
DraftsEntry(count = draftCount, onClick = onOpenDrafts)
HorizontalDivider()
}
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = viewModel::refresh,
@@ -200,6 +206,25 @@ private fun SearchField(query: String, onQueryChange: (String) -> Unit) {
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
@Composable
private fun DraftsEntry(count: Int, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Edit, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.drafts_count, count),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun AccountFilterRow(accounts: List<Account>, selectedId: String?, onSelect: (String?) -> Unit) {
Row(
@@ -52,6 +52,10 @@ class MailboxViewModel @Inject constructor(
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val draftCount: StateFlow<Int> = mailRepository.observeDrafts()
.map { it.size }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
@@ -9,6 +9,7 @@ object Routes {
const val SETTINGS = "settings"
const val ACCOUNT_SETUP = "account_setup"
const val MANUAL_SETUP = "manual_setup"
const val DRAFTS = "drafts"
const val READER_ARG_ID = "messageId"
const val READER_PATTERN = "reader/{$READER_ARG_ID}"
@@ -17,7 +18,10 @@ object Routes {
const val COMPOSE_ARG_TO = "to"
const val COMPOSE_ARG_SUBJECT = "subject"
const val COMPOSE_ARG_FROM = "from"
const val COMPOSE_PATTERN = "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}"
const val COMPOSE_ARG_DRAFT = "draft"
const val COMPOSE_PATTERN =
"compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}&from={$COMPOSE_ARG_FROM}&draft={$COMPOSE_ARG_DRAFT}"
fun compose(to: String = "", subject: String = "", from: String = ""): String =
"compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}&from=${Uri.encode(from)}"
fun composeDraft(draftId: String): String = "compose?draft=${Uri.encode(draftId)}"
}
+8
View File
@@ -41,6 +41,14 @@
<string name="compose_body">Message</string>
<string name="compose_no_account">Add an account before composing</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
<string name="drafts_empty">No drafts</string>
<string name="drafts_count">Drafts (%1$d)</string>
<string name="draft_no_subject">(no subject)</string>
<string name="draft_no_recipient">(no recipient)</string>
<string name="draft_delete">Delete draft</string>
<!-- Reader -->
<string name="reader_reply">Reply</string>
<string name="attachments_title">Attachments</string>
@@ -21,6 +21,7 @@ class MailRepositoryImplTest {
accountDao = mockk(),
attachmentDao = mockk(),
outboxDao = mockk(),
draftDao = mockk(),
imapClient = mockk(),
connectionFactory = mockk(),
sendScheduler = mockk(),