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.AccountRepository
import de.jeanlucmakiola.agendula.data.sync.CalDavGateway import de.jeanlucmakiola.agendula.data.sync.CalDavGateway
import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord 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.OkHttpCalDavGateway
import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore 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.RemoteListRepository
import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier
@@ -81,6 +83,10 @@ abstract class SyncBindModule {
@Singleton @Singleton
abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests
@Binds
@Singleton
abstract fun bindManualSync(impl: PullSync): ManualSync
@Binds @Binds
@IntoSet @IntoSet
abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher 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:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <!-- MAIN/LAUNCHER lives on the two aliases below, so the launcher
<action android:name="android.intent.action.MAIN" /> name can be switched at runtime (#104). -->
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Shared text becomes a new task's title. --> <!-- Shared text becomes a new task's title. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
@@ -89,6 +87,32 @@
<data android:mimeType="text/calendar" /> <data android:mimeType="text/calendar" />
</intent-filter> </intent-filter>
</activity> </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. --> <!-- Quick Settings "New task" tile. Only the system QS host can bind it. -->
<service <service
@@ -28,7 +28,6 @@ import de.jeanlucmakiola.agendula.data.di.LaunchHook
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.ui.imports.importIntentUri import de.jeanlucmakiola.agendula.ui.imports.importIntentUri
import de.jeanlucmakiola.agendula.ui.navigation.AppShortcuts 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.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.navigation.NavRequest import de.jeanlucmakiola.agendula.ui.navigation.NavRequest
@@ -88,15 +87,15 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel() val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle() val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (ui.settings.themeMode) { val darkTheme = ui.settings.themeMode.isDark(isSystemInDarkTheme())
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
val context = LocalContext.current val context = LocalContext.current
val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context)) val use24Hour = ui.settings.timeFormat.is24Hour(DateFormat.is24HourFormat(context))
val firstDayOfWeek = ui.settings.weekStart ?: localeFirstDayOfWeek() 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( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalFirstDayOfWeek provides firstDayOfWeek, LocalFirstDayOfWeek provides firstDayOfWeek,
@@ -105,6 +104,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
navRequest = navRequest, navRequest = navRequest,
onNavRequestConsumed = { navRequest = null }, onNavRequestConsumed = { navRequest = null },
onReturnHome = ::finish,
) )
pendingCrashReport?.let { report -> pendingCrashReport?.let { report ->
CrashReportDialog( CrashReportDialog(
@@ -153,6 +153,7 @@ class MainActivity : ComponentActivity() {
private const val EXTRA_FILTER_ID = "de.jeanlucmakiola.agendula.extra.FILTER_ID" 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_SMART_LIST = "de.jeanlucmakiola.agendula.extra.SMART_LIST"
private const val EXTRA_LIST_ID = "de.jeanlucmakiola.agendula.extra.LIST_ID" 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 EXTRA_PRESET_LIST_ID = "de.jeanlucmakiola.agendula.extra.PRESET_LIST_ID"
private const val SHARED_TITLE_LIMIT = 500 private const val SHARED_TITLE_LIMIT = 500
private const val NO_OCCURRENCE = -1L private const val NO_OCCURRENCE = -1L
@@ -161,10 +162,16 @@ class MainActivity : ComponentActivity() {
* Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis) * Opens a task's detail (reminder taps). [occurrenceStart] (epoch millis)
* picks the occurrence of a recurring task. * 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 { Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId) putExtra(EXTRA_TASK_ID, taskId)
putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE) putExtra(EXTRA_OCCURRENCE_START, occurrenceStart ?: NO_OCCURRENCE)
putExtra(EXTRA_RETURN_HOME, returnHome)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
@@ -216,6 +223,7 @@ class MainActivity : ComponentActivity() {
when (intent.action) { when (intent.action) {
ACTION_NEW_TASK -> return NavRequest.NewTask( ACTION_NEW_TASK -> return NavRequest.NewTask(
listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L }, listId = intent.getLongExtra(EXTRA_PRESET_LIST_ID, -1L).takeIf { it > 0L },
returnHome = true,
) )
ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY) ACTION_TODAY -> return NavRequest.OpenSmart(SmartList.TODAY)
ACTION_OPEN_SMART -> intent.getStringExtra(EXTRA_SMART_LIST) 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) { if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/plain") == true) {
val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() } val shared = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() }
?: intent.getStringExtra(Intent.EXTRA_TEXT) ?: 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 taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L).takeIf { it > 0L } ?: return null
val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE) val occurrence = intent.getLongExtra(EXTRA_OCCURRENCE_START, NO_OCCURRENCE)
.takeIf { it != 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.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskReminder import de.jeanlucmakiola.agendula.domain.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.asTaskReminder import de.jeanlucmakiola.agendula.domain.asTaskReminder
import de.jeanlucmakiola.agendula.domain.asReminderLead import de.jeanlucmakiola.agendula.domain.asReminderLead
import de.jeanlucmakiola.agendula.domain.TaskSortOrder import de.jeanlucmakiola.agendula.domain.TaskSortOrder
@@ -31,7 +32,18 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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. */ /** Clock convention: AUTO follows the device's 24-hour switch. */
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR } 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 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. */ /** The snooze lengths Settings offers. */
val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60) val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
@@ -103,6 +116,8 @@ data class Settings(
val hiddenFromSmartLists: Set<Long> = emptySet(), val hiddenFromSmartLists: Set<Long> = emptySet(),
/** Pre-fill a new task's start with today's date. */ /** Pre-fill a new task's start with today's date. */
val defaultStartToday: Boolean = false, 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. */ /** [tasks] as [filter] shows them: a smart list drops the lists kept out of it. */
fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> = fun visibleIn(filter: TaskFilter, tasks: List<Task>): List<Task> =
@@ -168,9 +183,18 @@ class SettingsPrefs @Inject constructor(
.toSet() .toSet()
}, },
defaultStartToday = p[DEFAULT_START_TODAY] ?: false, 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 setSortOrder(order: TaskSortOrder) = dataStore.edit { it[SORT_ORDER] = order.name }
suspend fun setSnoozeMinutes(minutes: Int) = dataStore.edit { it[SNOOZE_MINUTES] = minutes.coerceAtLeast(1) } 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 PUSH_ENABLED = booleanPreferencesKey("push_enabled")
val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden") val SMART_LIST_HIDDEN = stringSetPreferencesKey("smart_list_hidden")
val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today") val DEFAULT_START_TODAY = booleanPreferencesKey("default_start_today")
val COLLAPSED_SECTIONS = stringSetPreferencesKey("collapsed_sections")
val SAVED_FILTERS = stringPreferencesKey("saved_filters") val SAVED_FILTERS = stringPreferencesKey("saved_filters")
val SAVED_FILTER_NEXT_ID = longPreferencesKey("saved_filter_next_id") 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.MainActivity
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.Settings 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.prefs.is24Hour
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver 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( val tapIntent = PendingIntent.getActivity(
context, context,
task.taskId.toInt(), task.taskId.toInt(),
@@ -93,7 +95,10 @@ class TaskNotifier @Inject constructor(
val notification = NotificationCompat.Builder(context, channelId) val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .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) .setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
@@ -240,3 +245,10 @@ private fun relativeDay(date: LocalDate, today: LocalDate, words: RelativeDays,
else -> null 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, modifier: Modifier = Modifier,
navRequest: NavRequest? = null, navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {}, onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
permissionViewModel: PermissionViewModel = hiltViewModel(), permissionViewModel: PermissionViewModel = hiltViewModel(),
) { ) {
val permission by permissionViewModel.state.collectAsStateWithLifecycle() val permission by permissionViewModel.state.collectAsStateWithLifecycle()
@@ -86,6 +87,7 @@ fun RootScreen(
modifier = modifier, modifier = modifier,
navRequest = navRequest, navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed, onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
) )
} }
} }
@@ -101,6 +103,7 @@ private fun ReadyGate(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navRequest: NavRequest? = null, navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {}, onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
onboardingViewModel: OnboardingViewModel = hiltViewModel(), onboardingViewModel: OnboardingViewModel = hiltViewModel(),
) { ) {
val done by onboardingViewModel.done.collectAsStateWithLifecycle() val done by onboardingViewModel.done.collectAsStateWithLifecycle()
@@ -113,6 +116,7 @@ private fun ReadyGate(
modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface), modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
navRequest = navRequest, navRequest = navRequest,
onNavRequestConsumed = onNavRequestConsumed, onNavRequestConsumed = onNavRequestConsumed,
onReturnHome = onReturnHome,
) )
false -> ReadableWidth(modifier) { false -> ReadableWidth(modifier) {
OnboardingFlow(modifier = Modifier.fillMaxSize(), viewModel = onboardingViewModel) 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.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton 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.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
@@ -122,6 +123,8 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
var newList by rememberSaveable { mutableStateOf(false) } var newList by rememberSaveable { mutableStateOf(false) }
@@ -173,7 +176,12 @@ fun ListsScreen(
} }
}, },
) { inner -> ) { 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) { when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure -> ListsUiState.Failure ->
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs 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.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
@@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3
class ListsViewModel @Inject constructor( class ListsViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val remoteLists: RemoteLists, private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
settingsPrefs: SettingsPrefs, settingsPrefs: SettingsPrefs,
) : ViewModel() { ) : 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 * Where a new list may go: the device, then every account whose server said
* it would take one. * it would take one.
@@ -7,6 +7,11 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -46,8 +51,19 @@ fun AgendulaNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
navRequest: NavRequest? = null, navRequest: NavRequest? = null,
onNavRequestConsumed: () -> Unit = {}, onNavRequestConsumed: () -> Unit = {},
onReturnHome: () -> Unit = {},
) { ) {
val nav = rememberNavController() 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) { LaunchedEffect(navRequest) {
when (navRequest) { when (navRequest) {
@@ -71,6 +87,9 @@ fun AgendulaNavHost(
} }
is NavRequest.Import -> nav.navigate(Dest.Import.build(navRequest.uri)) 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() onNavRequestConsumed()
} }
@@ -9,8 +9,15 @@ import de.jeanlucmakiola.agendula.domain.SmartList
* [AgendulaNavHost], which pushes it over whatever is showing. * [AgendulaNavHost], which pushes it over whatever is showing.
*/ */
sealed interface NavRequest { 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. */ /** Settings → Accounts, where a sync report's detail lives. */
data object OpenAccounts : NavRequest data object OpenAccounts : NavRequest
@@ -21,8 +28,13 @@ sealed interface NavRequest {
/** /**
* A new task, optionally titled with text shared from another app, or * 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. * 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. */ /** One of the smart lists — the launcher's "Today" shortcut, a widget header. */
data class OpenSmart(val list: SmartList) : NavRequest 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R 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.ThemeMode
import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref import de.jeanlucmakiola.agendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.agendula.data.prefs.is24Hour import de.jeanlucmakiola.agendula.data.prefs.is24Hour
import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.agendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek import de.jeanlucmakiola.agendula.ui.common.localeFirstDayOfWeek
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
@@ -38,6 +41,8 @@ internal fun AppearanceScreen(
) { ) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) } 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 showTimeFormat by remember { mutableStateOf(false) }
var showWeekStart by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) }
val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current) val systemIs24Hour = DateFormat.is24HourFormat(LocalContext.current)
@@ -90,6 +95,26 @@ internal fun AppearanceScreen(
position = Position.Bottom, position = Position.Bottom,
onClick = { showWeekStart = true }, 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) { if (showTimeFormat) {
@@ -140,6 +165,7 @@ internal fun AppearanceScreen(
options = ThemeMode.entries, options = ThemeMode.entries,
selected = state.settings.themeMode, selected = state.settings.themeMode,
label = { stringResource(themeLabel(it)) }, label = { stringResource(themeLabel(it)) },
summary = { if (it == ThemeMode.BLACK) stringResource(R.string.settings_theme_black_summary) else null },
onSelect = viewModel::setThemeMode, onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false }, onDismiss = { showTheme = false },
) )
@@ -163,8 +189,17 @@ private fun weekStartLabel(day: DayOfWeek?): String =
/** 14:00, the sample time "Automatic" is shown with. */ /** 14:00, the sample time "Automatic" is shown with. */
private const val SAMPLE_MINUTE_OF_DAY = 14 * 60 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) { private fun themeLabel(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs 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.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.DayOfWeek import java.time.DayOfWeek
import javax.inject.Inject import javax.inject.Inject
@@ -102,7 +105,8 @@ class SettingsViewModel @Inject constructor(
private val oneShotImport: OneShotImport, private val oneShotImport: OneShotImport,
private val reminderScheduler: ReminderScheduler, private val reminderScheduler: ReminderScheduler,
private val taskNotifier: TaskNotifier, private val taskNotifier: TaskNotifier,
@IoDispatcher io: CoroutineDispatcher, private val launcherNameManager: LauncherNameManager,
@IoDispatcher private val io: CoroutineDispatcher,
repository: TasksRepository, repository: TasksRepository,
) : ViewModel() { ) : ViewModel() {
@@ -217,6 +221,23 @@ class SettingsViewModel @Inject constructor(
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport 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 setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } 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.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat 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.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet
import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage import de.jeanlucmakiola.agendula.ui.lists.listWriteFailureMessage
@@ -144,6 +146,8 @@ fun TaskListScreen(
viewModel: TaskListViewModel = hiltViewModel(), viewModel: TaskListViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle()
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val content = state as? TaskListUiState.Content val content = state as? TaskListUiState.Content
val list = content?.list val list = content?.list
@@ -247,13 +251,21 @@ fun TaskListScreen(
}, },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) { SyncPullBox(
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission available = syncAvailable,
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) syncing = syncing,
is TaskListUiState.Content -> TaskListBody( onSync = viewModel::syncAll,
s, filter, inner, onOpenTask, onRequestDelete, viewModel, modifier = Modifier.fillMaxSize(),
onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null, 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 // 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 // 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. // rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged || val showListName = filter is TaskFilter.Smart || filter is TaskFilter.Tagged ||
(filter is TaskFilter.Saved && state.savedFilter?.listIds?.size != 1) (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 // Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds. // grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) } var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
@@ -420,23 +430,20 @@ private fun TaskListBody(
} }
sections.forEach { section -> 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) { if (showHeaders) {
stickyHeader(key = "hdr-${section.section}") { stickyHeader(key = "hdr-${section.section}") {
if (collapsible) { CollapsibleSectionHeader(
CollapsibleSectionHeader( label = sectionLabel(section.section),
label = sectionLabel(section.section), count = section.tasks.size,
count = section.tasks.size, expanded = expanded,
expanded = completedExpanded, onToggle = { viewModel.setSectionCollapsed(section.section, expanded) },
onToggle = { completedExpanded = !completedExpanded }, onClear = onClearCompleted.takeIf { section.section == TaskSection.COMPLETED },
onClear = onClearCompleted, )
)
} else {
SectionHeader(sectionLabel(section.section))
}
} }
} }
if (!collapsible || completedExpanded) { if (expanded) {
// Flatten the section into one grouped run: each parent, followed // Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from // by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children // its place in the whole run (positionOf below) — so the children
@@ -655,7 +662,7 @@ private fun TaskRow(
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, 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 + // dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed. // icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
@@ -1037,7 +1044,7 @@ private fun SubtaskRow(
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp) val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, 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) }, backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) { ) {
Surface( 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) 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. */ /** A tappable section header with a count and a chevron that rotates on expand. */
@Composable @Composable
private fun CollapsibleSectionHeader( private fun CollapsibleSectionHeader(
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository 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.tagsInUse
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSortOrder import de.jeanlucmakiola.agendula.domain.TaskSortOrder
import de.jeanlucmakiola.agendula.domain.comparator import de.jeanlucmakiola.agendula.domain.comparator
import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure
import de.jeanlucmakiola.agendula.ui.lists.asFailure import de.jeanlucmakiola.agendula.ui.lists.asFailure
import de.jeanlucmakiola.agendula.data.di.ApplicationScope import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.sync.ManualSync
import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi 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. */ /** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false, val bottomAddBar: Boolean = false,
val sortOrder: TaskSortOrder = TaskSortOrder.DUE, 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. */ /** This real list is kept out of the smart lists. */
val hiddenFromSmartLists: Boolean = false, val hiddenFromSmartLists: Boolean = false,
/** Tags the unnarrowed view carries, offered as filter chips. */ /** Tags the unnarrowed view carries, offered as filter chips. */
@@ -89,9 +94,17 @@ class TaskListViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs, private val settingsPrefs: SettingsPrefs,
private val remoteLists: RemoteLists, private val remoteLists: RemoteLists,
private val manualSync: ManualSync,
@ApplicationScope private val appScope: CoroutineScope, @ApplicationScope private val appScope: CoroutineScope,
) : ViewModel() { ) : 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 filter = MutableStateFlow<TaskFilter?>(null)
private val tagFilter = MutableStateFlow<Set<String>>(emptySet()) private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
@@ -144,6 +157,7 @@ class TaskListViewModel @Inject constructor(
showAddSubtaskRow = settings.showAddSubtaskRow, showAddSubtaskRow = settings.showAddSubtaskRow,
bottomAddBar = settings.bottomAddBar, bottomAddBar = settings.bottomAddBar,
sortOrder = settings.sortOrder, sortOrder = settings.sortOrder,
collapsedSections = settings.collapsedSections,
hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false, hiddenFromSmartLists = st.list?.id?.let { it in settings.hiddenFromSmartLists } ?: false,
) )
} else { } else {
@@ -277,6 +291,9 @@ class TaskListViewModel @Inject constructor(
fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) = fun setHiddenFromSmartLists(listId: Long, hidden: Boolean) =
viewModelScope.launch { settingsPrefs.setHiddenFromSmartLists(listId, hidden) } 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) } fun setSortOrder(order: TaskSortOrder) = viewModelScope.launch { settingsPrefs.setSortOrder(order) }
/** /**
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
fun AgendulaTheme( fun AgendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
pureBlack: Boolean = false,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
FloretExpressiveTheme( FloretExpressiveTheme(
@@ -22,6 +23,7 @@ fun AgendulaTheme(
darkScheme = AgendulaDarkFallback, darkScheme = AgendulaDarkFallback,
darkTheme = darkTheme, darkTheme = darkTheme,
dynamicColor = dynamicColor, dynamicColor = dynamicColor,
pureBlack = pureBlack,
typography = AgendulaTypography, typography = AgendulaTypography,
content = content, content = content,
) )
@@ -328,7 +328,7 @@ internal fun AgendaTaskRow(
val context = LocalContext.current val context = LocalContext.current
val title = row.title.ifBlank { context.getString(R.string.task_untitled) } val title = row.title.ifBlank { context.getString(R.string.task_untitled) }
val open = actionStartActivity( 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 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 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.R
import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs 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.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.SavedFilter import de.jeanlucmakiola.agendula.domain.SavedFilter
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
@@ -135,12 +134,12 @@ class TaskWidgetConfigActivity : ComponentActivity() {
rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY]) rowColor = parseWidgetRowColor(state[WIDGET_ROW_COLOR_KEY])
selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY]) selectedListIds = parseWidgetListIds(state[WIDGET_LIST_IDS_KEY])
} }
val dark = when (settings.themeMode) { val dark = settings.themeMode.isDark(isSystemInDarkTheme())
ThemeMode.SYSTEM -> isSystemInDarkTheme() AgendulaTheme(
ThemeMode.LIGHT -> false darkTheme = dark,
ThemeMode.DARK -> true dynamicColor = settings.dynamicColor,
} pureBlack = settings.themeMode.isPureBlack,
AgendulaTheme(darkTheme = dark, dynamicColor = settings.dynamicColor) { ) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {} Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
WidgetFilterPicker( WidgetFilterPicker(
kind = kind, kind = kind,
@@ -339,7 +339,7 @@ private fun ListDot(color: Int) {
} }
private fun openTask(context: Context, row: WidgetTaskRow): Action = 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 = private fun completeTask(row: WidgetTaskRow): Action =
actionRunCallback<CompleteTaskAction>( actionRunCallback<CompleteTaskAction>(
@@ -82,7 +82,7 @@ internal fun effectiveThemeMode(appThemeMode: ThemeMode, override: WidgetThemeMo
override ?: when (appThemeMode) { override ?: when (appThemeMode) {
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
ThemeMode.DARK -> WidgetThemeMode.DARK ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
} }
/** The smart lists a widget can show, in picker order. */ /** The smart lists a widget can show, in picker order. */
+5
View File
@@ -1,5 +1,6 @@
<resources> <resources>
<string name="app_name">Agendula</string> <string name="app_name">Agendula</string>
<string name="app_name_tasks_alias">Tasks</string>
<!-- Generic --> <!-- Generic -->
<string name="task_untitled">Untitled task</string> <string name="task_untitled">Untitled task</string>
@@ -448,6 +449,10 @@
<string name="settings_theme_system">Follow system</string> <string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string> <string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</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">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string> <string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Notifications</string> <string name="settings_section_reminders">Notifications</string>
@@ -4,6 +4,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent 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
import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
@@ -26,6 +27,9 @@ object SyncModule {
@Provides @Provides
fun provideRemoteLists(): RemoteLists = NoRemoteLists fun provideRemoteLists(): RemoteLists = NoRemoteLists
@Provides
fun provideManualSync(): ManualSync = ManualSync.None
} }
private object NoRemoteLists : RemoteLists { 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`() { fun `no override resolves to whatever the app's own theme means`() {
assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK) assertThat(readyWithTheme(ThemeMode.DARK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM) assertThat(readyWithTheme(ThemeMode.SYSTEM).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.SYSTEM)
assertThat(readyWithTheme(ThemeMode.BLACK).effectiveThemeMode(null)).isEqualTo(WidgetThemeMode.DARK)
} }
@Test @Test