Compare commits

..
Author SHA1 Message Date
makiolaj a4e50bd161 feat: collapsible task list sections (#91) 2026-10-28 12:07:14 +01:00
10 changed files with 48 additions and 60 deletions
@@ -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,
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
@@ -31,18 +32,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 }
@@ -54,6 +44,7 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
}
const val DEFAULT_SNOOZE_MINUTES = 10
/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set<TaskSection> = setOf(TaskSection.COMPLETED)
/** The snooze lengths Settings offers. */
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
@@ -114,6 +105,8 @@ data class Settings(
val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false,
/** Task-list sections folded away; Done starts folded. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -179,9 +172,18 @@ class SettingsPrefs @Inject constructor(
.toSet()
},
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
collapsedSections = p[COLLAPSED_SECTIONS]
?.mapNotNull { name -> runCatching { TaskSection.valueOf(name) }.getOrNull() }
?.toSet()
?: DEFAULT_COLLAPSED_SECTIONS,
)
}
suspend fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = dataStore.edit { p ->
val current = p[COLLAPSED_SECTIONS] ?: DEFAULT_COLLAPSED_SECTIONS.mapTo(HashSet()) { it.name }
p[COLLAPSED_SECTIONS] = if (collapsed) current + section.name else current - section.name
}
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
@@ -339,6 +341,7 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
}
@@ -140,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 },
)
@@ -168,5 +167,4 @@ 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
}
@@ -371,8 +371,6 @@ private fun TaskListBody(
// rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
@@ -420,23 +418,20 @@ private fun TaskListBody(
}
sections.forEach { section ->
val collapsible = showHeaders && section.section == TaskSection.COMPLETED
// Every header folds its section away (#91); Done starts folded.
val expanded = !showHeaders || section.section !in state.collapsedSections
if (showHeaders) {
stickyHeader(key = "hdr-${section.section}") {
if (collapsible) {
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = completedExpanded,
onToggle = { completedExpanded = !completedExpanded },
onClear = onClearCompleted,
)
} else {
SectionHeader(sectionLabel(section.section))
}
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = expanded,
onToggle = { viewModel.setSectionCollapsed(section.section, expanded) },
onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED },
)
}
}
if (!collapsible || completedExpanded) {
if (expanded) {
// Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children
@@ -1127,19 +1122,6 @@ private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, sma
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
}
@Composable
private fun SectionHeader(text: String) {
// Opaque surface so rows scroll cleanly under the pinned header.
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** A tappable section header with a count and a chevron that rotates on expand. */
@Composable
private fun CollapsibleSectionHeader(
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_COLLAPSED_SECTIONS
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
@@ -14,6 +15,7 @@ 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
@@ -56,6 +58,8 @@ sealed interface TaskListUiState {
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false,
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** Sections whose rows are folded under their header. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
/** This real list is kept out of the smart lists. */
val hiddenFromSmartLists: Boolean = false,
/** Tags the unnarrowed view carries, offered as filter chips. */
@@ -144,6 +148,7 @@ class TaskListViewModel @Inject constructor(
showAddSubtaskRow = settings.showAddSubtaskRow,
bottomAddBar = settings.bottomAddBar,
sortOrder = settings.sortOrder,
collapsedSections = settings.collapsedSections,
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
)
} else {
@@ -277,6 +282,9 @@ class TaskListViewModel @Inject constructor(
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) }
fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) =
viewModelScope.launch { settingsPrefs.setSectionCollapsed(section, collapsed) }
fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) }
/**
@@ -15,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,
)
@@ -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,
@@ -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. */
-2
View File
@@ -448,8 +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_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Notifications</string>
@@ -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