sync: what the on-device round found

Everything here came from running the account flow against a real
Nextcloud rather than from reading the code.

Discovery
- A typed bare origin now gets the RFC 6764 well-known probe. It was
  returned as the only candidate, so `https://cloud.example.com` — what
  people actually type — was PROPFIND'd against the web UI, answered 405,
  and a working Nextcloud reported as "not a CalDAV server".
- A same-host HTTPS→HTTP redirect is put back on TLS instead of refused
  (`dav` change 7). A Nextcloud behind a TLS-terminating proxy without
  `overwriteprotocol` builds every redirect with http://, including the
  /.well-known/caldav hop discovery depends on. Cross-host still throws.
- Outcomes carry a `Cause` the UI translates, not the server's own words.
  "HTTP 405 Method Not Allowed" told someone entering an address nothing,
  in a language they may not read, from outside strings.xml.
- An IPv6 origin keeps its brackets: `HttpUrl.host` returns "fd00::1", so
  the rebuilt origin did not parse and a homelab address came back as
  "not an address".

Login Flow v2
- The poll response's scheme is coerced, never refused. Nextcloud returns
  the app password exactly once, so throwing there burned a live
  credential and left it dangling in the user's device list. The host
  mismatch already worked this way; the scheme now matches it.

Accounts
- The accounts screen observes Room and the sign-in state instead of
  taking a snapshot, so a sync landing — or a 401 stopping an account —
  reaches a screen that is already open.
- A per-account detail screen, and provider identity (`CalDavProvider`)
  shared with the quirk table so one list drives both the icon and the
  warning.
- The password field masks: floret-kit's `InlineTextField` gained a
  visual transformation, since `KeyboardType.Password` only tells the IME
  to drop suggestions.
