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:
@@ -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)}"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user