diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6863e9f..a165f4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -133,6 +133,7 @@ dependencies { implementation(libs.markwon.core) implementation(libs.markwon.ext.strikethrough) implementation(libs.markwon.linkify) + implementation(libs.markwon.html) implementation(libs.paging.runtime) implementation(libs.paging.compose) diff --git a/app/src/main/java/ch/rhosys/email/MainActivity.kt b/app/src/main/java/ch/rhosys/email/MainActivity.kt index b02f058..d14cafa 100644 --- a/app/src/main/java/ch/rhosys/email/MainActivity.kt +++ b/app/src/main/java/ch/rhosys/email/MainActivity.kt @@ -3,6 +3,7 @@ package ch.rhosys.email import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -17,12 +18,22 @@ import ch.rhosys.email.presentation.auth.BiometricLockScreen import ch.rhosys.email.presentation.navigation.RootNavGraph import ch.rhosys.email.sync.SyncForegroundService import ch.rhosys.email.ui.theme.EmailTheme +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch class MainActivity : FragmentActivity() { + private var realtimeJob: Job? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Forced automatically on Android 15+ (targetSdk 35) but not on older + // OSes, which left decorFitsSystemWindows=true there while TopAppBar's + // own statusBars inset padding assumed edge-to-edge — the two together + // reserved the status bar's height twice, showing as a blank strip + // above the header on API < 35 devices. Calling this explicitly makes + // the behavior consistent everywhere the app's minSdk supports. + enableEdgeToEdge() val appContainer = (application as EmailApp).appContainer // The Authress redirect can arrive either as the intent that started the @@ -78,9 +89,21 @@ class MainActivity : FragmentActivity() { override fun onStart() { super.onStart() SyncForegroundService.start(this) + val appContainer = (application as EmailApp).appContainer + // Live updates only while foregrounded — no FCM/push service needed to + // get them; decision #29's fetch-on-open + pull-to-refresh still covers + // the backgrounded case. + realtimeJob = lifecycleScope.launch { + appContainer.accountRepository.activeAccountId().filterNotNull().collect { accountId -> + appContainer.realtimeClient.start(accountId) + } + } } override fun onStop() { + realtimeJob?.cancel() + realtimeJob = null + (application as EmailApp).appContainer.realtimeClient.stop() SyncForegroundService.stop(this) super.onStop() } diff --git a/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt b/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt index 03cdd60..df74100 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt @@ -36,7 +36,7 @@ import ch.rhosys.email.data.local.entity.ViewEntity LabelEntity::class, RuleEntity::class, TemplateEntity::class, ViewEntity::class, LogEntryEntity::class, ], - version = 3, + version = 4, exportSchema = true, ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt b/app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt index ffecdf3..61d995e 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt @@ -18,9 +18,17 @@ interface ThreadDao { @Query("SELECT * FROM threads WHERE accountId = :accountId AND status = :status ORDER BY lastSignalAt DESC") fun pagingSource(accountId: String, status: String): PagingSource + /** Backs the "All" inbox tab — every thread for the account, regardless of status. */ + @Query("SELECT * FROM threads WHERE accountId = :accountId ORDER BY lastSignalAt DESC") + fun pagingSourceAll(accountId: String): PagingSource + @Query("SELECT * FROM threads WHERE accountId = :accountId AND status = :status ORDER BY lastSignalAt DESC") fun observeByStatus(accountId: String, status: String): Flow> + /** Backs the Inbox badge in the nav drawer. */ + @Query("SELECT COUNT(*) FROM threads WHERE accountId = :accountId AND status = :status") + fun observeCountByStatus(accountId: String, status: String): Flow + @Query( "SELECT * FROM threads WHERE accountId = :accountId AND status = :status " + "AND labels LIKE '%' || :label || '%' ORDER BY lastSignalAt DESC", @@ -66,4 +74,11 @@ interface ThreadDao { @Query("DELETE FROM threads WHERE accountId = :accountId") suspend fun clearAccount(accountId: String) + + /** + * Scoped clear used ahead of a status-filtered refresh, so refreshing one + * tab (e.g. Archived) doesn't wipe another tab's (e.g. Active) cached rows. + */ + @Query("DELETE FROM threads WHERE accountId = :accountId AND status = :status") + suspend fun clearAccountStatus(accountId: String, status: String) } diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/OtherEntities.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/OtherEntities.kt index 8262f9f..601f7c9 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/entity/OtherEntities.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/OtherEntities.kt @@ -25,6 +25,7 @@ data class LabelEntity( val name: String, val color: String?, val icon: String?, + val applyInstruction: String, val createdAt: Long?, ) @@ -71,6 +72,7 @@ fun LabelEntity.toDomain() = Label( name = name, color = color, icon = icon, + applyInstruction = applyInstruction, createdAt = createdAt?.let(Instant::ofEpochMilli), ) @@ -80,6 +82,7 @@ fun Label.toEntity() = LabelEntity( name = name, color = color, icon = icon, + applyInstruction = applyInstruction, createdAt = createdAt?.toEpochMilli(), ) diff --git a/app/src/main/java/ch/rhosys/email/data/realtime/RealtimeClient.kt b/app/src/main/java/ch/rhosys/email/data/realtime/RealtimeClient.kt new file mode 100644 index 0000000..dcdbf92 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/realtime/RealtimeClient.kt @@ -0,0 +1,135 @@ +package ch.rhosys.email.data.realtime + +import ch.rhosys.email.data.auth.AuthressLoginClient +import ch.rhosys.email.data.log.AppLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONObject +import java.net.URLEncoder + +/** + * Live thread updates over a WebSocket, mirroring the web app's SharedWorker + * (`workers/realtime.shared.ts`): connect to `?token=&accountId=`, + * ping every 25s to keep the connection alive, reconnect with exponential + * backoff on drop. This works entirely while the app is foregrounded — no + * push notification service (FCM) is required to get live updates. + * + * The only event the server emits is `thread:updated`; everything else + * (rules, labels, archived status) stays fetch-on-navigation, same as web. + */ +class RealtimeClient( + private val wsBaseUrl: String, + private val httpClient: OkHttpClient, + private val authManager: AuthressLoginClient, + private val logger: AppLogger, + private val onThreadUpdated: suspend (accountId: String, threadId: String) -> Unit, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var connectJob: Job? = null + private var pingJob: Job? = null + private var webSocket: WebSocket? = null + private var reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS + private var currentAccountId: String? = null + private var stopped = true + + /** Idempotent: switching accounts closes the old socket and reconnects under the new one. */ + fun start(accountId: String) { + if (!stopped && currentAccountId == accountId && webSocket != null) return + stopped = false + val accountChanged = currentAccountId != accountId + currentAccountId = accountId + if (accountChanged) { + webSocket?.close(NORMAL_CLOSURE, "switching account") + webSocket = null + } + reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS + connect() + } + + fun stop() { + stopped = true + connectJob?.cancel() + pingJob?.cancel() + webSocket?.close(NORMAL_CLOSURE, "app backgrounded") + webSocket = null + } + + private fun connect() { + val accountId = currentAccountId ?: return + connectJob?.cancel() + connectJob = scope.launch { + val token = runCatching { authManager.waitForToken() }.getOrNull().orEmpty() + if (stopped) return@launch + val url = "$wsBaseUrl?token=${URLEncoder.encode(token, "UTF-8")}&accountId=$accountId" + val request = Request.Builder().url(url).build() + webSocket = httpClient.newWebSocket(request, listener) + } + } + + private fun schedulePing() { + pingJob?.cancel() + pingJob = scope.launch { + while (isActive) { + delay(PING_INTERVAL_MS) + webSocket?.send("""{"type":"ping"}""") + } + } + } + + private fun scheduleReconnect() { + if (stopped) return + scope.launch { + delay(reconnectDelayMs) + reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_DELAY_MS) + if (!stopped) connect() + } + } + + private val listener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) { + reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS + logger.info("Realtime", "connected") + schedulePing() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val accountId = currentAccountId ?: return + val json = runCatching { JSONObject(text) }.getOrNull() ?: return + when (json.optString("type")) { + "thread:updated" -> { + val threadId = json.optString("threadId").takeIf { it.isNotBlank() } ?: return + scope.launch { runCatching { onThreadUpdated(accountId, threadId) } } + } + // "connected" (handshake ack) and "pong" need no action. + } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + pingJob?.cancel() + logger.info("Realtime", "closed: code=$code reason=$reason") + scheduleReconnect() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { + pingJob?.cancel() + logger.warn("Realtime", "connection failed", t) + scheduleReconnect() + } + } + + private companion object { + const val PING_INTERVAL_MS = 25_000L + const val INITIAL_RECONNECT_DELAY_MS = 1_000L + const val MAX_RECONNECT_DELAY_MS = 30_000L + const val NORMAL_CLOSURE = 1000 + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/remote/api/EmailApiService.kt b/app/src/main/java/ch/rhosys/email/data/remote/api/EmailApiService.kt index f9b7b8e..b124e84 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/api/EmailApiService.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/api/EmailApiService.kt @@ -25,7 +25,10 @@ import ch.rhosys.email.data.remote.dto.PatchLabelRequest import ch.rhosys.email.data.remote.dto.PatchRuleRequest import ch.rhosys.email.data.remote.dto.PatchSignalRequest import ch.rhosys.email.data.remote.dto.PatchThreadRequest +import ch.rhosys.email.data.remote.dto.PatchResourceRequest import ch.rhosys.email.data.remote.dto.QuarantineResponseRequest +import ch.rhosys.email.data.remote.dto.ResourceDto +import ch.rhosys.email.data.remote.dto.ResourceListResponse import ch.rhosys.email.data.remote.dto.RuleDto import ch.rhosys.email.data.remote.dto.RuleListResponse import ch.rhosys.email.data.remote.dto.SetAliasSenderRequest @@ -154,6 +157,23 @@ interface EmailApiService { @Path("threadId") threadId: String, ): UnsubscribeResultDto + // ── Resources ─────────────────────────────────────────────────────────── + + @GET("accounts/{accountId}/resources") + suspend fun getResources( + @Path("accountId") accountId: String, + @Query("status") status: String? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): ResourceListResponse + + @PATCH("accounts/{accountId}/resources/{resourceId}") + suspend fun patchResource( + @Path("accountId") accountId: String, + @Path("resourceId") resourceId: String, + @Body body: PatchResourceRequest, + ): ResourceDto + // ── Signals ───────────────────────────────────────────────────────────── @GET("accounts/{accountId}/threads/{threadId}/signals") diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt index 978f561..6e3c02e 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt @@ -7,6 +7,9 @@ import ch.rhosys.email.domain.model.Attachment import ch.rhosys.email.domain.model.EmailAddress import ch.rhosys.email.domain.model.Label import ch.rhosys.email.domain.model.MailThread +import ch.rhosys.email.domain.model.Resource +import ch.rhosys.email.domain.model.ResourceAsset +import ch.rhosys.email.domain.model.ResourceStatus import ch.rhosys.email.domain.model.Rule import ch.rhosys.email.domain.model.RuleAction import ch.rhosys.email.domain.model.RuleActionType @@ -140,6 +143,7 @@ internal fun LabelDto.toDomain(accountId: String) = Label( name = name, color = color, icon = icon, + applyInstruction = applyInstruction, createdAt = createdAt.toInstantOrNull(), ) @@ -176,3 +180,25 @@ internal fun AliasSenderDto.toDomain() = AliasSender( sender = sender, policy = SenderPolicy.fromWire(policy), ) + +internal fun ResourceAssetDto.toDomain() = ResourceAsset( + type = type, + label = label, + rawValue = rawValue, + sourceSignalId = sourceSignalId, + url = url, + extractedAt = extractedAt.toInstantOrNull(), +) + +internal fun ResourceDto.toDomain() = Resource( + resourceId = resourceId, + threadId = threadId, + workflow = Workflow.fromWire(workflow), + status = ResourceStatus.fromWire(status), + expectedResolutionDate = expectedResolutionDate.toInstantOrNull(), + displayDate = displayDate, + resolvedAt = resolvedAt.toInstantOrNull(), + assets = assets.map { it.toDomain() }, + createdAt = createdAt.toInstantOrNull(), + updatedAt = updatedAt.toInstantOrNull(), +) diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/OtherDtos.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/OtherDtos.kt index 01cb1e7..b38ca75 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/dto/OtherDtos.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/OtherDtos.kt @@ -11,6 +11,7 @@ data class LabelDto( val name: String, val color: String? = null, val icon: String? = null, + val applyInstruction: String = "", val createdAt: String, ) @@ -22,6 +23,7 @@ data class LabelListResponse( @JsonClass(generateAdapter = true) data class CreateLabelRequest( val name: String, + val applyInstruction: String, val color: String? = null, val icon: String? = null, ) @@ -29,6 +31,7 @@ data class CreateLabelRequest( @JsonClass(generateAdapter = true) data class PatchLabelRequest( val name: String? = null, + val applyInstruction: String? = null, val color: String? = null, val icon: String? = null, ) diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/ResourceDtos.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/ResourceDtos.kt new file mode 100644 index 0000000..e9991d0 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/ResourceDtos.kt @@ -0,0 +1,44 @@ +package ch.rhosys.email.data.remote.dto + +import com.squareup.moshi.JsonClass + +/** + * Resources are workflow artifacts extracted from a thread (a package tracking + * number, a boarding pass QR code, an invoice) that the backend surfaces + * separately from the thread itself. See ResourceView in the web app. + */ +@JsonClass(generateAdapter = true) +data class ResourceAssetDto( + val type: String, + val label: String, + val rawValue: String, + val sourceSignalId: String, + val url: String? = null, + val extractedAt: String, +) + +@JsonClass(generateAdapter = true) +data class ResourceDto( + val resourceId: String, + val threadId: String, + val workflow: String, + val status: String, + val expectedResolutionDate: String, + val displayDate: String? = null, + val resolvedAt: String? = null, + val assets: List = emptyList(), + val createdAt: String, + val updatedAt: String, +) + +@JsonClass(generateAdapter = true) +data class ResourceListResponse( + val resources: List = emptyList(), + val pagination: PaginationDto? = null, +) + +/** Request body for PATCHing a resource's status (active <-> complete). */ +@JsonClass(generateAdapter = true) +data class PatchResourceRequest( + val status: String, +) diff --git a/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt index 4841543..a620e98 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt @@ -5,10 +5,12 @@ import ch.rhosys.email.data.local.dao.AccountDao import ch.rhosys.email.data.local.entity.toDomain import ch.rhosys.email.data.local.entity.toEntity import ch.rhosys.email.data.remote.api.EmailApiService +import ch.rhosys.email.data.remote.dto.PatchAccountRequest import ch.rhosys.email.data.remote.dto.PatchAliasRequest import ch.rhosys.email.data.remote.dto.SetAliasSenderRequest import ch.rhosys.email.data.remote.dto.toDomain import ch.rhosys.email.domain.model.Account +import ch.rhosys.email.domain.model.AfterSendAction import ch.rhosys.email.domain.model.Alias import ch.rhosys.email.domain.model.AliasSender import ch.rhosys.email.domain.model.SenderPolicy @@ -78,4 +80,9 @@ class AccountRepositoryImpl( val updated = api.patchAlias(accountId, alias, PatchAliasRequest(policy.wire)) dao.upsertAliases(listOf(updated.toDomain(accountId).toEntity())) } + + override suspend fun updateAccountSettings(accountId: String, retentionDuration: String?, afterSendAction: AfterSendAction?) { + val updated = api.patchAccount(accountId, PatchAccountRequest(retentionDuration = retentionDuration, afterSendAction = afterSendAction?.wire)) + dao.upsertAll(listOf(updated.toDomain().toEntity())) + } } diff --git a/app/src/main/java/ch/rhosys/email/data/repository/LabelRuleTemplateRepositories.kt b/app/src/main/java/ch/rhosys/email/data/repository/LabelRuleTemplateRepositories.kt index 230db59..024e60b 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/LabelRuleTemplateRepositories.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/LabelRuleTemplateRepositories.kt @@ -39,15 +39,19 @@ class LabelRepositoryImpl( dao.upsertAll(labels.map { it.toDomain(accountId).toEntity() }) } - override suspend fun create(accountId: String, name: String, color: String?, icon: String?) { - val created = api.createLabel(accountId, CreateLabelRequest(name, color, icon)) + override suspend fun create(accountId: String, name: String, color: String?, icon: String?, applyInstruction: String) { + val created = api.createLabel(accountId, CreateLabelRequest(name = name, applyInstruction = applyInstruction, color = color, icon = icon)) dao.upsert(created.toDomain(accountId).toEntity()) } override suspend fun update(accountId: String, label: Label) { dao.upsert(label.toEntity()) runCatching { - api.patchLabel(accountId, label.label, PatchLabelRequest(label.name, label.color, label.icon)) + api.patchLabel( + accountId, + label.label, + PatchLabelRequest(name = label.name, applyInstruction = label.applyInstruction, color = label.color, icon = label.icon), + ) }.onSuccess { dao.upsert(it.toDomain(accountId).toEntity()) } } diff --git a/app/src/main/java/ch/rhosys/email/data/repository/ResourceRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/ResourceRepositoryImpl.kt new file mode 100644 index 0000000..548ba8a --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/repository/ResourceRepositoryImpl.kt @@ -0,0 +1,33 @@ +package ch.rhosys.email.data.repository + +import ch.rhosys.email.data.remote.api.EmailApiService +import ch.rhosys.email.data.remote.dto.PatchResourceRequest +import ch.rhosys.email.data.remote.dto.toDomain +import ch.rhosys.email.domain.model.Resource +import ch.rhosys.email.domain.model.ResourceStatus +import ch.rhosys.email.domain.repository.ResourceRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Resources are read-mostly and account-scoped with no offline mutation + * queue, unlike threads — an in-memory cache is enough; there is no need for + * the Room-backed paging machinery threads use. + */ +class ResourceRepositoryImpl(private val api: EmailApiService) : ResourceRepository { + + private val cache = MutableStateFlow>(emptyList()) + + override fun observeResources(accountId: String): Flow> = cache.asStateFlow() + + override suspend fun refresh(accountId: String, status: ResourceStatus?) { + val resources = api.getResources(accountId, status = status?.wire).resources.map { it.toDomain() } + cache.value = resources + } + + override suspend fun setStatus(accountId: String, resourceId: String, status: ResourceStatus) { + val updated = api.patchResource(accountId, resourceId, PatchResourceRequest(status.wire)).toDomain() + cache.value = cache.value.map { if (it.resourceId == resourceId) updated else it } + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRemoteMediator.kt b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRemoteMediator.kt index 0ae4748..888d485 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRemoteMediator.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRemoteMediator.kt @@ -18,7 +18,7 @@ import ch.rhosys.email.domain.model.ThreadStatus @OptIn(ExperimentalPagingApi::class) class ThreadRemoteMediator( private val accountId: String, - private val status: ThreadStatus, + private val status: ThreadStatus?, private val api: EmailApiService, private val db: EmailDatabase, ) : RemoteMediator() { @@ -39,14 +39,16 @@ class ThreadRemoteMediator( val page = api.getThreads( accountId = accountId, - status = status.wire, + status = status?.wire, cursor = cursor, limit = state.config.pageSize, ) nextCursor = page.pagination?.cursor if (loadType == LoadType.REFRESH) { - db.threadDao().clearAccount(accountId) + // Scoped to this status when possible, so refreshing one tab + // (e.g. Archived) doesn't wipe another tab's (e.g. Active) cache. + if (status != null) db.threadDao().clearAccountStatus(accountId, status.wire) else db.threadDao().clearAccount(accountId) } db.threadDao().upsertAll(page.threads.map { it.toDomain(accountId).toEntity() }) diff --git a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt index 4506b58..7b0f682 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt @@ -40,13 +40,18 @@ class ThreadRepositoryImpl( private val threadDao = db.threadDao() private val signalDao = db.signalDao() - override fun pagedThreads(accountId: String, status: ThreadStatus): Flow> = + override fun pagedThreads(accountId: String, status: ThreadStatus?): Flow> = Pager( config = PagingConfig(pageSize = 30, enablePlaceholders = false), remoteMediator = ThreadRemoteMediator(accountId, status, api, db), - pagingSourceFactory = { threadDao.pagingSource(accountId, status.wire) }, + pagingSourceFactory = { + if (status == null) threadDao.pagingSourceAll(accountId) else threadDao.pagingSource(accountId, status.wire) + }, ).flow.map { paging -> paging.map { it.toDomain() } } + override fun observeThreadCount(accountId: String, status: ThreadStatus): Flow = + threadDao.observeCountByStatus(accountId, status.wire) + override fun observeThread(threadId: String): Flow = threadDao.observeById(threadId).map { it?.toDomain() } @@ -63,11 +68,16 @@ class ThreadRepositoryImpl( override fun search(accountId: String, query: String): Flow> = threadDao.search(accountId, query).map { rows -> rows.map { it.toDomain() } } - override suspend fun refreshThreads(accountId: String, status: ThreadStatus) { - val page = api.getThreads(accountId, status = status.wire) + override suspend fun refreshThreads(accountId: String, status: ThreadStatus?) { + val page = api.getThreads(accountId, status = status?.wire) threadDao.upsertAll(page.threads.map { it.toDomain(accountId).toEntity() }) } + override suspend fun refreshThread(accountId: String, threadId: String) { + val dto = api.getThread(accountId, threadId) + threadDao.upsert(dto.toDomain(accountId).toEntity(isPendingSync = false)) + } + override suspend fun refreshSignals(accountId: String, threadId: String) { val page = api.getThreadSignals(accountId, threadId) signalDao.upsertAll(page.signals.map { it.toDomain().toEntity(accountId) }) diff --git a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt index c41736c..2d8a45c 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -8,6 +8,7 @@ import ch.rhosys.email.data.auth.AuthressLoginClient import ch.rhosys.email.data.auth.TokenStore import ch.rhosys.email.data.local.EmailDatabase import ch.rhosys.email.data.log.AppLogger +import ch.rhosys.email.data.realtime.RealtimeClient import ch.rhosys.email.data.remote.api.ApiLoggingInterceptor import ch.rhosys.email.data.remote.api.AuthInterceptor import ch.rhosys.email.data.remote.api.EmailApiService @@ -15,6 +16,7 @@ import ch.rhosys.email.data.remote.api.UserAgentInterceptor import ch.rhosys.email.data.repository.AccountRepositoryImpl import ch.rhosys.email.data.repository.ComposeRepositoryImpl import ch.rhosys.email.data.repository.LabelRepositoryImpl +import ch.rhosys.email.data.repository.ResourceRepositoryImpl import ch.rhosys.email.data.repository.RuleRepositoryImpl import ch.rhosys.email.data.repository.SettingsRepository import ch.rhosys.email.data.repository.StatsRepository @@ -23,6 +25,7 @@ import ch.rhosys.email.data.repository.ThreadRepositoryImpl import ch.rhosys.email.domain.repository.AccountRepository import ch.rhosys.email.domain.repository.ComposeRepository import ch.rhosys.email.domain.repository.LabelRepository +import ch.rhosys.email.domain.repository.ResourceRepository import ch.rhosys.email.domain.repository.RuleRepository import ch.rhosys.email.domain.repository.TemplateRepository import ch.rhosys.email.domain.repository.ThreadRepository @@ -108,6 +111,45 @@ class AppContainer(private val context: Context) { .create(EmailApiService::class.java) } + /** + * Same host as [EmailApiService], scheme swapped for the WebSocket + * upgrade — mirrors the web app's WS_BASE derivation in + * workers/realtime.shared.ts. + */ + private val wsBaseUrl: String by lazy { + BuildConfig.API_BASE_URL.trimEnd('/') + .replaceFirst("https://", "wss://") + .replaceFirst("http://", "ws://") + } + + /** A WebSocket is long-lived, so it needs no read timeout — unlike [okHttpClient]. */ + private val wsHttpClient: OkHttpClient by lazy { + authHttpClient.newBuilder() + .readTimeout(0, TimeUnit.SECONDS) + .build() + } + + /** + * Live thread updates while the app is foregrounded (decision: no FCM/push + * notification service required to get realtime updates out of the gate). + * Refreshing through [threadRepository] into Room means every Flow-backed + * list and badge picks the change up automatically — no separate wiring. + */ + val realtimeClient: RealtimeClient by lazy { + RealtimeClient( + wsBaseUrl = wsBaseUrl, + httpClient = wsHttpClient, + authManager = authManager, + logger = appLogger, + onThreadUpdated = { accountId, threadId -> + runCatching { + threadRepository.refreshThread(accountId, threadId) + threadRepository.refreshSignals(accountId, threadId) + } + }, + ) + } + val database: EmailDatabase by lazy { // The v1 schema described an API that does not exist, so there is nothing // worth migrating — the cache simply refetches against the real one. @@ -140,6 +182,8 @@ class AppContainer(private val context: Context) { TemplateRepositoryImpl(apiService, database.templateDao()) } + val resourceRepository: ResourceRepository by lazy { ResourceRepositoryImpl(apiService) } + val settingsRepository: SettingsRepository by lazy { SettingsRepository(apiService) } val statsRepository: StatsRepository by lazy { StatsRepository(apiService) } diff --git a/app/src/main/java/ch/rhosys/email/domain/model/Label.kt b/app/src/main/java/ch/rhosys/email/domain/model/Label.kt index 99c51b8..ff5bf5c 100644 --- a/app/src/main/java/ch/rhosys/email/domain/model/Label.kt +++ b/app/src/main/java/ch/rhosys/email/domain/model/Label.kt @@ -12,6 +12,8 @@ data class Label( val name: String, val color: String?, val icon: String?, + /** Freeform guidance the auto-labeling rules use to decide when this label applies. */ + val applyInstruction: String, val createdAt: Instant?, ) diff --git a/app/src/main/java/ch/rhosys/email/domain/model/Resource.kt b/app/src/main/java/ch/rhosys/email/domain/model/Resource.kt new file mode 100644 index 0000000..4bb0400 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/domain/model/Resource.kt @@ -0,0 +1,39 @@ +package ch.rhosys.email.domain.model + +import java.time.Instant + +enum class ResourceStatus { + ACTIVE, + COMPLETE, + ; + + val wire: String get() = name.lowercase() + + companion object { + fun fromWire(value: String?): ResourceStatus = + entries.firstOrNull { it.wire == value } ?: ACTIVE + } +} + +data class ResourceAsset( + val type: String, + val label: String, + val rawValue: String, + val sourceSignalId: String, + val url: String?, + val extractedAt: Instant?, +) + +/** A workflow artifact extracted from a thread — a tracking number, a boarding pass, an invoice. */ +data class Resource( + val resourceId: String, + val threadId: String, + val workflow: Workflow, + val status: ResourceStatus, + val expectedResolutionDate: Instant?, + val displayDate: String?, + val resolvedAt: Instant?, + val assets: List, + val createdAt: Instant?, + val updatedAt: Instant?, +) diff --git a/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt b/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt index 3bb1d3e..a01fd62 100644 --- a/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt +++ b/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt @@ -2,9 +2,12 @@ package ch.rhosys.email.domain.repository import androidx.paging.PagingData import ch.rhosys.email.domain.model.Account +import ch.rhosys.email.domain.model.AfterSendAction import ch.rhosys.email.domain.model.Alias import ch.rhosys.email.domain.model.Label import ch.rhosys.email.domain.model.MailThread +import ch.rhosys.email.domain.model.Resource +import ch.rhosys.email.domain.model.ResourceStatus import ch.rhosys.email.domain.model.Rule import ch.rhosys.email.domain.model.AliasSender import ch.rhosys.email.domain.model.SenderPolicy @@ -32,15 +35,25 @@ interface AccountRepository { suspend fun setSenderPolicy(accountId: String, alias: String, domain: String, policy: SenderPolicy) suspend fun setAliasUnknownSenderPolicy(accountId: String, alias: String, policy: UnknownSenderPolicy) + + /** Account-level compose/retention defaults, e.g. Email & Forwarding settings. */ + suspend fun updateAccountSettings(accountId: String, retentionDuration: String? = null, afterSendAction: AfterSendAction? = null) } interface ThreadRepository { - fun pagedThreads(accountId: String, status: ThreadStatus): Flow> + /** [status] null means every status — backs the "All" inbox tab. */ + fun pagedThreads(accountId: String, status: ThreadStatus?): Flow> + + /** Backs the Inbox badge in the nav drawer. */ + fun observeThreadCount(accountId: String, status: ThreadStatus): Flow fun observeThread(threadId: String): Flow fun observeSignals(threadId: String): Flow> fun search(accountId: String, query: String): Flow> - suspend fun refreshThreads(accountId: String, status: ThreadStatus) + suspend fun refreshThreads(accountId: String, status: ThreadStatus?) + + /** Pulls a single thread fresh — backs realtime `thread:updated` events. */ + suspend fun refreshThread(accountId: String, threadId: String) suspend fun refreshSignals(accountId: String, threadId: String) suspend fun archive(accountId: String, threadId: String) @@ -91,7 +104,7 @@ interface ComposeRepository { interface LabelRepository { fun observeLabels(accountId: String): Flow> suspend fun refresh(accountId: String) - suspend fun create(accountId: String, name: String, color: String?, icon: String?) + suspend fun create(accountId: String, name: String, color: String?, icon: String?, applyInstruction: String) suspend fun update(accountId: String, label: Label) suspend fun delete(accountId: String, labelId: String) } @@ -109,3 +122,9 @@ interface TemplateRepository { suspend fun upsert(accountId: String, templateId: String?, name: String, subject: String, body: String) suspend fun delete(accountId: String, templateId: String) } + +interface ResourceRepository { + fun observeResources(accountId: String): Flow> + suspend fun refresh(accountId: String, status: ResourceStatus? = null) + suspend fun setStatus(accountId: String, resourceId: String, status: ResourceStatus) +} diff --git a/app/src/main/java/ch/rhosys/email/presentation/components/MarkdownText.kt b/app/src/main/java/ch/rhosys/email/presentation/components/MarkdownText.kt index 649f9ae..903062b 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/components/MarkdownText.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/components/MarkdownText.kt @@ -10,9 +10,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import io.noties.markwon.Markwon import io.noties.markwon.ext.strikethrough.StrikethroughPlugin +import io.noties.markwon.html.HtmlPlugin import io.noties.markwon.linkify.LinkifyPlugin -/** Decision #79: Markwon renders Markdown message bodies, wrapped for Compose. */ +/** + * Decision #79: Markwon renders Markdown message bodies, wrapped for Compose. + * Signal bodies are frequently raw HTML (inbound/outbound email content), not + * Markdown, so [HtmlPlugin] is registered too — without it, Markwon escapes + * HTML tags and they show up as literal text instead of being rendered. + */ @Composable fun MarkdownText(markdown: String, modifier: Modifier = Modifier) { val context = LocalContext.current @@ -21,6 +27,7 @@ fun MarkdownText(markdown: String, modifier: Modifier = Modifier) { Markwon.builder(context) .usePlugin(StrikethroughPlugin.create()) .usePlugin(LinkifyPlugin.create()) + .usePlugin(HtmlPlugin.create()) .build() } AndroidView( diff --git a/app/src/main/java/ch/rhosys/email/presentation/components/Scrollbar.kt b/app/src/main/java/ch/rhosys/email/presentation/components/Scrollbar.kt new file mode 100644 index 0000000..922ea0e --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/presentation/components/Scrollbar.kt @@ -0,0 +1,56 @@ +package ch.rhosys.email.presentation.components + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.dp + +/** + * A thin, always-present scroll indicator for a [LazyListState]-backed list. + * Compose has no built-in scrollbar for LazyColumn, so the Settings sub-tabs + * gave no visual hint that their content scrolls — this draws a small thumb + * on the trailing edge, sized and positioned from the list's own state. + */ +@Composable +fun Modifier.verticalScrollbar(state: LazyListState): Modifier { + val thumbColor = MaterialTheme.colorScheme.onSurfaceVariant + return composed { + drawWithContent { + drawContent() + + val layoutInfo = state.layoutInfo + val totalCount = layoutInfo.totalItemsCount + val visible = layoutInfo.visibleItemsInfo + if (totalCount == 0 || visible.isEmpty() || visible.size >= totalCount) return@drawWithContent + + val firstVisible = visible.first() + val avgItemSize = visible.sumOf { it.size } / visible.size.toFloat() + if (avgItemSize <= 0f) return@drawWithContent + + val viewportHeight = layoutInfo.viewportSize.height.toFloat() + val estimatedTotalHeight = avgItemSize * totalCount + val thumbHeight = (viewportHeight * (viewportHeight / estimatedTotalHeight)) + .coerceIn(24.dp.toPx(), viewportHeight) + + val scrolledDistance = firstVisible.index * avgItemSize - firstVisible.offset + val maxScrollDistance = (estimatedTotalHeight - viewportHeight).coerceAtLeast(1f) + val thumbTravel = (viewportHeight - thumbHeight).coerceAtLeast(0f) + val thumbOffsetY = (scrolledDistance / maxScrollDistance * thumbTravel).coerceIn(0f, thumbTravel) + + val thumbWidth = 3.dp.toPx() + drawRoundRect( + color = thumbColor, + topLeft = Offset(size.width - thumbWidth - 2.dp.toPx(), thumbOffsetY), + size = Size(thumbWidth, thumbHeight), + cornerRadius = CornerRadius(thumbWidth / 2, thumbWidth / 2), + alpha = 0.5f, + ) + } + } +} diff --git a/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt b/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt index cb70440..b7806d3 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/components/ThemePicker.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -96,7 +97,7 @@ private fun ThemeTile( Box( modifier = modifier - .height(168.dp) + .height(184.dp) .clip(RoundedCornerShape(14.dp)) .background(palette.base) .border( @@ -134,26 +135,59 @@ private fun ThemeTile( } } - Row( + Column( modifier = Modifier .fillMaxWidth() .background(palette.mantle) .padding(horizontal = 10.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, ) { - Text( - name, - color = palette.text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - Text(mode, color = palette.subtext0, fontSize = 11.sp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + name, + color = palette.text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Text(mode, color = palette.subtext0, fontSize = 11.sp) + } + if (flavor != null) { + // Concrete proof the flavours are actually different colors, + // not just the same palette re-skinned: the base color swatch + // plus its hex value, which differs for every flavour. + Row( + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(3.dp)) + .background(palette.base) + .border(1.dp, palette.overlay0, RoundedCornerShape(3.dp)), + ) + Text( + palette.base.toHexLabel(), + color = palette.subtext0, + fontSize = 10.sp, + ) + } + } } } } } +/** e.g. "#1E1E2E" — proof-of-difference label under each theme tile's swatch. */ +private fun Color.toHexLabel(): String { + val argb = toArgb() + return "#" + (argb and 0x00FFFFFF).toString(16).padStart(6, '0').uppercase() +} + /** A miniature mail row plus the accent ramp — the parts a flavour actually changes. */ @Composable private fun FlavorPreview(palette: CatppuccinColors) { diff --git a/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxScreen.kt index 4fd1678..afb1f03 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxScreen.kt @@ -15,21 +15,33 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Label +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -55,33 +67,56 @@ fun InboxScreen(onThreadClick: (String) -> Unit) { } val uiState by viewModel.uiState.collectAsState() val threads = viewModel.threads.collectAsLazyPagingItems() + var showBulkArchiveConfirm by remember { mutableStateOf(false) } + var showBulkDeleteConfirm by remember { mutableStateOf(false) } - PullToRefreshBox( - isRefreshing = uiState.isRefreshing, - onRefresh = { viewModel.refresh() }, - modifier = Modifier.fillMaxSize(), - ) { - if (threads.itemCount == 0) { - EmptyState( - title = "Inbox zero", - message = "You're all caught up. New mail will show up here.", + Column(modifier = Modifier.fillMaxSize()) { + if (uiState.isSelectionMode) { + SelectionActionBar( + selectedCount = uiState.selectedIds.size, + onCancel = { viewModel.clearSelection() }, + onArchive = { showBulkArchiveConfirm = true }, + onDelete = { showBulkDeleteConfirm = true }, ) } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(threads.itemCount) { index -> - val thread = threads[index] ?: return@items - InboxRow( - thread = thread, - isSelected = thread.threadId in uiState.selectedIds, - isSelectionMode = uiState.isSelectionMode, - onClick = { - if (uiState.isSelectionMode) viewModel.toggleSelection(thread.threadId) else onThreadClick(thread.threadId) - }, - onLongClick = { viewModel.enterSelectionMode(thread.threadId) }, - onArchive = { viewModel.archive(thread.threadId) }, - onDelay = { viewModel.openSnoozePicker(thread.threadId) }, - onDelete = { viewModel.delete(thread.threadId) }, - ) + InboxTabBar(selected = uiState.tab, onSelect = viewModel::selectTab) + } + + PullToRefreshBox( + isRefreshing = uiState.isRefreshing, + onRefresh = { viewModel.refresh() }, + modifier = Modifier.fillMaxSize(), + ) { + if (threads.itemCount == 0) { + EmptyState( + title = when (uiState.tab) { + InboxTab.ACTIVE -> "Inbox zero" + InboxTab.ARCHIVED -> "Nothing archived" + InboxTab.ALL -> "No threads yet" + }, + message = when (uiState.tab) { + InboxTab.ACTIVE -> "You're all caught up. New mail will show up here." + InboxTab.ARCHIVED -> "Threads you archive will show up here." + InboxTab.ALL -> "Every thread, regardless of status, will show up here." + }, + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(threads.itemCount) { index -> + val thread = threads[index] ?: return@items + InboxRow( + thread = thread, + isSelected = thread.threadId in uiState.selectedIds, + isSelectionMode = uiState.isSelectionMode, + onClick = { + if (uiState.isSelectionMode) viewModel.toggleSelection(thread.threadId) else onThreadClick(thread.threadId) + }, + onLongClick = { viewModel.enterSelectionMode(thread.threadId) }, + onArchive = { viewModel.archive(thread.threadId) }, + onDelay = { viewModel.openSnoozePicker(thread.threadId) }, + onDelete = { viewModel.delete(thread.threadId) }, + ) + } } } } @@ -93,6 +128,84 @@ fun InboxScreen(onThreadClick: (String) -> Unit) { onConfirm = { millis -> viewModel.confirmSnooze(millis) }, ) } + + if (showBulkArchiveConfirm) { + AlertDialog( + onDismissRequest = { showBulkArchiveConfirm = false }, + title = { Text("Archive ${uiState.selectedIds.size} threads?") }, + text = { Text("These threads will be moved to your archive.") }, + confirmButton = { + TextButton(onClick = { + showBulkArchiveConfirm = false + viewModel.bulkArchive() + }) { Text("Archive") } + }, + dismissButton = { TextButton(onClick = { showBulkArchiveConfirm = false }) { Text("Cancel") } }, + ) + } + + if (showBulkDeleteConfirm) { + AlertDialog( + onDismissRequest = { showBulkDeleteConfirm = false }, + title = { Text("Delete ${uiState.selectedIds.size} threads?") }, + text = { Text("These threads will be permanently deleted. This can't be undone.") }, + confirmButton = { + TextButton(onClick = { + showBulkDeleteConfirm = false + viewModel.bulkDelete() + }) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { showBulkDeleteConfirm = false }) { Text("Cancel") } }, + ) + } +} + +/** Selection-mode action bar shown instead of the tab bar while bulk-selecting threads. */ +@Composable +private fun SelectionActionBar( + selectedCount: Int, + onCancel: () -> Unit, + onArchive: () -> Unit, + onDelete: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onCancel) { Icon(Icons.Filled.Close, contentDescription = "Cancel selection") } + Text( + "$selectedCount selected", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f).padding(start = 4.dp), + ) + IconButton(onClick = onArchive) { Icon(Icons.Filled.Archive, contentDescription = "Archive selected") } + IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, contentDescription = "Delete selected") } + } +} + +/** Mirrors the web app's Inbox tab bar: Active / Archived / All. */ +@Composable +private fun InboxTabBar(selected: InboxTab, onSelect: (InboxTab) -> Unit) { + TabRow(selectedTabIndex = selected.ordinal) { + Tab( + selected = selected == InboxTab.ACTIVE, + onClick = { onSelect(InboxTab.ACTIVE) }, + text = { Text("Inbox") }, + ) + Tab( + selected = selected == InboxTab.ARCHIVED, + onClick = { onSelect(InboxTab.ARCHIVED) }, + text = { Text("Archived") }, + ) + Tab( + selected = selected == InboxTab.ALL, + onClick = { onSelect(InboxTab.ALL) }, + text = { Text("All") }, + ) + } } /** @@ -111,25 +224,52 @@ private fun InboxRow( onDelay: () -> Unit, onDelete: () -> Unit, ) { + // Trigger the reveal at half the swipe distance instead of requiring a + // near-complete swipe before the action row becomes usable. val dismissState = rememberSwipeToDismissBoxState( confirmValueChange = { false }, + positionalThreshold = { totalDistance -> totalDistance * 0.5f }, ) + var showArchiveConfirm by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + var showOverflowMenu by remember { mutableStateOf(false) } SwipeToDismissBox( state = dismissState, backgroundContent = { + // Icons live at the edge the swipe is revealing them from: docked + // right when swiping left (EndToStart), left when swiping right + // (StartToEnd) — otherwise they stay hidden off-screen until the + // row is almost fully swiped open. + val revealingFromStart = dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd Row( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceVariant) .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.End, + horizontalArrangement = if (revealingFromStart) Arrangement.Start else Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = onArchive) { Icon(Icons.Filled.Archive, contentDescription = "Archive") } + IconButton(onClick = { showArchiveConfirm = true }) { + Icon(Icons.Filled.Archive, contentDescription = "Archive") + } IconButton(onClick = onDelay) { Icon(Icons.Filled.Schedule, contentDescription = "Delay") } - IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, contentDescription = "Delete") } IconButton(onClick = { /* label picker */ }) { Icon(Icons.Filled.Label, contentDescription = "Add label") } + Box { + IconButton(onClick = { showOverflowMenu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = "More") + } + DropdownMenu(expanded = showOverflowMenu, onDismissRequest = { showOverflowMenu = false }) { + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }, + onClick = { + showOverflowMenu = false + showDeleteConfirm = true + }, + ) + } + } } }, ) { @@ -179,4 +319,34 @@ private fun InboxRow( } } } + + if (showArchiveConfirm) { + AlertDialog( + onDismissRequest = { showArchiveConfirm = false }, + title = { Text("Archive thread?") }, + text = { Text("This thread will be moved to your archive.") }, + confirmButton = { + TextButton(onClick = { + showArchiveConfirm = false + onArchive() + }) { Text("Archive") } + }, + dismissButton = { TextButton(onClick = { showArchiveConfirm = false }) { Text("Cancel") } }, + ) + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete thread?") }, + text = { Text("This thread will be permanently deleted. This can't be undone.") }, + confirmButton = { + TextButton(onClick = { + showDeleteConfirm = false + onDelete() + }) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } }, + ) + } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxViewModel.kt index ae55828..2598aa0 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxViewModel.kt @@ -13,24 +13,34 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Instant +/** Mirrors the web app's Inbox tab bar (Active / Archived / All). */ +enum class InboxTab(val status: ThreadStatus?) { + ACTIVE(ThreadStatus.ACTIVE), + ARCHIVED(ThreadStatus.ARCHIVED), + ALL(null), +} + data class InboxUiState( val isRefreshing: Boolean = false, val selectedIds: Set = emptySet(), val isSelectionMode: Boolean = false, val snoozeTargetThreadId: String? = null, + val tab: InboxTab = InboxTab.ACTIVE, val error: String? = null, ) /** - * Backs the Inbox: threads with status ACTIVE. There is no unread count or - * mark-as-read here — the API has no such concept — so rows are emphasised by - * urgency instead. + * Backs the Inbox. There is no unread count or mark-as-read here — the API + * has no such concept — so rows are emphasised by urgency instead. */ class InboxViewModel( private val threadRepository: ThreadRepository, @@ -43,15 +53,24 @@ class InboxViewModel( val activeAccountId: StateFlow = accountRepository.activeAccountId() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - val threads: Flow> = activeAccountId.filterNotNull() - .flatMapLatest { accountId -> threadRepository.pagedThreads(accountId, ThreadStatus.ACTIVE) } + val threads: Flow> = _uiState + .map { it.tab } + .distinctUntilChanged() + .combine(activeAccountId.filterNotNull()) { tab, accountId -> tab to accountId } + .flatMapLatest { (tab, accountId) -> threadRepository.pagedThreads(accountId, tab.status) } .cachedIn(viewModelScope) + fun selectTab(tab: InboxTab) { + if (tab == _uiState.value.tab) return + _uiState.value = _uiState.value.copy(tab = tab) + } + fun refresh() { val accountId = activeAccountId.value ?: return + val status = _uiState.value.tab.status viewModelScope.launch { _uiState.value = _uiState.value.copy(isRefreshing = true) - runCatching { threadRepository.refreshThreads(accountId, ThreadStatus.ACTIVE) } + runCatching { threadRepository.refreshThreads(accountId, status) } .onFailure { _uiState.value = _uiState.value.copy(error = it.message) } _uiState.value = _uiState.value.copy(isRefreshing = false) } diff --git a/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsScreen.kt index 3454d12..b5e4003 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsScreen.kt @@ -1,6 +1,7 @@ package ch.rhosys.email.presentation.labels import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -10,7 +11,9 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -28,9 +31,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import ch.rhosys.email.di.LocalAppContainer +import ch.rhosys.email.domain.model.Label import ch.rhosys.email.presentation.components.EmptyState import ch.rhosys.email.presentation.components.rememberViewModel @@ -40,6 +43,7 @@ fun LabelsScreen() { val viewModel = rememberViewModel { LabelsViewModel(container.labelRepository, container.accountRepository) } val labels by viewModel.labels.collectAsState() var showCreateDialog by remember { mutableStateOf(false) } + var editingLabel by remember { mutableStateOf(null) } Scaffold( floatingActionButton = { @@ -60,8 +64,8 @@ fun LabelsScreen() { verticalAlignment = Alignment.CenterVertically, ) { Text("${label.icon ?: "🏷️"} ${label.name}", style = MaterialTheme.typography.bodyLarge) - IconButton(onClick = { viewModel.delete(label.label) }) { - Icon(Icons.Filled.Delete, contentDescription = "Delete ${label.name}") + IconButton(onClick = { editingLabel = label }) { + Icon(Icons.Filled.Edit, contentDescription = "Edit ${label.name}") } } } @@ -71,20 +75,111 @@ fun LabelsScreen() { } if (showCreateDialog) { - var name by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { showCreateDialog = false }, - title = { Text("New label") }, - text = { - TextField(value = name, onValueChange = { name = it }, label = { Text("Name") }) + LabelEditDialog( + label = null, + onDismiss = { showCreateDialog = false }, + onSave = { name, color, icon, instruction -> + viewModel.create(name, color, icon, instruction) + showCreateDialog = false + }, + onDelete = null, + ) + } + + editingLabel?.let { label -> + LabelEditDialog( + label = label, + onDismiss = { editingLabel = null }, + onSave = { name, color, icon, instruction -> + viewModel.update(label.copy(name = name, color = color, icon = icon, applyInstruction = instruction)) + editingLabel = null }, + onDelete = { + viewModel.delete(label.label) + editingLabel = null + }, + ) + } +} + +/** + * Create/edit dialog for a label's full configuration (name, color, icon, + * apply instructions). Delete lives here rather than as a standalone row + * action, so it's a deliberate step inside the label's own settings. + */ +@Composable +private fun LabelEditDialog( + label: Label?, + onDismiss: () -> Unit, + onSave: (name: String, color: String?, icon: String?, applyInstruction: String) -> Unit, + onDelete: (() -> Unit)?, +) { + var name by remember { mutableStateOf(label?.name.orEmpty()) } + var color by remember { mutableStateOf(label?.color.orEmpty()) } + var icon by remember { mutableStateOf(label?.icon.orEmpty()) } + var instruction by remember { mutableStateOf(label?.applyInstruction.orEmpty()) } + var showDeleteConfirm by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (label == null) "New label" else "Edit label") }, + text = { + Column { + TextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) + TextField( + value = icon, + onValueChange = { icon = it }, + label = { Text("Icon (emoji)") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + TextField( + value = color, + onValueChange = { color = it }, + label = { Text("Color (hex, e.g. #8839EF)") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + TextField( + value = instruction, + onValueChange = { instruction = it }, + label = { Text("Apply instructions") }, + supportingText = { Text("Guidance for when auto-labeling should apply this label") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + if (onDelete != null) { + TextButton( + onClick = { showDeleteConfirm = true }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.padding(top = 8.dp), + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.padding(end = 4.dp)) + Text("Delete label") + } + } + } + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { + onSave(name.trim(), color.trim().takeIf { it.isNotEmpty() }, icon.trim().takeIf { it.isNotEmpty() }, instruction.trim()) + }, + ) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) + + if (showDeleteConfirm && onDelete != null) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete label?") }, + text = { Text("Threads carrying this label will keep working, but it will no longer be assignable.") }, confirmButton = { TextButton(onClick = { - if (name.isNotBlank()) viewModel.create(name, "#8839EF", null) - showCreateDialog = false - }) { Text("Create") } + showDeleteConfirm = false + onDelete() + }) { Text("Delete") } }, - dismissButton = { TextButton(onClick = { showCreateDialog = false }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } }, ) } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsViewModel.kt index 53d6b04..39dcd33 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsViewModel.kt @@ -27,9 +27,9 @@ class LabelsViewModel( labelRepository.observeLabels(accountId) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - fun create(name: String, color: String?, icon: String?) { + fun create(name: String, color: String?, icon: String?, applyInstruction: String) { val accountId = activeAccountId.value ?: return - viewModelScope.launch { labelRepository.create(accountId, name, color, icon) } + viewModelScope.launch { labelRepository.create(accountId, name, color, icon, applyInstruction) } } fun update(label: Label) { diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/AppScaffold.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/AppScaffold.kt index 2b739dd..5310509 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/AppScaffold.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/AppScaffold.kt @@ -1,22 +1,28 @@ package ch.rhosys.email.presentation.navigation +import androidx.compose.foundation.background +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.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Assignment import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Inbox import androidx.compose.material.icons.filled.Label import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Rule import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Shield import androidx.compose.material3.DrawerValue 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 @@ -31,8 +37,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState @@ -44,6 +52,7 @@ private fun iconFor(destination: Destination): ImageVector = when (destination) Destination.Inbox -> Icons.Filled.Inbox Destination.Quarantine -> Icons.Filled.Shield Destination.Drafts -> Icons.Filled.Description + Destination.Resources -> Icons.Filled.Assignment Destination.Rules -> Icons.Filled.Rule Destination.Templates -> Icons.Filled.AutoAwesome Destination.Labels -> Icons.Filled.Label @@ -58,6 +67,7 @@ private fun titleFor(route: String?): String = when { route.startsWith("quarantine") -> "Quarantine" route.startsWith("spam") -> "Spam" route.startsWith("drafts") -> "Drafts" + route.startsWith("resources") -> "Resources" route.startsWith("labels") -> "Labels" route.startsWith("rules") -> "Rules" route.startsWith("templates") -> "Templates" @@ -78,27 +88,72 @@ fun AppScaffold(navController: NavController, content: @Composable (Modifier) -> val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route + fun navigateTo(destination: Destination) { + scope.launch { drawerState.close() } + navController.navigate(destination.route) { + launchSingleTop = true + popUpTo(Destination.Inbox.route) { inclusive = false; saveState = true } + restoreState = true + } + } + ModalNavigationDrawer( drawerState = drawerState, drawerContent = { ModalDrawerSheet { - AccountSwitcher() - Destination.drawerItems.forEach { destination -> - NavigationDrawerItem( - icon = { Icon(iconFor(destination), contentDescription = null) }, - label = { Text(titleFor(destination.route)) }, - selected = currentRoute == destination.route, - onClick = { - scope.launch { drawerState.close() } - navController.navigate(destination.route) { - launchSingleTop = true - popUpTo(Destination.Inbox.route) { inclusive = false; saveState = true } - restoreState = true - } - }, - modifier = Modifier.padding(horizontal = 12.dp), + val container = LocalAppContainer.current + val badgesViewModel = rememberViewModel { + NavBadgesViewModel( + container.threadRepository, + container.composeRepository, + container.resourceRepository, + container.accountRepository, ) } + val badges by badgesViewModel.badges.collectAsState() + + Column(modifier = Modifier.fillMaxWidth()) { + // Primary mailbox destinations. Archived/All live as tabs + // inside Inbox (decision mirrors the web InboxTabBar) + // rather than as separate drawer entries. + Destination.drawerMainItems.forEach { destination -> + val badgeCount = when (destination) { + Destination.Inbox -> badges.inboxActive + Destination.Drafts -> badges.drafts + Destination.Quarantine -> badges.quarantined + Destination.Resources -> badges.activeResources + else -> 0 + } + NavigationDrawerItem( + icon = { Icon(iconFor(destination), contentDescription = null) }, + label = { Text(titleFor(destination.route)) }, + badge = { if (badgeCount > 0) NavBadge(badgeCount) }, + selected = currentRoute == destination.route, + onClick = { navigateTo(destination) }, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Configuration destinations, pinned to the bottom of the + // scrollable area — mirrors the web sidebar's layout. + Destination.drawerConfigItems.forEach { destination -> + NavigationDrawerItem( + icon = { Icon(iconFor(destination), contentDescription = null) }, + label = { Text(titleFor(destination.route)) }, + selected = currentRoute == destination.route, + onClick = { navigateTo(destination) }, + modifier = Modifier.padding(horizontal = 12.dp), + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + AccountSwitcher() + + ProfileRow(onClick = { navigateTo(Destination.Settings) }) + } } }, ) { @@ -119,6 +174,21 @@ fun AppScaffold(navController: NavController, content: @Composable (Modifier) -> } } +@Composable +private fun NavBadge(count: Int) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(50)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + if (count > 99) "99+" else count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + @Composable private fun AccountSwitcher() { val container = LocalAppContainer.current @@ -126,17 +196,64 @@ private fun AccountSwitcher() { val accounts by viewModel.accounts.collectAsState() val activeId by viewModel.activeAccountId.collectAsState() - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Text("Accounts", style = MaterialTheme.typography.titleMedium) - LazyColumn { - items(accounts, key = { it.accountId }) { account -> - NavigationDrawerItem( - // An account has a name, not an address — addresses are aliases. - label = { Text(account.name) }, - selected = account.accountId == activeId, - onClick = { viewModel.select(account.accountId) }, - ) - } + if (accounts.size <= 1) return + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { + Text( + "Accounts", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // A plain Column, not a nested LazyColumn: the drawer sheet's own + // Column has unbounded height, and a LazyColumn measured against + // infinite constraints renders nothing — that was why this list came + // up blank. The account count is always small, so no lazy list is + // needed here anyway. + accounts.forEach { account -> + NavigationDrawerItem( + // An account has a name, not an address — addresses are aliases. + label = { Text(account.name) }, + selected = account.accountId == activeId, + onClick = { viewModel.select(account.accountId) }, + ) + } + } +} + +/** Bottom-of-drawer profile row, matching the web app's mobile profile entry point into Settings. */ +@Composable +private fun ProfileRow(onClick: () -> Unit) { + val container = LocalAppContainer.current + val viewModel = rememberViewModel { AccountSwitcherViewModel(container.accountRepository) } + val accounts by viewModel.accounts.collectAsState() + val activeId by viewModel.activeAccountId.collectAsState() + val activeAccountName = accounts.firstOrNull { it.accountId == activeId }?.name + val initials = activeAccountName?.trim()?.takeIf { it.isNotEmpty() }?.first()?.uppercase() ?: "?" + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + initials, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) } + Text( + activeAccountName ?: "Profile", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 12.dp), + ) } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/Destinations.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/Destinations.kt index d6c560a..b4be4cd 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/Destinations.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/Destinations.kt @@ -12,6 +12,7 @@ sealed class Destination(val route: String) { data object Templates : Destination("templates") data object Settings : Destination("settings") data object Stats : Destination("stats") + data object Resources : Destination("resources") data object Thread : Destination("thread/{threadId}") { fun route(threadId: String) = "thread/$threadId" @@ -26,8 +27,15 @@ sealed class Destination(val route: String) { /** * Spam, Admin, Billing and Support are absent: the API backs none of * them. Filtered mail surfaces under Quarantine, which maps to signal - * status plus quarantineResponse. + * status plus quarantineResponse. Archived/All live as tabs inside + * Inbox (matching the web app's InboxTabBar) rather than as separate + * drawer entries. + * + * [drawerMainItems] are the primary mailbox destinations at the top of + * the drawer; [drawerConfigItems] are configuration destinations + * pinned to the bottom, mirroring the web sidebar's layout. */ - val drawerItems = listOf(Inbox, Quarantine, Drafts, Rules, Templates, Labels, Settings) + val drawerMainItems = listOf(Inbox, Quarantine, Drafts, Resources) + val drawerConfigItems = listOf(Rules, Templates, Labels, Settings) } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavBadgesViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavBadgesViewModel.kt new file mode 100644 index 0000000..122aad5 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavBadgesViewModel.kt @@ -0,0 +1,65 @@ +package ch.rhosys.email.presentation.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.rhosys.email.domain.model.ResourceStatus +import ch.rhosys.email.domain.model.ThreadStatus +import ch.rhosys.email.domain.repository.AccountRepository +import ch.rhosys.email.domain.repository.ComposeRepository +import ch.rhosys.email.domain.repository.ResourceRepository +import ch.rhosys.email.domain.repository.ThreadRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class NavBadges( + val inboxActive: Int = 0, + val drafts: Int = 0, + val quarantined: Int = 0, + val activeResources: Int = 0, +) + +/** Backs the count badges next to Inbox/Drafts/Quarantine/Resources in the nav drawer. */ +class NavBadgesViewModel( + private val threadRepository: ThreadRepository, + private val composeRepository: ComposeRepository, + private val resourceRepository: ResourceRepository, + accountRepository: AccountRepository, +) : ViewModel() { + + private val activeAccountId = accountRepository.activeAccountId() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val _badges = MutableStateFlow(NavBadges()) + val badges: StateFlow = _badges.asStateFlow() + + init { + viewModelScope.launch { + activeAccountId.filterNotNull().flatMapLatest { accountId -> + threadRepository.observeThreadCount(accountId, ThreadStatus.ACTIVE) + }.collect { count -> _badges.value = _badges.value.copy(inboxActive = count) } + } + viewModelScope.launch { + activeAccountId.filterNotNull().flatMapLatest { accountId -> + composeRepository.observeDrafts(accountId).map { it.size } + }.collect { count -> _badges.value = _badges.value.copy(drafts = count) } + } + viewModelScope.launch { + activeAccountId.filterNotNull().flatMapLatest { accountId -> + threadRepository.observeQuarantined(accountId).map { it.size } + }.collect { count -> _badges.value = _badges.value.copy(quarantined = count) } + } + viewModelScope.launch { + activeAccountId.filterNotNull().flatMapLatest { accountId -> + viewModelScope.launch { runCatching { resourceRepository.refresh(accountId) } } + resourceRepository.observeResources(accountId).map { list -> list.count { it.status == ResourceStatus.ACTIVE } } + }.collect { count -> _badges.value = _badges.value.copy(activeResources = count) } + } + } +} diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt index 20100de..75cb332 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt @@ -25,6 +25,7 @@ import ch.rhosys.email.presentation.labels.LabelsScreen import ch.rhosys.email.presentation.onboarding.FeatureTourDialog import ch.rhosys.email.presentation.onboarding.OnboardingScreen import ch.rhosys.email.presentation.quarantine.QuarantineScreen +import ch.rhosys.email.presentation.resources.ResourcesScreen import ch.rhosys.email.presentation.rules.RulesScreen import ch.rhosys.email.presentation.settings.SettingsScreen import ch.rhosys.email.presentation.stats.StatsScreen @@ -103,7 +104,6 @@ private fun AppNavHost() { Destination.Settings.route, ) { SettingsScreen( - onNavigateStats = { navController.navigate(Destination.Stats.route) }, onSignedOut = { navController.navigate(Destination.Inbox.route) { popUpTo(0) @@ -112,6 +112,7 @@ private fun AppNavHost() { ) } composable(Destination.Stats.route) { StatsScreen() } + composable(Destination.Resources.route) { ResourcesScreen() } composable( Destination.Thread.route, arguments = listOf(navArgument("threadId") { type = NavType.StringType }), diff --git a/app/src/main/java/ch/rhosys/email/presentation/resources/ResourcesScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/resources/ResourcesScreen.kt new file mode 100644 index 0000000..70be210 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/presentation/resources/ResourcesScreen.kt @@ -0,0 +1,127 @@ +package ch.rhosys.email.presentation.resources + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.rhosys.email.di.LocalAppContainer +import ch.rhosys.email.domain.model.Resource +import ch.rhosys.email.domain.model.ResourceStatus +import ch.rhosys.email.domain.repository.AccountRepository +import ch.rhosys.email.domain.repository.ResourceRepository +import ch.rhosys.email.presentation.components.EmptyState +import ch.rhosys.email.presentation.components.rememberViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +/** Mirrors the web app's Resources view: extracted workflow artifacts across all threads. */ +class ResourcesViewModel( + private val resourceRepository: ResourceRepository, + accountRepository: AccountRepository, +) : ViewModel() { + private val activeAccountId = accountRepository.activeAccountId() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val resources: StateFlow> = activeAccountId.filterNotNull().flatMapLatest { accountId -> + viewModelScope.launch { runCatching { resourceRepository.refresh(accountId) } } + resourceRepository.observeResources(accountId) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun toggleStatus(resource: Resource) { + val accountId = activeAccountId.value ?: return + val next = if (resource.status == ResourceStatus.ACTIVE) ResourceStatus.COMPLETE else ResourceStatus.ACTIVE + viewModelScope.launch { resourceRepository.setStatus(accountId, resource.resourceId, next) } + } +} + +@Composable +fun ResourcesScreen() { + val container = LocalAppContainer.current + val viewModel = rememberViewModel { ResourcesViewModel(container.resourceRepository, container.accountRepository) } + val resources by viewModel.resources.collectAsState() + + if (resources.isEmpty()) { + EmptyState( + title = "No resources", + message = "Tracking numbers, boarding passes, and other extracted details will show up here.", + celebration = false, + ) + return + } + + LazyColumn(modifier = Modifier.fillMaxSize().padding(vertical = 8.dp)) { + items(resources, key = { it.resourceId }) { resource -> + ResourceCard(resource = resource, onToggleStatus = { viewModel.toggleStatus(resource) }) + } + } +} + +@Composable +private fun ResourceCard(resource: Resource, onToggleStatus: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + resource.workflow.name.lowercase().replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + ) + Text( + if (resource.status == ResourceStatus.COMPLETE) "Complete" else "Active", + style = MaterialTheme.typography.labelLarge, + color = if (resource.status == ResourceStatus.COMPLETE) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + }, + ) + } + resource.displayDate?.let { date -> + Text(date, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } ?: resource.expectedResolutionDate?.let { at -> + Text( + DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(at.toEpochMilli())), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + resource.assets.forEach { asset -> + Text( + "${asset.label}: ${asset.rawValue}", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + ) + } + Row(modifier = Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onToggleStatus) { + Text(if (resource.status == ResourceStatus.COMPLETE) "Mark active" else "Mark complete") + } + } + } + } +} diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt index 88f4e7f..c3f8532 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt @@ -10,13 +10,18 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Switch import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -33,15 +38,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import ch.rhosys.email.data.local.entity.LogEntryEntity import ch.rhosys.email.di.LocalAppContainer +import ch.rhosys.email.domain.model.AfterSendAction +import ch.rhosys.email.domain.model.UnknownSenderPolicy import ch.rhosys.email.presentation.components.ThemePicker import ch.rhosys.email.presentation.components.rememberViewModel +import ch.rhosys.email.presentation.components.verticalScrollbar +import ch.rhosys.email.presentation.stats.StatsScreen import ch.rhosys.email.ui.theme.CatppuccinFlavor import java.text.DateFormat import java.util.Date @Composable fun SettingsScreen( - onNavigateStats: () -> Unit, onSignedOut: () -> Unit, ) { val container = LocalAppContainer.current @@ -58,39 +66,42 @@ fun SettingsScreen( var tabIndex by remember { mutableStateOf(0) } var showSignOutConfirm by remember { mutableStateOf(false) } // No Security tab: the API has no MFA endpoints. No billing either. - val tabs = listOf("Aliases", "Email & Forwarding", "Users", "Logs") + // Theme leads (it's the setting people reach for first) and Stats is its + // own tab rather than a link that navigates away from Settings. + val tabs = listOf("Theme", "Aliases", "Email & Forwarding", "Stats", "Users", "Logs") LaunchedEffect(tabIndex) { when (tabIndex) { - 1 -> viewModel.loadForwardingAndDomains() - 2 -> viewModel.loadAccountUsers() + 2 -> viewModel.loadForwardingAndDomains() + 4 -> viewModel.loadAccountUsers() } } Column(modifier = Modifier.fillMaxSize()) { - AppPreferencesSection( - uiState = uiState, - onThemeSelected = viewModel::setThemeFlavor, - onBiometricToggle = viewModel::setBiometricLockEnabled, - onNavigateStats = onNavigateStats, - onSignOutClick = { showSignOutConfirm = true }, - ) - HorizontalDivider() - TabRow(selectedTabIndex = tabIndex) { + ScrollableTabRow(selectedTabIndex = tabIndex) { tabs.forEachIndexed { index, title -> Tab(selected = tabIndex == index, onClick = { tabIndex = index }, text = { Text(title) }) } } when (tabIndex) { - 0 -> AliasesTab(uiState) - 1 -> ForwardingTab( + 0 -> ThemeTab( + uiState = uiState, + onThemeSelected = viewModel::setThemeFlavor, + onBiometricToggle = viewModel::setBiometricLockEnabled, + onSignOutClick = { showSignOutConfirm = true }, + ) + 1 -> AliasesTab(uiState, onSetUnknownSenderPolicy = viewModel::setAliasUnknownSenderPolicy) + 2 -> ForwardingTab( uiState = uiState, onAddForwarding = viewModel::addForwardingTarget, onRemoveForwarding = viewModel::removeForwardingTarget, onVerifyForwarding = viewModel::verifyForwardingTarget, + onRetentionSelected = viewModel::updateRetentionDuration, + onAfterSendActionSelected = viewModel::updateAfterSendAction, ) - 2 -> UsersTab(uiState) - 3 -> LogsTab(uiState, onClear = viewModel::clearLogs) + 3 -> StatsScreen() + 4 -> UsersTab(uiState) + 5 -> LogsTab(uiState, onClear = viewModel::clearLogs) } } @@ -107,64 +118,157 @@ fun SettingsScreen( } @Composable -private fun AppPreferencesSection( +private fun ThemeTab( uiState: SettingsUiState, onThemeSelected: (CatppuccinFlavor?) -> Unit, onBiometricToggle: (Boolean) -> Unit, - onNavigateStats: () -> Unit, onSignOutClick: () -> Unit, ) { - Column(modifier = Modifier.padding(12.dp)) { - Text("Theme", style = MaterialTheme.typography.titleMedium) - Text( - "Each tile is drawn in the theme it applies.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp, bottom = 8.dp), - ) - ThemePicker( - selected = uiState.themeFlavor, - onSelect = onThemeSelected, - modifier = Modifier.padding(bottom = 8.dp), - ) - ListItem( - headlineContent = { Text("Biometric lock") }, - supportingContent = { Text("Require Face/Fingerprint unlock to open the app") }, - trailingContent = { Switch(checked = uiState.biometricLockEnabled, onCheckedChange = onBiometricToggle) }, - ) - ListItem(headlineContent = { Text("Stats") }, modifier = Modifier.clickableSettings(onNavigateStats)) - ListItem( - headlineContent = { Text("Sign out", color = MaterialTheme.colorScheme.error) }, - modifier = Modifier.clickableSettings(onSignOutClick), - ) + val listState = rememberLazyListState() + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().verticalScrollbar(listState), + ) { + item { + Column(modifier = Modifier.padding(12.dp)) { + Text("Theme", style = MaterialTheme.typography.titleMedium) + Text( + "Each tile is drawn in the theme it applies.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp, bottom = 8.dp), + ) + ThemePicker( + selected = uiState.themeFlavor, + onSelect = onThemeSelected, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + item { HorizontalDivider() } + item { + ListItem( + headlineContent = { Text("Biometric lock") }, + supportingContent = { Text("Require Face/Fingerprint unlock to open the app") }, + trailingContent = { Switch(checked = uiState.biometricLockEnabled, onCheckedChange = onBiometricToggle) }, + ) + } + item { + ListItem( + headlineContent = { Text("Sign out", color = MaterialTheme.colorScheme.error) }, + modifier = Modifier.clickable(onClick = onSignOutClick), + ) + } } } -private fun Modifier.clickableSettings(onClick: () -> Unit): Modifier = - this.clickable(onClick = onClick) - @Composable -private fun AliasesTab(uiState: SettingsUiState) { - LazyColumn(modifier = Modifier.fillMaxSize()) { +private fun AliasesTab(uiState: SettingsUiState, onSetUnknownSenderPolicy: (String, UnknownSenderPolicy) -> Unit) { + val listState = rememberLazyListState() + LazyColumn(state = listState, modifier = Modifier.fillMaxSize().verticalScrollbar(listState)) { items(uiState.aliases, key = { it.alias }) { alias -> + var menuExpanded by remember { mutableStateOf(false) } ListItem( headlineContent = { Text(alias.alias) }, supportingContent = { Text("Unknown senders: ${alias.unknownSenderPolicy.label}") }, + trailingContent = { + androidx.compose.foundation.layout.Box { + TextButton(onClick = { menuExpanded = true }) { Text("Edit") } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + UnknownSenderPolicy.entries.forEach { policy -> + DropdownMenuItem( + text = { Text(policy.label) }, + onClick = { + menuExpanded = false + onSetUnknownSenderPolicy(alias.alias, policy) + }, + ) + } + } + } + }, ) + HorizontalDivider() } } } +private val RETENTION_OPTIONS = listOf( + "P1M" to "1 month", "P2M" to "2 months", "P3M" to "3 months", "P5M" to "5 months", "P6M" to "6 months", + "P1Y" to "1 year", "P2Y" to "2 years", "P5Y" to "5 years", "P10Y" to "10 years", "Infinity" to "Forever", +) + +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable private fun ForwardingTab( uiState: SettingsUiState, onAddForwarding: (String) -> Unit, onRemoveForwarding: (String) -> Unit, onVerifyForwarding: (String) -> Unit, + onRetentionSelected: (String) -> Unit, + onAfterSendActionSelected: (AfterSendAction) -> Unit, ) { var newAddress by remember { mutableStateOf("") } - LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp)) { - item { Text("DNS records", style = MaterialTheme.typography.titleMedium) } + var retentionMenuExpanded by remember { mutableStateOf(false) } + var afterSendMenuExpanded by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + + LazyColumn(state = listState, modifier = Modifier.fillMaxSize().padding(12.dp).verticalScrollbar(listState)) { + item { Text("Compose behavior", style = MaterialTheme.typography.titleMedium) } + item { + ExposedDropdownMenuBox( + expanded = afterSendMenuExpanded, + onExpandedChange = { afterSendMenuExpanded = it }, + modifier = Modifier.padding(top = 4.dp), + ) { + TextField( + value = if (uiState.currentAccount?.afterSendAction == AfterSendAction.ARCHIVE) "Archive after sending" else "Keep active after sending", + onValueChange = {}, + readOnly = true, + label = { Text("After you send a reply") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = afterSendMenuExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable), + ) + DropdownMenu(expanded = afterSendMenuExpanded, onDismissRequest = { afterSendMenuExpanded = false }) { + DropdownMenuItem(text = { Text("Keep active after sending") }, onClick = { + afterSendMenuExpanded = false + onAfterSendActionSelected(AfterSendAction.KEEP_ACTIVE) + }) + DropdownMenuItem(text = { Text("Archive after sending") }, onClick = { + afterSendMenuExpanded = false + onAfterSendActionSelected(AfterSendAction.ARCHIVE) + }) + } + } + } + item { + ExposedDropdownMenuBox( + expanded = retentionMenuExpanded, + onExpandedChange = { retentionMenuExpanded = it }, + modifier = Modifier.padding(top = 12.dp), + ) { + val currentLabel = RETENTION_OPTIONS.firstOrNull { it.first == uiState.currentAccount?.retentionDuration }?.second + ?: "Not set" + TextField( + value = currentLabel, + onValueChange = {}, + readOnly = true, + label = { Text("Mail retention") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = retentionMenuExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable), + ) + DropdownMenu(expanded = retentionMenuExpanded, onDismissRequest = { retentionMenuExpanded = false }) { + RETENTION_OPTIONS.forEach { (value, label) -> + DropdownMenuItem(text = { Text(label) }, onClick = { + retentionMenuExpanded = false + onRetentionSelected(value) + }) + } + } + } + } + + item { Text("Domains & DNS records", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 20.dp)) } items(uiState.dnsRecords) { record -> ListItem( headlineContent = { Text("${record.type} — ${record.name}") }, @@ -174,6 +278,7 @@ private fun ForwardingTab( } item { Text("Forwarding addresses", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 16.dp)) } items(uiState.forwardingTargets, key = { it.target }) { target -> + var showRemoveConfirm by remember { mutableStateOf(false) } ListItem( headlineContent = { Text(target.target) }, supportingContent = { Text(target.status.replaceFirstChar { it.uppercase() }) }, @@ -182,10 +287,24 @@ private fun ForwardingTab( if (target.status != "verified") { TextButton(onClick = { onVerifyForwarding(target.target) }) { Text("Verify") } } - TextButton(onClick = { onRemoveForwarding(target.target) }) { Text("Remove") } + TextButton(onClick = { showRemoveConfirm = true }) { Text("Remove") } } }, ) + if (showRemoveConfirm) { + AlertDialog( + onDismissRequest = { showRemoveConfirm = false }, + title = { Text("Remove forwarding address?") }, + text = { Text("Mail will stop forwarding to ${target.target}.") }, + confirmButton = { + TextButton(onClick = { + showRemoveConfirm = false + onRemoveForwarding(target.target) + }) { Text("Remove") } + }, + dismissButton = { TextButton(onClick = { showRemoveConfirm = false }) { Text("Cancel") } }, + ) + } } item { Row(verticalAlignment = Alignment.CenterVertically) { @@ -198,7 +317,8 @@ private fun ForwardingTab( @Composable private fun UsersTab(uiState: SettingsUiState) { - LazyColumn(modifier = Modifier.fillMaxSize()) { + val listState = rememberLazyListState() + LazyColumn(state = listState, modifier = Modifier.fillMaxSize().verticalScrollbar(listState)) { items(uiState.accountUsers, key = { it.userId }) { user -> ListItem( headlineContent = { Text(user.email ?: user.name ?: user.userId) }, @@ -216,6 +336,8 @@ private fun UsersTab(uiState: SettingsUiState) { @Composable private fun LogsTab(uiState: SettingsUiState, onClear: () -> Unit) { val context = LocalContext.current + val listState = rememberLazyListState() + var showClearConfirm by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), @@ -233,7 +355,7 @@ private fun LogsTab(uiState: SettingsUiState, onClear: () -> Unit) { context.startActivity(Intent.createChooser(intent, "Share logs")) }, ) { Text("Share") } - TextButton(enabled = uiState.logs.isNotEmpty(), onClick = onClear) { Text("Clear") } + TextButton(enabled = uiState.logs.isNotEmpty(), onClick = { showClearConfirm = true }) { Text("Clear") } } HorizontalDivider() if (uiState.logs.isEmpty()) { @@ -244,7 +366,7 @@ private fun LogsTab(uiState: SettingsUiState, onClear: () -> Unit) { modifier = Modifier.padding(12.dp), ) } else { - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize().verticalScrollbar(listState)) { items(uiState.logs, key = { it.id }) { entry -> ListItem( headlineContent = { @@ -260,6 +382,21 @@ private fun LogsTab(uiState: SettingsUiState, onClear: () -> Unit) { } } } + + if (showClearConfirm) { + AlertDialog( + onDismissRequest = { showClearConfirm = false }, + title = { Text("Clear logs?") }, + text = { Text("This will permanently delete the diagnostic log on this device.") }, + confirmButton = { + TextButton(onClick = { + showClearConfirm = false + onClear() + }) { Text("Clear") } + }, + dismissButton = { TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") } }, + ) + } } private fun LogEntryEntity.toShareText(): String { diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt index 0856b72..0ab221e 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt @@ -11,13 +11,17 @@ import ch.rhosys.email.data.remote.dto.AccountUserDto import ch.rhosys.email.data.remote.dto.DnsRecordDto import ch.rhosys.email.data.remote.dto.DomainDto import ch.rhosys.email.data.remote.dto.ForwardingTargetDto +import ch.rhosys.email.domain.model.Account +import ch.rhosys.email.domain.model.AfterSendAction import ch.rhosys.email.domain.model.Alias +import ch.rhosys.email.domain.model.UnknownSenderPolicy import ch.rhosys.email.domain.repository.AccountRepository import ch.rhosys.email.ui.theme.CatppuccinFlavor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -32,6 +36,7 @@ data class SettingsUiState( val dnsRecords: List = emptyList(), val forwardingTargets: List = emptyList(), val accountUsers: List = emptyList(), + val currentAccount: Account? = null, val themeFlavor: CatppuccinFlavor? = null, val biometricLockEnabled: Boolean = false, val logs: List = emptyList(), @@ -68,6 +73,11 @@ class SettingsViewModel( viewModelScope.launch { appLogger.observeAll().collect { logs -> _uiState.value = _uiState.value.copy(logs = logs) } } + viewModelScope.launch { + activeAccountId.filterNotNull().combine(accountRepository.observeAccounts()) { accountId, accounts -> + accounts.firstOrNull { it.accountId == accountId } + }.collect { account -> _uiState.value = _uiState.value.copy(currentAccount = account) } + } } fun clearLogs() = viewModelScope.launch { appLogger.clear() } @@ -129,6 +139,21 @@ class SettingsViewModel( } } + fun setAliasUnknownSenderPolicy(alias: String, policy: UnknownSenderPolicy) { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { accountRepository.setAliasUnknownSenderPolicy(accountId, alias, policy) } + } + + fun updateRetentionDuration(retentionDuration: String) { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { accountRepository.updateAccountSettings(accountId, retentionDuration = retentionDuration) } + } + + fun updateAfterSendAction(afterSendAction: AfterSendAction) { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { accountRepository.updateAccountSettings(accountId, afterSendAction = afterSendAction) } + } + fun setThemeFlavor(flavor: CatppuccinFlavor?) = viewModelScope.launch { preferencesStore.setThemeFlavor(flavor) } fun setBiometricLockEnabled(enabled: Boolean) = viewModelScope.launch { preferencesStore.setBiometricLockEnabled(enabled) } diff --git a/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadScreen.kt index e950e4f..409e984 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.core.text.HtmlCompat import ch.rhosys.email.di.LocalAppContainer import androidx.compose.ui.platform.LocalUriHandler import ch.rhosys.email.domain.model.Attachment @@ -59,6 +60,8 @@ fun ThreadScreen(accountId: String, threadId: String, onBack: () -> Unit, onRepl val signals by viewModel.signals.collectAsState() val uiState by viewModel.uiState.collectAsState() var showMenu by remember { mutableStateOf(false) } + var showArchiveConfirm by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } val uriHandler = LocalUriHandler.current // Unsubscribe returns a URL to open rather than completing server-side. @@ -77,12 +80,9 @@ fun ThreadScreen(accountId: String, threadId: String, onBack: () -> Unit, onRepl IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back") } }, actions = { - IconButton(onClick = { viewModel.archive(); onBack() }) { + IconButton(onClick = { showArchiveConfirm = true }) { Icon(Icons.Filled.Archive, contentDescription = "Archive") } - IconButton(onClick = { viewModel.delete(); onBack() }) { - Icon(Icons.Filled.Delete, contentDescription = "Delete") - } IconButton(onClick = { showMenu = true }) { Icon(Icons.Filled.MoreVert, contentDescription = "More") } @@ -91,6 +91,10 @@ fun ThreadScreen(accountId: String, threadId: String, onBack: () -> Unit, onRepl showMenu = false viewModel.openSenderPolicy() }, leadingIcon = { Icon(Icons.Filled.Block, contentDescription = null) }) + DropdownMenuItem(text = { Text("Delete") }, onClick = { + showMenu = false + showDeleteConfirm = true + }, leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) }) } }, ) @@ -126,6 +130,38 @@ fun ThreadScreen(accountId: String, threadId: String, onBack: () -> Unit, onRepl onDismiss = { viewModel.dismissSenderPolicy() }, ) } + + if (showArchiveConfirm) { + AlertDialog( + onDismissRequest = { showArchiveConfirm = false }, + title = { Text("Archive thread?") }, + text = { Text("This thread will be moved to your archive.") }, + confirmButton = { + TextButton(onClick = { + showArchiveConfirm = false + viewModel.archive() + onBack() + }) { Text("Archive") } + }, + dismissButton = { TextButton(onClick = { showArchiveConfirm = false }) { Text("Cancel") } }, + ) + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete thread?") }, + text = { Text("This thread will be permanently deleted. This can't be undone.") }, + confirmButton = { + TextButton(onClick = { + showDeleteConfirm = false + viewModel.delete() + onBack() + }) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } }, + ) + } } /** @@ -206,6 +242,13 @@ private fun UnsubscribeBar(onUnsubscribe: () -> Unit) { } } +/** + * Signal bodies are frequently HTML; the collapsed one-line preview isn't run + * through Markwon, so strip tags here to avoid literal "
" markup showing. + */ +private fun plainTextPreview(body: String): String = + HtmlCompat.fromHtml(body, HtmlCompat.FROM_HTML_MODE_COMPACT).toString().trim() + /** * Renders any of the three signal shapes. Attachments open at the URL the * backend supplies on the signal — there is no download endpoint. @@ -263,7 +306,7 @@ private fun SignalCard( } } else { Text( - body.take(80), + plainTextPreview(body).take(80), maxLines = 1, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a44c48a..126c4b7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ coil-compose = { module = "io.coil-kt:coil-compose", markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" } markwon-ext-strikethrough = { module = "io.noties.markwon:ext-strikethrough", version.ref = "markwon" } markwon-linkify = { module = "io.noties.markwon:linkify", version.ref = "markwon" } +markwon-html = { module = "io.noties.markwon:html", version.ref = "markwon" } paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "paging" } paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }