Compare commits

...

6 Commits

Author SHA1 Message Date
Jean-Luc Makiola
7e8119be02 Hide the quick-switch button below two views (#150) (#221)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/221
2026-08-18 17:14:43 +02:00
Jean-Luc Makiola
7e843aa740 Drop the ellipsis in week and day event chips too (#164) (#222)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/222
2026-08-18 17:14:12 +02:00
9775e0a652 Drop the ellipsis in week and day event chips too (#164)
#183 only covered the month grid, but a week column is just as narrow. The
all-day chips and the drag copy take the same treatment; the timed blocks
only when they are showing a single line, since wrapping needs softWrap on
and clipping with it on breaks at the last whole word.

The rule now lives in one helper instead of being copied per chip.
2026-08-18 15:57:17 +02:00
2cf7d590bd Hide the quick-switch button below two views (#150)
The pill only appears with something to switch between, so one enabled view
hides it just like zero. The floor that blocked disabling is gone from both
the settings screen and the view model.
2026-08-18 15:52:39 +02:00
Jean-Luc Makiola
8c76cbdf5e Show as much of a month event title as fits (#164) (#183)
Month-view event titles no longer truncate with "…". `MonthBar` — the single
chip renderer for every month style and for the drag's floating copy — now uses
`TextOverflow.Clip` with `softWrap = false`, so a title runs to the chip's edge
and clips mid-glyph instead of spending two characters' width on an ellipsis.

`softWrap = false` is load-bearing: `Clip` on its own still breaks a `maxLines = 1`
line at the last whole word, so "Team standup meeting" would render as "Team" —
less title than the ellipsis showed, not more.

Deviation from the issue: right-to-left layouts keep the ellipsis. With
`softWrap` off Compose lays the line out at its full intrinsic width and clips to
the node's left edge, which in RTL is the *end* of the string — an Arabic title
would have lost its beginning. `Ellipsis` truncates at the logical end in both
directions, so RTL keeps it.

The Glance month widget needs nothing: its `Text` has no overflow parameter, and
a RemoteViews `TextView` with `maxLines = 1` and no ellipsize already clips.

Closes #164

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/183
2026-08-13 15:26:31 +02:00
Jean-Luc Makiola
e0fdadc7ca 2.19.1 — drag to reschedule can be turned off (#176)
All checks were successful
Renovate / renovate (push) Successful in 3m1s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 16m19s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 2m29s
Release branch for 2.19.1. Merging this cuts the release — versionName is bumped to 2.19.1 (versionCode 21901), which is what release.yaml picks up.

### What's in it

- **Drag to reschedule can be turned off** (#173). Settings → Views → *Drag to reschedule*, on by default. Turned off, no event block in the month, week or day view registers a drag gesture. The gate is `EventMoveScope.dragEnabled` rather than the scope itself, so the screen-reader "Move event" action — which only opens the edit form — stays available either way. The setting reads as off until the stored value has actually loaded, so a cold start can't hand out a drag before the preference is known.

### Release prep

- CHANGELOG 2.19.1 section written.
- Per-version "What's New" hand-written for all 12 store locales (`ar`, `de-DE`, `en-GB`, `en-US`, `es-ES`, `fr-FR`, `it-IT`, `pl-PL`, `pt-BR`, `pt-PT`, `ru-RU`, `zh-CN`).
- `lint test assembleDebug` and `check_translations.py` green locally.
- main is one commit ahead (Weblate #175, translations only) and merges clean — not merged into the branch, it comes along with this one.

Closes #173

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/176
2026-08-11 18:20:09 +02:00
35 changed files with 326 additions and 41 deletions

View File

@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Month-view event titles no longer end in an ellipsis. The "…" took the width
of a couple of characters and told you nothing you couldn't already see, so
the title now simply runs to the edge of its chip — a few more letters per
cell, which is often the difference between two events you can tell apart and
two you can't ([#164]).
## [2.19.1] — 2026-08-11
### Added
- **Drag to reschedule can be turned off.** Settings → Views → *Drag to
reschedule*, on by default. Off, no event can be picked up in the month, week
or day view — for anyone who kept moving appointments by accident while
scrolling ([#173]).
- An early Brazilian Portuguese translation. Calendula has started speaking
Brazilian Portuguese, contributed as a community translation through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
About a third of the app is covered, so the rest still shows in English — you
can pick it under Settings → Language or in Android's per-app language
settings. Thanks to
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
started; help finishing it is very welcome.
### Fixed
- **The language picker forgot which language was picked.** Android hands the
applied language back with its script filled in — Simplified Chinese goes in
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
Settings → Language showed as selected, and the row above it named the
language the long way round. Only languages that imply a script were
affected.
## [2.19.0] — 2026-08-10
### Added
@@ -1389,3 +1420,5 @@ automatically, with zero telemetry and no internet permission.
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21900
versionName = "2.19.0"
versionCode = 21901
versionName = "2.19.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -301,6 +301,19 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
}
/**
* Whether events can be dragged to another slot in the calendar views (#68,
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
* rescheduling then goes through the edit form.
*/
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
}
suspend fun setDragToReschedule(enabled: Boolean) {
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
}
/**
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
* [AgendaRange.Month] — a month of upcoming events. Independent of the
@@ -945,6 +958,7 @@ class SettingsPrefs @Inject constructor(
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")

View File

@@ -312,9 +312,13 @@ fun CalendarHost(
heldEditKey = key
editKey = key
}
val moveScope = remember(movableCalendarIds, reschedule) {
// Off by preference (#173) switches off the gesture, not the scope: blocks
// register no drag, while the edit form and its TalkBack action stay put.
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
EventMoveScope(
movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule,
move = reschedule::move,
inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted,

View File

@@ -53,4 +53,18 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
/**
* Whether events may be dragged to another slot to reschedule them (#68, #173).
* Off until the stored value arrives, even though the preference itself defaults
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
* start takes to read DataStore, but re-arms the one thing a disabled user
* switched off to be rid of.
*/
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
}

View File

@@ -135,6 +135,7 @@ fun AgendaScreen(
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
@@ -409,6 +410,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
@@ -440,6 +442,7 @@ private fun AgendaTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)

View File

@@ -57,7 +57,8 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there.
* disabled here stays reachable there — including when [cycle] is emptied and
* the pill disappears altogether.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
@@ -67,14 +68,15 @@ data class QuickSwitchConfig(
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/**
* Fewest views that keep the switch meaningful. A single target is not a
* switch, so below this the pill is hidden rather than special-cased (#150);
* the drawer still reaches every view.
*/
const val MIN_CYCLE = 2
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}

View File

@@ -29,6 +29,12 @@ class EventMoveScope(
* the repository writes whatever it is handed — so this gate is load-bearing.
*/
val movableCalendarIds: Set<Long>,
/**
* Whether the *gesture* is on (#173). Off gates the drag alone: [edit] and the
* TalkBack action below stay, since invoking a menu item can't cause the
* accidental drags the setting exists to stop.
*/
val dragEnabled: Boolean,
/** False when the drop was refused outright, so nothing will be written. */
val move: (MoveRequest) -> Boolean,
/**
@@ -91,6 +97,17 @@ fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
label = "ghost-alpha",
).value
/**
* Whether [event] can be picked up and dragged — its calendar allows moving and
* the gesture is switched on. Not the same question as [eventMoveAction], which
* survives the setting.
*/
@Composable
fun eventDragAllowed(event: EventInstance): Boolean {
val move = LocalEventMove.current ?: return false
return move.dragEnabled && move.allows(event)
}
/**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved.

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
/** How an event chip's title should overflow: what to pass to `Text`. */
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
/**
* Overflow for an event chip's title (#164). Chips are narrow enough that the
* "…" costs a couple of readable characters, so the title runs to the chip's
* edge and clips mid-glyph instead.
*
* Two cases keep the ellipsis:
*
* - **[rtl].** With `softWrap` off Compose lays the line out at its full
* intrinsic width and clips to the node's left edge, which in RTL is the *end*
* of the string — an Arabic title would lose its beginning. The ellipsis
* truncates at the logical end in both directions.
* - **More than one line** ([singleLine] false). Wrapping needs `softWrap` on,
* and clipping with it on breaks the last line at the last whole word — less
* title than the ellipsis showed, not more.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
if (rtl || !singleLine) {
EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
} else {
EventTitleOverflow(TextOverflow.Clip, softWrap = false)
}
/** [eventTitleOverflowFor] against the current layout direction. */
@Composable
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
eventTitleOverflowFor(
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
singleLine = singleLine,
)

View File

@@ -464,11 +464,13 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
.padding(horizontal = 4.dp, vertical = 2.dp),
) {
Column {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f),
)
Text(

View File

@@ -10,13 +10,19 @@ import androidx.compose.ui.res.stringResource
/**
* Top-bar pill that shows the current view and cycles to the next one on tap
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
*
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
* views (#150), so it drops straight into an app bar's `actions` slot without a
* wrapping condition — the same way [TodayAction] handles being turned off.
*/
@Composable
fun ViewSwitcherPill(
current: CalendarView,
cycle: List<CalendarView>,
onCycle: () -> Unit,
modifier: Modifier = Modifier,
) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
FilledTonalButton(
onClick = onCycle,
shape = MaterialTheme.shapes.large,

View File

@@ -65,7 +65,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -92,7 +91,9 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
@@ -242,6 +243,7 @@ fun DayScreen(
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
@@ -410,6 +412,7 @@ private fun DayTopBar(
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
@@ -444,6 +447,7 @@ private fun DayTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
@@ -518,11 +522,13 @@ private fun AllDayBar(
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill),
)
}
@@ -737,7 +743,7 @@ private fun EventBlock(
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge
// is midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone)
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
@@ -765,11 +771,13 @@ private fun EventBlock(
) {
Column {
if (showTitle) {
val titleOverflow = eventTitleOverflow(singleLine = showTime)
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f),
)
}

View File

@@ -89,6 +89,7 @@ import androidx.compose.ui.geometry.Offset
import kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -386,6 +387,7 @@ fun MonthScreen(
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
@@ -650,6 +652,7 @@ private fun MonthTopBar(
titleDate: LocalDate,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
@@ -683,6 +686,7 @@ private fun MonthTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
@@ -2172,7 +2176,7 @@ private fun monthChipDragModifier(
rowHeightPx: Float,
isRtl: Boolean,
): Modifier = rememberDragSurface(
enabled = moveScope != null && controller != null,
enabled = moveScope?.dragEnabled == true && controller != null,
key = week.days.first(),
onPickUp = { local, pointerInRoot, nodeInRoot, size ->
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
@@ -2352,11 +2356,13 @@ private fun MonthBar(
},
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill),
)
}

View File

@@ -302,13 +302,14 @@ private fun ReportProblemRow(position: Position) {
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
// Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
val options = remember(supported) { listOf<String?>(null) + supported }
GroupedRow(
title = stringResource(R.string.settings_language),

View File

@@ -42,6 +42,8 @@ data class SettingsUiState(
val showWeekNumbers: Boolean = false,
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
val todayButtonInToolbar: Boolean = false,
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
val dragToReschedule: Boolean = true,
/** How far ahead the in-app Agenda screen shows events (v2.11). */
val agendaScreenRange: AgendaRange = AgendaRange.Month,
/** How far ahead the agenda widget shows events (v2.11). */

View File

@@ -145,9 +145,15 @@ class SettingsViewModel @Inject constructor(
prefs.showWeekNumbers,
prefs.agendaShowToday,
prefs.softenCalendarColors,
prefs.todayButtonInToolbar,
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
combine(
prefs.todayButtonInToolbar,
prefs.dragToReschedule,
::Pair,
),
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
DisplayToggles(
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
)
},
) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings(
@@ -157,6 +163,7 @@ class SettingsViewModel @Inject constructor(
agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar,
dragToReschedule = toggles.dragToReschedule,
)
},
combine(
@@ -189,6 +196,7 @@ class SettingsViewModel @Inject constructor(
agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar,
dragToReschedule = views.dragToReschedule,
agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
@@ -289,6 +297,7 @@ class SettingsViewModel @Inject constructor(
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
)
private data class DisplayToggles(
@@ -297,6 +306,7 @@ class SettingsViewModel @Inject constructor(
val agendaShowToday: Boolean,
val softenColors: Boolean,
val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
)
private data class MiscSettings(
@@ -534,6 +544,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
}
fun setDragToReschedule(enabled: Boolean) {
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
}
/**
* Switch the launcher label (#44). The card highlights at once, then settles
* on what the component state reports; the writes are binder round-trips and
@@ -577,15 +591,15 @@ class SettingsViewModel @Inject constructor(
/**
* Enable or disable [view] in the quick-switch cycle, via an atomic
* read-modify-write so a concurrent reorder can't clobber it. The
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
* own guard reads an async-echoed snapshot.
* read-modify-write so a concurrent reorder can't clobber it. Any number of
* views may be turned off; below two the pill hides itself (#150).
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
val next = if (enabled) config.enabled + view else config.enabled - view
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
config.copy(
enabled = if (enabled) config.enabled + view else config.enabled - view,
)
}
}
}

View File

@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
@@ -55,8 +54,8 @@ import java.time.format.TextStyle as JavaTextStyle
* it belongs to, plus the two cross-view ordering lists (#24, #69).
*
* The quick-switch cycle and the drawer list are independent orders — a view
* off in the cycle is still reachable from the drawer. The cycle needs at least
* [QuickSwitchConfig.MIN_ENABLED] targets.
* off in the cycle is still reachable from the drawer, including when the cycle
* is emptied and the pill disappears (#150).
*/
@Composable
internal fun ViewsScreen(
@@ -114,7 +113,7 @@ internal fun ViewsScreen(
GroupedRow(
title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Bottom,
position = Position.Middle,
trailing = {
Switch(
checked = state.dimCompletedEvents,
@@ -123,6 +122,18 @@ internal fun ViewsScreen(
},
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
)
GroupedRow(
title = stringResource(R.string.settings_drag_to_reschedule),
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dragToReschedule,
onCheckedChange = viewModel::setDragToReschedule,
)
},
onClick = { viewModel.setDragToReschedule(!state.dragToReschedule) },
)
Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_month_header))
@@ -209,8 +220,6 @@ internal fun ViewsScreen(
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
@@ -226,8 +235,6 @@ internal fun ViewsScreen(
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},

View File

@@ -72,7 +72,6 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -100,7 +99,9 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
@@ -265,6 +266,7 @@ fun WeekScreen(
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
@@ -445,6 +447,7 @@ private fun WeekTopBar(
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
@@ -479,6 +482,7 @@ private fun WeekTopBar(
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
@@ -655,11 +659,13 @@ private fun AllDayBar(
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill),
)
}
@@ -905,7 +911,7 @@ private fun EventBlock(
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge
// is midnight, not the event's start, so dragging it would invent a time.
val draggable = moveAction != null && block.beginsOn(date, zone)
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
@@ -933,11 +939,13 @@ private fun EventBlock(
) {
Column {
if (showTitle) {
val titleOverflow = eventTitleOverflow(singleLine = titleMaxLines == 1)
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f),
)
}

View File

@@ -439,6 +439,8 @@
<string name="timeline_scale_custom_summary">The height you pinched the timeline to</string>
<string name="settings_dim_completed">Dim completed events</string>
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
<string name="settings_drag_to_reschedule">Drag to reschedule</string>
<string name="settings_drag_to_reschedule_summary">Move an event by dragging it to another day or time</string>
<string name="settings_past_events">Past events</string>
<string name="settings_past_events_show">Show</string>
<string name="settings_past_events_dim">Dim</string>
@@ -494,7 +496,7 @@
<string name="month_split_expand">Show the whole month</string>
<string name="month_split_collapse">Show the day\'s events</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. With fewer than two views turned on the button is hidden. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
<string name="reorder_drag_handle">Drag to reorder</string>

View File

@@ -15,5 +15,6 @@
<locale android:name="fr" />
<locale android:name="it" />
<locale android:name="pl" />
<locale android:name="pt-BR" />
<locale android:name="zh-CN" />
</locale-config>

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.text.style.TextOverflow
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventTitleOverflowTest {
@Test
fun `a single LTR line clips, so the title runs to the chip's edge`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = true)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
// Load-bearing: with softWrap on, a clipped line breaks at the last
// whole word and shows less title than the ellipsis did (#164).
assertThat(result.softWrap).isFalse()
}
@Test
fun `RTL keeps the ellipsis, which truncates at the logical end`() {
// Clipping with softWrap off cuts at the node's left edge, which in RTL
// is the end of the string — the title would lose its beginning.
val result = eventTitleOverflowFor(rtl = true, singleLine = true)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.softWrap).isTrue()
}
@Test
fun `a multi-line block keeps the ellipsis, since wrapping needs softWrap`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.softWrap).isTrue()
}
@Test
fun `multi-line in RTL keeps the ellipsis too`() {
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
assertThat(result.softWrap).isTrue()
}
}

View File

@@ -120,4 +120,30 @@ class ViewBackStackTest {
)
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
@Test
fun `a cycle can be emptied down to no views at all`() {
// #150: the settings screen no longer holds a floor, so every view may go.
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = emptySet())
assertThat(config.cycle).isEmpty()
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
}
@Test
fun `one enabled view is below the cycle minimum, so the pill hides`() {
// A single target is not a switch — it hides rather than becoming a
// jump-to-one-view button, which would be dead once you were there.
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = setOf(CalendarView.Day))
assertThat(config.cycle).containsExactly(CalendarView.Day)
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
}
@Test
fun `two enabled views are enough to show the pill`() {
val config = QuickSwitchConfig(
order = IMPLEMENTED_VIEWS,
enabled = setOf(CalendarView.Day, CalendarView.Month),
)
assertThat(config.cycle.size).isAtLeast(QuickSwitchConfig.MIN_CYCLE)
}
}

View File

@@ -0,0 +1,3 @@
جديد
• صار بالإمكان إيقاف السحب لتغيير الموعد: الإعدادات ← العروض ← «السحب لتغيير الموعد». يبقى مفعّلاً افتراضياً؛ وعند إيقافه لا يمكن سحب الأحداث في عرض الشهر أو الأسبوع أو اليوم.
• صار Calendula يتحدّث البرتغالية البرازيلية بفضل ترجمة من المجتمع. اخترها من الإعدادات ← اللغة.

View File

@@ -0,0 +1,3 @@
Neu
• Verschieben per Drag & Drop lässt sich abschalten: Einstellungen → Ansichten → „Per Ziehen verschieben“. Standardmäßig bleibt es an; abgeschaltet lassen sich Termine in der Monats-, Wochen- und Tagesansicht nicht mehr aufnehmen.
• Calendula spricht jetzt brasilianisches Portugiesisch dank einer Community-Übersetzung. Auswählbar unter Einstellungen → Sprache.

View File

@@ -0,0 +1,3 @@
Added
• Drag to reschedule can now be turned off: Settings → Views → “Drag to reschedule”. It stays on by default; switched off, events can no longer be picked up in the month, week or day view.
• Calendula now speaks Brazilian Portuguese, thanks to a community translation. Pick it under Settings → Language.

View File

@@ -0,0 +1,3 @@
Added
• Drag to reschedule can now be turned off: Settings → Views → “Drag to reschedule”. It stays on by default; switched off, events can no longer be picked up in the month, week or day view.
• Calendula now speaks Brazilian Portuguese, thanks to a community translation. Pick it under Settings → Language.

View File

@@ -0,0 +1,3 @@
Novedades
• Ahora se puede desactivar el mover eventos arrastrando: Ajustes → Vistas → «Arrastrar para reprogramar». Sigue activado por defecto; desactivado, los eventos ya no se pueden arrastrar en las vistas de mes, semana y día.
• Calendula ya habla portugués de Brasil, gracias a una traducción de la comunidad. Elígelo en Ajustes → Idioma.

View File

@@ -0,0 +1,3 @@
Nouveautés
• Le déplacement par glisser-déposer peut désormais être désactivé : Réglages → Vues → « Glisser pour déplacer ». Il reste activé par défaut ; désactivé, les événements ne peuvent plus être saisis en vue mois, semaine ou jour.
• Calendula parle désormais le portugais brésilien, grâce à une traduction de la communauté. À choisir dans Réglages → Langue.

View File

@@ -0,0 +1,3 @@
Novità
• Ora lo spostamento con il trascinamento si può disattivare: Impostazioni → Viste → «Trascina per spostare». Resta attivo di default; disattivato, gli eventi non si possono più trascinare nelle viste mese, settimana e giorno.
• Calendula ora parla portoghese brasiliano, grazie a una traduzione della comunità. Si sceglie in Impostazioni → Lingua.

View File

@@ -0,0 +1,3 @@
Nowości
• Przenoszenie wydarzeń przeciąganiem można teraz wyłączyć: Ustawienia → Widoki → „Przeciągnij, aby przenieść”. Domyślnie pozostaje włączone; po wyłączeniu wydarzeń nie da się już chwycić w widoku miesiąca, tygodnia i dnia.
• Calendula mówi teraz po portugalsku (Brazylia) dzięki tłumaczeniu społeczności. Wybierzesz je w Ustawienia → Język.

View File

@@ -0,0 +1,3 @@
Novidades
• Agora é possível desativar o mover eventos arrastando: Configurações → Visualizações → “Arrastar para reagendar”. Continua ativo por padrão; desativado, os eventos não podem mais ser arrastados nas visualizações de mês, semana e dia.
• O Calendula agora fala português do Brasil, graças a uma tradução da comunidade. Escolha em Configurações → Idioma.

View File

@@ -0,0 +1,3 @@
Novidades
• Já é possível desativar o mover eventos arrastando: Definições → Vistas → «Arrastar para reagendar». Continua ativo por predefinição; desativado, os eventos deixam de poder ser arrastados nas vistas de mês, semana e dia.
• O Calendula fala agora português do Brasil, graças a uma tradução da comunidade. Escolha em Definições → Idioma.

View File

@@ -0,0 +1,3 @@
Новое
• Перенос событий перетаскиванием теперь можно отключить: Настройки → Виды → «Перетаскивание для переноса». По умолчанию включён; при отключении события больше нельзя подхватить в видах месяца, недели и дня.
• Calendula теперь говорит на бразильском португальском — благодаря переводу сообщества. Выбрать можно в Настройки → Язык.

View File

@@ -0,0 +1,3 @@
新增
• 现在可以关闭拖动改期:设置 → 视图 → “拖动改期”。默认保持开启;关闭后,月、周、日视图中的事件将无法再被拖起。
• Calendula 现已支持巴西葡萄牙语,感谢社区翻译。可在设置 → 语言中选择。