Compare commits

..
Author SHA1 Message Date
makiolaj 4f86dc46bb chore: 1.2.0-beta.2
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Successful in 10m2s
2026-10-25 19:56:20 +01:00
Jean-Luc Makiolaandmakiolaj ffc5900db4 Fix widget class merge, ru date crash, widget padding, form label clipping and SRV discovery (#96)
Fixes four of the five open bug reports, plus #97 (mailbox.org sign-in, reported by email).

- **#92 widgets reverting / "Can't load content"**: R8's horizontal class merging folded `TodayWidget`, `UpNextWidget`, `ListsWidget` and `WeekWidget` into one class. Glance maps providers to receivers by class name, so `TodayWidget().updateAll()` also targeted every Up Next, Lists and Week slot. Any slot without a running session got Today's composition, which is why widgets turned into the Today ring. In a 4x1 slot the ring size goes negative and Glance shows its error layout. A keep rule for `GlanceAppWidget` subclasses stops the merge. I checked the fresh `fullReleaseTest` mapping: all five classes are separate and none has a `$r8$classId`.
- **#70 crash in ru-RU**: fixed in floret-kit (jlmakiola/floret-kit#20). Its year-stripping regex broke `d MMM y 'г'.` into an unterminated quote. The new version also keeps the dot that closes the day-month part (de-DE `05.03.`). That PR is merged; the submodule points at its tip, which is on floret-kit main.
- **#86 widget padding**: when the Tasks widget drops its redundant section label (e.g. the Today list), a spacer above the list now puts the first row 8dp under the header (one `widget_tasks_header_gap` dimen shared with the preview), as in the preview, instead of 2dp.
- **#76 "Starts" clipped**: the label and values in the task form's Starts/Due rows share one line when they fit. Otherwise the values move to their own end-aligned line, using a `FlowRow` rather than a custom layout. The time can wrap under the date, and the clear button always keeps its 40dp.
- **#97 mailbox.org sign-in ("isn't a CalDAV server")**: the SRV/TXT lookup never reached the network. dnsjava was never set up for Android, so every lookup came back empty. mailbox.org's server is only published by SRV (`dav.mailbox.org`), so discovery fell back to `mailbox.org/.well-known/caldav`, which redirects to `auto.mailbox.org` and answers 405. Posteo (SRV-only, port 8443) hit the same dead end. Lookups now go through `android.net.DnsResolver.rawQuery` (minSdk is 29), so they follow the current network and honour Private DNS. SRV and TXT are queried at once under one 10s deadline, and a failure reads as no records. The wait stops as soon as the sign-in is cancelled, and answered lookups are kept for a minute so the signed-in pass does not ask again. `:caldav` only parses the raw answers (`DnsAnswers`, which replaces `DnsJavaResolver`). PRIVACY.md now says so.
  - If an email address still finds no server, the address step switches straight to a server-address field with its own explanation, rather than ending at an error. That also happens when nothing answered for the domain (a mail-only domain), with the error left visible in case it was a typo or no network, and after a sign-in on the email domain. The email (without a `mailto:` prefix) is kept as the username for the sign-in step, and Back returns to the email.

Not included: **#75** (ticking in the 2x2 widget does nothing). The widget is most likely Today, which was one of the merged classes, so this may fix it too. I couldn't show the link from the code, so the issue stays open until it's checked on a device.

Closes #70, closes #76, closes #86, closes #92, closes #97

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/96
2026-10-25 19:55:03 +01:00
makiolaj 44d6038c8b chore: 1.2.0-beta.1
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Successful in 14m27s
2026-10-21 18:46:51 +02:00
Jean-Luc Makiolaandmakiolaj c2826d3ab6 fix: drop the duplicate TaskAlarmDao.update (#89)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Merging #88 left two `@Update fun update(alarm: TaskAlarmEntity)` declarations in `TaskAlarmDao`: one returning `Int` from the release branch, and one returning `Unit` from the feature branch. `release/v1.2.0` doesn't compile with both. This keeps the `Int` one, which `ProviderWriter` uses.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/89
2026-10-21 18:43:30 +02:00
7c943f4831 Start-based reminders and timed all-day reminders (#72, #78) (#88)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Reminders can count from a task's start as well as its due date (#72), and each reminder on an all-day task can have its own time (#78).

## Changes
- A reminder counts from the start or the due date. Tasks with only a start date get reminders. Defaults in Settings can mix both, globally and per list.
- Each reminder on an all-day task can have its own time of day. Reminders without one ring at the all-day time set in Settings. 00:00 can't be stored, so it becomes 00:01.
- The editor shows the defaults a new task, or a newly added date, will get, and keeps them in step with dates, list and all-day until you change a reminder. What it shows is what gets saved. Removing the shown defaults now leaves the task without a reminder (Room store only; OpenTasks mode still falls back to the default).
- Switching between timed and all-day converts the reminders right away in the editor. Switching straight back restores them.
- Labels name the date when a task has both: "15 minutes before the start", "On the due date at 09:00". New reminders preselect the start.
- Defaults that have already passed don't ring when the task is saved.
- Sync: reminders rounded to whole days by builds before 1.2 pick up their real time of day on the next download.

## Deviations from the issues
- The start/due anchor lives in floret-kit (`ReminderLead`, floret-kit#19) rather than being packed into minute values. That PR is additive, so Calendula is unaffected.
- `error_reminder_without_due` was renamed to `error_reminder_without_date`, and the "(before start)/(before due)" strings were replaced by full sentences. The stale keys were deleted from the translation files so lint passes, and Weblate needs to offer the new strings to translators.
- The dead single-reminder API (`setAlarm`, `alarms()`, `editable()`, `reminderFor`) was removed.
- `AlarmLift` is back to its frozen 2→3 migration state.

## Release branch
`release/v1.2.0` is merged into this branch. Through it, this PR also brings main's commits the release branch was missing, including the reminder sync from #81 that this work builds on. I resolved conflicts in the data source interface, `TaskAlarmDao`, CHANGELOG, PRIVACY.md and the floret-kit pointer by keeping both sides.

Closes #72
Closes #78

Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Robin Schanbacher <robin@schanbros.de>
Co-authored-by: Knee8811 <mk-weby@protonmail.com>
Co-authored-by: Sonja Kraus <klein.felix@pm.me>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/88
2026-10-21 18:40:28 +02:00
Jean-Luc Makiolaandmakiolaj 5004f5213c Align motion and settings with Calendula (#66) (#69)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Brings Agendula's screen transitions and settings in line with Calendula.

**Motion**
- Screens slide in on the M3 Expressive spring and leave through floret's `predictiveBackExit`, everywhere: main navigation and every settings sub-screen.
- Settings sub-screens get Calendula's back-swipe preview (shrink, edge shift, rounded corners). The central settings BackHandler is gone; each screen handles its own back.
- Main navigation keeps NavHost's own predictive back, which seeks the new exit over the previous screen.
- `enableOnBackInvokedCallback` is set, so Android 14/15 show the preview too.

**Settings**
- Reminders → Notifications, Task form → New task form, with Calendula's subtitles and labels inside Notifications.
- Export and import move out of Storage into a new Backup & restore section; Storage keeps only the task store.
- The language row uses floret-kit's `LanguagePickerRow` with the translate header.

**Deviations from the issue**
- The main-navigation swipe preview scales and fades but doesn't shift or round its corners like Calendula's. Matching that exactly would mean replacing navigation-compose with Calendula-style overlays. Settings match exactly.
- The AOSP/GrapheneOS Settings transition suggested in the issue isn't adopted; the family stays on its M3 Expressive motion.
- Renamed labels keep their string keys, so existing translations stay until Weblate re-reviews them rather than falling back to English.

Depends on jlmakiola/floret-kit#17 (the submodule points at it).

Closes #66

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/69
2026-10-19 17:14:31 +02:00
Jean-Luc Makiola 9b7f7f60d2 Pin the clock in the delete-following subtree test (#65)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/65
2026-10-15 16:23:52 +02:00
Jean-Luc Makiolaandmakiolaj cff6308018 Let other apps write to the own store through the provider (#64)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
Builds on the read-only provider (#62) and makes it writable for other apps, as long as Agendula uses its own store.

- `tasks`: insert, update and delete. Writes follow the same rules as the app's own edits. Status, progress and completion time stay consistent, a subtree moves and deletes as one, and a write that changes nothing doesn't mark the task for sync.
- `instances`: completion columns complete, cancel or reopen that one occurrence. Other content columns edit the whole series. Changing a time column is rejected, so a series is moved through `tasks`.
- `properties`: alarms and categories can be inserted, updated and deleted.
- `tasklists` stays read-only. Batches run in one transaction.
- A client can write back a row it read. Columns it carries unchanged are ignored, and so are rdate/exdate values in the shape they were synced in.
- A task inserted with a time but no `tz` is floating, as in OpenTasks.
- Deleting a task now takes its whole subtree in the app as well, including "this and following" from the first occurrence and in External mode.

Deviation: tag property ids stay stable, with one rare exception. If two tags on one task have hash-colliding spellings, removing one can shift the other's id. Avoiding that would mean storing the ids, which didn't seem worth it at about a 1 in 2³¹ chance.

Closes #63

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/64
2026-10-15 16:16:51 +02:00
Jean-Luc Makiolaandmakiolaj e7bf23e276 Publish the own store through a read-only OpenTasks provider (#43) (#62)
Beta — Codeberg pre-release / detect (push) Successful in 7s
Beta — Codeberg pre-release / beta (push) Skipped
In Own storage mode, Agendula now publishes its Room store as a ContentProvider that follows the OpenTasks contract. It serves `tasklists`, `tasks`, `instances` and `properties` (reminders and tags) at `<package>.tasks`, behind a dangerous-level `<package>.permission.READ_TASKS` that the user grants per app. An app that already reads OpenTasks or tasks.org only has to change the authority and the permission.

On a query, the provider lays the Room store out in OpenTasks' columns and loads that into an in-memory SQLite database, so a client's selection, arguments and sort order run as real SQL. The snapshot is rebuilt after a write, after a storage mode switch, or once it is an hour old, because instance ids and `distance_from_current` move with the clock. Observers on the authority get one notification per burst of changes. In External mode every table is empty.

Recurrence expansion moved out of `RoomTasksDataSource` into shared functions, so the app and the provider expand a series the same way.

`docs/CONTENT_PROVIDER.md` describes the contract for client developers, with the authority and permission for each build. README, CHANGELOG and the privacy policy mention the new permission.

Where this differs from the issue: the issue asked for read and write permissions, and this PR is read-only. Insert, update and delete throw. Accepting writes from other apps means deciding what happens to the fields Agendula keeps verbatim for CalDAV round-tripping, which was the issue's second open question, and that needs its own design. That is tracked in #63.

Closes #43

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/62
2026-10-15 13:25:48 +02:00
51 changed files with 78 additions and 817 deletions
-9
View File
@@ -195,15 +195,6 @@ 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: |
-4
View File
@@ -7,8 +7,6 @@ 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
@@ -57,8 +55,6 @@ All notable changes to this project are documented here. The format follows
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).
+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 = 1020099
versionName = "1.2.0"
versionCode = 1020002
versionName = "1.2.0-beta.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+7 -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); Room is now also our own task store, so the rule matters more,
# not less — TasksDatabase is built on the first store read.
# 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.
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# Compose Compiler may keep its own; defaults are fine
@@ -20,3 +20,8 @@
-dontwarn javax.naming.**
-dontwarn lombok.Generated
-dontwarn org.slf4j.impl.StaticLoggerBinder
# Glance maps each widget to its receivers by the provider's class name. R8's
# class merging folded the GlanceAppWidget subclasses into one class, so they
# all shared a name and redrew each other's slots (#92).
-keep class * extends androidx.glance.appwidget.GlanceAppWidget { <init>(); }
@@ -15,10 +15,8 @@ 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
@@ -83,10 +81,6 @@ 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
@@ -1,76 +0,0 @@
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
}
}
+4 -28
View File
@@ -65,8 +65,10 @@
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher
name can be switched at runtime (#104). -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Shared text becomes a new task's title. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
@@ -87,32 +89,6 @@
<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,6 +28,7 @@ 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
@@ -87,15 +88,15 @@ class MainActivity : ComponentActivity() {
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme())
val darkTheme = when (ui.settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
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,
pureBlack = ui.settings.themeMode.isPureBlack,
) {
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalFirstDayOfWeek provides firstDayOfWeek,
@@ -104,7 +105,6 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
navRequest = navRequest,
onNavRequestConsumed = { navRequest = null },
onReturnHome = ::finish,
)
pendingCrashReport?.let { report ->
CrashReportDialog(
@@ -153,7 +153,6 @@ 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
@@ -162,16 +161,10 @@ 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,
returnHome: Boolean = false,
): Intent =
fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): 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)
}
@@ -223,7 +216,6 @@ 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)
@@ -238,12 +230,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), returnHome = true)
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
}
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, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
return NavRequest.OpenTask(taskId, occurrence)
}
}
}
@@ -1,86 +0,0 @@
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,7 +15,6 @@ 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
@@ -32,18 +31,7 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/** [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
}
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** Clock convention: AUTO follows the device's 24-hour switch. */
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
@@ -55,7 +43,6 @@ 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)
@@ -116,8 +103,6 @@ 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> =
@@ -183,18 +168,9 @@ 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) }
@@ -352,7 +328,6 @@ 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,7 +18,6 @@ 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
@@ -85,7 +84,6 @@ class TaskNotifier @Inject constructor(
),
)
}
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
val tapIntent = PendingIntent.getActivity(
context,
task.taskId.toInt(),
@@ -95,10 +93,7 @@ class TaskNotifier @Inject constructor(
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.apply { (text ?: description)?.let(::setContentText) }
.apply {
expandedText(text, description)?.let { setStyle(NotificationCompat.BigTextStyle().bigText(it)) }
}
.apply { if (text != null) setContentText(text) }
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
@@ -245,10 +240,3 @@ 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"
}
@@ -1,23 +0,0 @@
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,7 +46,6 @@ fun RootScreen(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
permissionViewModel: PermissionViewModel = hiltViewModel(),
) {
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
@@ -87,7 +86,6 @@ fun RootScreen(
modifier = modifier,
navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
)
}
}
@@ -103,7 +101,6 @@ private fun ReadyGate(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
) {
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
@@ -116,7 +113,6 @@ 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)
@@ -1,66 +0,0 @@
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
}
}
}
}
@@ -1,51 +0,0 @@
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,
)
}
@@ -97,7 +97,6 @@ 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
@@ -123,8 +122,6 @@ 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) }
@@ -176,12 +173,7 @@ fun ListsScreen(
}
},
) { inner ->
SyncPullBox(
available = syncAvailable && !searchActive,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
) {
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure ->
@@ -4,7 +4,6 @@ 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
@@ -106,17 +105,9 @@ 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,11 +7,6 @@ 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
@@ -51,19 +46,8 @@ 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) {
@@ -87,9 +71,6 @@ 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,15 +9,8 @@ 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.
* [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
/** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */
data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest
/** Settings → Accounts, where a sync report's detail lives. */
data object OpenAccounts : NavRequest
@@ -28,13 +21,8 @@ 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,
val returnHome: Boolean = false,
) : NavRequest
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest
@@ -14,16 +14,13 @@ 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
@@ -41,8 +38,6 @@ 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)
@@ -95,26 +90,6 @@ 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) {
@@ -165,7 +140,6 @@ 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 },
)
@@ -189,17 +163,8 @@ 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,8 +3,6 @@ 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
@@ -35,7 +33,6 @@ 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
@@ -105,8 +102,7 @@ class SettingsViewModel @Inject constructor(
private val oneShotImport: OneShotImport,
private val reminderScheduler: ReminderScheduler,
private val taskNotifier: TaskNotifier,
private val launcherNameManager: LauncherNameManager,
@IoDispatcher private val io: CoroutineDispatcher,
@IoDispatcher io: CoroutineDispatcher,
repository: TasksRepository,
) : ViewModel() {
@@ -221,23 +217,6 @@ 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,8 +112,6 @@ 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
@@ -146,8 +144,6 @@ 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
@@ -251,21 +247,13 @@ fun TaskListScreen(
},
) { inner ->
Box(modifier = Modifier.fillMaxSize()) {
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,
)
}
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
@@ -383,6 +371,8 @@ 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>()) }
@@ -430,20 +420,23 @@ private fun TaskListBody(
}
sections.forEach { section ->
// Every header folds its section away (#91); Done starts folded.
val expanded = !showHeaders || section.section !in state.collapsedSections
val collapsible = showHeaders && section.section == TaskSection.COMPLETED
if (showHeaders) {
stickyHeader(key = "hdr-${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) {
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = completedExpanded,
onToggle = { completedExpanded = !completedExpanded },
onClear = onClearCompleted,
)
} else {
SectionHeader(sectionLabel(section.section))
}
}
}
if (expanded) {
if (!collapsible || completedExpanded) {
// 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
@@ -662,7 +655,7 @@ private fun TaskRow(
SwipeToDismissBox(
state = dismissState,
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.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) },
@@ -1044,7 +1037,7 @@ private fun SubtaskRow(
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox(
state = dismissState,
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) {
Surface(
@@ -1134,6 +1127,19 @@ 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,7 +3,6 @@ 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
@@ -15,13 +14,11 @@ 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
@@ -59,8 +56,6 @@ 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. */
@@ -94,17 +89,9 @@ 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())
@@ -157,7 +144,6 @@ 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 {
@@ -291,9 +277,6 @@ 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,7 +15,6 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
fun AgendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
pureBlack: Boolean = false,
content: @Composable () -> Unit,
) {
FloretExpressiveTheme(
@@ -23,7 +22,6 @@ fun AgendulaTheme(
darkScheme = AgendulaDarkFallback,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
pureBlack = pureBlack,
typography = AgendulaTypography,
content = content,
)
@@ -328,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(), returnHome = true),
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()),
)
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,6 +39,7 @@ 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
@@ -134,12 +135,12 @@ class TaskWidgetConfigActivity : ComponentActivity() {
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
}
val dark = settings.themeMode.isDark(isSystemInDarkTheme())
AgendulaTheme(
darkTheme = dark,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode.isPureBlack,
) {
val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
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(), returnHome = true))
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
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, ThemeMode.BLACK -> WidgetThemeMode.DARK
ThemeMode.DARK -> WidgetThemeMode.DARK
}
/** The smart lists a widget can show, in picker order. */
-5
View File
@@ -1,6 +1,5 @@
<resources>
<string name="app_name">Agendula</string>
<string name="app_name_tasks_alias">Tasks</string>
<!-- Generic -->
<string name="task_untitled">Untitled task</string>
@@ -449,10 +448,6 @@
<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>
@@ -4,7 +4,6 @@ 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
@@ -27,9 +26,6 @@ object SyncModule {
@Provides
fun provideRemoteLists(): RemoteLists = NoRemoteLists
@Provides
fun provideManualSync(): ManualSync = ManualSync.None
}
private object NoRemoteLists : RemoteLists {
@@ -1,31 +0,0 @@
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()
}
}
@@ -1,22 +0,0 @@
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,7 +210,6 @@ 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
@@ -1,11 +0,0 @@
### جديد
- يمكن أن يُحسب التذكير من بدء المهمة أو من موعد الاستحقاق.
- يمكن لمهام اليوم الكامل أن تحمل تذكيرات في أوقات محددة.
- يمكن للتطبيقات الأخرى قراءة مهامك المحلية وتعديلها عبر OpenTasks، بإذنك.
### تغييرات
- تُزامَن التذكيرات كـ VALARM، فترنّ أيضًا في تذكيرات Apple وThunderbird وtasks.org.
### إصلاحات
- تعمل الأدوات مجددًا بعد إنجاز المهام.
- عاد تسجيل الدخول بالبريد الإلكتروني يعمل لدى mailbox.org وPosteo.
@@ -1,11 +0,0 @@
Agendula تطبيق مهام حديث ومفتوح المصدر لنظام Android.
زامن مهامك عبر CalDAV: أضف حساب Nextcloud أو Radicale أو Baïkal أو أي حساب CalDAV آخر، واختر قوائم المهام التي تريد مزامنتها. أو احتفظ بكل شيء على الهاتف، فـ Agendula يخزّن مهامك بنفسه، دون حساب ودون تثبيت أي شيء آخر.
هل تستخدم OpenTasks أو tasks.org؟ اربط Agendula بذلك التطبيق لتظهر المهام التي يزامنها DAVx5 أو ما يشبهه كما هي، وانسخها إلى تخزين Agendula الخاص متى شئت من الإعدادات ← التخزين.
ما يقدمه: قوائم تديرها داخل التطبيق، تواريخ الاستحقاق والبدء، مهام اليوم الكامل، مهام فرعية، أولويات، نسبة الإنجاز، مهام متكررة يمكنك تعديل موعد واحد منها، عدة تذكيرات لكل مهمة مع الغفوة، استيراد iCalendar وتصديره، أداة للشاشة الرئيسية، اختصارات المشغّل، وبلاطة في الإعدادات السريعة.
التصميم Material 3 Expressive حقيقي في كل مكان، مع ألوان ديناميكية وحركة وأشكال معبّرة.
الخصوصية: لا قياس عن بُعد، ولا تحليلات، ولا إعلانات. لا يتصل Agendula بالإنترنت إلا للمزامنة مع حسابات CalDAV التي تضيفها. ودون حساب، لا تغادر مهامك الجهاز إلا إذا صدّرتها.
@@ -1 +0,0 @@
تطبيق مهام بتصميم Material 3 Expressive مع مزامنة CalDAV.
-1
View File
@@ -1 +0,0 @@
Agendula: Tasks
@@ -1,11 +0,0 @@
### 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.
@@ -1,11 +0,0 @@
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.
@@ -1 +0,0 @@
Úkolníček v Material 3 Expressive se synchronizací CalDAV.
@@ -1 +0,0 @@
Agendula: Tasks
@@ -1,11 +0,0 @@
### 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.
@@ -1,11 +0,0 @@
### 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.
@@ -1,11 +0,0 @@
### 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.
@@ -1,11 +0,0 @@
### 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.
@@ -1,11 +0,0 @@
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.
@@ -1 +0,0 @@
Une appli de tâches Material 3 Expressive avec synchro CalDAV.
@@ -1 +0,0 @@
Agendula: Tasks
@@ -1,11 +0,0 @@
### 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.
-55
View File
@@ -1,55 +0,0 @@
#!/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())