Compare commits

..
Author SHA1 Message Date
Jean-Luc Makiola 7afa2f41f0 Review fixes for advancing series and provider occurrences (#135, #137) (#147)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 19:03:41 +01:00
makiolaj 4b1294341a Release 1.3.1
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 18:34:28 +01:00
Jean-Luc Makiola 2dfc676b8f Keep the list order with tasks.org and OpenTasks (#136) (#143)
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 18:30:33 +01:00
Jean-Luc Makiola 109eda82b2 Recurring tasks keep their rule with tasks.org and OpenTasks (#135) (#142)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-09 18:27:58 +01:00
Jean-Luc Makiolaandmakiolaj 68495a936c Ticking a repeating task moves the series on (#137) (#144)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
In Agendula's own storage, ticking off the current occurrence of a repeating task now moves the series on to its next date and keeps a completed copy, as tasks.org and Apple Reminders do.

Before, the series stayed open and the completion was a `RECURRENCE-ID` override with `STATUS:COMPLETED` in the same resource. That's valid iCalendar, but Infomaniak's web view and Apple Reminders ignore it and keep showing the task as open, which is what #137 reports. The reporter hasn't confirmed their storage mode yet. With tasks.org/OpenTasks the provider already moves the series on, so this change is about the built-in CalDAV sync.

How it works (`RoomTasksDataSource`):
- Ticking or cancelling the series' **first open occurrence** moves the master to its next open occurrence. Closed overrides on either side become standalone completed copies (new UID, fresh alarm identities), including the ones 1.3.0 left behind. `COUNT` drops by the occurrences used up (counted before `EXDATE`, shared with `splitSeries`), `EXDATE` values before the new start are dropped, and status and progress are reset. An edited occurrence is converted in place, so its id, edits and own properties survive. Lines inside nested components, such as a `VALARM`'s `UID`, are kept.
- Series with `RDATE`s keep the override model, because moving the anchor onto an `RDATE` would re-align the `RRULE`. So do recurring subtasks, whose copies would otherwise pile up under the parent.
- How the series moved is recorded in two new local tables, `advanced_occurrences` and `advanced_detached` (schema v5, migration and migration test included). They are never part of the synced `VTODO`. A record keeps mapping the occurrence to its copy (pruned after 30 days), so the detail screen and reminders find the copy even after it was edited. Every save path goes through one check: when the only thing that changes on a copy is that it reopens (compared as the editor writes values back), the move is undone. That covers undo, the completed section, the editor, the detail screen and other apps through Agendula's provider. The anchor goes back, `COUNT` regains what it spent, the dropped `EXDATE`s return in the list's own value shape, other edits made since are kept, and a reopened state such as 40% stays on the occurrence. Detached overrides come back as overrides; one deleted since, or given its own parent or subtasks, is excluded instead. A copy that was edited, moved or given subtasks, or whose series moved on, changed its rule or was deleted, reopens as a task of its own.
- If a sync discards the local version of a moved series because the server's is newer, the copies of moves the server never saw are deleted too, and the server's own overrides stand for those occurrences. Moves already uploaded are marked settled and left alone.
- `task(series, anchor)` resolves a moved-past occurrence to its copy, so the detail screen and reminder checks see it as done. The repository then acts on the row it resolved, including the detail screen's subtasks, so deleting or saving from a screen opened before the move can't hit the whole series.
- A stale tick (an old notification or widget row) for an occurrence the series has already moved past does nothing.
- Ticking the last open occurrence closes the series, and reopening any of its occurrences reopens the series.
- Any other occurrence (with earlier ones still open) keeps the override model.

Also fixed here: `AgendulaTaskProviderTest` failed on `release/v1.3.1` whenever Agendula ran on an external provider, because `ProviderWriter` checked the active store first. Read-only tables are now refused as such whichever store is active (one exhaustive `when` per operation), and the two tests that check value errors skip when the provider takes no writes at all.

Tests: `RoomTasksDataSourceTest` covers each case above. There is also a migration test, a `ProviderWriterTest` for reopening through the provider, and `RoomSyncStoreAdvancesTest` (full flavour) for the sync discard. The full on-device suite (223 tests) passes on the emulator, and the unit tests pass for both flavours.

Closes #137

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/144
2026-11-09 18:19:57 +01:00
10 changed files with 2 additions and 373 deletions

No files matched your search

-5
View File
@@ -7,11 +7,6 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
### Added
- A "Description in reminders" setting under Notifications sets how much of a
task's description a reminder shows: none, a short preview of up to three
lines (the new default), or all of it as before (#156).
## [1.3.1] - 2026-11-09
### Changed
@@ -54,9 +54,6 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
TimeFormatPref.TWENTY_FOUR_HOUR -> true
}
/** How much of a task's description a due reminder shows when expanded. */
enum class ReminderDescription { HIDDEN, SHORT, FULL }
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)
@@ -106,7 +103,6 @@ data class Settings(
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** How long a reminder's "Snooze" action puts it off. */
val snoozeMinutes: Int = DEFAULT_SNOOZE_MINUTES,
val reminderDescription: ReminderDescription = ReminderDescription.SHORT,
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** The first day of the week; `null` follows the locale. */
val weekStart: DayOfWeek? = null,
@@ -175,9 +171,6 @@ class SettingsPrefs @Inject constructor(
sortOrder = p[SORT_ORDER]?.let { runCatching { TaskSortOrder.valueOf(it) }.getOrNull() }
?: TaskSortOrder.DUE,
snoozeMinutes = p[SNOOZE_MINUTES]?.takeIf { it > 0 } ?: DEFAULT_SNOOZE_MINUTES,
reminderDescription = p[REMINDER_DESCRIPTION]
?.let { runCatching { ReminderDescription.valueOf(it) }.getOrNull() }
?: ReminderDescription.SHORT,
timeFormat = p[TIME_FORMAT]?.let { runCatching { TimeFormatPref.valueOf(it) }.getOrNull() }
?: TimeFormatPref.AUTO,
autofocusTitle = p[AUTOFOCUS_TITLE] ?: true,
@@ -206,9 +199,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
suspend fun setReminderDescription(pref: ReminderDescription) =
dataStore.edit { it[REMINDER_DESCRIPTION] = pref.name }
suspend fun setSyncIntervalMinutes(minutes: Int) = dataStore.edit { it[SYNC_INTERVAL] = minutes }
suspend fun setPushEnabled(enabled: Boolean) = dataStore.edit { it[PUSH_ENABLED] = enabled }
@@ -355,7 +345,6 @@ class SettingsPrefs @Inject constructor(
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
val SORT_ORDER = stringPreferencesKey("sort_order")
val SNOOZE_MINUTES = intPreferencesKey("snooze_minutes")
val REMINDER_DESCRIPTION = stringPreferencesKey("reminder_description")
val TIME_FORMAT = stringPreferencesKey("time_format")
val WEEK_START = stringPreferencesKey("week_start")
val AUTOFOCUS_TITLE = booleanPreferencesKey("autofocus_title")
@@ -18,7 +18,6 @@ import androidx.core.content.ContextCompat
import androidx.core.content.edit
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.domain.readableDescription
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
@@ -87,11 +86,7 @@ class TaskNotifier @Inject constructor(
),
)
}
val description = reminderBody(
task.description?.readableDescription(),
settings.reminderDescription,
lineChars = (NOTIFICATION_LINE_CHARS / context.resources.configuration.fontScale).toInt(),
)
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
val tapIntent = PendingIntent.getActivity(
context,
task.taskId.toInt(),
@@ -282,96 +277,3 @@ internal fun expandedText(dueLine: String?, description: String?): String? = whe
dueLine == null -> description
else -> "$dueLine\n\n$description"
}
/** Rough characters per line of an expanded notification on a phone, at the default font size. */
private const val NOTIFICATION_LINE_CHARS = 40
/** The description a reminder shows, as [mode] asks; null when there is none to show. */
internal fun reminderBody(
description: String?,
mode: ReminderDescription,
lineChars: Int = NOTIFICATION_LINE_CHARS,
): String? {
val text = description?.trim()?.takeIf { it.isNotBlank() } ?: return null
return when (mode) {
ReminderDescription.HIDDEN -> null
ReminderDescription.SHORT -> shortDescription(text, lineChars = lineChars)
ReminderDescription.FULL -> text
}
}
/**
* [description] cut to about [maxLines] lines of an expanded notification,
* ending in an ellipsis when anything was left out. Blank lines are dropped,
* and a long line counts as the lines it wraps to.
*/
internal fun shortDescription(
description: String,
maxLines: Int = 3,
lineChars: Int = NOTIFICATION_LINE_CHARS,
): String {
val perLine = lineChars.coerceAtLeast(2)
val lines = description.lines().map { it.trimEnd() }.filter { it.isNotBlank() }
val kept = mutableListOf<String>()
var rowsLeft = maxLines
for ((index, line) in lines.withIndex()) {
val width = displayWidth(line)
val rows = (width + perLine - 1) / perLine
val last = index == lines.lastIndex
// The ellipsis after a line that ends the budget needs a cell of its own.
val fits = rows < rowsLeft || (rows == rowsLeft && (last || width < rows * perLine))
if (fits) {
kept += line
rowsLeft -= rows
if (rowsLeft == 0 && !last) return ellipsize(kept)
continue
}
if (rowsLeft > 0) kept += cutAtWord(line, rowsLeft * perLine - 1)
return ellipsize(kept)
}
return kept.joinToString("\n")
}
private fun ellipsize(lines: List<String>): String =
lines.joinToString("\n").trimEnd { it.isWhitespace() || it in ".,;:" } + "\u2026"
/** [line] cut to at most [max] cells, at a word break when there is one, never inside a character. */
private fun cutAtWord(line: String, max: Int): String {
var end = 0
var width = 0
while (end < line.length) {
val codePoint = line.codePointAt(end)
width += cellsOf(codePoint)
if (width > max) break
end += Character.charCount(codePoint)
}
if (end >= line.length || line[end].isWhitespace()) return line.substring(0, end)
val cut = line.substring(0, end)
val space = cut.indexOfLast { it.isWhitespace() }
return if (space > end / 2) cut.substring(0, space) else cut
}
/** How many cells [text] takes, wide characters (CJK, emoji) counting two. */
private fun displayWidth(text: String): Int {
var width = 0
var i = 0
while (i < text.length) {
val codePoint = text.codePointAt(i)
width += cellsOf(codePoint)
i += Character.charCount(codePoint)
}
return width
}
private fun cellsOf(codePoint: Int): Int = when {
Character.isSupplementaryCodePoint(codePoint) -> 2
Character.UnicodeScript.of(codePoint) in WIDE_SCRIPTS -> 2
else -> 1
}
private val WIDE_SCRIPTS = setOf(
Character.UnicodeScript.HAN,
Character.UnicodeScript.HIRAGANA,
Character.UnicodeScript.KATAKANA,
Character.UnicodeScript.HANGUL,
)
@@ -39,7 +39,6 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
import de.jeanlucmakiola.agendula.data.prefs.SNOOZE_PRESETS
import de.jeanlucmakiola.agendula.data.reminders.TaskNotifier
import de.jeanlucmakiola.agendula.domain.TaskReminderOrder
@@ -114,7 +113,6 @@ internal fun NotificationsScreen(
var showOffset by remember { mutableStateOf(false) }
var showAllDay by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var showDescription by remember { mutableStateOf(false) }
var showAllDayTime by remember { mutableStateOf(false) }
var perListExpanded by remember { mutableStateOf(false) }
var expandedLists by remember { mutableStateOf(emptySet<Long>()) }
@@ -180,14 +178,8 @@ internal fun NotificationsScreen(
R.string.settings_allday_reminder_time_summary,
formatMinuteOfDay(state.settings.allDayReminderMinuteOfDay, LocalUse24HourFormat.current),
),
position = Position.Middle,
onClick = { showAllDayTime = true },
)
GroupedRow(
title = stringResource(R.string.settings_reminder_description),
summary = reminderDescriptionLabel(state.settings.reminderDescription),
position = Position.Bottom,
onClick = { showDescription = true },
onClick = { showAllDayTime = true },
)
Spacer(Modifier.height(24.dp))
@@ -326,17 +318,6 @@ internal fun NotificationsScreen(
onDismiss = { showSnooze = false },
)
}
if (showDescription) {
OptionPicker(
title = stringResource(R.string.settings_reminder_description),
options = ReminderDescription.entries,
selected = state.settings.reminderDescription,
label = { reminderDescriptionLabel(it) },
summary = { reminderDescriptionSummary(it) },
onSelect = viewModel::setReminderDescription,
onDismiss = { showDescription = false },
)
}
if (showAllDayTime) {
val minute = state.settings.allDayReminderMinuteOfDay
TimePickerAlert(
@@ -373,24 +354,6 @@ private fun snoozeLabel(minutes: Int): String =
if (minutes % 60 == 0) pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
else pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
@Composable
private fun reminderDescriptionLabel(pref: ReminderDescription): String = stringResource(
when (pref) {
ReminderDescription.HIDDEN -> R.string.settings_reminder_description_hidden
ReminderDescription.SHORT -> R.string.settings_reminder_description_short
ReminderDescription.FULL -> R.string.settings_reminder_description_full
},
)
@Composable
private fun reminderDescriptionSummary(pref: ReminderDescription): String = stringResource(
when (pref) {
ReminderDescription.HIDDEN -> R.string.settings_reminder_description_hidden_summary
ReminderDescription.SHORT -> R.string.settings_reminder_description_short_summary
ReminderDescription.FULL -> R.string.settings_reminder_description_full_summary
},
)
/** Which list's override picker is open, and for which kind of task. */
private data class OverrideTarget(val listId: Long, val allDay: Boolean)
@@ -6,7 +6,6 @@ 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.ReminderDescription
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
@@ -260,7 +259,6 @@ class SettingsViewModel @Inject constructor(
fun setTimeFormat(pref: TimeFormatPref) = viewModelScope.launch { prefs.setTimeFormat(pref) }
fun setWeekStart(day: DayOfWeek?) = viewModelScope.launch { prefs.setWeekStart(day) }
fun setSnoozeMinutes(minutes: Int) = viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
fun setReminderDescription(pref: ReminderDescription) = viewModelScope.launch { prefs.setReminderDescription(pref) }
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
fun setDefaultStartToday(enabled: Boolean) = viewModelScope.launch { prefs.setDefaultStartToday(enabled) }
-91
View File
@@ -763,95 +763,4 @@
<string name="widget_config_background">الخلفية</string>
<string name="widget_config_background_solid">سادة</string>
<string name="widget_config_theme">السمة</string>
<string name="app_name_tasks_alias">مهام</string>
<string name="list_where">أين</string>
<string name="settings_backup_subtitle">التصدير و الاستيراد</string>
<string name="error_reminder_without_date">اضبط تاريخ بدء أو استحقاق لاستخدام تذكير.</string>
<string name="reminder_at_start">في وقت البدء</string>
<string name="reminder_at_due_time">في وقت الاستحقاق</string>
<string name="reminder_on_start_date">عند موعد البدء</string>
<string name="reminder_on_due_date">عند موعد الاستحقاق</string>
<string name="reminder_at_time">%1$s عند %2$s</string>
<string name="reminder_anchor_start">من البدء</string>
<string name="reminder_anchor_due">من تاريخ الاستحقاق</string>
<string name="reminder_time">الوقت</string>
<string name="reminder_benefit_delivery_title">تنبيه عندما يكون الأمر مهمًا</string>
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
<string name="settings_app_name_hint">اعرض Agendula كـ ”مهام“ في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد. يُغلَق Agendula عندما تقوم بالتبديل، لذا أعِد فتحه من المشغّل.</string>
<string name="settings_allday_reminder_time_summary">العد التنازلي لمهام التي تستمر طوال اليوم عند %1$s</string>
<string name="settings_store_permission_denied_hint">مهام التطبيق الآخر تبقى غير قابلة للوصول حتى تسمح بالوصول. اضغط لفتح إعدادات التطبيق.</string>
<string name="settings_copy_counting">جارٍ حساب ما هناك لنسخه…</string>
<string name="settings_copy_confirm_empty">%1$s لا يحتوي على مهام لنسخها.</string>
<string name="settings_copy_nothing">لم يكن هناك شيء للنسخ</string>
<string name="export_hint">ملف .ics واحد لكل قائمة، قابل للقراءة بواسطة تطبيقات المهام والتقويم الأخرى. القوائم الموضوع عليها بعلامة صح تذهب إلى مجلد تختاره أنت، أو إلى ملف مضغوط (zip) واحد.</string>
<string name="export_no_lists">لا توجد قوائم للتصدير</string>
<string name="export_to_folder">الحفظ إلى مجلد</string>
<string name="export_to_zip">حفظ كملف مضغوط (zip)</string>
<plurals name="export_done">
<item quantity="zero">تم تصدير (%1$d) لا قوائم</item>
<item quantity="one">تم تصدير قائمة واحدة (%1$d)</item>
<item quantity="two">تم تصدير قائمتان (%1$d)</item>
<item quantity="few">تم تصدير %1$d قوائم</item>
<item quantity="many">تم تصدير %1$d قائمة</item>
<item quantity="other">تم تصدير %1$d قائمة</item>
</plurals>
<plurals name="reminder_minutes_before_start">
<item quantity="zero">(%1$d) لا دقائق قبل البدء</item>
<item quantity="one">%1$d دقيقة واحده قبل البدء</item>
<item quantity="two">%1$d دقيقتان قبل البدء</item>
<item quantity="few">%1$d دقائق قبل البدء</item>
<item quantity="many">%1$d دقيقة قبل البدء</item>
<item quantity="other">%1$d دقيقة قبل البدء</item>
</plurals>
<plurals name="reminder_hours_before_start">
<item quantity="zero">(%1$d) لا ساعات قبل البدء</item>
<item quantity="one">%1$d ساعة واحده قبل البدء</item>
<item quantity="two">%1$d ساعتان قبل البدء</item>
<item quantity="few">%1$d ساعات قبل البدء</item>
<item quantity="many">%1$d ساعة قبل البدء</item>
<item quantity="other">%1$d ساعة قبل البدء</item>
</plurals>
<plurals name="reminder_days_before_start">
<item quantity="zero">(%1$d) لا أيام قبل البدء</item>
<item quantity="one">%1$d يوم واحد قبل البدء</item>
<item quantity="two">%1$d يومان قبل البدء</item>
<item quantity="few">%1$d أيام قبل البدء</item>
<item quantity="many">%1$d يوم قبل البدء</item>
<item quantity="other">%1$d يوم قبل البدء</item>
</plurals>
<plurals name="reminder_weeks_before_start">
<item quantity="zero">(%1$d) لا أسابيع قبل البدء</item>
<item quantity="one">%1$d أسبوع واحد قبل البدء</item>
<item quantity="two">%1$d أسبوعان قبل البدء</item>
<item quantity="few">%1$d أسابيع قبل البدء</item>
<item quantity="many">%1$d أسبوع قبل البدء</item>
<item quantity="other">%1$d أسبوع قبل البدء</item>
</plurals>
<string name="settings_accounts_subtitle">مزامنة مهامك مع خادم CalDAV</string>
<string name="accounts_add">إضافة حسابًا</string>
<string name="accounts_sync_interval">المزامنة تلقائيًا</string>
<string name="accounts_sync_interval_manual">فقط عند تقوم بالمزامنة</string>
<string name="accounts_push_via">تصل التغييرات فورًا، عبر %1$s</string>
<string name="widget_unavailable">افتح Agendula لإنهاء الإعداد</string>
<string name="widget_today_all_done">انتهى كل شيء</string>
<string name="widget_config_title">إظهار في الأداة المصغرة</string>
<string name="widget_config_group_by_list">تجميع حسب القائمة</string>
<string name="widget_config_group_by_list_hint">إظهار مهام كل قائمة ضمن عنوانها الخاص</string>
<string name="widget_config_background_solid_hint">يملأ الأداة المصغّرة بلون سطح السمة</string>
<string name="widget_config_background_tinted_hint">أكثر نعومة، تعبئة لونية متناغمة</string>
<string name="widget_config_background_transparent">شفافة</string>
<string name="widget_config_background_transparent_hint">لا تعبئة — تبقى المهام قابلة للقراءة في حاوياتها الخاصة</string>
<string name="widget_config_row_color_neutral_hint">يستخدم كل صف نفس التعبئة اللونية المتناغمة</string>
<string name="widget_config_row_color_by_list">حسب القائمة</string>
<string name="widget_config_row_color_by_list_hint">كل صف متناغم باللون الخاص بقائمته</string>
<string name="widget_today_description">حلقة تقدم لمهام اليوم</string>
<string name="widget_up_next_description">مهمتك الوحيدة الأقرب لموعد الاستحقاق</string>
<string name="widget_lists_label">قوائم</string>
<string name="widget_lists_description">مربع اختصار لكل قائمة، مع عدد مرّات فتحه</string>
<string name="widget_week_label">أسبوع</string>
<string name="widget_week_description">شريط من سبعة أيام لعدد المهام</string>
<string name="widget_config_background_tinted">ملونة</string>
<string name="settings_theme_black">أسود</string>
<string name="settings_theme_black_summary">داكن مع خلفيات سوداء نقية، لشاشات AMOLED</string>
<string name="settings_app_name">اسم التطبيق</string>
</resources>
-14
View File
@@ -320,18 +320,4 @@
<string name="reminder_at_due">V čase úkolu</string>
<string name="reminder_on_the_day">V daný den</string>
<string name="settings_list_reminders_title">Upozornění pro jednotlivé seznamy</string>
<string name="shortcut_new_task">Nový úkol</string>
<string name="shortcut_today">Dnes</string>
<string name="qs_tile_new_task">Nový úkol</string>
<string name="sort_title">Seřadit podle</string>
<string name="view_options_title">Řazení a filtrování</string>
<string name="view_filter_tags">Filtrovat podle štítku</string>
<string name="view_filter_clear">Zrušit filtr</string>
<string name="sort_due">Termín splnění</string>
<string name="sort_title_alpha">Název</string>
<string name="sort_created">Nedávno přidané</string>
<string name="clear_completed_action">Zrušit</string>
<string name="undo">Zpět</string>
<string name="section_today">Dnes</string>
<string name="section_upcoming">Nadcházející</string>
</resources>
-7
View File
@@ -28,11 +28,4 @@
<string name="qs_tile_new_task">Новая задача</string>
<string name="lists_reorder">Переопределить список</string>
<string name="lists_reorder_done">Завершить переопределение</string>
<string name="app_name_tasks_alias">Задачи</string>
<string name="lists_reorder_handle">Перетащите, чтобы изменить порядок</string>
<string name="sort_title">Сортировать по</string>
<string name="view_options_title">Сортировать и фильтровать</string>
<string name="view_filter_tags">Фильтр по тегу</string>
<string name="view_filter_clear">Очистить фильтр</string>
<string name="sort_due">Срок выполнения</string>
</resources>
-7
View File
@@ -470,13 +470,6 @@
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_snooze_duration">Snooze duration</string>
<string name="settings_reminder_description">Description in reminders</string>
<string name="settings_reminder_description_hidden">Hidden</string>
<string name="settings_reminder_description_short">Short</string>
<string name="settings_reminder_description_full">Full</string>
<string name="settings_reminder_description_hidden_summary">Only the title and due time</string>
<string name="settings_reminder_description_short_summary">A short preview of the description</string>
<string name="settings_reminder_description_full_summary">The whole description</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Agendula so they arrive on time.</string>
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
<string name="settings_default_list">Default list</string>
@@ -1,99 +0,0 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.prefs.ReminderDescription
import org.junit.jupiter.api.Test
class ShortDescriptionTest {
@Test
fun `short text is left alone`() {
assertThat(shortDescription("Buy oat milk")).isEqualTo("Buy oat milk")
}
@Test
fun `three short lines fit`() {
assertThat(shortDescription("one\ntwo\nthree")).isEqualTo("one\ntwo\nthree")
}
@Test
fun `lines past the third are cut with an ellipsis`() {
assertThat(shortDescription("one\ntwo\nthree\nfour\nfive")).isEqualTo("one\ntwo\nthree…")
}
@Test
fun `blank lines take no room`() {
assertThat(shortDescription("one\n\n\ntwo\n \nthree")).isEqualTo("one\ntwo\nthree")
}
@Test
fun `a long paragraph is cut at a word within the line budget`() {
val text = List(40) { "word" }.joinToString(" ")
val short = shortDescription(text, maxLines = 3, lineChars = 40)
assertThat(short).endsWith("word…")
assertThat(short.length).isAtMost(3 * 40)
}
@Test
fun `a wrapping line counts as the lines it takes`() {
val long = "a".repeat(70)
val short = shortDescription("$long\nsecond\nthird", maxLines = 3, lineChars = 40)
assertThat(short).isEqualTo("$long\nsecond…")
}
@Test
fun `a word with no break is cut hard`() {
val short = shortDescription("x".repeat(200), maxLines = 3, lineChars = 40)
assertThat(short).isEqualTo("x".repeat(119) + "…")
}
@Test
fun `trailing punctuation gives way to the ellipsis`() {
assertThat(shortDescription("one.\ntwo,\nthree.\nfour")).isEqualTo("one.\ntwo,\nthree…")
}
@Test
fun `a line that fills the budget exactly leaves room for the ellipsis`() {
val full = "a".repeat(120)
val short = shortDescription("$full\nmore", maxLines = 3, lineChars = 40)
assertThat(short).isEqualTo("a".repeat(119) + "…")
}
@Test
fun `a cut never splits an emoji`() {
val short = shortDescription("x".repeat(118) + "😀" + "y".repeat(10), maxLines = 3, lineChars = 40)
assertThat(short).isEqualTo("x".repeat(118) + "…")
}
@Test
fun `a word that ends right at the cut is kept`() {
val short = shortDescription("aaaa bbbb cccc", maxLines = 1, lineChars = 10)
assertThat(short).isEqualTo("aaaa bbbb…")
}
@Test
fun `tabs count as word breaks`() {
val short = shortDescription("Meeting\tagenda\tnotes", maxLines = 1, lineChars = 12)
assertThat(short).isEqualTo("Meeting…")
}
@Test
fun `no space is left before the ellipsis`() {
assertThat(shortDescription("see list :\nmore", maxLines = 1, lineChars = 40)).isEqualTo("see list…")
}
@Test
fun `wide characters take two cells`() {
val short = shortDescription("中".repeat(30), maxLines = 1, lineChars = 20)
assertThat(short).isEqualTo("中".repeat(9) + "…")
}
@Test
fun `the setting picks what the reminder shows`() {
val long = List(60) { "word" }.joinToString(" ")
assertThat(reminderBody(long, ReminderDescription.HIDDEN)).isNull()
assertThat(reminderBody(long, ReminderDescription.FULL)).isEqualTo(long)
assertThat(reminderBody(long, ReminderDescription.SHORT)).isEqualTo(shortDescription(long))
assertThat(reminderBody(" ", ReminderDescription.FULL)).isNull()
}
}