Compare commits

..
Author SHA1 Message Date
makiolaj 061e85ddcd Merge release/v1.3.0 into feat/73-pull-to-sync 2026-10-28 14:31:12 +01:00
Jean-Luc Makiola 2c450d300f Return home after adding or opening a task from a widget (#93) (#113)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:30:47 +01:00
Jean-Luc Makiola df1fb4e46b Collapsible task list sections (#91) (#112)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Canceled after 0s
2026-10-28 14:30:36 +01:00
Jean-Luc Makiola 63c7aa64e2 Ignore task swipes that start at the screen edge (#90) (#111)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:30:27 +01:00
Jean-Luc Makiola c26ff5909b Switchable launcher name: Agendula or Tasks (#104) (#110)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:30:12 +01:00
Jean-Luc Makiola c1219a434d Pure black theme for AMOLED screens (#99) (#109)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:29:25 +01:00
Jean-Luc Makiola 5dc1d49295 Show the task description in reminder notifications (#94) (#108)
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-28 14:28:45 +01:00
makiolaj 172591901a feat: pull to sync on the home and task list screens (#73) 2026-10-28 12:14:57 +01:00
29 changed files with 628 additions and 68 deletions
@@ -15,8 +15,10 @@ import de.jeanlucmakiola.agendula.data.sync.AccountCreator
import de.jeanlucmakiola.agendula.data.sync.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore
import de.jeanlucmakiola.agendula.data.sync.PullSync
import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
@@ -81,6 +83,10 @@ abstract class SyncBindModule {
@Singleton
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
@Binds
@Singleton
abstract fun bindManualSync(impl: PullSync): ManualSync
@Binds
@IntoSet
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher
@@ -0,0 +1,76 @@
package de.jeanlucmakiola.agendula.data.sync
import android.content.Context
import androidx.work.WorkInfo
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Pull to sync over [SyncTrigger]: an expedited sync per account, and
* [syncing] held until they finish.
*
* A sync waiting on its network constraint never starts, so if none is running
* after [START_GRACE] the indicator lets go; the work stays queued and runs
* once the device is back online.
*/
@Singleton
class PullSync @Inject constructor(
@ApplicationContext private val context: Context,
private val accounts: AccountRepository,
private val trigger: SyncTrigger,
availability: SyncAvailability,
@ApplicationScope private val scope: CoroutineScope,
) : ManualSync {
override val available: Flow<Boolean> =
combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() }
private val _syncing = MutableStateFlow(false)
override val syncing: StateFlow<Boolean> = _syncing
override fun syncAll() {
if (_syncing.value) return
_syncing.value = true
scope.launch {
try {
val names = accounts.syncable().map { trigger.enqueue(it.displayName, expedited = true) }
if (names.isNotEmpty()) awaitSyncs(names)
} finally {
_syncing.value = false
}
}
}
private suspend fun awaitSyncs(names: List<String>) {
val workManager = WorkManager.getInstance(context)
val states = combine(names.map { workManager.getWorkInfosForUniqueWorkFlow(it) }) { infos ->
infos.flatMap { it }.map { it.state }
}
withTimeoutOrNull(MAX_WAIT) {
val started = withTimeoutOrNull(START_GRACE) {
states.first { all -> WorkInfo.State.RUNNING in all || all.all { it.isFinished } }
} ?: return@withTimeoutOrNull
if (started.all { it.isFinished }) return@withTimeoutOrNull
states.map { all -> WorkInfo.State.RUNNING in all }.first { running -> !running }
}
}
private companion object {
val START_GRACE = 3.seconds
val MAX_WAIT = 2.minutes
}
}
+28 -4
View File
@@ -65,10 +65,8 @@
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher
name can be switched at runtime (#104). -->
<!-- Shared text becomes a new task's title. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
@@ -89,6 +87,32 @@
<data android:mimeType="text/calendar" />
</intent-filter>
</activity>
<activity-alias
android:name=".DefaultNameAlias"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".TasksNameAlias"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_tasks_alias"
android:roundIcon="@mipmap/ic_launcher_round"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
<service
@@ -28,7 +28,6 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
@@ -88,15 +87,15 @@ class MainActivity : ComponentActivity() {
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (ui.settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme())
val context = LocalContext.current
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek()
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
AgendulaTheme(
darkTheme = darkTheme,
dynamicColor = ui.settings.dynamicColor,
pureBlack = ui.settings.themeMode.isPureBlack,
) {
CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour,
LocalFirstDayOfWeek provides firstDayOfWeek,
@@ -105,6 +104,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
navRequest = navRequest,
onNavRequestConsumed = { navRequest = null },
onReturnHome = ::finish,
)
pendingCrashReport?.let { report ->
CrashReportDialog(
@@ -153,6 +153,7 @@ class MainActivity : ComponentActivity() {
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID"
private const val EXTRA_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID"
private const val EXTRA_RETURN_HOME = "de.jeanlucmakiola.agendula.extra.RETURN_HOME"
private const val EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L
@@ -161,10 +162,16 @@ class MainActivity : ComponentActivity() {
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
* picks the occurrence of a recurring task.
*/
fun taskIntent(context: Context, taskId: Long, occurrenceStart: Long? = null): Intent =
fun taskIntent(
context: Context,
taskId: Long,
occurrenceStart: Long? = null,
returnHome: Boolean = false,
): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
putExtra(EXTRA_RETURN_HOME, returnHome)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
@@ -216,6 +223,7 @@ class MainActivity : ComponentActivity() {
when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
returnHome = true,
)
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST)
@@ -230,12 +238,12 @@ class MainActivity : ComponentActivity() {
if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
?: intent.getStringExtra(Intent.EXTRA_TEXT)
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT))
return NavRequest.NewTask(shared?.trim()?.take(SHARED_TITLE_LIMIT), returnHome = true)
}
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
.takeIf { it != NO_OCCURRENCE }
return NavRequest.OpenTask(taskId, occurrence)
return NavRequest.OpenTask(taskId, occurrence, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
}
}
}
@@ -0,0 +1,86 @@
package de.jeanlucmakiola.agendula.data.appname
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts
import javax.inject.Inject
import javax.inject.Singleton
/** The label the launcher shows for the app (#104). */
enum class LauncherName { AGENDULA, TASKS }
/** The two launcher aliases declared in the manifest. */
enum class LauncherAlias { DEFAULT, TASKS }
/** One component-enable change in an [aliasWritePlan]. */
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
/**
* Reads the `TasksNameAlias` enabled state as a [LauncherName]. Only an explicit
* enable means "Tasks"; never toggled and disabled both mean the manifest default.
*/
fun launcherNameFor(tasksAliasState: Int): LauncherName =
if (tasksAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
LauncherName.TASKS
} else {
LauncherName.AGENDULA
}
/**
* The steps to switch to [target]: enable the target alias first, then disable
* the other, so the launcher can briefly see two entries but never none.
*/
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
LauncherName.TASKS -> listOf(
AliasStateChange(LauncherAlias.TASKS, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
)
LauncherName.AGENDULA -> listOf(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.TASKS, enabled = false),
)
}
/**
* Switches the launcher label by enabling one of the two `<activity-alias>`
* components. The component state is the only source of truth; nothing is
* stored in prefs.
*/
@Singleton
class LauncherNameManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val packageManager: PackageManager get() = context.packageManager
// The package is the (suffixed) applicationId, the class is namespace-qualified.
private fun component(alias: LauncherAlias): ComponentName {
val simpleName = when (alias) {
LauncherAlias.DEFAULT -> "DefaultNameAlias"
LauncherAlias.TASKS -> "TasksNameAlias"
}
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
}
fun current(): LauncherName =
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.TASKS)))
fun set(name: LauncherName) {
for (change in aliasWritePlan(name)) {
val state = if (change.enabled) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
packageManager.setComponentEnabledSetting(component(change.alias), state, PackageManager.DONT_KILL_APP)
}
// Dynamic shortcuts hang off the launcher activity, which just changed.
AppShortcuts.publish(context)
}
private companion object {
/** `namespace` in app/build.gradle.kts, not the suffixed applicationId. */
const val NAMESPACE = "de.jeanlucmakiola.agendula"
}
}
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
@@ -31,7 +32,18 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** [BLACK] is dark with pure-black surfaces, for AMOLED screens. */
enum class ThemeMode {
SYSTEM, LIGHT, DARK, BLACK;
fun isDark(systemDark: Boolean): Boolean = when (this) {
SYSTEM -> systemDark
LIGHT -> false
DARK, BLACK -> true
}
val isPureBlack: Boolean get() = this == BLACK
}
/** Clock convention: AUTO follows the device's 24-hour switch. */
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
@@ -43,6 +55,7 @@ fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
}
const val DEFAULT_SNOOZE_MINUTES = 10
/** Done starts folded away, to keep the open tasks in focus. */ val DEFAULT_COLLAPSED_SECTIONS: Set<TaskSection> = setOf(TaskSection.COMPLETED)
/** The snooze lengths Settings offers. */
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
@@ -103,6 +116,8 @@ data class Settings(
val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false,
/** Task-list sections folded away; Done starts folded. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
) {
/** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -168,9 +183,18 @@ class SettingsPrefs @Inject constructor(
.toSet()
},
defaultStartToday = p[DEFAULT_START_TODAY] ?: false,
collapsedSections = p[COLLAPSED_SECTIONS]
?.mapNotNull { name -> runCatching { TaskSection.valueOf(name) }.getOrNull() }
?.toSet()
?: DEFAULT_COLLAPSED_SECTIONS,
)
}
suspend fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) = dataStore.edit { p ->
val current = p[COLLAPSED_SECTIONS] ?: DEFAULT_COLLAPSED_SECTIONS.mapTo(HashSet()) { it.name }
p[COLLAPSED_SECTIONS] = if (collapsed) current + section.name else current - section.name
}
suspend fun setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) }
@@ -328,6 +352,7 @@ class SettingsPrefs @Inject constructor(
val PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id")
}
@@ -18,6 +18,7 @@ import androidx.core.content.edit
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.domain.readableDescription
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
@@ -84,6 +85,7 @@ class TaskNotifier @Inject constructor(
),
)
}
val description = task.description?.readableDescription()?.trim()?.takeIf { it.isNotBlank() }
val tapIntent = PendingIntent.getActivity(
context,
task.taskId.toInt(),
@@ -93,7 +95,10 @@ class TaskNotifier @Inject constructor(
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.apply { if (text != null) setContentText(text) }
.apply { (text ?: description)?.let(::setContentText) }
.apply {
expandedText(text, description)?.let { setStyle(NotificationCompat.BigTextStyle().bigText(it)) }
}
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
@@ -240,3 +245,10 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays,
else -> null
}
}
/** The expanded body of a due reminder: the due line, then the task's description. */
internal fun expandedText(dueLine: String?, description: String?): String? = when {
description == null -> null
dueLine == null -> description
else -> "$dueLine\n\n$description"
}
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.agendula.data.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
/** Pull to sync (#73): syncs every account at once. Inert in the `offline` flavor. */
interface ManualSync {
/** Whether there is anything to sync: our own store, with at least one account. */
val available: Flow<Boolean>
/** True from [syncAll] until the syncs it started have finished. */
val syncing: StateFlow<Boolean>
fun syncAll()
object None : ManualSync {
override val available: Flow<Boolean> = flowOf(false)
override val syncing: StateFlow<Boolean> = MutableStateFlow(false)
override fun syncAll() = Unit
}
}
@@ -46,6 +46,7 @@ fun RootScreen(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
permissionViewModel: PermissionViewModel = hiltViewModel(),
) {
val permission by permissionViewModel.state.collectAsStateWithLifecycle()
@@ -86,6 +87,7 @@ fun RootScreen(
modifier = modifier,
navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
)
}
}
@@ -101,6 +103,7 @@ private fun ReadyGate(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
onboardingViewModel: OnboardingViewModel = hiltViewModel(),
) {
val done by onboardingViewModel.done.collectAsStateWithLifecycle()
@@ -113,6 +116,7 @@ private fun ReadyGate(
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
)
false -> ReadableWidth(modifier) {
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel)
@@ -0,0 +1,66 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.dp
import kotlin.math.abs
/** How far past the system back-gesture zone a horizontal swipe is still ignored. */
private val EDGE_MARGIN = 24.dp
/**
* Swallows horizontal swipes that start near the screen's left or right edge,
* before a swipe box below it sees them (#90). A back gesture started just
* inside the system's gesture zone otherwise reaches the app as a row swipe.
* Taps and vertical scrolls are left alone.
*/
@Composable
fun Modifier.ignoringEdgeSwipes(): Modifier {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val windowWidth = LocalWindowInfo.current.containerSize.width
val insets = WindowInsets.systemGestures
val margin = with(density) { EDGE_MARGIN.roundToPx() }
val leftEdge = insets.getLeft(density, layoutDirection) + margin
val rightEdge = windowWidth - insets.getRight(density, layoutDirection) - margin
var offsetX by remember { mutableFloatStateOf(0f) }
return this
.onGloballyPositioned { offsetX = it.positionInWindow().x }
.pointerInput(leftEdge, rightEdge) {
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val x = offsetX + down.position.x
if (x > leftEdge && x < rightEdge) return@awaitEachGesture
var horizontal = false
while (true) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes
.firstOrNull { it.id == down.id } ?: break
if (!horizontal) {
val delta = change.position - down.position
when {
abs(delta.x) > slop -> horizontal = true
abs(delta.y) > slop -> break
}
}
if (horizontal) change.consume()
if (!change.pressed) break
}
}
}
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Pull down to sync every account (#73). Without an account there is nothing
* to sync, so the gesture is off. [topPadding] keeps the indicator below a top
* bar the content scrolls under.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SyncPullBox(
available: Boolean,
syncing: Boolean,
onSync: () -> Unit,
modifier: Modifier = Modifier,
topPadding: Dp = 0.dp,
content: @Composable BoxScope.() -> Unit,
) {
if (!available) {
Box(modifier, content = content)
return
}
val state = rememberPullToRefreshState()
PullToRefreshBox(
isRefreshing = syncing,
onRefresh = onSync,
modifier = modifier,
state = state,
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = state,
isRefreshing = syncing,
modifier = Modifier.align(Alignment.TopCenter).padding(top = topPadding),
)
},
content = content,
)
}
@@ -97,6 +97,7 @@ import kotlinx.coroutines.delay
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.components.positionOf
@@ -122,6 +123,8 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
var newList by rememberSaveable { mutableStateOf(false) }
@@ -173,7 +176,12 @@ fun ListsScreen(
}
},
) { inner ->
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
SyncPullBox(
available = syncAvailable && !searchActive,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()),
) {
when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure ->
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
@@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3
class ListsViewModel @Inject constructor(
private val repository: TasksRepository,
private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
settingsPrefs: SettingsPrefs,
) : ViewModel() {
/** Pull to sync (#73); off without an account. */
val syncAvailable: StateFlow<Boolean> =
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncing: StateFlow<Boolean> = manualSync.syncing
fun syncAll() = manualSync.syncAll()
/**
* Where a new list may go: the device, then every account whose server said
* it would take one.
@@ -7,6 +7,11 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
@@ -46,8 +51,19 @@ fun AgendulaNavHost(
modifier: Modifier = Modifier,
navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
) {
val nav = rememberNavController()
// The entry a widget, tile, shortcut or share opened; closing it leaves the app (#93).
var returnHomeEntry by rememberSaveable { mutableStateOf<String?>(null) }
val backStack by nav.currentBackStack.collectAsState()
LaunchedEffect(backStack, returnHomeEntry) {
val id = returnHomeEntry ?: return@LaunchedEffect
if (backStack.none { it.id == id }) {
returnHomeEntry = null
onReturnHome()
}
}
LaunchedEffect(navRequest) {
when (navRequest) {
@@ -71,6 +87,9 @@ fun AgendulaNavHost(
}
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri))
}
val returnHome = (navRequest as? NavRequest.NewTask)?.returnHome == true ||
(navRequest as? NavRequest.OpenTask)?.returnHome == true
returnHomeEntry = if (returnHome) nav.currentBackStackEntry?.id else null
onNavRequestConsumed()
}
@@ -9,8 +9,15 @@ import de.jeanlucmakiola.agendula.domain.SmartList
* [AgendulaNavHost], which pushes it over whatever is showing.
*/
sealed interface NavRequest {
/** One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series. */
data class OpenTask(val taskId: Long, val occurrenceStart: Long? = null) : NavRequest
/**
* One task's detail; [occurrenceStart] (epoch millis) picks the occurrence of a series.
* [returnHome] leaves the app once the detail is closed (a widget row, #93).
*/
data class OpenTask(
val taskId: Long,
val occurrenceStart: Long? = null,
val returnHome: Boolean = false,
) : NavRequest
/** Settings → Accounts, where a sync report's detail lives. */
data object OpenAccounts : NavRequest
@@ -21,8 +28,13 @@ sealed interface NavRequest {
/**
* A new task, optionally titled with text shared from another app, or
* preset to a list — a widget's own add button, when it shows one list.
* [returnHome] leaves the app once the form is closed (#93).
*/
data class NewTask(val title: String? = null, val listId: Long? = null) : NavRequest
data class NewTask(
val title: String? = null,
val listId: Long? = null,
val returnHome: Boolean = false,
) : NavRequest
/** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest
@@ -14,13 +14,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.appname.LauncherName
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
@@ -38,6 +41,8 @@ internal fun AppearanceScreen(
) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) }
var showAppName by remember { mutableStateOf(false) }
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
var showTimeFormat by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) }
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
@@ -90,6 +95,26 @@ internal fun AppearanceScreen(
position = Position.Bottom,
onClick = { showWeekStart = true },
)
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_app_name),
summary = launcherNameLabel(launcherName),
position = Position.Alone,
onClick = { showAppName = true },
)
}
if (showAppName) {
OptionPicker(
title = stringResource(R.string.settings_app_name),
options = LauncherName.entries,
selected = launcherName,
label = { launcherNameLabel(it) },
header = { GroupedHint(stringResource(R.string.settings_app_name_hint)) },
onSelect = viewModel::setLauncherName,
onDismiss = { showAppName = false },
)
}
if (showTimeFormat) {
@@ -140,6 +165,7 @@ internal fun AppearanceScreen(
options = ThemeMode.entries,
selected = state.settings.themeMode,
label = { stringResource(themeLabel(it)) },
summary = { if (it == ThemeMode.BLACK) stringResource(R.string.settings_theme_black_summary) else null },
onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false },
)
@@ -163,8 +189,17 @@ private fun weekStartLabel(day: DayOfWeek?): String =
/** 14:00, the sample time "Automatic" is shown with. */
private const val SAMPLE_MINUTE_OF_DAY = 14 * 60
@Composable
private fun launcherNameLabel(name: LauncherName): String = stringResource(
when (name) {
LauncherName.AGENDULA -> R.string.app_name
LauncherName.TASKS -> R.string.app_name_tasks_alias
},
)
private fun themeLabel(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
ThemeMode.BLACK -> R.string.settings_theme_black
}
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.appname.LauncherName
import de.jeanlucmakiola.agendula.data.appname.LauncherNameManager
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
@@ -33,6 +35,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.DayOfWeek
import javax.inject.Inject
@@ -102,7 +105,8 @@ class SettingsViewModel @Inject constructor(
private val oneShotImport: OneShotImport,
private val reminderScheduler: ReminderScheduler,
private val taskNotifier: TaskNotifier,
@IoDispatcher io: CoroutineDispatcher,
private val launcherNameManager: LauncherNameManager,
@IoDispatcher private val io: CoroutineDispatcher,
repository: TasksRepository,
) : ViewModel() {
@@ -217,6 +221,23 @@ class SettingsViewModel @Inject constructor(
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
private val _launcherName = MutableStateFlow(LauncherName.AGENDULA)
val launcherName: StateFlow<LauncherName> = _launcherName
init {
viewModelScope.launch { _launcherName.value = withContext(io) { launcherNameManager.current() } }
}
fun setLauncherName(name: LauncherName) {
_launcherName.value = name
viewModelScope.launch {
_launcherName.value = withContext(io) {
launcherNameManager.set(name)
launcherNameManager.current()
}
}
}
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
@@ -112,6 +112,8 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.SyncPullBox
import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
@@ -144,6 +146,8 @@ fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val content = state as? TaskListUiState.Content
val list = content?.list
@@ -247,13 +251,21 @@ fun TaskListScreen(
},
) { inner ->
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
)
SyncPullBox(
available = syncAvailable,
syncing = syncing,
onSync = viewModel::syncAll,
modifier = Modifier.fillMaxSize(),
topPadding = inner.calculateTopPadding(),
) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(
s, filter, inner, onOpenTask, onRequestDelete, viewModel,
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null,
)
}
}
// A FAB-height band anchored at the bottom-start with the FAB's own
// margin; centring the chip in it lines the chip up beside the bottom-end
@@ -371,8 +383,6 @@ private fun TaskListBody(
// rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1)
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
@@ -420,23 +430,20 @@ private fun TaskListBody(
}
sections.forEach { section ->
val collapsible = showHeaders && section.section == TaskSection.COMPLETED
// Every header folds its section away (#91); Done starts folded.
val expanded = !showHeaders || section.section !in state.collapsedSections
if (showHeaders) {
stickyHeader(key = "hdr-${section.section}") {
if (collapsible) {
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = completedExpanded,
onToggle = { completedExpanded = !completedExpanded },
onClear = onClearCompleted,
)
} else {
SectionHeader(sectionLabel(section.section))
}
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = expanded,
onToggle = { viewModel.setSectionCollapsed(section.section, expanded) },
onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED },
)
}
}
if (!collapsible || completedExpanded) {
if (expanded) {
// Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children
@@ -655,7 +662,7 @@ private fun TaskRow(
SwipeToDismissBox(
state = dismissState,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
@@ -1037,7 +1044,7 @@ private fun SubtaskRow(
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox(
state = dismissState,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
modifier = modifier.ignoringEdgeSwipes().fillMaxWidth().padding(horizontal = 16.dp).then(gap),
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) {
Surface(
@@ -1127,19 +1134,6 @@ private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, sma
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
}
@Composable
private fun SectionHeader(text: String) {
// Opaque surface so rows scroll cleanly under the pinned header.
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** A tappable section header with a count and a chevron that rotates on expand. */
@Composable
private fun CollapsibleSectionHeader(
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.DEFAULT_COLLAPSED_SECTIONS
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
@@ -14,11 +15,13 @@ import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.tagsInUse
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import de.jeanlucmakiola.agendula.ui.lists.asFailure
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -56,6 +59,8 @@ sealed interface TaskListUiState {
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false,
val sortOrder: TaskSortOrder = TaskSortOrder.DUE,
/** Sections whose rows are folded under their header. */
val collapsedSections: Set<TaskSection> = DEFAULT_COLLAPSED_SECTIONS,
/** This real list is kept out of the smart lists. */
val hiddenFromSmartLists: Boolean = false,
/** Tags the unnarrowed view carries, offered as filter chips. */
@@ -89,9 +94,17 @@ class TaskListViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
@ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() {
/** Pull to sync (#73); off without an account. */
val syncAvailable: StateFlow<Boolean> =
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val syncing: StateFlow<Boolean> = manualSync.syncing
fun syncAll() = manualSync.syncAll()
private val filter = MutableStateFlow<TaskFilter?>(null)
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
@@ -144,6 +157,7 @@ class TaskListViewModel @Inject constructor(
showAddSubtaskRow = settings.showAddSubtaskRow,
bottomAddBar = settings.bottomAddBar,
sortOrder = settings.sortOrder,
collapsedSections = settings.collapsedSections,
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
)
} else {
@@ -277,6 +291,9 @@ class TaskListViewModel @Inject constructor(
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) }
fun setSectionCollapsed(section: TaskSection, collapsed: Boolean) =
viewModelScope.launch { settingsPrefs.setSectionCollapsed(section, collapsed) }
fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) }
/**
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
fun AgendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
pureBlack: Boolean = false,
content: @Composable () -> Unit,
) {
FloretExpressiveTheme(
@@ -22,6 +23,7 @@ fun AgendulaTheme(
darkScheme = AgendulaDarkFallback,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
pureBlack = pureBlack,
typography = AgendulaTypography,
content = content,
)
@@ -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()),
MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true),
)
val onColor = if (colored) GlanceColorProvider(Color.White) else GlanceTheme.colors.onSurface
val onColorVariant = if (colored) GlanceColorProvider(Color.White.copy(alpha = 0.8f)) else GlanceTheme.colors.onSurfaceVariant
@@ -39,7 +39,6 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList
@@ -135,12 +134,12 @@ class TaskWidgetConfigActivity : ComponentActivity() {
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
}
val dark = when (settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) {
val dark = settings.themeMode.isDark(isSystemInDarkTheme())
AgendulaTheme(
darkTheme = dark,
dynamicColor = settings.dynamicColor,
pureBlack = settings.themeMode.isPureBlack,
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
WidgetFilterPicker(
kind = kind,
@@ -339,7 +339,7 @@ private fun ListDot(color: Int) {
}
private fun openTask(context: Context, row: WidgetTaskRow): Action =
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds()))
actionStartActivity(MainActivity.taskIntent(context, row.taskId, row.occurrenceStart?.toEpochMilliseconds(), returnHome = true))
private fun completeTask(row: WidgetTaskRow): Action =
actionRunCallback<CompleteTaskAction>(
@@ -82,7 +82,7 @@ internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMo
override ?: when (appThemeMode) {
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
ThemeMode.DARK -> WidgetThemeMode.DARK
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
}
/** The smart lists a widget can show, in picker order. */
+5
View File
@@ -1,5 +1,6 @@
<resources>
<string name="app_name">Agendula</string>
<string name="app_name_tasks_alias">Tasks</string>
<!-- Generic -->
<string name="task_untitled">Untitled task</string>
@@ -448,6 +449,10 @@
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_theme_black">Black</string>
<string name="settings_theme_black_summary">Dark with pure black backgrounds, for AMOLED screens</string>
<string name="settings_app_name">App name</string>
<string name="settings_app_name_hint">Show Agendula as “Tasks” in your launcher. Only the launcher name changes; the icon may move to a new spot. Agendula closes when you switch, so reopen it from the launcher.</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Notifications</string>
@@ -4,6 +4,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
@@ -26,6 +27,9 @@ object SyncModule {
@Provides
fun provideRemoteLists(): RemoteLists = NoRemoteLists
@Provides
fun provideManualSync(): ManualSync = ManualSync.None
}
private object NoRemoteLists : RemoteLists {
@@ -0,0 +1,31 @@
package de.jeanlucmakiola.agendula.data.appname
import android.content.pm.PackageManager
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class LauncherNameManagerTest {
@Test
fun `an enabled Tasks alias reads as Tasks`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED)).isEqualTo(LauncherName.TASKS)
}
@Test
fun `a default or disabled Tasks alias reads as Agendula`() {
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)).isEqualTo(LauncherName.AGENDULA)
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)).isEqualTo(LauncherName.AGENDULA)
}
@Test
fun `switching always enables the target before disabling the other`() {
assertThat(aliasWritePlan(LauncherName.TASKS)).containsExactly(
AliasStateChange(LauncherAlias.TASKS, enabled = true),
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
).inOrder()
assertThat(aliasWritePlan(LauncherName.AGENDULA)).containsExactly(
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
AliasStateChange(LauncherAlias.TASKS, enabled = false),
).inOrder()
}
}
@@ -0,0 +1,22 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ExpandedTextTest {
@Test
fun `no description means no expanded body`() {
assertThat(expandedText("Due today", null)).isNull()
}
@Test
fun `description alone when there is no due line`() {
assertThat(expandedText(null, "Buy oat milk")).isEqualTo("Buy oat milk")
}
@Test
fun `due line comes first, separated by a blank line`() {
assertThat(expandedText("Due today", "Buy oat milk")).isEqualTo("Due today\n\nBuy oat milk")
}
}
@@ -210,6 +210,7 @@ class WidgetDataTest {
fun `no override resolves to whatever the app's own theme means`() {
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
assertThat(readyWithTheme(ThemeMode.BLACK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
}
@Test