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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
+11
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user