From dc3147a1386d67a8e39464aaf9f91b372bd53398 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 16:24:15 -0500 Subject: [PATCH] Add drafts: save-for-later and resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- .../libremail/data/local/LibreMailDatabase.kt | 6 +- .../org/libremail/data/local/Mappers.kt | 22 ++++ .../org/libremail/data/local/Migrations.kt | 12 ++ .../org/libremail/data/local/dao/DraftDao.kt | 27 ++++ .../data/local/entity/DraftEntity.kt | 17 +++ .../data/repository/MailRepositoryImpl.kt | 12 ++ .../kotlin/org/libremail/di/DatabaseModule.kt | 7 +- .../org/libremail/domain/model/Draft.kt | 12 ++ .../domain/repository/MailRepository.kt | 7 + .../kotlin/org/libremail/ui/LibreMailApp.kt | 9 ++ .../org/libremail/ui/compose/ComposeScreen.kt | 7 +- .../libremail/ui/compose/ComposeViewModel.kt | 63 ++++++++- .../org/libremail/ui/drafts/DraftsScreen.kt | 121 ++++++++++++++++++ .../libremail/ui/drafts/DraftsViewModel.kt | 26 ++++ .../org/libremail/ui/mailbox/MailboxScreen.kt | 25 ++++ .../libremail/ui/mailbox/MailboxViewModel.kt | 4 + .../org/libremail/ui/navigation/Routes.kt | 6 +- app/src/main/res/values/strings.xml | 8 ++ .../data/repository/MailRepositoryImplTest.kt | 1 + 20 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt create mode 100644 app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/Draft.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt diff --git a/README.md b/README.md index f1306eb..13a92d2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index cbd8de9..ab908ec 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -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 } diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index 7bddea3..8101c64 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -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) diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index 206c3a1..bf119df 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -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`))", + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt new file mode 100644 index 0000000..1ad234b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/DraftDao.kt @@ -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> + + @Query("SELECT COUNT(*) FROM drafts") + fun observeCount(): Flow + + @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) +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt new file mode 100644 index 0000000..c368a0b --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt @@ -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, +) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index 6bae420..f11673f 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -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> = + 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() } diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index cdc3ce4..b962769 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -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() } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt new file mode 100644 index 0000000..8592f65 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt @@ -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, +) diff --git a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt index 6bce7bf..42454f6 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -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 suspend fun sendMessage(outgoing: OutgoingMessage): Result + + /** Drafts: composed-but-unsent messages saved for later. */ + fun observeDrafts(): Flow> + suspend fun getDraft(id: String): Draft? + suspend fun saveDraft(draft: Draft) + suspend fun deleteDraft(id: String) } diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 8c9d7e0..ae7021d 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -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)) }, + ) + } } } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 06caf29..097d81a 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -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)) } }, diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 1f8e4c8..4f7942b 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -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 = 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(Routes.COMPOSE_ARG_DRAFT)?.takeIf { it.isNotBlank() } + private val _state = MutableStateFlow( ComposeUiState( to = savedStateHandle.get(Routes.COMPOSE_ARG_TO).orEmpty(), @@ -55,8 +61,30 @@ class ComposeViewModel @Inject constructor( val accounts: StateFlow> = 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(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") } }, ) } diff --git a/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt new file mode 100644 index 0000000..1d2ab62 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt new file mode 100644 index 0000000..1a8c30c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/drafts/DraftsViewModel.kt @@ -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> = repository.observeDrafts() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun deleteDraft(id: String) { + viewModelScope.launch { repository.deleteDraft(id) } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index 796f0bd..dc42bc0 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -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, selectedId: String?, onSelect: (String?) -> Unit) { Row( diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 0ecb837..6159882 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -52,6 +52,10 @@ class MailboxViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val draftCount: StateFlow = mailRepository.observeDrafts() + .map { it.size } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 7068ea7..151a34c 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -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)}" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9adc19..43526e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,14 @@ Message Add an account before composing + + Drafts + No drafts + Drafts (%1$d) + (no subject) + (no recipient) + Delete draft + Reply Attachments diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index ba52e6a..8f7abd5 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -21,6 +21,7 @@ class MailRepositoryImplTest { accountDao = mockk(), attachmentDao = mockk(), outboxDao = mockk(), + draftDao = mockk(), imapClient = mockk(), connectionFactory = mockk(), sendScheduler = mockk(),