This commit is contained in:
2026-09-07 18:41:45 +02:00
parent 28b2423ad9
commit b4a7fcb46e
31 changed files with 1455 additions and 280 deletions
@@ -7,6 +7,7 @@ import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.TaskCollection
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -62,6 +63,9 @@ class AccountRepository @Inject constructor(
suspend fun all(): List<AccountEntity> = withContext(io) { database.accounts().all() }
/** The accounts, observed, so a sync landing updates a screen that is open. */
fun observeAll(): Flow<List<AccountEntity>> = database.accounts().observeAll()
/**
* Creates an account and the task lists the user chose.
*
@@ -5,7 +5,9 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.data.di.SyncStateDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@@ -28,8 +30,12 @@ class AccountStateStore @Inject constructor(
@SyncStateDataStore private val dataStore: DataStore<Preferences>,
) {
suspend fun needingSignIn(): Set<Long> =
dataStore.data.first()[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
suspend fun needingSignIn(): Set<Long> = observeNeedingSignIn().first()
/** Observed, so a 401 during a background sync reaches an open screen. */
fun observeNeedingSignIn(): Flow<Set<Long>> = dataStore.data.map { prefs ->
prefs[KEY].orEmpty().mapNotNull { it.toLongOrNull() }.toSet()
}
suspend fun needsSignIn(accountId: Long): Boolean = accountId in needingSignIn()
@@ -4,15 +4,27 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/** Reads and writes over `accounts`. Unused until sync lands. */
/** Reads and writes over `accounts`. */
@Dao
interface AccountDao {
@Query("SELECT * FROM accounts ORDER BY display_name")
fun all(): List<AccountEntity>
/**
* The same rows, observed.
*
* ⚠️ A one-shot read cannot show a sync: the worker writes `last_sync_at`
* from a background thread minutes after the button was pressed, so a screen
* holding a snapshot goes on saying "never synced" until the user leaves and
* comes back. Room's invalidation tracker is what closes that gap.
*/
@Query("SELECT * FROM accounts ORDER BY display_name")
fun observeAll(): Flow<List<AccountEntity>>
@Query("SELECT * FROM accounts WHERE id = :accountId")
fun account(accountId: Long): AccountEntity?
@@ -0,0 +1,211 @@
package de.jeanlucmakiola.agendula.ui.accounts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Login
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.DeleteOutline
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.Inventory2
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/**
* One account: who it is, how its last sync went, and what can be done to it.
*
* Tapping a row in the list opens this rather than a destructive prompt — a list
* row leads somewhere, it does not fire an irreversible action — so removing an
* account is a button on the account's own screen.
*/
@Composable
internal fun AccountDetailScreen(
accountId: Long,
onBack: () -> Unit,
onRemoved: () -> Unit,
onSignInAgain: () -> Unit,
viewModel: AccountsViewModel,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val row = accounts?.firstOrNull { it.account.id == accountId }
// The row is gone the instant it is removed, while this screen is still
// sliding away. Keeping the last one it had stops that exit animating an
// empty screen.
var lastKnown by remember(accountId) { mutableStateOf(row) }
LaunchedEffect(row) { if (row != null) lastKnown = row }
val shown = row ?: lastKnown ?: return
val account = shown.account
val identity = account.identity()
var removing by remember { mutableStateOf(false) }
CollapsingScaffold(title = identity.title, onBack = onBack) {
AccountHero(identity)
Spacer(Modifier.height(24.dp))
val actions = buildList<@Composable (Position) -> Unit> {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_last_sync),
summary = syncState(shown),
position = position,
leading = { Icon(Icons.Rounded.History, contentDescription = null) },
)
}
if (shown.needsSignIn) {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_sign_in_again),
position = position,
leading = { Icon(Icons.AutoMirrored.Rounded.Login, contentDescription = null) },
onClick = onSignInAgain,
)
}
} else {
add { position ->
GroupedRow(
title = stringResource(R.string.accounts_sync_now),
position = position,
leading = { Icon(Icons.Rounded.CloudSync, contentDescription = null) },
onClick = { viewModel.syncNow(account) },
)
}
}
}
actions.forEachIndexed { index, action -> action(positionOf(index, actions.size)) }
Spacer(Modifier.height(24.dp))
// Its own group, away from the things that are safe to press twice.
GroupedRow(
title = errorTitle(stringResource(R.string.accounts_remove)),
position = Position.Alone,
leading = {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { removing = true },
)
Spacer(Modifier.height(24.dp))
}
if (removing) {
RemoveAccountPicker(
identity = identity,
onDismiss = { removing = false },
onRemove = { deleteLocalData ->
viewModel.remove(account, deleteLocalData)
removing = false
onRemoved()
},
)
}
}
/** The account's logo at full size, over the two things the title bar left out. */
@Composable
private fun AccountHero(identity: AccountIdentity) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ProviderLogo(identity.provider, size = 72.dp)
Spacer(Modifier.height(12.dp))
identity.user?.let {
Text(it, style = MaterialTheme.typography.titleMedium)
}
Text(
identity.secondary ?: stringResource(R.string.accounts_generic_provider),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* Removing an account is two different things — the lists stay behind as
* device-only lists, or they go with it — so it is a browse-style choice, and
* those are full-screen. Each row says what it does and does it; leaving without
* choosing is back.
*/
@Composable
private fun RemoveAccountPicker(
identity: AccountIdentity,
onDismiss: () -> Unit,
onRemove: (deleteLocalData: Boolean) -> Unit,
) {
FullScreenPicker(
title = stringResource(R.string.accounts_remove),
onDismiss = onDismiss,
predictiveBack = true,
) {
Text(
stringResource(R.string.accounts_remove_body, identity.title),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.accounts_remove_keep),
summary = stringResource(R.string.accounts_remove_keep_body),
position = Position.Top,
leading = { Icon(Icons.Rounded.Inventory2, contentDescription = null) },
onClick = { onRemove(false) },
)
GroupedRow(
title = errorTitle(stringResource(R.string.accounts_remove_wipe)),
summary = AnnotatedString(stringResource(R.string.accounts_remove_wipe_body)),
position = Position.Bottom,
leading = {
Icon(
Icons.Rounded.DeleteOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
onClick = { onRemove(true) },
)
}
}
/** A destructive row's title, in the error colour the row itself cannot take. */
@Composable
private fun errorTitle(text: String): AnnotatedString {
val error = MaterialTheme.colorScheme.error
return remember(text, error) {
buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } }
}
}
@@ -0,0 +1,135 @@
package de.jeanlucmakiola.agendula.ui.accounts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AlternateEmail
import androidx.compose.material.icons.rounded.Cloud
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Dns
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.caldav.CalDavProvider
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* What an account is called on screen, and by what mark.
*
* `display_name` is `user@host` — the right identity for the system account and
* for the sync trigger that keys off it, and far too long for a row title. Both
* halves are stored separately anyway, so the screen shows the one that
* identifies the account and demotes the other to the supporting line.
*/
internal data class AccountIdentity(
val provider: CalDavProvider?,
/** The name: a hosted service's brand, or the host of a server the user runs. */
val title: String,
/** The half [title] left out — the host under a brand, the software under a host. */
val secondary: String?,
/** Who, on that server. */
val user: String?,
)
internal fun AccountEntity.identity(): AccountIdentity {
val url = principalUrl?.toHttpUrlOrNull()
val provider = url?.let { CalDavProvider.forPrincipal(it) }
val host = url?.host?.removePrefix("www.")
val hosted = provider?.hosted == true
return AccountIdentity(
provider = provider,
// The fallback is the stored name: an account with no principal URL
// never got past discovery, so there is nothing better to call it.
title = if (hosted) provider.label else host ?: displayName,
secondary = if (hosted) host else provider?.label,
user = username?.takeIf { it.isNotBlank() },
)
}
/**
* The provider's logo: its mark, in white, on a disc of its own brand colour —
* the 40dp leading avatar Calendula gives a synced calendar, in colour, so a
* list of accounts is scannable by mark rather than by reading hostnames.
*
* White-on-brand rather than a brand-tinted glyph on a neutral chip, because it
* is the shape the marks are actually drawn in and it is the one treatment that
* needs no second colour for dark mode. Providers with no brand colour of their
* own take the app's [MaterialTheme] accent and say what they are — a mail
* service, a server, a cloud.
*/
@Composable
internal fun ProviderLogo(provider: CalDavProvider?, size: Dp = 40.dp) {
val accent = provider?.accent
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(accent ?: MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
val tint = if (accent != null) Color.White else MaterialTheme.colorScheme.onPrimary
val glyph = Modifier.size(size * GLYPH_FRACTION)
if (provider == CalDavProvider.NEXTCLOUD) {
Icon(
painter = painterResource(R.drawable.ic_provider_nextcloud),
contentDescription = provider.label,
tint = tint,
modifier = glyph,
)
} else {
Icon(
imageVector = when (provider) {
CalDavProvider.BAIKAL,
CalDavProvider.DAVICAL,
CalDavProvider.SOGO,
-> Icons.Rounded.Dns
CalDavProvider.ICLOUD -> Icons.Rounded.Cloud
null -> Icons.Rounded.CloudSync
else -> Icons.Rounded.AlternateEmail
},
contentDescription = provider?.label,
tint = tint,
modifier = glyph,
)
}
}
}
/**
* The provider's own brand colour, where it publishes one recognisable enough to
* be worth carrying. Null means "we would be inventing it" — Baïkal, DAViCal and
* SOGo have no colour anyone would recognise, so they take the theme's accent
* instead of a made-up one.
*
* Each is dark enough to carry a white mark, which is the whole treatment: no
* dark-mode variant is needed because neither colour in the pair moves.
*/
private val CalDavProvider.accent: Color?
get() = when (this) {
CalDavProvider.NEXTCLOUD -> Color(0xFF0082C9)
CalDavProvider.ICLOUD -> Color(0xFF007AFF)
CalDavProvider.GOOGLE -> Color(0xFF1A73E8)
CalDavProvider.FASTMAIL -> Color(0xFF2B6CB0)
CalDavProvider.MAILBOX_ORG -> Color(0xFF0069B4)
CalDavProvider.POSTEO -> Color(0xFF5E9B23)
CalDavProvider.ZOHO -> Color(0xFFE42527)
CalDavProvider.YANDEX -> Color(0xFFE03A1B)
CalDavProvider.BAIKAL, CalDavProvider.DAVICAL, CalDavProvider.SOGO -> null
}
/** The mark sits on the disc the way a launcher icon does — a little over half. */
private const val GLYPH_FRACTION = 0.55f
@@ -2,39 +2,26 @@ package de.jeanlucmakiola.agendula.ui.accounts
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Login
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Login
import androidx.compose.material.icons.rounded.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
@@ -43,15 +30,11 @@ import de.jeanlucmakiola.floret.components.positionOf
@Composable
internal fun AccountsScreen(
onAddAccount: () -> Unit,
onOpenAccount: (Long) -> Unit,
onBack: () -> Unit,
viewModel: AccountsViewModel,
) {
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
var pendingRemoval by remember { mutableStateOf<AccountEntity?>(null) }
var deleteLocalData by remember { mutableStateOf(false) }
// An account can be removed from system Settings while we are away.
OnResume { viewModel.refresh() }
CollapsingScaffold(
title = stringResource(R.string.settings_section_accounts),
@@ -63,50 +46,36 @@ internal fun AccountsScreen(
Icon(
Icons.Rounded.CloudSync,
contentDescription = null,
modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.accounts_empty_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.accounts_empty_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(24.dp))
} else {
loaded.forEachIndexed { index, row ->
val account = row.account
val identity = account.identity()
GroupedRow(
title = account.displayName,
// Never the raw values: lastSyncError is an exception string
// and lastSyncAt renders as an ISO-8601 UTC instant, and both
// bypass strings.xml entirely.
summary = when {
// Distinct from a failed sync on purpose: this one has
// stopped retrying, and only the user can restart it.
row.needsSignIn -> stringResource(R.string.accounts_needs_sign_in)
account.lastSyncError != null ->
stringResource(R.string.accounts_sync_failed)
account.lastSyncAt != null -> DateUtils.getRelativeTimeSpanString(
account.lastSyncAt.toEpochMilliseconds(),
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
else -> stringResource(R.string.accounts_never_synced)
},
title = identity.title,
summary = accountSummary(identity.user, syncState(row)),
position = positionOf(index, loaded.size),
modifier = Modifier.padding(horizontal = 16.dp),
leading = { ProviderLogo(identity.provider) },
trailing = {
if (row.needsSignIn) {
IconButton(onClick = onAddAccount) {
Icon(
Icons.Rounded.Login,
Icons.AutoMirrored.Rounded.Login,
contentDescription = stringResource(R.string.accounts_sign_in_again),
)
}
@@ -119,7 +88,7 @@ internal fun AccountsScreen(
}
}
},
onClick = { pendingRemoval = account },
onClick = { onOpenAccount(account.id) },
)
}
Spacer(Modifier.height(24.dp))
@@ -128,53 +97,32 @@ internal fun AccountsScreen(
GroupedRow(
title = stringResource(R.string.accounts_add),
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
leading = { Icon(Icons.Rounded.Add, contentDescription = null) },
onClick = onAddAccount,
)
}
// A plain confirmation, which is the one thing CLAUDE.md still allows an
// AlertDialog for. The checkbox modifies the confirmation rather than turning
// it into a chooser — a full-screen picker for "are you sure" would be worse,
// and a radio list is banned outright.
pendingRemoval?.let { account ->
AlertDialog(
onDismissRequest = { pendingRemoval = null; deleteLocalData = false },
title = { Text(stringResource(R.string.accounts_remove_confirm_title, account.displayName)) },
text = {
Column {
Text(stringResource(R.string.accounts_remove_confirm_body))
Spacer(Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.toggleable(
value = deleteLocalData,
role = Role.Checkbox,
onValueChange = { deleteLocalData = it },
),
) {
Checkbox(checked = deleteLocalData, onCheckedChange = null)
Spacer(Modifier.width(12.dp))
Text(
stringResource(R.string.accounts_remove_delete_local),
style = MaterialTheme.typography.bodyMedium,
)
}
}
},
confirmButton = {
TextButton(onClick = {
viewModel.remove(account, deleteLocalData)
pendingRemoval = null
deleteLocalData = false
}) { Text(stringResource(R.string.accounts_remove_confirm_action)) }
},
dismissButton = {
TextButton(onClick = { pendingRemoval = null; deleteLocalData = false }) {
Text(stringResource(android.R.string.cancel))
}
},
)
}
}
/**
* Never the raw values: `lastSyncError` is an exception string and `lastSyncAt`
* renders as an ISO-8601 UTC instant, and both bypass `strings.xml` entirely.
*/
@Composable
internal fun syncState(row: AccountsViewModel.AccountRow): String = when {
// Distinct from a failed sync on purpose: this one has stopped retrying, and
// only the user can restart it.
row.needsSignIn -> stringResource(R.string.accounts_needs_sign_in)
row.account.lastSyncError != null -> stringResource(R.string.accounts_sync_failed)
row.account.lastSyncAt != null -> DateUtils.getRelativeTimeSpanString(
row.account.lastSyncAt.toEpochMilliseconds(),
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
).toString()
else -> stringResource(R.string.accounts_never_synced)
}
/** Who, then how it last went — the two things the title does not already say. */
@Composable
private fun accountSummary(user: String?, state: String): String =
if (user == null) state else stringResource(R.string.accounts_summary, user, state)
@@ -7,9 +7,10 @@ import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.AccountStateStore
import de.jeanlucmakiola.agendula.data.sync.SyncTrigger
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
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.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -23,21 +24,26 @@ class AccountsViewModel @Inject constructor(
/** An account row, plus the one thing the row cannot read from the entity. */
data class AccountRow(val account: AccountEntity, val needsSignIn: Boolean)
private val _accounts = MutableStateFlow<List<AccountRow>?>(null)
/** `null` until the first load, so the empty state does not flash. */
val accounts: StateFlow<List<AccountRow>?> = _accounts.asStateFlow()
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
val stopped = accountState.needingSignIn()
_accounts.value = repository.all().map { AccountRow(it, it.id in stopped) }
}
}
/**
* `null` until the first load, so the empty state does not flash.
*
* ⚠️ Observed, not fetched. The sync that a tap on this screen starts
* finishes on a background thread some seconds later, and a snapshot taken
* when the screen opened cannot show it — the row went on saying "never
* synced" until the user left and came back. This also carries a *background*
* sync, and a 401 that stops an account, onto a screen already open.
*/
val accounts: StateFlow<List<AccountRow>?> =
combine(
repository.observeAll(),
accountState.observeNeedingSignIn(),
) { accounts, stopped ->
accounts.map { AccountRow(it, it.id in stopped) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS),
initialValue = null,
)
/**
* The app's own sync trigger.
@@ -54,9 +60,13 @@ class AccountsViewModel @Inject constructor(
}
fun remove(account: AccountEntity, deleteLocalData: Boolean) {
viewModelScope.launch {
repository.remove(account.id, account.displayName, deleteLocalData)
refresh()
}
// No refresh: the row disappears because the query behind `accounts`
// re-emits.
viewModelScope.launch { repository.remove(account.id, account.displayName, deleteLocalData) }
}
private companion object {
/** Survives a configuration change without re-subscribing. */
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
@@ -4,21 +4,29 @@ import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.CloudSync
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.OpenInBrowser
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -26,21 +34,31 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.navigation.compose.hiltViewModel
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.ServerQuirk
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OnboardingProgress
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
/**
@@ -86,26 +104,56 @@ internal fun AddAccountScreen(
viewModel.onBrowserLaunched()
}
CollapsingScaffold(
title = stringResource(R.string.add_account_title),
// Backing out abandons a flow that may still be polling the server every
// two seconds for the rest of its twenty-minute window.
onBack = {
viewModel.onStartOver()
onBack()
// Backing out abandons a flow that may still be polling the server every two
// seconds for the rest of its twenty-minute window.
val abandon = {
viewModel.onStartOver()
onBack()
}
OnboardingScaffold(
hero = { StepHero(state) },
// Grouped rows and fields carry their own inset, so the column adds none.
contentPadding = 0.dp,
topSpacing = OnboardingSpace.md,
progress = {
val position = state.step.position
if (position != null) {
OnboardingProgress(
step = position,
total = ADD_ACCOUNT_STEPS,
label = stringResource(
R.string.add_account_step_of,
position,
ADD_ACCOUNT_STEPS,
),
)
}
},
navigationIcon = {
IconButton(onClick = abandon) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = { StepActions(state, viewModel) },
) {
val fatal = state.fatal
if (fatal != null) {
Message(icon = { Icon(Icons.Rounded.CloudOff, contentDescription = null) }, text = fatal)
Spacer(Modifier.height(16.dp))
Button(
onClick = viewModel::onStartOver,
modifier = Modifier.padding(horizontal = 16.dp),
) { Text(stringResource(R.string.add_account_start_over)) }
return@CollapsingScaffold
Text(
fatal,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
return@OnboardingScaffold
}
StepTitle(state.step)
when (val step = state.step) {
is AddAccountStep.EnterServer -> ServerStep(step, state.quirk, viewModel)
is AddAccountStep.Working -> WorkingStep(step)
@@ -117,6 +165,74 @@ internal fun AddAccountScreen(
}
}
/** Which of the flow's visible steps this is, or null for one that has no place. */
private val AddAccountStep.position: Int?
get() = when (this) {
is AddAccountStep.EnterServer -> 1
is AddAccountStep.EnterCredentials, is AddAccountStep.WaitingForBrowser -> 2
is AddAccountStep.ChooseLists -> 3
// Working is a moment inside whichever step spawned it, and Done is gone
// before it draws — neither is a place the user can be.
is AddAccountStep.Working, AddAccountStep.Done -> null
}
private const val ADD_ACCOUNT_STEPS = 3
/** The mark for the step, in the family's tonal circle. */
@Composable
private fun StepHero(state: AddAccountUiState) {
val icon = when {
state.fatal != null -> Icons.Rounded.CloudOff
state.step is AddAccountStep.EnterCredentials -> Icons.Rounded.Lock
state.step is AddAccountStep.WaitingForBrowser -> Icons.Rounded.OpenInBrowser
state.step is AddAccountStep.ChooseLists -> Icons.Rounded.Checklist
else -> Icons.Rounded.CloudSync
}
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(34.dp),
)
}
}
/** Title and one line of explanation, centred above whatever the step asks for. */
@Composable
private fun StepTitle(step: AddAccountStep) {
val (title, body) = when (step) {
is AddAccountStep.EnterServer ->
R.string.add_account_server_title to R.string.add_account_server_body
is AddAccountStep.EnterCredentials ->
R.string.add_account_credentials_title to R.string.add_account_credentials_body
is AddAccountStep.ChooseLists ->
R.string.add_account_lists_title to R.string.add_account_lists_body
else -> return
}
Text(
stringResource(title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(8.dp))
Text(
stringResource(body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(Modifier.height(OnboardingSpace.lg))
}
@Composable
private fun ServerStep(
step: AddAccountStep.EnterServer,
@@ -124,69 +240,219 @@ private fun ServerStep(
viewModel: AddAccountViewModel,
) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
FieldRow(
label = stringResource(R.string.add_account_server_label),
value = step.input,
onValueChange = viewModel::onServerInputChanged,
label = { Text(stringResource(R.string.add_account_server_label)) },
placeholder = { Text(stringResource(R.string.add_account_server_hint)) },
isError = step.error != null,
supportingText = step.error?.let { { Text(it) } },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go,
),
modifier = Modifier.fillMaxWidth(),
placeholder = stringResource(R.string.add_account_server_hint),
error = step.error?.let { stringResource(it.message) },
keyboardType = KeyboardType.Uri,
onImeAction = viewModel::onServerSubmitted,
)
// Named before the attempt, not after a 401 the user cannot act on.
quirk?.let { QuirkNote(it) }
Button(
onClick = viewModel::onServerSubmitted,
enabled = step.input.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_continue)) }
}
}
@Composable
private fun CredentialsStep(step: AddAccountStep.EnterCredentials, viewModel: AddAccountViewModel) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = step.username,
onValueChange = viewModel::onUsernameChanged,
label = { Text(stringResource(R.string.add_account_username_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = step.password,
onValueChange = viewModel::onPasswordChanged,
label = { Text(stringResource(R.string.add_account_password_label)) },
visualTransformation = PasswordVisualTransformation(),
isError = step.error != null,
supportingText = {
Text(step.error ?: stringResource(R.string.add_account_password_hint))
},
singleLine = true,
keyboardOptions = KeyboardOptions(
// Two fields in one connected group, which is what the family does with
// a pair that is filled in together.
Column {
FieldRow(
label = stringResource(R.string.add_account_username_label),
value = step.username,
onValueChange = viewModel::onUsernameChanged,
position = Position.Top,
)
FieldRow(
label = stringResource(R.string.add_account_password_label),
value = step.password,
onValueChange = viewModel::onPasswordChanged,
position = Position.Bottom,
error = step.error,
hint = stringResource(R.string.add_account_password_hint),
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
modifier = Modifier.fillMaxWidth(),
onImeAction = viewModel::onCredentialsSubmitted,
)
}
}
}
/**
* A discovery failure, in words rather than a status code.
*
* ⚠️ The server's own message never reaches the screen. The most common failure
* here is a **405** — what an ordinary web server answers to `PROPFIND`, and so
* what someone typing their *website* instead of their CalDAV address gets — and
* "HTTP 405 Method Not Allowed" tells them nothing they can act on. It is also
* untranslatable, and frequently not in a language they read.
*/
private val CalDavDiscovery.Outcome.Cause.message: Int
get() = when (this) {
CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS -> R.string.add_account_error_not_an_address
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER -> R.string.add_account_error_not_dav
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT -> R.string.add_account_error_no_calendar
CalDavDiscovery.Outcome.Cause.UNREACHABLE -> R.string.add_account_error_unreachable
CalDavDiscovery.Outcome.Cause.INSECURE -> R.string.add_account_error_insecure
CalDavDiscovery.Outcome.Cause.NO_CALENDARS -> R.string.add_account_error_no_calendars
CalDavDiscovery.Outcome.Cause.SERVER_ERROR -> R.string.add_account_error_server
}
/**
* The family's text input: a tonal grouped surface with a borderless field in
* it, never Material's outlined box.
*
* The label sits above the value rather than floating into a notch, because a
* `GroupedSurface` has no outline for a notch to interrupt.
*/
@Composable
private fun FieldRow(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
position: Position = Position.Alone,
placeholder: String = "",
error: String? = null,
hint: String? = null,
keyboardType: KeyboardType = KeyboardType.Text,
onImeAction: (() -> Unit)? = null,
) {
// ⚠️ KeyboardType.Password only tells the IME to drop suggestions; it does
// not mask anything. Without the transformation the app password renders in
// the clear on screen.
val masked = keyboardType == KeyboardType.Password
Column(modifier) {
GroupedSurface(position = position) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
InlineTextField(
value = value,
onValueChange = onValueChange,
placeholder = placeholder,
keyboardType = keyboardType,
// A server address and a password are both case-sensitive,
// and sentence-casing either is a support ticket.
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Go,
onImeAction = onImeAction,
visualTransformation = if (masked) {
PasswordVisualTransformation()
} else {
VisualTransformation.None
},
)
}
}
// ⚠️ Outside the surface, not inside it. An error rendered within the
// field's own card reads as part of the value the user typed.
(error ?: hint)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (error != null) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 6.dp),
)
}
}
}
/**
* The step's call to action, pinned at the bottom the way the onboarding shell
* puts it.
*
* One primary action per step, always in the same place, so the flow reads as
* one screen advancing rather than four different ones. A step that commits
* nothing renders no button rather than a disabled one.
*/
@Composable
private fun ColumnScope.StepActions(state: AddAccountUiState, viewModel: AddAccountViewModel) {
if (state.fatal != null) {
PrimaryAction(
label = stringResource(R.string.add_account_start_over),
enabled = true,
onClick = viewModel::onStartOver,
)
Button(
onClick = viewModel::onCredentialsSubmitted,
return
}
when (val step = state.step) {
is AddAccountStep.EnterServer -> PrimaryAction(
label = stringResource(R.string.add_account_continue),
enabled = step.input.isNotBlank(),
onClick = viewModel::onServerSubmitted,
)
is AddAccountStep.EnterCredentials -> PrimaryAction(
label = stringResource(R.string.add_account_sign_in),
enabled = step.username.isNotBlank() && step.password.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_sign_in)) }
onClick = viewModel::onCredentialsSubmitted,
)
is AddAccountStep.ChooseLists -> PrimaryAction(
// The count is the feedback: a disabled button with no explanation
// is the commonest way a picker looks broken.
label = if (step.selected.isEmpty()) {
stringResource(R.string.add_account_no_lists_selected)
} else {
stringResource(R.string.add_account_save)
},
enabled = step.selected.isNotEmpty(),
onClick = viewModel::onSave,
)
is AddAccountStep.WaitingForBrowser -> {
// A failed flow gets the primary action: there is nothing left to
// wait for, and the way forward is a password.
if (step.error != null) {
PrimaryAction(
label = stringResource(R.string.add_account_browser_use_password),
enabled = true,
onClick = viewModel::onBrowserCancelled,
)
} else {
TextButton(
onClick = viewModel::onBrowserCancelled,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_browser_use_password)) }
}
}
is AddAccountStep.Working, AddAccountStep.Done -> Unit
}
}
/** The shell's primary action shape, so every step ends identically. */
@Composable
private fun PrimaryAction(label: String, enabled: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(label, style = MaterialTheme.typography.titleMedium)
}
}
@Composable
private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAccountViewModel) {
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// ⚠️ The title changes too. Swapping only the body left every failure in
// this phase — a burnt credential, a 429, a maintenance page — sitting
// under the heading "Waiting for your browser", which reads as "still
// working" when nothing is working and nothing else will happen.
Message(
icon = {
if (step.error == null) {
@@ -195,7 +461,11 @@ private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAc
Icon(Icons.Rounded.CloudOff, contentDescription = null)
}
},
title = stringResource(R.string.add_account_browser_title),
title = if (step.error == null) {
stringResource(R.string.add_account_browser_title)
} else {
stringResource(R.string.add_account_browser_failed_title)
},
text = step.error ?: stringResource(R.string.add_account_browser_body),
)
@@ -209,10 +479,6 @@ private fun BrowserStep(step: AddAccountStep.WaitingForBrowser, viewModel: AddAc
)
}
TextButton(
onClick = viewModel::onBrowserCancelled,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.add_account_browser_use_password)) }
}
}
@@ -230,11 +496,7 @@ private fun WorkingStep(step: AddAccountStep.Working) {
@Composable
private fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountViewModel) {
Text(
stringResource(R.string.add_account_lists_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// The heading is the shell's; this step only lists.
step.collections.forEachIndexed { index, collection ->
val checked = collection.url in step.selected
GroupedRow(
@@ -246,27 +508,13 @@ private fun ListsStep(step: AddAccountStep.ChooseLists, viewModel: AddAccountVie
},
position = positionOf(index, step.collections.size),
selected = checked,
modifier = Modifier.padding(horizontal = 16.dp),
trailing = {
Checkbox(checked = checked, onCheckedChange = null)
},
// The family marks a chosen row with a check, not a Material
// checkbox — same affordance every picker in the app uses.
trailing = { if (checked) SelectedCheck() },
onClick = { viewModel.onListToggled(collection.url) },
)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = viewModel::onSave,
enabled = step.selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Text(
if (step.selected.isEmpty()) {
stringResource(R.string.add_account_no_lists_selected)
} else {
stringResource(R.string.add_account_save)
},
)
}
}
@Composable
@@ -25,7 +25,11 @@ import javax.inject.Inject
sealed interface AddAccountStep {
/** Type an address. The whole flow starts from one field. */
data class EnterServer(val input: String = "", val error: String? = null) : AddAccountStep
data class EnterServer(
val input: String = "",
/** A cause, not a message — the screen owns the wording. */
val error: CalDavDiscovery.Outcome.Cause? = null,
) : AddAccountStep
data class Working(val message: String) : AddAccountStep
@@ -134,9 +138,9 @@ class AddAccountViewModel @Inject constructor(
// would make it permanently unreachable.
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.cause)
}
}
}
@@ -171,15 +175,15 @@ class AddAccountViewModel @Inject constructor(
)
}
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.reason)
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.cause)
}
}
}
/** Step 2b → the browser flow, if this looks like a Nextcloud. */
private suspend fun offerSignIn() {
val root = serverRoot ?: return backToServer("Could not work out the server address.")
val root = serverRoot ?: return backToServer(CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS)
val flow = gateway.startLoginFlow(root)
if (flow == null) {
@@ -230,7 +234,7 @@ class AddAccountViewModel @Inject constructor(
if (outcome is CalDavDiscovery.Outcome.Found) {
onDiscovered(outcome)
} else {
backToServer("Signed in, but no task lists could be read.")
backToServer(CalDavDiscovery.Outcome.Cause.NO_CALENDARS)
}
return@launch
}
@@ -371,8 +375,16 @@ class AddAccountViewModel @Inject constructor(
it.copy(step = AddAccountStep.EnterServer(input = typedInput), fatal = reason)
}
private fun backToServer(reason: String) = _state.update {
it.copy(step = AddAccountStep.EnterServer(input = typedInput, error = reason))
/**
* ⚠️ Carries the [CalDavDiscovery.Outcome.Cause], never the detail string.
*
* The detail is the server's own words — routinely a bare status line, often
* in a language the user does not read, and always outside `strings.xml`.
* The screen turns the cause into a translated sentence; nothing renders the
* detail.
*/
private fun backToServer(cause: CalDavDiscovery.Outcome.Cause) = _state.update {
it.copy(step = AddAccountStep.EnterServer(input = typedInput, error = cause))
}
private fun browserFailed(reason: String) = _state.update {
@@ -12,6 +12,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
@@ -35,7 +36,7 @@ internal fun LicencesScreen(onBack: () -> Unit) {
stringResource(R.string.licences_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(16.dp))
@@ -44,7 +45,6 @@ internal fun LicencesScreen(onBack: () -> Unit) {
title = attribution.name,
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
position = positionOf(index, OpenSourceLicences.ALL.size),
modifier = Modifier.padding(horizontal = 16.dp),
onClick = { uriHandler.openUri(attribution.sourceUrl) },
)
}
@@ -54,7 +54,7 @@ internal fun LicencesScreen(onBack: () -> Unit) {
stringResource(R.string.licences_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(24.dp))
}
@@ -99,6 +99,7 @@ import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsScreen
import de.jeanlucmakiola.agendula.ui.accounts.AccountsViewModel
import de.jeanlucmakiola.agendula.ui.accounts.AddAccountScreen
@@ -113,14 +114,15 @@ private enum class SettingsSection {
Export,
Accounts,
AddAccount,
Account,
Licences,
;
/** Where back goes: Export is opened from Storage, AddAccount from Accounts. */
/** Where back goes: Export is opened from Storage, the account screens from Accounts. */
val parent: SettingsSection?
get() = when (this) {
Export -> Storage
AddAccount -> Accounts
AddAccount, Account -> Accounts
else -> null
}
}
@@ -148,6 +150,8 @@ fun SettingsScreen(
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
// Hoisted so the add flow can refresh the list it returns to.
val accountsViewModel: AccountsViewModel = hiltViewModel()
// Which account the detail screen is showing; the section alone cannot say.
var openAccount by rememberSaveable { mutableStateOf<Long?>(null) }
// Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview.
@@ -189,6 +193,10 @@ fun SettingsScreen(
SlideInSection(visible = accountsOpen) {
AccountsScreen(
onAddAccount = { section = SettingsSection.AddAccount },
onOpenAccount = {
openAccount = it
section = SettingsSection.Account
},
onBack = { section = null },
viewModel = accountsViewModel,
)
@@ -196,15 +204,24 @@ fun SettingsScreen(
SlideInSection(visible = section == SettingsSection.Licences) {
LicencesScreen(onBack = { section = null })
}
SlideInSection(visible = section == SettingsSection.Account) {
openAccount?.let { id ->
AccountDetailScreen(
accountId = id,
onBack = { section = SettingsSection.Accounts },
onRemoved = { section = SettingsSection.Accounts },
onSignInAgain = { section = SettingsSection.AddAccount },
viewModel = accountsViewModel,
)
}
}
SlideInSection(visible = section == SettingsSection.AddAccount) {
AddAccountScreen(
onDone = {
// The list stays composed underneath, so nothing re-runs its
// init and OnResume never fires on a section change — without
// this the user returns to the empty state they just left.
accountsViewModel.refresh()
section = SettingsSection.Accounts
},
// The list stays composed underneath, so nothing re-runs its
// init and OnResume never fires on a section change. It no longer
// needs to: the accounts come from an observed query, so a new
// account — and every later sync — arrives on its own.
onDone = { section = SettingsSection.Accounts },
onBack = { section = SettingsSection.Accounts },
)
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The Nextcloud mark: three tangent circles, the middle one larger. Outlined
rather than filled so it sits in the same monochrome family as every other
leading glyph, and tinted by the caller so it follows the theme.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="192"
android:viewportHeight="192">
<path
android:pathData="M66,96 a30,30 0 1,0 60,0 a30,30 0 1,0 -60,0"
android:strokeWidth="12"
android:strokeColor="#FF000000" />
<path
android:pathData="M22,96 a22,22 0 1,0 44,0 a22,22 0 1,0 -44,0"
android:strokeWidth="12"
android:strokeColor="#FF000000" />
<path
android:pathData="M126,96 a22,22 0 1,0 44,0 a22,22 0 1,0 -44,0"
android:strokeWidth="12"
android:strokeColor="#FF000000" />
</vector>
+22 -4
View File
@@ -309,15 +309,33 @@
<string name="accounts_empty_body">Add a CalDAV account and Agendula keeps your task lists in step with it — Nextcloud, Radicale, Baïkal and anything else that speaks the protocol.</string>
<string name="accounts_add">Add an account</string>
<string name="accounts_remove">Remove account</string>
<string name="accounts_remove_confirm_title">Remove %1$s?</string>
<string name="accounts_remove_confirm_body">Its task lists stay on this device and stop syncing. Nothing is deleted from the server.</string>
<string name="accounts_remove_confirm_action">Remove</string>
<string name="accounts_remove_body">%1$s stops syncing with this device. Nothing is deleted from the server.</string>
<string name="accounts_remove_keep">Remove, keep the tasks</string>
<string name="accounts_remove_keep_body">Its lists stay on this device as device-only lists.</string>
<string name="accounts_remove_wipe">Remove and delete the tasks</string>
<string name="accounts_remove_wipe_body">Also deletes this account\u2019s lists and tasks from this device.</string>
<string name="accounts_never_synced">Never synced</string>
<string name="accounts_last_sync">Last sync</string>
<string name="accounts_generic_provider">CalDAV account</string>
<string name="accounts_sync_failed">Last sync didn\u2019t finish</string>
<string name="accounts_sync_now">Sync now</string>
<string name="add_account_browser_failed_title">Sign-in didn\u2019t finish</string>
<string name="add_account_step_of">Step %1$d of %2$d</string>
<string name="add_account_server_title">Where are your tasks?</string>
<string name="add_account_server_body">Enter the address of your CalDAV server, or the email address you use with it.</string>
<string name="add_account_credentials_title">Sign in</string>
<string name="add_account_credentials_body">Use an app password if your provider offers one \u2014 it can be revoked without changing your account password.</string>
<string name="add_account_lists_body">Pick the task lists to keep in sync. You can change this later.</string>
<string name="add_account_error_not_an_address">That doesn\u2019t look like a web address. Try something like cloud.example.com.</string>
<string name="add_account_error_not_dav">That address answered, but it isn\u2019t a CalDAV server. If you pasted the website, try the server address your provider gives for calendars.</string>
<string name="add_account_error_no_calendar">That server doesn\u2019t offer calendars. Check the address with your provider.</string>
<string name="add_account_error_unreachable">Couldn\u2019t reach that server. Check the address and your connection.</string>
<string name="add_account_error_insecure">That address is unencrypted. Agendula only sends your password over https.</string>
<string name="add_account_error_no_calendars">Signed in, but this account has no task lists.</string>
<string name="add_account_error_server">The server ran into a problem. Try again in a moment.</string>
<string name="accounts_needs_sign_in">Sign in again to keep syncing</string>
<string name="accounts_sign_in_again">Sign in again</string>
<string name="accounts_remove_delete_local">Also delete this account\u2019s tasks from this device</string>
<string name="accounts_summary">%1$s \u00b7 %2$s</string>
<string name="sync_notification_channel">Syncing</string>
<string name="sync_notification_title">Syncing tasks</string>
@@ -85,7 +85,10 @@ class AddAccountViewModelTest {
@Test
fun `a server that is not CalDAV says so on the address step`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav("no calendar-access")
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
"no calendar-access",
)
val vm = viewModel()
vm.onServerInputChanged("https://example.com/")
@@ -93,7 +96,9 @@ class AddAccountViewModelTest {
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.EnterServer
assertThat(step.error).contains("calendar-access")
// ⚠️ A cause, never the server's own words — the screen owns the
// wording, and a raw status line bypasses strings.xml entirely.
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT)
// The input survives, so the user can correct it rather than retype it.
assertThat(step.input).isEqualTo("https://example.com/")
}
@@ -333,7 +338,10 @@ class AddAccountViewModelTest {
): CalDavDiscovery.Outcome {
discoveries += Call(target, credentials)
return discoveryOutcomes.removeFirstOrNull()
?: CalDavDiscovery.Outcome.Failed("no outcome queued")
?: CalDavDiscovery.Outcome.Failed(
CalDavDiscovery.Outcome.Cause.SERVER_ERROR,
"no outcome queued",
)
}
override suspend fun startLoginFlow(server: HttpUrl) = loginFlow