Compare commits

..
Author SHA1 Message Date
makiolaj 061e85ddcd Merge release/v1.3.0 into feat/73-pull-to-sync 2026-10-28 14:31:12 +01:00
Jean-Luc Makiola 2c450d300f Return home after adding or opening a task from a widget (#93) (#113)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:30:47 +01:00
Jean-Luc Makiola df1fb4e46b Collapsible task list sections (#91) (#112)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Canceled after 0s
2026-10-28 14:30:36 +01:00
Jean-Luc Makiola 63c7aa64e2 Ignore task swipes that start at the screen edge (#90) (#111)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:30:27 +01:00
Jean-Luc Makiola c26ff5909b Switchable launcher name: Agendula or Tasks (#104) (#110)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:30:12 +01:00
Jean-Luc Makiola c1219a434d Pure black theme for AMOLED screens (#99) (#109)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:29:25 +01:00
Jean-Luc Makiola 5dc1d49295 Show the task description in reminder notifications (#94) (#108)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:28:45 +01:00
makiolaj 172591901a feat: pull to sync on the home and task list screens (#73) 2026-10-28 12:14:57 +01:00
Jean-Luc Makiolaandmakiolaj 08c297f30a Release 1.2.0 (#103)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m4s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m33s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Stable 1.2.0, after 1.2.0-beta.1 and beta.2.

- Version bumped to 1.2.0 (1020099); the CHANGELOG entries moved from Unreleased to 1.2.0.
- What's New for every shipped locale, now including cs-CZ.
- Full store listing (title, short and full description) for every locale; new for cs-CZ, ar and fr-FR. Play only gets these through the `listing` lane.
- `main` is merged in with the branch side kept. #81 and the Weblate updates already came in through the #88 squash, and the only lines unique to `main` were string keys that #88 removed.

The #75 fix landed after beta.2, so no reporter has tested it yet.

Closes #72, closes #78, closes #75, closes #92

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/103
2026-10-26 20:41:58 +01:00
Jean-Luc Makiolaandmakiolaj 1d6c2a0d59 fix: sync task reminders as VALARMs (#52) (#81)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m1s
Reminders set in Agendula never reached the server, so a Mac or any other client saw none, and alarms set there never rang here. Reminders now sync as VALARMs in both directions.

**What syncs**
- Alarms Agendula can fire exactly as written become reminders: DISPLAY/AUDIO, relative to start or due, or absolute on a non-recurring task.
- Everything else stays in the residue and round-trips untouched: Apple default alarms, location alarms, repeating alarms, RFC 9074 snoozes, and triggers after their anchor.
- An unchanged alarm is re-emitted verbatim. An edited one is authored fresh under its existing uid. All-day leads go out as whole days.
- `ACKNOWLEDGED` is only added once a reminder is past the planner's missed-reminder grace period.

**Behaviour changes**
- Default reminders are stored on the task when it first gets a due date, so other clients see them. While untouched, they swap with the other default when the task flips between timed and all-day.
- Removing a task's alarms in another app turns them off here instead of falling back to the default. Tasks from clients that never write alarms keep the default.
- "All occurrences" reminder edits now reach occurrences that still had the series' old reminders.
- `.ics` import keeps VALARMs as reminders.

**Database v3**
- Alarms gain a `uid` and `raw` VALARM. Tasks gain `reminders_off`.
- The migration lifts claimable alarms out of the residue. It queues a one-time upload for tasks with local-only reminders on writable, non-confidential lists.

**Testing**
- Unit tests: full 662, offline 489. All instrumented tests pass on the emulator, including the migration. Lint is clean for both flavours.
- Not covered: the repository has no unit-test harness, so storing defaults at creation and the timed/all-day swap have no automated tests. Nothing has been checked against a real Mac yet.

Closes #52

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/81
2026-10-19 20:09:55 +02:00
Jean-Luc Makiola 2a1230c0ca Translations update from Weblate (#67)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m14s
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/67
2026-10-19 17:14:47 +02:00
Knee8811 cd3b33c959 Translated using Weblate (Czech)
Currently translated at 18.5% (121 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-19 01:54:42 +00:00
Knee8811 8175d58dcd Translated using Weblate (Czech)
Currently translated at 12.9% (85 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-18 18:45:06 +00:00
Knee8811 05696b2991 Translated using Weblate (Czech)
Currently translated at 12.0% (79 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-17 06:45:09 +00:00
Weblate 23f6156e71 Translated using Weblate (Arabic)
Currently translated at 74.3% (486 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-17 06:45:08 +00:00
Sup99999 c1b65c0c86 Translated using Weblate (Arabic)
Currently translated at 74.3% (486 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-17 06:45:08 +00:00
Weblate b5ac279b94 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/
2026-10-15 21:45:14 +00:00
Knee8811 8fba3f01a4 Translated using Weblate (Czech)
Currently translated at 9.3% (61 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-15 21:45:14 +00:00
Weblate 3b73dd4c53 Translated using Weblate (Czech)
Currently translated at 9.3% (61 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/cs/
2026-10-15 21:45:14 +00:00
Sup99999 01850a7b74 Translated using Weblate (Arabic)
Currently translated at 52.1% (341 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-15 21:45:14 +00:00
Weblate 28e646fb31 Translated using Weblate (German)
Currently translated at 59.6% (390 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-15 21:45:14 +00:00
Sonja Kraus 6e3cc4bdbd Translated using Weblate (German)
Currently translated at 59.6% (390 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-15 21:45:14 +00:00
Jean-Luc Makiola b6185ed53c Translations update from Weblate (#53)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 1m6s
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/53
2026-10-15 13:26:10 +02:00
Knee8811 9338c64dc7 Added translation using Weblate (Czech) 2026-10-14 21:41:45 +00:00
Weblate 9a72fb0880 Translated using Weblate (Arabic)
Currently translated at 51.8% (339 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Sup99999 24e978efc3 Translated using Weblate (Arabic)
Currently translated at 51.8% (339 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Robin Schanbacher 2740dd5562 Translated using Weblate (German)
Currently translated at 58.5% (383 of 654 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/de/
2026-10-14 21:41:45 +00:00
Sup99999 8dbfd07d10 Translated using Weblate (Arabic)
Currently translated at 46.0% (265 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
Weblate 3e1a84ab7d Translated using Weblate (Arabic)
Currently translated at 46.0% (265 of 575 strings)

Translation: Agendula/agendula
Translate-URL: https://weblate.dev.jeanlucmakiola.de/projects/agendula/agendula/ar/
2026-10-14 21:41:45 +00:00
67 changed files with 1450 additions and 185 deletions

No files matched your search

+9
View File
@@ -195,6 +195,15 @@ jobs:
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug
# Debug builds aren't minified, so a keep rule that drops a reflectively
# created class's constructor only breaks release builds (#1, #75). The
# releaseTest twin is R8-shrunk with the release rules and debug-signed.
- name: R8 keeps reflective constructors
if: steps.scope.outputs.code == 'true'
run: |
./gradlew assembleFullReleaseTest
python3 scripts/check_r8_keeps.py
- name: Trivy filesystem scan
if: steps.scope.outputs.code == 'true'
run: |
+20
View File
@@ -7,6 +7,8 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
## [1.2.0] - 2026-10-26
### Added
- A reminder can count from the task's start as well as from its due date, and
tasks with only a start date get reminders too. Default reminders can mix
@@ -50,6 +52,24 @@ All notable changes to this project are documented here. The format follows
Notifications, Task form is now New task form, and export and import moved
out of Storage into a new Backup & restore section (#66).
### Fixed
- Fixed a crash when a task's date was shown in Russian and other languages
whose dates put a word after the year (#70).
- Widgets no longer turn into the Today widget or show "Can't load content"
after a task is ticked off (#92).
- Ticking off a task in the Tasks and Up next widgets now works in release
builds; until now the tap did nothing (#75).
- The Tasks widget leaves the same gap under its header as its preview (#86).
- The Starts and Due labels in the task form no longer get cut off at large
font sizes (#76).
- Adding an account by email address finds the server again for providers
that publish it in DNS, such as mailbox.org and Posteo. The DNS lookup never
reached the network, so they ended in "isn't a CalDAV server". It now goes
through Android's resolver and respects Private DNS (#97).
- When the server can't be found from your email address, account setup asks
for the server address straight away and keeps your email as the username,
instead of stopping at an error (#97).
## [1.1.0] - 2026-10-10
### Added
+2 -2
View File
@@ -33,8 +33,8 @@ android {
// (1.0.x: 1.0.0 -> 10000; from 1.1.0: 1.1.0-beta.1 -> 1010001,
// 1.1.0 -> 1010099), and CI fails if the committed one doesn't match.
// See docs/RELEASING.md.
versionCode = 1020001
versionName = "1.2.0-beta.1"
versionCode = 1020099
versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+2 -2
View File
@@ -7,8 +7,8 @@
# nothing calls it directly — Room then throws InstantiationException, reported
# as "Failed to create an instance of ...". This first bit us through a
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
# issue #1); Glance is gone and Room is now our own task store, so the rule
# matters more, not less — TasksDatabase is built on the first store read.
# issue #1); Room is now also our own task store, so the rule matters more,
# not less — TasksDatabase is built on the first store read.
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# Compose Compiler may keep its own; defaults are fine
@@ -15,8 +15,10 @@ import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.PullSync
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
@@ -81,6 +83,10 @@ abstract class SyncBindModule {
@Singleton
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
@Binds
@Singleton
abstract fun bindManualSync(impl: PullSync): ManualSync
@Binds
@IntoSet
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
@@ -4,9 +4,9 @@ import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.caldav.AppPassword
import de.jeanlucmakiola.caldav.CalDavDiscovery
import de.jeanlucmakiola.caldav.CalDavHttp
import de.jeanlucmakiola.caldav.DnsJavaResolver
import de.jeanlucmakiola.caldav.NextcloudLoginFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.job
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.util.concurrent.TimeUnit
@@ -75,7 +75,8 @@ class OkHttpCalDavGateway @Inject constructor(
val client = credentials?.let {
CalDavHttp.authenticated(userAgent, it.username, it.password, it.origin)
} ?: CalDavHttp.anonymous(userAgent)
CalDavDiscovery(client, DnsJavaResolver()).discover(target)
val job = coroutineContext.job
CalDavDiscovery(client, SystemDnsResolver { !job.isActive }).discover(target)
}
override suspend fun startLoginFlow(server: HttpUrl): NextcloudLoginFlow.Flow? =
@@ -0,0 +1,76 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.work.WorkInfo
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Pull to sync over [SyncTrigger]: an expedited sync per account, and
* [syncing] held until they finish.
*
* A sync waiting on its network constraint never starts, so if none is running
* after [START_GRACE] the indicator lets go; the work stays queued and runs
* once the device is back online.
*/
@Singleton
class PullSync @Inject constructor(
@ApplicationContext private val context: Context,
private val accounts: AccountRepository,
private val trigger: SyncTrigger,
availability: SyncAvailability,
@ApplicationScope private val scope: CoroutineScope,
) : ManualSync {
override val available: Flow<Boolean> =
combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() }
private val _syncing = MutableStateFlow(false)
override val syncing: StateFlow<Boolean> = _syncing
override fun syncAll() {
if (_syncing.value) return
_syncing.value = true
scope.launch {
try {
val names = accounts.syncable().map { trigger.enqueue(it.displayName, expedited = true) }
if (names.isNotEmpty()) awaitSyncs(names)
} finally {
_syncing.value = false
}
}
}
private suspend fun awaitSyncs(names: List<String>) {
val workManager = WorkManager.getInstance(context)
val states = combine(names.map { workManager.getWorkInfosForUniqueWorkFlow(it) }) { infos ->
infos.flatMap { it }.map { it.state }
}
withTimeoutOrNull(MAX_WAIT) {
val started = withTimeoutOrNull(START_GRACE) {
states.first { all -> WorkInfo.State.RUNNING in all || all.all { it.isFinished } }
} ?: return@withTimeoutOrNull
if (started.all { it.isFinished }) return@withTimeoutOrNull
states.map { all -> WorkInfo.State.RUNNING in all }.first { running -> !running }
}
}
private companion object {
val START_GRACE = 3.seconds
val MAX_WAIT = 2.minutes
}
}
@@ -0,0 +1,103 @@
package de.jeanlucmakiola.agendula.data.sync
import android.net.DnsResolver as PlatformDnsResolver
import android.os.CancellationSignal
import android.os.Looper
import de.jeanlucmakiola.caldav.DnsAnswers
import de.jeanlucmakiola.caldav.DnsRecords
import de.jeanlucmakiola.caldav.DnsResolver
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* SRV/TXT lookups through the system resolver, so discovery uses the current
* network's DNS and honours Private DNS rather than sending plain queries of
* its own. A failed or timed-out lookup reads as no records.
*
* The platform delivers answers through the main looper, so this must run on a
* worker thread; waiting on the main one would deadlock until the timeout.
* The wait gives up as soon as [isCancelled] says so, and answered lookups are
* kept briefly so the authenticated walk does not ask again.
*/
class SystemDnsResolver(
private val isCancelled: () -> Boolean = { false },
) : DnsResolver {
override fun lookup(name: String): DnsRecords {
check(!Looper.getMainLooper().isCurrentThread) { "DNS lookup on the main thread" }
val now = System.nanoTime()
cache[name]?.takeIf { now - it.at < CACHE_NANOS }?.let { return it.records }
val srv = Query(name, DnsAnswers.TYPE_SRV)
val txt = Query(name, DnsAnswers.TYPE_TXT)
val answered = try {
val deadline = now + TimeUnit.SECONDS.toNanos(TIMEOUT_SECONDS)
srv.await(deadline, isCancelled) && txt.await(deadline, isCancelled)
} finally {
srv.cancel()
txt.cancel()
}
val records = DnsRecords(
srv.answer()?.let(DnsAnswers::srv).orEmpty(),
txt.answer()?.let(DnsAnswers::txt).orEmpty(),
)
if (answered) cache[name] = Cached(now, records)
return records
}
private class Cached(val at: Long, val records: DnsRecords)
private class Query(name: String, type: Int) {
private val done = CountDownLatch(1)
private val signal = CancellationSignal()
private val result = AtomicReference<ByteArray?>()
init {
try {
PlatformDnsResolver.getInstance().rawQuery(
null,
name,
PlatformDnsResolver.CLASS_IN,
type,
PlatformDnsResolver.FLAG_EMPTY,
Runnable::run,
signal,
object : PlatformDnsResolver.Callback<ByteArray> {
override fun onAnswer(answer: ByteArray, rcode: Int) {
result.set(answer)
done.countDown()
}
override fun onError(error: PlatformDnsResolver.DnsException) {
done.countDown()
}
},
)
} catch (e: RuntimeException) {
done.countDown()
}
}
/** Whether the query finished before [deadline] and before [isCancelled]. */
fun await(deadline: Long, isCancelled: () -> Boolean): Boolean {
while (!isCancelled()) {
val left = deadline - System.nanoTime()
if (left <= 0) return false
if (done.await(minOf(left, SLICE_NANOS), TimeUnit.NANOSECONDS)) return true
}
return false
}
fun cancel() = signal.cancel()
fun answer(): ByteArray? = result.get()
}
private companion object {
const val TIMEOUT_SECONDS = 10L
val SLICE_NANOS = TimeUnit.MILLISECONDS.toNanos(100)
val CACHE_NANOS = TimeUnit.MINUTES.toNanos(1)
val cache = ConcurrentHashMap<String, Cached>()
}
}
@@ -300,7 +300,9 @@ private fun StepTitle(step: AddAccountStep) {
R.string.add_account_provider_title to R.string.add_account_provider_body
is AddAccountStep.PrepareAccess ->
step.quirk.setupTitle to R.string.add_account_setup_body
is AddAccountStep.EnterAddress -> if (step.choice.provider?.hosted == true) {
is AddAccountStep.EnterAddress -> if (step.manualServer) {
R.string.add_account_server_title to R.string.add_account_server_fallback_body
} else if (step.asksForEmail) {
R.string.add_account_email_title to R.string.add_account_email_body
} else {
R.string.add_account_server_title to R.string.add_account_server_body
@@ -77,7 +77,12 @@ sealed interface AddAccountStep {
val input: String = "",
/** A cause, not a message — the screen owns the wording. */
val error: CalDavDiscovery.Outcome.Cause? = null,
) : AddAccountStep
/** The email address found no server, so a hosted choice asks for its URL instead. */
val manualServer: Boolean = false,
) : AddAccountStep {
/** Whether the field takes an email address rather than a server URL. */
val asksForEmail: Boolean get() = choice.provider?.hosted == true && !manualServer
}
data class Working(val message: AddAccountMessage.Progress) : AddAccountStep
@@ -279,6 +284,11 @@ class AddAccountViewModel @Inject constructor(
get() = handle[KEY_REAUTH_ACCOUNT]
set(value) { handle[KEY_REAUTH_ACCOUNT] = value }
/** The email that found no server; Back returns to it from the server field. */
private var fallbackEmail: String?
get() = handle[KEY_FALLBACK_EMAIL]
set(value) { handle[KEY_FALLBACK_EMAIL] = value }
/** The username the credentials step starts with on a re-authentication. */
private var suggestedUsername: String
get() = handle[KEY_SUGGESTED_USERNAME] ?: ""
@@ -370,6 +380,7 @@ class AddAccountViewModel @Inject constructor(
// one is not an answer to this one.
if (picked != choice) addressInput = ""
choice = picked
clearFallback()
val quirk = picked.provider?.let(ServerQuirk::forProvider)
val setup = quirk?.hasSetupSteps == true
updateState {
@@ -430,11 +441,10 @@ class AddAccountViewModel @Inject constructor(
addressInput = value
updateState {
it.copy(
step = AddAccountStep.EnterAddress(picked, value),
step = addressStep(value),
// The service's own rule outranks the one the typed host implies:
// it was chosen deliberately, and it is known before a key is hit.
quirk = picked.provider?.let(ServerQuirk::forProvider)
?: ServerQuirk.forInput(value),
quirk = quirkFor(value),
)
}
}
@@ -505,13 +515,47 @@ class AddAccountViewModel @Inject constructor(
// would make it permanently unreachable.
CalDavDiscovery.Outcome.Unauthenticated -> offerSignIn()
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
}
}
}
/**
* An email address whose domain has no CalDAV server goes straight to the
* server field rather than an error with nowhere to go; the email stays as
* the username. A domain nothing answered for may also be a typo or no
* network, so that message stays visible on the server field. Any other
* failure keeps its own message on the email field.
*/
private fun backToAddressOrServer(cause: CalDavDiscovery.Outcome.Cause) {
val email = ServiceDiscovery.emailOf(typedInput)
if (fallbackEmail != null || email == null || cause !in NO_SERVER_FOUND) {
return backToAddress(cause)
}
fallbackEmail = email
addressInput = ""
updateState {
it.copy(
step = addressStep("", cause.takeIf { it == CalDavDiscovery.Outcome.Cause.UNREACHABLE }),
quirk = quirkFor(""),
)
}
}
private fun clearFallback() {
fallbackEmail = null
}
/**
* The username the credentials step starts with: a re-authentication's, else
* the email that fell back to the server field — or one typed into it.
*/
private fun prefilledUsername(): String = suggestedUsername.ifBlank {
fallbackEmail?.let { ServiceDiscovery.emailOf(typedInput) ?: it }.orEmpty()
}
/** Step 3a → re-run discovery with the password the user typed. */
fun onCredentialsSubmitted() {
val step = current.step as? AddAccountStep.EnterCredentials ?: return
@@ -549,8 +593,8 @@ class AddAccountViewModel @Inject constructor(
CalDavDiscovery.Outcome.Unauthenticated -> credentialsRejected()
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
}
}
}
@@ -577,7 +621,7 @@ class AddAccountViewModel @Inject constructor(
updateState {
it.copy(
step = AddAccountStep.EnterCredentials(
username = suggestedUsername,
username = prefilledUsername(),
error = quirkHint(),
),
)
@@ -803,7 +847,7 @@ class AddAccountViewModel @Inject constructor(
pollJob?.cancel()
updateState {
it.copy(
step = AddAccountStep.EnterCredentials(username = suggestedUsername, error = null),
step = AddAccountStep.EnterCredentials(username = prefilledUsername(), error = null),
openInBrowser = null,
)
}
@@ -916,7 +960,17 @@ class AddAccountViewModel @Inject constructor(
// Back into the errand when there was one, so the steps can be re-read
// without starting the flow again — that is the screen people return to.
is AddAccountStep.EnterAddress -> {
updateState { it.copy(step = providerOrSetupStep(), fatal = null) }
val email = fallbackEmail
if (email != null) {
clearFallback()
addressInput = email
typedInput = email
updateState {
it.copy(step = addressStep(email), fatal = null, quirk = quirkFor(email))
}
} else {
updateState { it.copy(step = providerOrSetupStep(), fatal = null) }
}
true
}
@@ -990,6 +1044,7 @@ class AddAccountViewModel @Inject constructor(
hostsNeedingAuth = emptyList()
reauthAccountId = null
suggestedUsername = ""
fallbackEmail = null
current = AddAccountUiState()
}
@@ -1119,8 +1174,7 @@ class AddAccountViewModel @Inject constructor(
reauthenticating = reauthAccountId != null,
// Derived rather than saved: it is a pure function of the choice and the
// address, both of which are.
quirk = choice?.provider?.let(ServerQuirk::forProvider)
?: ServerQuirk.forInput(addressInput),
quirk = quirkFor(addressInput),
)
/**
@@ -1148,8 +1202,8 @@ class AddAccountViewModel @Inject constructor(
val credentials = CalDavGateway.Credentials(username, appPassword, root)
when (val outcome = gateway.discover(target, credentials)) {
is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome, selection)
is CalDavDiscovery.Outcome.NotCalDav -> backToAddress(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddress(outcome.cause)
is CalDavDiscovery.Outcome.NotCalDav -> backToAddressOrServer(outcome.cause)
is CalDavDiscovery.Outcome.Failed -> backToAddressOrServer(outcome.cause)
// The credential we were holding is no longer accepted, which on
// this path is indistinguishable from never having had one.
is CalDavDiscovery.Outcome.NeedsAuthentication -> {
@@ -1250,6 +1304,7 @@ class AddAccountViewModel @Inject constructor(
choice = choice ?: ProviderChoice.OtherServer,
input = input,
error = error,
manualServer = fallbackEmail != null,
)
/** What was actually set up, named the way the accounts list will name it. */
@@ -1286,12 +1341,13 @@ class AddAccountViewModel @Inject constructor(
updateState { it.copy(step = transform(step)) }
}
/** The chosen service's quirk, else the one the typed host implies. */
private fun quirkFor(input: String): ServerQuirk? =
choice?.provider?.let(ServerQuirk::forProvider) ?: ServerQuirk.forInput(input)
/** The provider-specific reason a correct-looking password gets rejected. */
private fun quirkHint(): AddAccountMessage? =
when (
val quirk = choice?.provider?.let(ServerQuirk::forProvider)
?: ServerQuirk.forInput(typedInput)
) {
when (val quirk = quirkFor(typedInput)) {
ServerQuirk.FASTMAIL_APP_PASSWORD,
ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD,
-> AddAccountMessage.Quirk(quirk)
@@ -1377,6 +1433,14 @@ class AddAccountViewModel @Inject constructor(
const val KEY_SUMMARY_LISTS = "add_account.summary_lists"
const val KEY_REAUTH_ACCOUNT = "add_account.reauth_account"
const val KEY_SUGGESTED_USERNAME = "add_account.suggested_username"
const val KEY_FALLBACK_EMAIL = "add_account.fallback_email"
/** What sends an email to the server field: no CalDAV on its domain, or nothing answering for it. */
val NO_SERVER_FOUND = setOf(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
CalDavDiscovery.Outcome.Cause.NO_CALENDAR_SUPPORT,
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
)
/**
* What to type for a server, from the principal an account stored.
@@ -20,7 +20,7 @@ import de.jeanlucmakiola.agendula.R
*/
@Composable
internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccountViewModel) {
val hosted = step.choice.provider?.hosted == true
val hosted = step.asksForEmail
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
FieldRow(
label = if (hosted) {
@@ -30,7 +30,7 @@ internal fun AddressStep(step: AddAccountStep.EnterAddress, viewModel: AddAccoun
},
value = step.input,
onValueChange = viewModel::onAddressChanged,
placeholder = step.choice.provider?.primaryDomain?.let { "you@$it" }
placeholder = step.choice.provider?.primaryDomain?.takeIf { hosted }?.let { "you@$it" }
?: stringResource(R.string.add_account_server_hint),
error = step.error?.let { stringResource(it.message) },
// An email address is still typed with the URI keyboard: it is the
+28 -4
View File
@@ -65,10 +65,8 @@
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher
name can be switched at runtime (#104). -->
<!-- Shared text becomes a new task's title. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
@@ -89,6 +87,32 @@
<data android:mimeType="text/calendar" />
</intent-filter>
</activity>
<activity-alias
android:name=".DefaultNameAlias"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".TasksNameAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_tasks_alias"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
<service
@@ -28,7 +28,6 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
@@ -88,15 +87,15 @@ class MainActivity : ComponentActivity() {
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (ui.settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme())
val context = LocalContext.current
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
AgendulaTheme(
darkTheme = darkTheme,
dynamicColor = ui.settings.dynamicColor,
pureBlack = ui.settings.themeMode.isPureBlack,
) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalFirstDayOfWeek provides firstDayOfWeek,
@@ -105,6 +104,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
navRequest = navRequest,
onNavRequestConsumed = { navRequest = null },
onReturnHome = ::finish,
)
pendingCrashReport?.let { report ->
CrashReportDialog(
@@ -153,6 +153,7 @@ class MainActivity : ComponentActivity() {
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_RETURN_HOME = "de.jeanlucmakiola.agendula.extra.RETURN_HOME"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L
@@ -161,10 +162,16 @@ class MainActivity : ComponentActivity() {
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
* picks the occurrence of a recurring task.
*/
fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
fun taskIntent(
context: Context,
taskId: Long,
occurrenceStart: Long? = null,
returnHome: Boolean = false,
): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
putExtra(EXTRA_RETURN_HOME, returnHome)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
@@ -216,6 +223,7 @@ class MainActivity : ComponentActivity() {
when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
returnHome = true,
)
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
@@ -230,12 +238,12 @@ class MainActivity : ComponentActivity() {
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
?: intent.getStringExtra(Intent.EXTRA_TEXT)
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT), returnHome = true)
}
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
.takeIf { it != NO_OCCURRENCE }
return NavRequest.OpenTask(taskId, occurrence)
return NavRequest.OpenTask(taskId, occurrence, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
}
}
}
@@ -0,0 +1,86 @@
package de.jeanlucmakiola.agendula.data.appname
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import javax.inject.Inject
import javax.inject.Singleton
/** The label the launcher shows for the app (#104). */
enum class LauncherName { AGENDULA, TASKS }
/** The two launcher aliases declared in the manifest. */
enum class LauncherAlias { DEFAULT, TASKS }
/** One component-enable change in an [aliasWritePlan]. */
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
/**
* Reads the `TasksNameAlias` enabled state as a [LauncherName]. Only an explicit
* enable means "Tasks"; never toggled and disabled both mean the manifest default.
*/
fun launcherNameFor(tasksAliasState: Int): LauncherName =
if (tasksAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
LauncherName.TASKS
} else {
LauncherName.AGENDULA
}
/**
* The steps to switch to [target]: enable the target alias first, then disable
* the other, so the launcher can briefly see two entries but never none.
*/
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
LauncherName.TASKS -> listOf(
AliasStateChange(LauncherAlias.TASKS, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
)
LauncherName.AGENDULA -> listOf(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.TASKS, enabled = false),
)
}
/**
* Switches the launcher label by enabling one of the two `<activity-alias>`
* components. The component state is the only source of truth; nothing is
* stored in prefs.
*/
@Singleton
class LauncherNameManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val packageManager: PackageManager get() = context.packageManager
// The package is the (suffixed) applicationId, the class is namespace-qualified.
private fun component(alias: LauncherAlias): ComponentName {
val simpleName = when (alias) {
LauncherAlias.DEFAULT -> "DefaultNameAlias"
LauncherAlias.TASKS -> "TasksNameAlias"
}
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
}
fun current(): LauncherName =
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.TASKS)))
fun set(name: LauncherName) {
for (change in aliasWritePlan(name)) {
val state = if (change.enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
packageManager.setComponentEnabledSetting(component(change.alias), state, PackageManager.DONT_KILL_APP)
}
// Dynamic shortcuts hang off the launcher activity, which just changed.
AppShortcuts.publish(context)
}
private companion object {
/** `namespace` in app/build.gradle.kts, not the suffixed applicationId. */
const val NAMESPACE = "de.jeanlucmakiola.agendula"
}
}
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
@@ -31,7 +32,18 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** [BLACK] is dark with pure-black surfaces, for AMOLED screens. */
enum class ThemeMode {
SYSTEM, LIGHT, DARK, BLACK;
fun isDark(systemDark: Boolean): Boolean = when (this) {
SYSTEM -> systemDark
LIGHT -> false
DARK, BLACK -> true
}
val isPureBlack: Boolean get() = this == BLACK
}
/** Clock convention: AUTO follows the device's 24-hour switch. */
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
@@ -43,6 +55,7 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
}
const val DEFAULT_SNOOZE_MINUTES = 10
/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set<TaskSection> = setOf(TaskSection.COMPLETED)
/** The snooze lengths Settings offers. */
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
@@ -103,6 +116,8 @@ data class Settings(
val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false,
/** Task-list sections folded away; Done starts folded. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -168,9 +183,18 @@ class SettingsPrefs @Inject constructor(
.toSet()
},
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
collapsedSections = p[COLLAPSED_SECTIONS]
?.mapNotNull { name -> runCatching { TaskSection.valueOf(name) }.getOrNull() }
?.toSet()
?: DEFAULT_COLLAPSED_SECTIONS,
)
}
suspend fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = dataStore.edit { p ->
val current = p[COLLAPSED_SECTIONS] ?: DEFAULT_COLLAPSED_SECTIONS.mapTo(HashSet()) { it.name }
p[COLLAPSED_SECTIONS] = if (collapsed) current + section.name else current - section.name
}
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
@@ -328,6 +352,7 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
}
@@ -18,6 +18,7 @@ import androidx.core.content.edit
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.domain.readableDescription
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
@@ -84,6 +85,7 @@ class TaskNotifier @Inject constructor(
),
)
}
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
val tapIntent = PendingIntent.getActivity(
context,
task.taskId.toInt(),
@@ -93,7 +95,10 @@ class TaskNotifier @Inject constructor(
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.apply { if (text != null) setContentText(text) }
.apply { (text ?: description)?.let(::setContentText) }
.apply {
expandedText(text, description)?.let { setStyle(NotificationCompat.BigTextStyle().bigText(it)) }
}
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
@@ -240,3 +245,10 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays,
else -> null
}
}
/** The expanded body of a due reminder: the due line, then the task's description. */
internal fun expandedText(dueLine: String?, description: String?): String? = when {
description == null -> null
dueLine == null -> description
else -> "$dueLine\n\n$description"
}
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.agendula.data.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
/** Pull to sync (#73): syncs every account at once. Inert in the `offline` flavor. */
interface ManualSync {
/** Whether there is anything to sync: our own store, with at least one account. */
val available: Flow<Boolean>
/** True from [syncAll] until the syncs it started have finished. */
val syncing: StateFlow<Boolean>
fun syncAll()
object None : ManualSync {
override val available: Flow<Boolean> = flowOf(false)
override val syncing: StateFlow<Boolean> = MutableStateFlow(false)
override fun syncAll() = Unit
}
}
@@ -46,6 +46,7 @@ fun RootScreen(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
permissionViewModel: PermissionViewModel = hiltViewModel(),
) {
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
@@ -86,6 +87,7 @@ fun RootScreen(
modifier = modifier,
navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
)
}
}
@@ -101,6 +103,7 @@ private fun ReadyGate(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
) {
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
@@ -113,6 +116,7 @@ private fun ReadyGate(
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
)
false -> ReadableWidth(modifier) {
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel)
@@ -0,0 +1,66 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import kotlin.math.abs
/** How far past the system back-gesture zone a horizontal swipe is still ignored. */
private val EDGE_MARGIN = 24.dp
/**
* Swallows horizontal swipes that start near the screen's left or right edge,
* before a swipe box below it sees them (#90). A back gesture started just
* inside the system's gesture zone otherwise reaches the app as a row swipe.
* Taps and vertical scrolls are left alone.
*/
@Composable
fun Modifier.ignoringEdgeSwipes(): Modifier {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val windowWidth = LocalWindowInfo.current.containerSize.width
val insets = WindowInsets.systemGestures
val margin = with(density) { EDGE_MARGIN.roundToPx() }
val leftEdge = insets.getLeft(density, layoutDirection) + margin
val rightEdge = windowWidth - insets.getRight(density, layoutDirection) - margin
var offsetX by remember { mutableFloatStateOf(0f) }
return this
.onGloballyPositioned { offsetX = it.positionInWindow().x }
.pointerInput(leftEdge, rightEdge) {
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val x = offsetX + down.position.x
if (x > leftEdge && x < rightEdge) return@awaitEachGesture
var horizontal = false
while (true) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes
.firstOrNull { it.id == down.id } ?: break
if (!horizontal) {
val delta = change.position - down.position
when {
abs(delta.x) > slop -> horizontal = true
abs(delta.y) > slop -> break
}
}
if (horizontal) change.consume()
if (!change.pressed) break
}
}
}
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Pull down to sync every account (#73). Without an account there is nothing
* to sync, so the gesture is off. [topPadding] keeps the indicator below a top
* bar the content scrolls under.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SyncPullBox(
available: Boolean,
syncing: Boolean,
onSync: () -> Unit,
modifier: Modifier = Modifier,
topPadding: Dp = 0.dp,
content: @Composable BoxScope.() -> Unit,
) {
if (!available) {
Box(modifier, content = content)
return
}
val state = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = syncing,
onRefresh = onSync,
modifier = modifier,
state = state,
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = state,
isRefreshing = syncing,
modifier = Modifier.align(Alignment.TopCenter).padding(top = topPadding),
)
},
content = content,
)
}
@@ -51,6 +51,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -883,6 +884,7 @@ private fun InlineField(
* affordance appears once a value is set. Tappable values read as links
* (primary); an error flips them to the error colour.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ScheduleRow(
label: String,
@@ -893,43 +895,76 @@ private fun ScheduleRow(
isError: Boolean = false,
) {
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (value == null) {
val valueModifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp)
LabelledValueLayout(
label = {
Text(
text = stringResource(R.string.edit_set),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp),
)
} else {
Text(
text = value.formatDate(allDay),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!allDay) {
Text(
text = value.formatTime(LocalUse24HourFormat.current),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
)
}
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Rounded.Clear,
contentDescription = stringResource(R.string.edit_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
},
value = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (value == null) {
Text(
text = stringResource(R.string.edit_set),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = valueModifier,
)
} else {
FlowRow(
horizontalArrangement = Arrangement.End,
itemVerticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f, fill = false),
) {
Text(
text = value.formatDate(allDay),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = valueModifier,
)
if (!allDay) {
Text(
text = value.formatTime(LocalUse24HourFormat.current),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = valueModifier,
)
}
}
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Rounded.Clear,
contentDescription = stringResource(R.string.edit_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
},
)
}
/**
* [label] at the start and [value] at the end of one line when both fit, else
* [value] on its own line below, still end-aligned — so large font scales wrap
* rather than clip either side (#76).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun LabelledValueLayout(label: @Composable () -> Unit, value: @Composable () -> Unit) {
FlowRow(
itemVerticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
label()
// Its whole width as its minimum, so the value wraps as one piece.
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.width(IntrinsicSize.Max)) { value() }
}
}
}
@@ -97,6 +97,7 @@ import kotlinx.coroutines.delay
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.components.positionOf
@@ -122,6 +123,8 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
var newList by rememberSaveable { mutableStateOf(false) }
@@ -173,7 +176,12 @@ fun ListsScreen(
}
},
) { inner ->
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
SyncPullBox(
available = syncAvailable && !searchActive,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
) {
when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure ->
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
@@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3
class ListsViewModel @Inject constructor(
private val repository: TasksRepository,
private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
settingsPrefs: SettingsPrefs,
) : ViewModel() {
/** Pull to sync (#73); off without an account. */
val syncAvailable: StateFlow<Boolean> =
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncing: StateFlow<Boolean> = manualSync.syncing
fun syncAll() = manualSync.syncAll()
/**
* Where a new list may go: the device, then every account whose server said
* it would take one.
@@ -7,6 +7,11 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
@@ -46,8 +51,19 @@ fun AgendulaNavHost(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
) {
val nav = rememberNavController()
// The entry a widget, tile, shortcut or share opened; closing it leaves the app (#93).
var returnHomeEntry by rememberSaveable { mutableStateOf<String?>(null) }
val backStack by nav.currentBackStack.collectAsState()
LaunchedEffect(backStack, returnHomeEntry) {
val id = returnHomeEntry ?: return@LaunchedEffect
if (backStack.none { it.id == id }) {
returnHomeEntry = null
onReturnHome()
}
}
LaunchedEffect(navRequest) {
when (navRequest) {
@@ -71,6 +87,9 @@ fun AgendulaNavHost(
}
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
}
val returnHome = (navRequest as? NavRequest.NewTask)?.returnHome == true ||
(navRequest as? NavRequest.OpenTask)?.returnHome == true
returnHomeEntry = if (returnHome) nav.currentBackStackEntry?.id else null
onNavRequestConsumed()
}
@@ -9,8 +9,15 @@ import de.jeanlucmakiola.agendula.domain.SmartList
* [AgendulaNavHost], which pushes it over whatever is showing.
*/
sealed interface NavRequest {
/** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */
data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest
/**
* One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series.
* [returnHome] leaves the app once the detail is closed (a widget row, #93).
*/
data class OpenTask(
val taskId: Long,
val occurrenceStart: Long? = null,
val returnHome: Boolean = false,
) : NavRequest
/** Settings → Accounts, where a sync report's detail lives. */
data object OpenAccounts : NavRequest
@@ -21,8 +28,13 @@ sealed interface NavRequest {
/**
* A new task, optionally titled with text shared from another app, or
* preset to a list — a widget's own add button, when it shows one list.
* [returnHome] leaves the app once the form is closed (#93).
*/
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
data class NewTask(
val title: String? = null,
val listId: Long? = null,
val returnHome: Boolean = false,
) : NavRequest
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest
@@ -14,13 +14,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.appname.LauncherName
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
@@ -38,6 +41,8 @@ internal fun AppearanceScreen(
) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) }
var showAppName by remember { mutableStateOf(false) }
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
var showTimeFormat by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
@@ -90,6 +95,26 @@ internal fun AppearanceScreen(
position = Position.Bottom,
onClick = { showWeekStart = true },
)
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = launcherNameLabel(launcherName),
position = Position.Alone,
onClick = { showAppName = true },
)
}
if (showAppName) {
OptionPicker(
title = stringResource(R.string.settings_app_name),
options = LauncherName.entries,
selected = launcherName,
label = { launcherNameLabel(it) },
header = { GroupedHint(stringResource(R.string.settings_app_name_hint)) },
onSelect = viewModel::setLauncherName,
onDismiss = { showAppName = false },
)
}
if (showTimeFormat) {
@@ -140,6 +165,7 @@ internal fun AppearanceScreen(
options = ThemeMode.entries,
selected = state.settings.themeMode,
label = { stringResource(themeLabel(it)) },
summary = { if (it == ThemeMode.BLACK) stringResource(R.string.settings_theme_black_summary) else null },
onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false },
)
@@ -163,8 +189,17 @@ private fun weekStartLabel(day: DayOfWeek?): String =
/** 14:00, the sample time "Automatic" is shown with. */
private const val SAMPLE_MINUTE_OF_DAY = 14 * 60
@Composable
private fun launcherNameLabel(name: LauncherName): String = stringResource(
when (name) {
LauncherName.AGENDULA -> R.string.app_name
LauncherName.TASKS -> R.string.app_name_tasks_alias
},
)
private fun themeLabel(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
ThemeMode.BLACK -> R.string.settings_theme_black
}
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.appname.LauncherName
import de.jeanlucmakiola.agendula.data.appname.LauncherNameManager
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
@@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.DayOfWeek
import javax.inject.Inject
@@ -102,7 +105,8 @@ class SettingsViewModel @Inject constructor(
private val oneShotImport: OneShotImport,
private val reminderScheduler: ReminderScheduler,
private val taskNotifier: TaskNotifier,
@IoDispatcher io: CoroutineDispatcher,
private val launcherNameManager: LauncherNameManager,
@IoDispatcher private val io: CoroutineDispatcher,
repository: TasksRepository,
) : ViewModel() {
@@ -217,6 +221,23 @@ class SettingsViewModel @Inject constructor(
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
private val _launcherName = MutableStateFlow(LauncherName.AGENDULA)
val launcherName: StateFlow<LauncherName> = _launcherName
init {
viewModelScope.launch { _launcherName.value = withContext(io) { launcherNameManager.current() } }
}
fun setLauncherName(name: LauncherName) {
_launcherName.value = name
viewModelScope.launch {
_launcherName.value = withContext(io) {
launcherNameManager.set(name)
launcherNameManager.current()
}
}
}
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
@@ -112,6 +112,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
@@ -144,6 +146,8 @@ fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val content = state as? TaskListUiState.Content
val list = content?.list
@@ -247,13 +251,21 @@ fun TaskListScreen(
},
) { inner ->
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
)
SyncPullBox(
available = syncAvailable,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier.fillMaxSize(),
topPadding = inner.calculateTopPadding(),
) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
)
}
}
// A FAB-height band anchored at the bottom-start with the FAB's own
// margin; centring the chip in it lines the chip up beside the bottom-end
@@ -371,8 +383,6 @@ private fun TaskListBody(
// rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
@@ -420,23 +430,20 @@ private fun TaskListBody(
}
sections.forEach { section ->
val collapsible = showHeaders && section.section == TaskSection.COMPLETED
// Every header folds its section away (#91); Done starts folded.
val expanded = !showHeaders || section.section !in state.collapsedSections
if (showHeaders) {
stickyHeader(key = "hdr-${section.section}") {
if (collapsible) {
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = completedExpanded,
onToggle = { completedExpanded = !completedExpanded },
onClear = onClearCompleted,
)
} else {
SectionHeader(sectionLabel(section.section))
}
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = expanded,
onToggle = { viewModel.setSectionCollapsed(section.section, expanded) },
onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED },
)
}
}
if (!collapsible || completedExpanded) {
if (expanded) {
// Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children
@@ -655,7 +662,7 @@ private fun TaskRow(
SwipeToDismissBox(
state = dismissState,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
@@ -1037,7 +1044,7 @@ private fun SubtaskRow(
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox(
state = dismissState,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) {
Surface(
@@ -1127,19 +1134,6 @@ private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, sma
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
}
@Composable
private fun SectionHeader(text: String) {
// Opaque surface so rows scroll cleanly under the pinned header.
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** A tappable section header with a count and a chevron that rotates on expand. */
@Composable
private fun CollapsibleSectionHeader(
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_COLLAPSED_SECTIONS
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
@@ -14,11 +15,13 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import de.jeanlucmakiola.agendula.ui.lists.asFailure
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -56,6 +59,8 @@ sealed interface TaskListUiState {
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false,
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** Sections whose rows are folded under their header. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
/** This real list is kept out of the smart lists. */
val hiddenFromSmartLists: Boolean = false,
/** Tags the unnarrowed view carries, offered as filter chips. */
@@ -89,9 +94,17 @@ class TaskListViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
@ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
/** Pull to sync (#73); off without an account. */
val syncAvailable: StateFlow<Boolean> =
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncing: StateFlow<Boolean> = manualSync.syncing
fun syncAll() = manualSync.syncAll()
private val filter = MutableStateFlow<TaskFilter?>(null)
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
@@ -144,6 +157,7 @@ class TaskListViewModel @Inject constructor(
showAddSubtaskRow = settings.showAddSubtaskRow,
bottomAddBar = settings.bottomAddBar,
sortOrder = settings.sortOrder,
collapsedSections = settings.collapsedSections,
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
)
} else {
@@ -277,6 +291,9 @@ class TaskListViewModel @Inject constructor(
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) }
fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) =
viewModelScope.launch { settingsPrefs.setSectionCollapsed(section, collapsed) }
fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) }
/**
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
fun AgendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
pureBlack: Boolean = false,
content: @Composable () -> Unit,
) {
FloretExpressiveTheme(
@@ -22,6 +23,7 @@ fun AgendulaTheme(
darkScheme = AgendulaDarkFallback,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
pureBlack = pureBlack,
typography = AgendulaTypography,
content = content,
)
@@ -183,12 +183,15 @@ private fun TaskWidgetBody(
modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),
) {
TaskWidgetHeader(title, filter, ready?.singleListId)
Spacer(GlanceModifier.height(2.dp))
when (data) {
null -> Unit
TaskWidgetData.Unavailable -> WidgetMessage(context.getString(R.string.widget_unavailable))
TaskWidgetData.Unavailable -> {
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
WidgetMessage(context.getString(R.string.widget_unavailable))
}
is TaskWidgetData.Ready ->
if (data.rows.isEmpty()) {
Spacer(GlanceModifier.height(R.dimen.widget_tasks_header_gap_tight))
WidgetDoneState(context.getString(R.string.widget_empty), context.getString(R.string.widget_empty_hint))
} else {
val allSections = widgetSections(
@@ -213,6 +216,12 @@ private fun TaskWidgetBody(
val redundantHeader = onlyHeader != null && redundantDueKind != null &&
onlyHeader.key == "due:${redundantDueKind.name}"
val sections = if (redundantHeader) allSections.filterNot { it is WidgetSection.Header } else allSections
val leadingGap = if (sections.firstOrNull() is WidgetSection.TaskItem) {
R.dimen.widget_tasks_header_gap
} else {
R.dimen.widget_tasks_header_gap_tight
}
Spacer(GlanceModifier.height(leadingGap))
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
items(sections.size) { index ->
when (val section = sections[index]) {
@@ -319,7 +328,7 @@ internal fun AgendaTaskRow(
val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity(
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true),
)
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
@@ -39,7 +39,6 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
@@ -135,12 +134,12 @@ class TaskWidgetConfigActivity : ComponentActivity() {
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
}
val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
val dark = settings.themeMode.isDark(isSystemInDarkTheme())
AgendulaTheme(
darkTheme = dark,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode.isPureBlack,
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
WidgetFilterPicker(
kind = kind,
@@ -339,7 +339,7 @@ private fun ListDot(color: Int) {
}
private fun openTask(context: Context, row: WidgetTaskRow): Action =
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true))
private fun completeTask(row: WidgetTaskRow): Action =
actionRunCallback<CompleteTaskAction>(
@@ -82,7 +82,7 @@ internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMo
override ?: when (appThemeMode) {
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
ThemeMode.DARK -> WidgetThemeMode.DARK
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
}
/** The smart lists a widget can show, in picker order. */
@@ -39,7 +39,7 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginTop="@dimen/widget_tasks_header_gap"
android:minHeight="40dp"
android:orientation="horizontal"
android:gravity="center_vertical"
+6
View File
@@ -1,5 +1,6 @@
<resources>
<string name="app_name">Agendula</string>
<string name="app_name_tasks_alias">Tasks</string>
<!-- Generic -->
<string name="task_untitled">Untitled task</string>
@@ -448,6 +449,10 @@
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_black">Black</string>
<string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string>
<string name="settings_app_name">App name</string>
<string name="settings_app_name_hint">Show Agendula as “Tasks” in your launcher. Only the launcher name changes; the icon may move to a new spot. Agendula closes when you switch, so reopen it from the launcher.</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Notifications</string>
@@ -718,6 +723,7 @@
<string name="add_account_summary_body">Agendula will keep these lists in sync from now on. You can change what syncs in Settings.</string>
<string name="add_account_summary_done">Done</string>
<string name="add_account_server_title">Where are your tasks?</string>
<string name="add_account_server_fallback_body">Agendula couldn\u2019t find the server from your email address. Enter the CalDAV server address your provider gives for calendars instead.</string>
<string name="add_account_server_body">Enter the address of your CalDAV server. An email address works too, if the server publishes itself under its domain.</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>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Header to first task row in the Tasks widget; shared with its picker preview. -->
<dimen name="widget_tasks_header_gap">8dp</dimen>
<!-- Header to anything that brings its own top spacing (a section label, a message). -->
<dimen name="widget_tasks_header_gap_tight">2dp</dimen>
</resources>
@@ -4,6 +4,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
@@ -26,6 +27,9 @@ object SyncModule {
@Provides
fun provideRemoteLists(): RemoteLists = NoRemoteLists
@Provides
fun provideManualSync(): ManualSync = ManualSync.None
}
private object NoRemoteLists : RemoteLists {
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.agendula.data.appname
import android.content.pm.PackageManager
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class LauncherNameManagerTest {
@Test
fun `an enabled Tasks alias reads as Tasks`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED)).isEqualTo(LauncherName.TASKS)
}
@Test
fun `a default or disabled Tasks alias reads as Agendula`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)).isEqualTo(LauncherName.AGENDULA)
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)).isEqualTo(LauncherName.AGENDULA)
}
@Test
fun `switching always enables the target before disabling the other`() {
assertThat(aliasWritePlan(LauncherName.TASKS)).containsExactly(
AliasStateChange(LauncherAlias.TASKS, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
).inOrder()
assertThat(aliasWritePlan(LauncherName.AGENDULA)).containsExactly(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.TASKS, enabled = false),
).inOrder()
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ExpandedTextTest {
@Test
fun `no description means no expanded body`() {
assertThat(expandedText("Due today", null)).isNull()
}
@Test
fun `description alone when there is no due line`() {
assertThat(expandedText(null, "Buy oat milk")).isEqualTo("Buy oat milk")
}
@Test
fun `due line comes first, separated by a blank line`() {
assertThat(expandedText("Due today", "Buy oat milk")).isEqualTo("Due today\n\nBuy oat milk")
}
}
@@ -210,6 +210,7 @@ class WidgetDataTest {
fun `no override resolves to whatever the app's own theme means`() {
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
assertThat(readyWithTheme(ThemeMode.BLACK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
}
@Test
@@ -218,6 +218,226 @@ class AddAccountViewModelTest {
// The input survives, so the user can correct it rather than retype it.
assertThat(step.input).isEqualTo("https://example.com/")
}
@Test
fun `an email that finds no server asks for the server address instead`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405 on mailbox.org",
)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.mailbox.org"))
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
vm.onAddressChanged("me@mailbox.org")
vm.onAddressSubmitted()
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.EnterAddress
assertThat(step.manualServer).isTrue()
assertThat(step.input).isEmpty()
assertThat(step.error).isNull()
vm.onAddressChanged("https://dav.mailbox.org/")
vm.onAddressSubmitted()
advanceUntilIdle()
// The email the user already gave is the username, not retyped.
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
assertThat(credentials.username).isEqualTo("me@mailbox.org")
}
@Test
fun `back from the server fallback returns to the email`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405 on mailbox.org",
)
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
vm.onAddressChanged("me@mailbox.org")
vm.onAddressSubmitted()
advanceUntilIdle()
assertThat(vm.onBackWithin()).isTrue()
val step = vm.state.value.step as AddAccountStep.EnterAddress
assertThat(step.manualServer).isFalse()
assertThat(step.input).isEqualTo("me@mailbox.org")
}
@Test
fun `a failure that is not a missing server keeps its error on the email`() = runTest(dispatcher) {
for (cause in listOf(
CalDavDiscovery.Outcome.Cause.NOT_AN_ADDRESS,
CalDavDiscovery.Outcome.Cause.SERVER_ERROR,
)) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(cause, "$cause")
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
vm.onAddressChanged("me@mailbox.org")
vm.onAddressSubmitted()
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.EnterAddress
assertThat(step.manualServer).isFalse()
assertThat(step.error).isEqualTo(cause)
assertThat(step.input).isEqualTo("me@mailbox.org")
}
}
@Test
fun `an email nothing answered for asks for the server and keeps the error`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
"no route",
)
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.OtherServer)
vm.onAddressChanged("me@mail-only.example")
vm.onAddressSubmitted()
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.EnterAddress
assertThat(step.manualServer).isTrue()
assertThat(step.input).isEmpty()
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.UNREACHABLE)
}
@Test
fun `an email that asked for a password first still reaches the server field`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("example.com"))
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405",
)
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.OtherServer)
vm.onAddressChanged("me@example.com")
vm.onAddressSubmitted()
advanceUntilIdle()
vm.onUsernameChanged("me@example.com")
vm.onPasswordChanged("secret")
vm.onCredentialsSubmitted()
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.EnterAddress
assertThat(step.manualServer).isTrue()
assertThat(step.error).isNull()
}
@Test
fun `the guidance follows the field through the fallback`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405",
)
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.OtherServer)
vm.onAddressChanged("me@fastmail.com")
vm.onAddressSubmitted()
advanceUntilIdle()
assertThat(vm.state.value.quirk).isNull()
vm.onAddressChanged("https://caldav.icloud.com/")
assertThat(vm.onBackWithin()).isTrue()
assertThat(vm.state.value.quirk).isEqualTo(ServerQuirk.FASTMAIL_APP_PASSWORD)
}
@Test
fun `a different email typed into the server field becomes the username`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405",
)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("new.example"))
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.OtherServer)
vm.onAddressChanged("a@old.example")
vm.onAddressSubmitted()
advanceUntilIdle()
vm.onAddressChanged("b@new.example")
vm.onAddressSubmitted()
advanceUntilIdle()
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
assertThat(credentials.username).isEqualTo("b@new.example")
}
@Test
fun `a mailto address falls back without its prefix`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405",
)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.example.com"))
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.OtherServer)
vm.onAddressChanged("mailto:me@example.com")
vm.onAddressSubmitted()
advanceUntilIdle()
vm.onAddressChanged("https://dav.example.com/")
vm.onAddressSubmitted()
advanceUntilIdle()
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
assertThat(credentials.username).isEqualTo("me@example.com")
vm.onBackWithin()
vm.onBackWithin()
assertThat((vm.state.value.step as AddAccountStep.EnterAddress).input).isEqualTo("me@example.com")
}
@Test
fun `back from the fallback keeps a re-authentication's username`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405",
)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(listOf("dav.mailbox.org"))
val vm = viewModel()
vm.startReauthentication(1L, "https://dav.mailbox.org/caldav/me/", "me@mailbox.org")
vm.onAddressChanged("me@mailbox.org")
vm.onAddressSubmitted()
advanceUntilIdle()
assertThat(vm.onBackWithin()).isTrue()
vm.onAddressSubmitted()
advanceUntilIdle()
val credentials = vm.state.value.step as AddAccountStep.EnterCredentials
assertThat(credentials.username).isEqualTo("me@mailbox.org")
}
@Test
fun `a server address that fails stays on the server field`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405",
)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NotCalDav(
CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER,
"405 again",
)
val vm = viewModel()
vm.onProviderChosen(ProviderChoice.Service(CalDavProvider.MAILBOX_ORG))
vm.onAddressChanged("me@mailbox.org")
vm.onAddressSubmitted()
advanceUntilIdle()
vm.onAddressChanged("https://mailbox.org/")
vm.onAddressSubmitted()
advanceUntilIdle()
val step = vm.state.value.step as AddAccountStep.EnterAddress
assertThat(step.manualServer).isTrue()
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.NOT_A_DAV_SERVER)
assertThat(step.input).isEqualTo("https://mailbox.org/")
}
}
@Nested
@@ -0,0 +1,29 @@
package de.jeanlucmakiola.caldav
import org.xbill.DNS.Message
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.Section
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
/**
* Reads SRV and TXT records out of a raw DNS answer.
*
* The lookup itself is the platform's job — on Android the system resolver,
* which follows the current network and honours Private DNS — so this only
* parses the wire format it hands back. A malformed answer reads as no records.
*/
object DnsAnswers {
const val TYPE_SRV = Type.SRV
const val TYPE_TXT = Type.TXT
fun srv(answer: ByteArray): List<SrvRecord> = records<SRVRecord>(answer)
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
fun txt(answer: ByteArray): List<String> = records<TXTRecord>(answer).flatMap { it.strings }
private inline fun <reified T> records(answer: ByteArray): List<T> = runCatching {
Message(answer).getSection(Section.ANSWER).filterIsInstance<T>()
}.getOrDefault(emptyList())
}
@@ -1,34 +0,0 @@
package de.jeanlucmakiola.caldav
import org.xbill.DNS.Lookup
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
/**
* SRV/TXT lookups over dnsjava.
*
* Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is
* callback-only and does not help with the `TXT path=` half — and JNDI's DNS
* provider does not exist on Android at all. dnsjava is what DAVx5 uses.
*
* A lookup that fails returns an empty list rather than throwing: DNS being
* unavailable means "no SRV record", which is an ordinary and common answer, and
* the well-known ladder still has rungs left.
*/
class DnsJavaResolver : DnsResolver {
override fun srv(name: String): List<SrvRecord> = runCatching {
Lookup(name, Type.SRV).run()
.orEmpty()
.filterIsInstance<SRVRecord>()
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
}.getOrDefault(emptyList())
override fun txt(name: String): List<String> = runCatching {
Lookup(name, Type.TXT).run()
.orEmpty()
.filterIsInstance<TXTRecord>()
.flatMap { it.strings }
}.getOrDefault(emptyList())
}
@@ -11,18 +11,20 @@ data class SrvRecord(
val target: String,
)
/** The SRV and TXT records published under one name. */
data class DnsRecords(val srv: List<SrvRecord>, val txt: List<String>)
/**
* DNS lookups for RFC 6764. Behind an interface so the pipeline is testable
* without a network, and so the resolver can be swapped per platform.
*/
interface DnsResolver {
fun srv(name: String): List<SrvRecord>
fun txt(name: String): List<String>
/** The SRV and TXT records for [name], looked up together. */
fun lookup(name: String): DnsRecords
/** For the base-URL path, where DNS is never consulted. */
object None : DnsResolver {
override fun srv(name: String) = emptyList<SrvRecord>()
override fun txt(name: String) = emptyList<String>()
override fun lookup(name: String) = DnsRecords(emptyList(), emptyList())
}
}
@@ -112,14 +114,16 @@ object ServiceDiscovery {
val domain = domainOf(trimmed) ?: return emptyList()
val candidates = mutableListOf<Candidate>()
val srv = dns.srv("$SRV_SERVICE.$domain")
val records = dns.lookup("$SRV_SERVICE.$domain")
val srv = records.srv
.filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 }
// DNS names are case-insensitive and dnsjava returns the wire case, so
// DNS names are case-insensitive and answers keep the wire case, so
// "Calendar.Google.com." would otherwise walk straight past the guard.
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
// ⚠️ RFC 2782 does not require a target to live inside the domain it
// was queried for, and this ladder is walked over plain UDP DNS with
// no DNSSEC — then walked *again* after a 401, with the
// was queried for, and the answer is not DNSSEC-validated (Private
// DNS encrypts the hop, it does not authenticate the zone) — and
// this ladder is walked *again* after a 401, with the
// authenticated client. An out-of-domain target is also one the
// credential would never be offered to, since CalDavHttp scopes it
// to the typed address's registrable domain, so following it can
@@ -131,7 +135,7 @@ object ServiceDiscovery {
// The TXT path applies to the SRV target, and to the bare domain when
// there is no SRV record — that is the GMX/Web.de shape.
val txtPath = dns.txt("$SRV_SERVICE.$domain")
val txtPath = records.txt
.firstNotNullOfOrNull { record ->
record.split(' ', ';')
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
@@ -205,6 +209,19 @@ object ServiceDiscovery {
return input.toHttpUrlOrNull()
}
/**
* The email address [input] names, without a `mailto:` prefix, or null when
* it is a URL or a bare domain — the same split [candidatesFor] makes.
*/
fun emailOf(input: String): String? {
val trimmed = input.trim()
if (asBaseUrl(trimmed) != null) return null
val email = trimmed.removePrefix("mailto:").removePrefix("MAILTO:")
return email.takeIf {
it.substringBeforeLast('@', "").isNotEmpty() && domainOf(it) != null
}
}
/** The domain of an email address, a `mailto:` URI, or a bare domain. */
internal fun domainOf(input: String): String? {
val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:")
@@ -0,0 +1,44 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.xbill.DNS.DClass
import org.xbill.DNS.Message
import org.xbill.DNS.Name
import org.xbill.DNS.Record
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.Section
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.Type
class DnsAnswersTest {
private val name = Name.fromString("_caldavs._tcp.mailbox.org.")
private fun answer(type: Int, vararg records: Record): ByteArray {
val message = Message.newQuery(Record.newRecord(name, type, DClass.IN))
records.forEach { message.addRecord(it, Section.ANSWER) }
return message.toWire()
}
@Test
fun `reads SRV records`() {
val bytes = answer(
Type.SRV,
SRVRecord(name, DClass.IN, 300, 10, 1, 443, Name.fromString("dav.mailbox.org.")),
)
assertThat(DnsAnswers.srv(bytes)).containsExactly(SrvRecord(10, 1, 443, "dav.mailbox.org"))
}
@Test
fun `reads TXT strings`() {
val bytes = answer(Type.TXT, TXTRecord(name, DClass.IN, 300, "path=/"))
assertThat(DnsAnswers.txt(bytes)).containsExactly("path=/")
}
@Test
fun `an empty or malformed answer has no records`() {
assertThat(DnsAnswers.srv(answer(Type.SRV))).isEmpty()
assertThat(DnsAnswers.srv(byteArrayOf(1, 2, 3))).isEmpty()
}
}
@@ -14,8 +14,7 @@ class ServiceDiscoveryTest {
val srv: Map<String, List<SrvRecord>> = emptyMap(),
val txt: Map<String, List<String>> = emptyMap(),
) : DnsResolver {
override fun srv(name: String) = srv[name].orEmpty()
override fun txt(name: String) = txt[name].orEmpty()
override fun lookup(name: String) = DnsRecords(srv[name].orEmpty(), txt[name].orEmpty())
}
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
@@ -153,6 +152,15 @@ class ServiceDiscoveryTest {
assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com")
}
@Test
fun `an email is told apart from a URL and loses its mailto prefix`() {
assertThat(ServiceDiscovery.emailOf("mailto:me@example.com")).isEqualTo("me@example.com")
assertThat(ServiceDiscovery.emailOf(" me@example.com ")).isEqualTo("me@example.com")
assertThat(ServiceDiscovery.emailOf("https://me@example.com/")).isNull()
assertThat(ServiceDiscovery.emailOf("example.com")).isNull()
assertThat(ServiceDiscovery.emailOf("@example.com")).isNull()
}
@Test
fun `something that is neither an address nor a URL yields nothing`() {
assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty()
+2 -2
View File
@@ -99,8 +99,8 @@ nominated. Data is encrypted in transit.
When you type a server address or an email domain, Agendula follows the
standard discovery procedure (RFC 6764): a DNS lookup for the `_caldavs._tcp`
service record of that domain, then `/.well-known/caldav` on the host. The DNS
query goes to whichever resolver your device or network uses, and the requests
go to the domain you typed — no directory of servers is consulted and no lookup
query goes through Android's own resolver, so it uses your device's Private DNS
setting when one is on, and the requests go to the domain you typed — no directory of servers is consulted and no lookup
is sent to the developer.
### Signing in to a Nextcloud
@@ -0,0 +1,11 @@
### جديد
- يمكن أن يُحسب التذكير من بدء المهمة أو من موعد الاستحقاق.
- يمكن لمهام اليوم الكامل أن تحمل تذكيرات في أوقات محددة.
- يمكن للتطبيقات الأخرى قراءة مهامك المحلية وتعديلها عبر OpenTasks، بإذنك.
### تغييرات
- تُزامَن التذكيرات كـ VALARM، فترنّ أيضًا في تذكيرات Apple وThunderbird وtasks.org.
### إصلاحات
- تعمل الأدوات مجددًا بعد إنجاز المهام.
- عاد تسجيل الدخول بالبريد الإلكتروني يعمل لدى mailbox.org وPosteo.
@@ -0,0 +1,11 @@
Agendula تطبيق مهام حديث ومفتوح المصدر لنظام Android.
زامن مهامك عبر CalDAV: أضف حساب Nextcloud أو Radicale أو Baïkal أو أي حساب CalDAV آخر، واختر قوائم المهام التي تريد مزامنتها. أو احتفظ بكل شيء على الهاتف، فـ Agendula يخزّن مهامك بنفسه، دون حساب ودون تثبيت أي شيء آخر.
هل تستخدم OpenTasks أو tasks.org؟ اربط Agendula بذلك التطبيق لتظهر المهام التي يزامنها DAVx5 أو ما يشبهه كما هي، وانسخها إلى تخزين Agendula الخاص متى شئت من الإعدادات ← التخزين.
ما يقدمه: قوائم تديرها داخل التطبيق، تواريخ الاستحقاق والبدء، مهام اليوم الكامل، مهام فرعية، أولويات، نسبة الإنجاز، مهام متكررة يمكنك تعديل موعد واحد منها، عدة تذكيرات لكل مهمة مع الغفوة، استيراد iCalendar وتصديره، أداة للشاشة الرئيسية، اختصارات المشغّل، وبلاطة في الإعدادات السريعة.
التصميم Material 3 Expressive حقيقي في كل مكان، مع ألوان ديناميكية وحركة وأشكال معبّرة.
الخصوصية: لا قياس عن بُعد، ولا تحليلات، ولا إعلانات. لا يتصل Agendula بالإنترنت إلا للمزامنة مع حسابات CalDAV التي تضيفها. ودون حساب، لا تغادر مهامك الجهاز إلا إذا صدّرتها.
@@ -0,0 +1 @@
تطبيق مهام بتصميم Material 3 Expressive مع مزامنة CalDAV.
+1
View File
@@ -0,0 +1 @@
Agendula: Tasks
@@ -0,0 +1,11 @@
### Nové
- Připomínky se mohou počítat od začátku úkolu i od termínu.
- Celodenní úkoly mohou mít připomínky v pevný čas.
- Jiné aplikace mohou s vaším svolením číst a upravovat místní úkoly přes OpenTasks.
### Změny
- Připomínky se synchronizují jako VALARM a zvoní i v Připomínkách Apple, Thunderbirdu a tasks.org.
### Opravy
- Widgety opět fungují po odškrtnutí úkolu.
- Přihlášení e-mailem u mailbox.org a Posteo opět funguje.
@@ -0,0 +1,11 @@
Agendula je moderní open-source aplikace na úkoly pro Android.
Synchronizujte úkoly přes CalDAV: přidejte účet Nextcloud, Radicale, Baïkal nebo jiný CalDAV účet a vyberte, které seznamy úkolů se mají synchronizovat. Nebo nechte vše v telefonu. Agendula si úkoly ukládá sama, bez účtu a bez instalace čehokoli dalšího.
Už používáte OpenTasks nebo tasks.org? Nasměrujte Agendulu na tuto aplikaci, aby se úkoly synchronizované přes DAVx5 a podobně zobrazily tak, jak jsou, a kdykoli si je zkopírujte do vlastního úložiště Agenduly v Nastavení → Úložiště.
Co umí: seznamy spravované přímo v aplikaci, termíny a data začátku, celodenní úkoly, podúkoly, priority, průběh, opakované úkoly s možností upravit jediný výskyt, více připomínek na úkol s odložením, import a export iCalendar, widget na plochu, zkratky spouštěče a dlaždici Rychlého nastavení.
Design je skrz naskrz Material 3 Expressive, s dynamickými barvami a expresivními animacemi a tvary.
Soukromí: žádná telemetrie, žádná analytika, žádné reklamy. Agendula se připojuje k internetu jen kvůli synchronizaci s CalDAV účty, které sami přidáte. Bez účtu vaše úkoly zařízení neopustí, pokud je sami neexportujete.
@@ -0,0 +1 @@
Úkolníček v Material 3 Expressive se synchronizací CalDAV.
@@ -0,0 +1 @@
Agendula: Tasks
@@ -0,0 +1,11 @@
### Neu
- Erinnerungen können ab dem Start oder ab der Fälligkeit zählen.
- Ganztägige Aufgaben können Erinnerungen zu festen Uhrzeiten haben.
- Andere Apps können lokale Aufgaben über OpenTasks lesen und bearbeiten, wenn du es erlaubst.
### Geändert
- Erinnerungen werden als VALARM synchronisiert und klingeln auch in Apple Erinnerungen, Thunderbird und tasks.org.
### Behoben
- Widgets funktionieren wieder nach dem Abhaken.
- Die Anmeldung per E-Mail klappt wieder bei mailbox.org und Posteo.
@@ -0,0 +1,11 @@
### Added
- Reminders can count from a task's start as well as its due date.
- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00.
- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission.
### Changed
- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org.
### Fixed
- Widgets work again after ticking off tasks.
- Signing in by email works again for mailbox.org and Posteo.
@@ -0,0 +1,11 @@
### Added
- Reminders can count from a task's start as well as its due date.
- All-day tasks can have reminders at set times, e.g. 09:00 and 17:00.
- Other apps can read and edit your local tasks through an OpenTasks provider, with your permission.
### Changed
- Reminders sync as VALARMs, so they also ring in Apple Reminders, Thunderbird and tasks.org.
### Fixed
- Widgets work again after ticking off tasks.
- Signing in by email works again for mailbox.org and Posteo.
@@ -0,0 +1,11 @@
### Nouveautés
- Un rappel peut partir du début ou de l'échéance d'une tâche.
- Rappels à heure fixe pour les tâches sur la journée.
- Avec votre accord, d'autres applis peuvent lire et modifier vos tâches locales via OpenTasks.
### Modifications
- Les rappels se synchronisent en VALARM et sonnent aussi dans Rappels d'Apple, Thunderbird et tasks.org.
### Corrections
- Les widgets marchent de nouveau après une tâche cochée.
- La connexion par e-mail marche de nouveau avec mailbox.org et Posteo.
@@ -0,0 +1,11 @@
Agendula est une appli de tâches moderne et open source pour Android.
Synchronisez vos tâches via CalDAV : ajoutez un compte Nextcloud, Radicale, Baïkal ou tout autre compte CalDAV et choisissez les listes de tâches à synchroniser. Ou gardez tout sur le téléphone : Agendula stocke vos tâches lui-même, sans compte et sans rien d'autre à installer.
Vous utilisez déjà OpenTasks ou tasks.org ? Reliez Agendula à cette appli pour que les tâches synchronisées par DAVx5 ou similaire s'affichent telles quelles, et copiez-les quand vous voulez dans le stockage propre d'Agendula, sous Réglages → Stockage.
Ce qu'il fait : listes gérées dans l'appli, dates d'échéance et de début, tâches sur toute la journée, sous-tâches, priorités, avancement, tâches récurrentes dont vous pouvez modifier une seule occurrence, plusieurs rappels par tâche avec report, import et export iCalendar, un widget d'écran d'accueil, des raccourcis de lanceur et une tuile de Réglages rapides.
Le design est du vrai Material 3 Expressive de bout en bout, avec couleurs dynamiques, animations et formes expressives.
Confidentialité : pas de télémétrie, pas d'analytique, pas de publicité. Agendula ne se connecte que pour synchroniser les comptes CalDAV que vous ajoutez. Sans compte, vos tâches ne quittent l'appareil que si vous les exportez.
@@ -0,0 +1 @@
Une appli de tâches Material 3 Expressive avec synchro CalDAV.
@@ -0,0 +1 @@
Agendula: Tasks
@@ -0,0 +1,11 @@
### Novidades
- Lembretes podem contar a partir do início da tarefa ou do prazo.
- Tarefas de dia inteiro podem ter lembretes em horários fixos.
- Outros apps podem ler e editar suas tarefas locais via OpenTasks, com sua permissão.
### Alterações
- Lembretes sincronizam como VALARM e tocam também no Lembretes da Apple, Thunderbird e tasks.org.
### Correções
- Widgets voltam a funcionar após marcar tarefas.
- O login por e-mail volta a funcionar no mailbox.org e Posteo.
+3 -4
View File
@@ -51,10 +51,9 @@ okhttp = "4.12.0"
xpp3 = "1.1.6"
# --- :caldav (our CalDAV protocol layer) -------------------------------------
# SRV/TXT lookups for RFC 6764 discovery. Android's resolver exposes no SRV API
# below API 29's DnsResolver (which is callback-only and cannot do TXT paths),
# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5
# uses. BSD-3 — it goes on the attribution screen with the rest.
# Parses the raw SRV/TXT answers for RFC 6764 discovery; the lookup itself goes
# through Android's DnsResolver, so it follows the network and Private DNS.
# BSD-3 — it goes on the attribution screen with the rest.
dnsjava = "3.6.3"
# WebDAV-Push delivery. The connector only: DAVx5 also ships the embedded FCM
# distributor, which pulls in Play Services and would get us flagged on F-Droid.
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Fail when R8 keeps one of our classes by name but strips its no-arg constructor.
A keep rule that names a class but no members keeps the class and lets R8 drop
the constructor nothing calls directly. Anything that then instantiates it by
reflection (Room, Glance's ActionCallback, WorkManager) fails at runtime only,
in release builds only (#1, #75). R8 reports both halves: seeds.txt lists what
keep rules pinned, usage.txt what it removed.
scripts/check_r8_keeps.py [mapping dir] default: fullReleaseTest's
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DIR = ROOT / "app/build/outputs/mapping/fullReleaseTest"
PACKAGES = ("de.jeanlucmakiola.",)
def kept_classes(seeds: Path) -> set[str]:
return {
line.strip()
for line in seeds.read_text().splitlines()
if line.startswith(PACKAGES) and ":" not in line
}
def classes_without_default_ctor(usage: Path) -> set[str]:
stripped, current = set(), None
for line in usage.read_text().splitlines():
if not line.startswith(" "):
current = line.rstrip(":") if line.endswith(":") else None
elif current and line.strip().endswith(" <init>()"):
stripped.add(current)
return stripped
def main() -> int:
mapping = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_DIR
seeds, usage = mapping / "seeds.txt", mapping / "usage.txt"
if not seeds.exists() or not usage.exists():
print(f"ERROR: no seeds.txt/usage.txt in {mapping}; build a minified variant first.", file=sys.stderr)
return 1
broken = sorted(kept_classes(seeds) & classes_without_default_ctor(usage))
for name in broken:
print(f"ERROR: {name} is kept by a rule but R8 removed its no-arg constructor.", file=sys.stderr)
if broken:
print("Keep the constructor too: -keep class ... { <init>(); }", file=sys.stderr)
return 1
print(f"OK: every kept class under {', '.join(PACKAGES)} still has its no-arg constructor.")
return 0
if __name__ == "__main__":
sys.exit(main())