Compare commits
18 Commits
76a0139fda
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b266653e4e | |||
| 2f5012dc34 | |||
| ccc07e86c7 | |||
| 211450bc46 | |||
| 9d134be621 | |||
| 37e3c4e659 | |||
| 78632865f9 | |||
| e7a62e71df | |||
| 4c05c86e95 | |||
| 37662e83bb | |||
| 63c6191676 | |||
| 4994f5ec2c | |||
| e4c8dbf2c1 | |||
| 069ae38b2c | |||
| a5ddf16537 | |||
| 18e03e27b6 | |||
| 152226c1b2 | |||
| ec7b696eb9 |
@@ -21,6 +21,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
@@ -73,6 +75,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
@@ -322,6 +326,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Extract changelog section for this tag
|
- name: Extract changelog section for this tag
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "floret-kit"]
|
||||||
|
path = floret-kit
|
||||||
|
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||||
@@ -120,6 +120,12 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||||
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
|||||||
@@ -52,6 +52,14 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Standalone crash-report surface; MainActivity routes here on a
|
||||||
|
startup crash-loop. Not exported, kept out of recents. -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.crash.CrashReportActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
|
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.DueReminderReceiver"
|
android:name=".data.reminders.DueReminderReceiver"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -22,6 +24,17 @@ class AgendulaApp : Application() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
|
// respecting, on-device; the user submits the report by hand).
|
||||||
|
CrashReporter.install(
|
||||||
|
this,
|
||||||
|
CrashConfig(
|
||||||
|
appLabel = getString(R.string.app_name),
|
||||||
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
chooseIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
|
),
|
||||||
|
)
|
||||||
val scheduler = EntryPointAccessors
|
val scheduler = EntryPointAccessors
|
||||||
.fromApplication(this, ReminderEntryPoint::class.java)
|
.fromApplication(this, ReminderEntryPoint::class.java)
|
||||||
.reminderScheduler()
|
.reminderScheduler()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -17,8 +19,12 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
|
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
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.settings.SettingsViewModel
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -32,10 +38,28 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
@Inject lateinit var demoSeeder: DemoSeeder
|
@Inject lateinit var demoSeeder: DemoSeeder
|
||||||
|
|
||||||
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
|
// over the app on the next launch (the single-crash path). A startup
|
||||||
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
|
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// If the app keeps crashing as it starts, the main UI can't be trusted
|
||||||
|
// to come up. Route to the standalone report screen instead of
|
||||||
|
// re-entering the crashing graph.
|
||||||
|
if (CrashReporter.isCrashLoop(this)) {
|
||||||
|
startActivity(Intent(this, CrashReportActivity::class.java))
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Surface a single captured crash as a dialog on the next launch.
|
||||||
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
|
|
||||||
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
|
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
|
||||||
// local (non-syncing) demo list once; no-op without the extra.
|
// local (non-syncing) demo list once; no-op without the extra.
|
||||||
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
||||||
@@ -51,10 +75,31 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
||||||
RootScreen(modifier = Modifier.fillMaxSize())
|
RootScreen(modifier = Modifier.fillMaxSize())
|
||||||
|
pendingCrashReport?.let { report ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this@MainActivity, report)
|
||||||
|
CrashReporter.clearReport(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
CrashReporter.dismissPrompt(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// A successful start breaks any loop; reset the timing trail so a later
|
||||||
|
// ordinary crash isn't mistaken for a loop.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||||
private const val EXTRA_SEED = "agendula_seed"
|
private const val EXTRA_SEED = "agendula_seed"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package de.jeanlucmakiola.agendula.data.demo
|
|||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.domain.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -16,16 +20,6 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||||
|
|
||||||
/** A list's override of the default reminder lead time (mirrors Calendula's per-calendar override). */
|
|
||||||
sealed interface ListReminderOverride {
|
|
||||||
/** No override — the list uses the global default. */
|
|
||||||
data object Inherit : ListReminderOverride
|
|
||||||
/** Explicit "no reminder" for this list, regardless of the global default. */
|
|
||||||
data object None : ListReminderOverride
|
|
||||||
/** A specific lead time in minutes before due. */
|
|
||||||
data class Minutes(val minutes: Int) : ListReminderOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Settings(
|
data class Settings(
|
||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
@@ -37,6 +31,12 @@ data class Settings(
|
|||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
|
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
|
||||||
val showAddSubtaskRow: Boolean = true,
|
val showAddSubtaskRow: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Add affordance for a list: `false` = the floating "New task" button (opens
|
||||||
|
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
|
||||||
|
* Smart lists always use the button (they have no single list to add into).
|
||||||
|
*/
|
||||||
|
val bottomAddBar: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||||
* overrides the global default (a null value = no reminder); absent = inherit.
|
* overrides the global default (a null value = no reminder); absent = inherit.
|
||||||
@@ -47,11 +47,7 @@ data class Settings(
|
|||||||
) {
|
) {
|
||||||
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
/** The lead time for a task in [listId]: its override if set, else the global default. */
|
||||||
fun reminderLeadFor(listId: Long): Int? =
|
fun reminderLeadFor(listId: Long): Int? =
|
||||||
if (perListReminderOverride.containsKey(listId)) {
|
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
|
||||||
perListReminderOverride[listId] // null = explicitly no reminder
|
|
||||||
} else {
|
|
||||||
reminderLeadMinutes
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
|
||||||
@@ -68,7 +64,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
|
||||||
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
|
||||||
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
|
||||||
perListReminderOverride = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]),
|
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
||||||
|
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
|
||||||
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
|
||||||
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
|
||||||
.toSet(),
|
.toSet(),
|
||||||
@@ -92,15 +89,13 @@ class SettingsPrefs @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
|
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
|
||||||
|
|
||||||
/** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */
|
suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
|
||||||
suspend fun setListReminderOverride(listId: Long, override: ListReminderOverride) = dataStore.edit { p ->
|
|
||||||
val current = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
when (override) {
|
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||||
ListReminderOverride.Inherit -> current.remove(listId)
|
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||||
ListReminderOverride.None -> current[listId] = null
|
current.applyReminderOverride(listId, override)
|
||||||
is ListReminderOverride.Minutes -> current[listId] = override.minutes
|
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
|
||||||
}
|
|
||||||
p[LIST_REMINDER_OVERRIDE] = serializeReminderOverrides(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
|
||||||
@@ -114,26 +109,16 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
|
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
|
||||||
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
|
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
|
||||||
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
|
||||||
|
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
||||||
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
||||||
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val OVERRIDE_ENTRY_SEP = ";"
|
/**
|
||||||
private const val OVERRIDE_KV_SEP = ":"
|
* Agendula's stored dialect for the per-list override map: `id:minutes` entries
|
||||||
private const val OVERRIDE_NONE = "none"
|
* joined by `;` (the family default). Fixed at release — don't change without a
|
||||||
|
* data migration.
|
||||||
/** Decode "id:minutes;id:none;…" into a map (value null = explicit no-reminder). */
|
*/
|
||||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||||
if (stored.isNullOrBlank()) return emptyMap()
|
|
||||||
return stored.split(OVERRIDE_ENTRY_SEP).mapNotNull { entry ->
|
|
||||||
val parts = entry.split(OVERRIDE_KV_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
|
||||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
|
||||||
val value = if (parts[1] == OVERRIDE_NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
|
||||||
id to value
|
|
||||||
}.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
|
||||||
map.entries.joinToString(OVERRIDE_ENTRY_SEP) { (id, minutes) -> "$id$OVERRIDE_KV_SEP${minutes ?: OVERRIDE_NONE}" }
|
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
|||||||
interface TasksRepository {
|
interface TasksRepository {
|
||||||
fun taskLists(): Flow<List<TaskList>>
|
fun taskLists(): Flow<List<TaskList>>
|
||||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The direct children of [parentId], re-emitting live like the other flows.
|
||||||
|
* Lets a smart-list view pull a parent's full subtask set on demand — those
|
||||||
|
* children are usually filtered out of the list's own (date-based) query.
|
||||||
|
*/
|
||||||
|
fun subtasks(parentId: Long): Flow<List<Task>>
|
||||||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||||||
|
|
||||||
suspend fun createTask(form: TaskForm): Long
|
suspend fun createTask(form: TaskForm): Long
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package de.jeanlucmakiola.agendula.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.agendula.domain.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
@@ -35,6 +35,12 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
||||||
|
|
||||||
|
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||||
|
dataSource.subtasks(parentId)
|
||||||
|
.filter { it.taskId != parentId }
|
||||||
|
.sortedWith(TaskSorting.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
||||||
dataSource.task(taskId)?.let { task ->
|
dataSource.task(taskId)?.let { task ->
|
||||||
TaskDetail(
|
TaskDetail(
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.domain
|
|
||||||
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/** Local-day boundaries used by the smart-list predicates. Pure + testable. */
|
|
||||||
object DayWindow {
|
|
||||||
|
|
||||||
/** Returns `[startOfToday, startOfTomorrow)` in [zone] for the instant [now]. */
|
|
||||||
fun today(now: Instant, zone: ZoneId): Pair<Instant, Instant> {
|
|
||||||
val date = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds())
|
|
||||||
.atZone(zone)
|
|
||||||
.toLocalDate()
|
|
||||||
val start = date.atStartOfDay(zone).toInstant().toEpochMilli()
|
|
||||||
val end = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
|
||||||
return Instant.fromEpochMilliseconds(start) to Instant.fromEpochMilliseconds(end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LargeTopAppBar
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.agendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared collapsing scaffold for the settings hub, its sub-screens and the
|
|
||||||
* full-screen pickers: a [LargeTopAppBar] that collapses on scroll, a back
|
|
||||||
* button, and a vertically scrolling content column. Ported from Calendula so
|
|
||||||
* the two apps' settings read as one family. The content lays out the connected
|
|
||||||
* grouped rows.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CollapsingScaffold(
|
|
||||||
title: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val scrollBehavior =
|
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
topBar = {
|
|
||||||
LargeTopAppBar(
|
|
||||||
title = { Text(title) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
// Consume the scaffold's bar insets so imePadding adds only the
|
|
||||||
// keyboard height beyond them (max, not sum).
|
|
||||||
.consumeWindowInsets(innerPadding)
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.imePadding()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(top = 8.dp, bottom = 24.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.formatDateTime
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locale-aware display formatting for the [kotlin.time.Instant]s the domain
|
|
||||||
* uses. Kept tiny and dependency-free; the data layer stores instants, the UI
|
|
||||||
* renders them in the device's zone and locale.
|
|
||||||
*/
|
|
||||||
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
|
||||||
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
|
||||||
|
|
||||||
private fun Instant.atSystemZone() =
|
|
||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
|
|
||||||
.atZone(ZoneId.systemDefault())
|
|
||||||
|
|
||||||
/** Medium localized date, e.g. "18 Jun 2026". */
|
|
||||||
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter)
|
|
||||||
|
|
||||||
/** Short localized time, e.g. "14:30". */
|
|
||||||
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
|
|
||||||
|
|
||||||
/** Date alone for all-day items, otherwise date + time. */
|
|
||||||
fun Instant.formatDateTime(allDay: Boolean): String =
|
|
||||||
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Position of a row within a grouped list (the Android-15 settings pattern): a
|
|
||||||
* run of rows shares one rounded container, full corners at the group's outer
|
|
||||||
* edges and small corners between, separated by small gaps. Copied from
|
|
||||||
* Calendula so the two apps read as one family.
|
|
||||||
*/
|
|
||||||
enum class Position { Top, Middle, Bottom, Alone }
|
|
||||||
|
|
||||||
fun positionOf(index: Int, count: Int): Position = when {
|
|
||||||
count <= 1 -> Position.Alone
|
|
||||||
index == 0 -> Position.Top
|
|
||||||
index == count - 1 -> Position.Bottom
|
|
||||||
else -> Position.Middle
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */
|
|
||||||
fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) {
|
|
||||||
Position.Alone -> RoundedCornerShape(full)
|
|
||||||
Position.Top -> RoundedCornerShape(topStart = full, topEnd = full, bottomStart = small, bottomEnd = small)
|
|
||||||
Position.Middle -> RoundedCornerShape(small)
|
|
||||||
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One segment of a grouped list: a tonal [Surface] whose corner radii come from
|
|
||||||
* [position] (so a run reads as a single rounded card), with a small gap below
|
|
||||||
* all but the last. Corners morph rounder on press — the expressive shape play.
|
|
||||||
* The caller supplies [content] and any horizontal inset via [modifier], so the
|
|
||||||
* same primitive serves both the 16dp-inset lists and full-width detail cards.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupedSurface(
|
|
||||||
position: Position,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val interaction = remember { MutableInteractionSource() }
|
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
|
||||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
|
||||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
|
||||||
val shape = groupedShape(position, full, small)
|
|
||||||
val gap = when (position) {
|
|
||||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
|
||||||
Position.Bottom, Position.Alone -> Modifier
|
|
||||||
}
|
|
||||||
val base = modifier.fillMaxWidth().then(gap)
|
|
||||||
if (onClick != null) {
|
|
||||||
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() }
|
|
||||||
} else {
|
|
||||||
Surface(color = color, shape = shape, modifier = base) { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the
|
|
||||||
* tonal, position-shaped, press-morphing family container). Insets 16dp like
|
|
||||||
* the lists overview.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupedRow(
|
|
||||||
title: String,
|
|
||||||
position: Position,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
summary: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
minHeight: Dp = 72.dp,
|
|
||||||
leading: @Composable (() -> Unit)? = null,
|
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val itemColors = if (selected) {
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
|
||||||
}
|
|
||||||
val containerColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
}
|
|
||||||
GroupedSurface(
|
|
||||||
position = position,
|
|
||||||
modifier = modifier.padding(horizontal = 16.dp),
|
|
||||||
onClick = onClick,
|
|
||||||
color = containerColor,
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(title) },
|
|
||||||
supportingContent = summary?.let { text -> { Text(text) } },
|
|
||||||
leadingContent = leading,
|
|
||||||
trailingContent = trailing,
|
|
||||||
colors = itemColors,
|
|
||||||
modifier = Modifier.heightIn(min = minHeight),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's borderless text input: no underline, no outline, just the tonal
|
|
||||||
* card behind it. Agendula uses this inside a tonal [androidx.compose.material3.Surface]
|
|
||||||
* for the edit form (title + cards), so anything that takes text reads as one
|
|
||||||
* family rather than a boxed Material field. Ported from Calendula so the two
|
|
||||||
* apps share one input style.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun InlineTextField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
singleLine: Boolean = true,
|
|
||||||
minLines: Int = 1,
|
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
|
||||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
|
||||||
onImeAction: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val resolvedStyle = textStyle.copy(
|
|
||||||
color = if (textStyle.color.isSpecified) {
|
|
||||||
textStyle.color
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
textStyle = resolvedStyle,
|
|
||||||
singleLine = singleLine,
|
|
||||||
minLines = minLines,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = keyboardType,
|
|
||||||
capitalization = capitalization,
|
|
||||||
imeAction = imeAction,
|
|
||||||
),
|
|
||||||
keyboardActions = onImeAction?.let { action ->
|
|
||||||
KeyboardActions(onAny = { action() })
|
|
||||||
} ?: KeyboardActions.Default,
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
// Clearly fainter than typed text, so a hint never reads as
|
|
||||||
// prefilled content.
|
|
||||||
Text(
|
|
||||||
text = placeholder,
|
|
||||||
style = resolvedStyle,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -22,20 +23,6 @@ import androidx.compose.ui.graphics.luminance
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
|
||||||
* Soften a raw provider colour toward a pastel that fits the active theme —
|
|
||||||
* keeps the hue (lists stay recognisable), caps saturation so harsh sync
|
|
||||||
* colours stop screaming, pins brightness to read on light and dark. Copied
|
|
||||||
* from Calendula so the families' colours behave identically.
|
|
||||||
*/
|
|
||||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
|
||||||
val hsv = FloatArray(3)
|
|
||||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
|
||||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
|
||||||
hsv[2] = if (dark) 0.82f else 0.72f
|
|
||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leading avatar for a task list: a neutral round chip holding a checklist glyph
|
* Leading avatar for a task list: a neutral round chip holding a checklist glyph
|
||||||
* tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern,
|
* tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern,
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's standard pick in a selection dialog: a full-width tonal card,
|
|
||||||
* optionally with a leading icon and a supporting line; the selected option is
|
|
||||||
* highlighted. Stack with 8dp gaps inside an AlertDialog — the only sanctioned
|
|
||||||
* selection-modal style (no radio rows, no bare text lists). Ported from
|
|
||||||
* Calendula so the families' dialogs read identically.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun OptionCard(
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
icon: ImageVector? = null,
|
|
||||||
/** Icon tint override, e.g. a list colour; unspecified follows selection. */
|
|
||||||
iconTint: Color = Color.Unspecified,
|
|
||||||
supportingText: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
/** Label colour override, e.g. primary for an emphasised entry. */
|
|
||||||
labelColor: Color = Color.Unspecified,
|
|
||||||
) {
|
|
||||||
val contentColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
) {
|
|
||||||
if (icon != null) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = when {
|
|
||||||
iconTint.isSpecified -> iconTint
|
|
||||||
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = if (labelColor.isSpecified) labelColor else contentColor,
|
|
||||||
)
|
|
||||||
if (supportingText != null) {
|
|
||||||
Text(
|
|
||||||
text = supportingText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Check
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
|
||||||
* reuses [CollapsingScaffold] (collapsing title + back), so a picker is visually
|
|
||||||
* identical to a settings sub-page and uses the full width. [content] places the
|
|
||||||
* connected grouped rows; selecting one calls [onDismiss]. Ported from Calendula.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FullScreenPicker(
|
|
||||||
title: String,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
properties = DialogProperties(
|
|
||||||
usePlatformDefaultWidth = false,
|
|
||||||
decorFitsSystemWindows = false,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General single-select picker, full-screen: each option is a connected grouped
|
|
||||||
* row and the current one carries a check. The drop-in for the family's option
|
|
||||||
* dialogs (theme, default list, reminder offset, …).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun <T> OptionPicker(
|
|
||||||
title: String,
|
|
||||||
options: List<T>,
|
|
||||||
selected: T,
|
|
||||||
label: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
leading: (@Composable (T) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
|
||||||
options.forEachIndexed { index, option ->
|
|
||||||
val isSelected = option == selected
|
|
||||||
GroupedRow(
|
|
||||||
title = label(option),
|
|
||||||
position = positionOf(index, options.size),
|
|
||||||
selected = isSelected,
|
|
||||||
leading = leading?.let { { it(option) } },
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onSelect(option)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SelectedCheck() {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -44,6 +45,27 @@ fun priorityFill(priority: Priority, dark: Boolean): Color {
|
|||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground tint (icon/text) for a priority level — the same red/amber/green as
|
||||||
|
* [priorityFill], but kept saturated enough to read as a small glyph directly on
|
||||||
|
* the surface rather than as a soft fill. Used by the task list's quiet meta line,
|
||||||
|
* where priority is a tinted flag instead of a filled pill. [Priority.NONE] is
|
||||||
|
* unspecified (callers render it neutral / omit it).
|
||||||
|
*/
|
||||||
|
fun priorityAccent(priority: Priority, dark: Boolean): Color {
|
||||||
|
val base = when (priority) {
|
||||||
|
Priority.HIGH -> 0xFFE53935.toInt() // red
|
||||||
|
Priority.MEDIUM -> 0xFFFB8C00.toInt() // orange-amber (darker than the fill for contrast)
|
||||||
|
Priority.LOW -> 0xFF43A047.toInt() // green
|
||||||
|
Priority.NONE -> return Color.Unspecified
|
||||||
|
}
|
||||||
|
val hsv = FloatArray(3)
|
||||||
|
android.graphics.Color.colorToHSV(base, hsv)
|
||||||
|
hsv[1] = if (dark) 0.55f else 0.85f
|
||||||
|
hsv[2] = if (dark) 0.85f else 0.62f // light on dark surfaces; deep on light ones
|
||||||
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A coloured status pill for a task's priority: a flag plus the level's [label],
|
* A coloured status pill for a task's priority: a flag plus the level's [label],
|
||||||
* filled in that level's pastel hue. Used read-only in the list and detail
|
* filled in that level's pastel hue. Used read-only in the list and detail
|
||||||
@@ -90,14 +112,14 @@ fun PriorityChip(
|
|||||||
|
|
||||||
val body: @Composable () -> Unit = {
|
val body: @Composable () -> Unit = {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (showIcon) {
|
if (showIcon) {
|
||||||
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp))
|
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(14.dp))
|
||||||
}
|
}
|
||||||
Text(label, style = MaterialTheme.typography.labelMedium, color = content)
|
Text(label, style = MaterialTheme.typography.labelSmall, color = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val shape = RoundedCornerShape(50)
|
val shape = RoundedCornerShape(50)
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
||||||
val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
|
val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
|
||||||
enum class ReminderUnit(val minutesFactor: Int) {
|
|
||||||
Minutes(1),
|
|
||||||
Hours(60),
|
|
||||||
Days(1_440),
|
|
||||||
Weeks(10_080),
|
|
||||||
}
|
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import androidx.compose.animation.fadeIn
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -28,40 +34,38 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
|
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
|
||||||
* plus a "Custom" row that expands an inline amount field + unit selector.
|
* plus a "Custom" row that expands an inline amount field + unit selector.
|
||||||
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
|
||||||
* a "None" row. Returns the choice as a [ListReminderOverride]. Ported from
|
* a "None" row. Returns the choice as a [ReminderOverride]. Ported from
|
||||||
* Calendula's ReminderDefaultPicker.
|
* Calendula's ReminderDefaultPicker.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderLeadPicker(
|
fun ReminderLeadPicker(
|
||||||
title: String,
|
title: String,
|
||||||
selected: ListReminderOverride,
|
selected: ReminderOverride,
|
||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
allowNone: Boolean,
|
allowNone: Boolean,
|
||||||
onSelect: (ListReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
presets: List<Int> = REMINDER_PRESETS,
|
presets: List<Int> = REMINDER_PRESETS,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? ListReminderOverride.Minutes)?.minutes
|
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
||||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
||||||
|
|
||||||
val options = buildList {
|
val options = buildList {
|
||||||
if (allowInherit) add(ListReminderOverride.Inherit)
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
if (allowNone) add(ListReminderOverride.None)
|
if (allowNone) add(ReminderOverride.None)
|
||||||
presets.forEach { add(ListReminderOverride.Minutes(it)) }
|
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
val rowCount = options.size + 1 // + the custom row
|
||||||
|
|
||||||
@@ -92,8 +96,8 @@ fun ReminderLeadPicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandEnter(),
|
||||||
exit = shrinkVertically() + fadeOut(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -101,7 +105,7 @@ fun ReminderLeadPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(ListReminderOverride.Minutes(minutes))
|
onSelect(ReminderOverride.Minutes(minutes))
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -139,19 +143,7 @@ private fun CustomReminderEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Surface(
|
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
InlineTextField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = { text -> if (text.length <= 3 && text.all(Char::isDigit)) onAmountChange(text) },
|
|
||||||
placeholder = "10",
|
|
||||||
textStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
modifier = Modifier.width(72.dp).padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
@@ -173,17 +165,8 @@ private fun CustomReminderEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderOverrideLabel(override: ListReminderOverride): String = when (override) {
|
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
|
||||||
ListReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
ListReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ListReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||||
}
|
|
||||||
|
|
||||||
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
|
||||||
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
|
||||||
minutes == null || minutes <= 0 -> "" to ReminderUnit.Minutes
|
|
||||||
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
|
||||||
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
|
||||||
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
|
||||||
else -> minutes.toString() to ReminderUnit.Minutes
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.crash
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone surface for a captured crash report. [MainActivity] routes here
|
||||||
|
* when it detects a startup crash-loop (see [CrashReporter.isCrashLoop]): the
|
||||||
|
* main UI can't be trusted to start, so this screen stays clear of the app's
|
||||||
|
* Hilt graph, DataStore-backed theme and Compose content — it only reads the
|
||||||
|
* report file and shows the kit's report dialog. Plain [AgendulaTheme] defaults
|
||||||
|
* (follow-system, dynamic colour) avoid touching anything that might be the
|
||||||
|
* cause of the crash. The reusable machinery lives in floret-kit's core-crash.
|
||||||
|
*/
|
||||||
|
class CrashReportActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val report = CrashReporter.pendingReport(this)
|
||||||
|
if (report == null) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
AgendulaTheme {
|
||||||
|
// Opaque backdrop so the dialog doesn't float over a bare task.
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this, report)
|
||||||
|
CrashReporter.clearReport(this)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
CrashReporter.clearReport(this)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Reaching this screen breaks the loop; reset the timing trail so a
|
||||||
|
// later ordinary crash isn't mistaken for a loop.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,12 +70,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.agendula.ui.common.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.formatDate
|
import de.jeanlucmakiola.floret.time.formatDate
|
||||||
import de.jeanlucmakiola.agendula.ui.common.formatTime
|
import de.jeanlucmakiola.floret.time.formatTime
|
||||||
import de.jeanlucmakiola.agendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.agendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.edit
|
package de.jeanlucmakiola.agendula.ui.edit
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -86,7 +81,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.domain.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||||
@@ -94,15 +89,16 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
|
|||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
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.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.agendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.OptionalFormSection
|
||||||
import de.jeanlucmakiola.agendula.ui.common.OptionCard
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.agendula.ui.common.priorityFill
|
import de.jeanlucmakiola.agendula.ui.common.priorityFill
|
||||||
import de.jeanlucmakiola.agendula.ui.common.formatDate
|
import de.jeanlucmakiola.floret.time.formatDate
|
||||||
import de.jeanlucmakiola.agendula.ui.common.formatTime
|
import de.jeanlucmakiola.floret.time.formatTime
|
||||||
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
||||||
import de.jeanlucmakiola.agendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.agendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
|
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
|
||||||
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
||||||
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
@@ -545,17 +541,6 @@ private fun EditContent(
|
|||||||
* open instead of popping in. (Initially-visible sections render without
|
* open instead of popping in. (Initially-visible sections render without
|
||||||
* animating on the first frame they're added.)
|
* animating on the first frame they're added.)
|
||||||
*/
|
*/
|
||||||
@Composable
|
|
||||||
private fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = expandVertically() + fadeIn(),
|
|
||||||
exit = shrinkVertically() + fadeOut(),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One info card mirroring the detail screen's card: tonal container, leading
|
* One info card mirroring the detail screen's card: tonal container, leading
|
||||||
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
||||||
@@ -853,7 +838,7 @@ private fun ParentPickerSheet(
|
|||||||
item(key = "none") {
|
item(key = "none") {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.edit_parent_none),
|
title = stringResource(R.string.edit_parent_none),
|
||||||
position = de.jeanlucmakiola.agendula.ui.common.Position.Alone,
|
position = de.jeanlucmakiola.floret.components.Position.Alone,
|
||||||
selected = selectedId == null,
|
selected = selectedId == null,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
onClick = { choose(null) },
|
onClick = { choose(null) },
|
||||||
|
|||||||
@@ -49,10 +49,10 @@ import de.jeanlucmakiola.agendula.R
|
|||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
|
||||||
import de.jeanlucmakiola.agendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
|
||||||
import de.jeanlucmakiola.agendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
|
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
|
||||||
|
|||||||
@@ -4,7 +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.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.domain.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import androidx.compose.animation.EnterTransition
|
|||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -34,7 +34,6 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
|
|||||||
@Composable
|
@Composable
|
||||||
fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
val slide = rememberNavSlideSpec()
|
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = nav,
|
navController = nav,
|
||||||
@@ -42,12 +41,13 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
// Only the screen on top moves; the background is always held static.
|
// Only the screen on top moves; the background is always held static.
|
||||||
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
||||||
// Back (and the predictive-back drag, which drives popExit) is the snappy
|
// Back is the predictive-back peek: navigation-compose composes the
|
||||||
// slide-out to the right, revealing the held screen unmoved.
|
// screen being revealed underneath and seeks popExit with the gesture, so
|
||||||
|
// the leaving screen scales down to reveal the (unmoved) previous screen.
|
||||||
enterTransition = { fadeIn() },
|
enterTransition = { fadeIn() },
|
||||||
exitTransition = { ExitTransition.None },
|
exitTransition = { ExitTransition.None },
|
||||||
popEnterTransition = { EnterTransition.None },
|
popEnterTransition = { EnterTransition.None },
|
||||||
popExitTransition = { slideOutHorizontally(slide) { it } + fadeOut() },
|
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
|
||||||
) {
|
) {
|
||||||
composable(Dest.LISTS) {
|
composable(Dest.LISTS) {
|
||||||
ListsScreen(
|
ListsScreen(
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.navigation
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The horizontal slide spec for screen-to-screen navigation: the *fast*
|
|
||||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
|
||||||
* springy settle, rather than a fixed easing curve. Same choice as Calendula's
|
|
||||||
* calendar slide, so the two apps move the same way.
|
|
||||||
*
|
|
||||||
* Read in composable scope so the non-composable `NavHost` transition lambdas
|
|
||||||
* can capture it.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun rememberNavSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's onboarding hero: an icon in the brand squircle. Each app keeps its
|
||||||
|
* own hero; the surrounding shell comes from floret-kit's OnboardingScaffold.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SquircleHero(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.permission
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
|
||||||
internal object OnboardingSpace {
|
|
||||||
val xs = 8.dp
|
|
||||||
val sm = 16.dp
|
|
||||||
val md = 24.dp
|
|
||||||
val lg = 32.dp
|
|
||||||
val xl = 48.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared onboarding shell: a scrollable, centred hero + body with the call(s)
|
|
||||||
* to action pinned to the bottom (clear of the navigation bar). The content
|
|
||||||
* slot is centred horizontally; benefit rows fill the width so their own
|
|
||||||
* content left-aligns. Ported from Calendula so the two apps read as one family.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun OnboardingScaffold(
|
|
||||||
hero: @Composable () -> Unit,
|
|
||||||
actions: @Composable ColumnScope.() -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
body: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
bottomBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
content = actions,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = OnboardingSpace.md),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
|
||||||
hero()
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
|
||||||
body()
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.md))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An icon in the brand squircle — the shared onboarding hero silhouette. */
|
|
||||||
@Composable
|
|
||||||
internal fun SquircleHero(icon: ImageVector) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
|
||||||
@Composable
|
|
||||||
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(OnboardingSpace.sm))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
text = body,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,6 +22,9 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time onboarding step after the tasks-provider grant: explains that Agendula
|
* One-time onboarding step after the tasks-provider grant: explains that Agendula
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.core.os.LocaleListCompat
|
|
||||||
import de.jeanlucmakiola.agendula.R
|
|
||||||
import org.xmlpull.v1.XmlPullParser
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
|
||||||
*
|
|
||||||
* That file is the single source of truth for which languages we ship: dropping
|
|
||||||
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
|
||||||
* language show up here and in the system per-app-language settings, with no
|
|
||||||
* other code change. The system-default choice is represented as `null`.
|
|
||||||
*
|
|
||||||
* On API 33+ this delegates to the platform per-app-languages API; below that
|
|
||||||
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
|
||||||
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
|
||||||
* activity, which re-reads the current value for the picker.
|
|
||||||
*/
|
|
||||||
object AppLanguage {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The BCP-47 tags the app ships translations for, in declaration order, as
|
|
||||||
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
|
||||||
* or malformed config yields an empty list (the picker then offers only the
|
|
||||||
* system-default entry rather than crashing).
|
|
||||||
*/
|
|
||||||
fun supportedTags(context: Context): List<String> {
|
|
||||||
val tags = mutableListOf<String>()
|
|
||||||
val parser = context.resources.getXml(R.xml.locales_config)
|
|
||||||
try {
|
|
||||||
var event = parser.eventType
|
|
||||||
while (event != XmlPullParser.END_DOCUMENT) {
|
|
||||||
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
|
||||||
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
|
||||||
}
|
|
||||||
event = parser.next()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Fall back to whatever was parsed before the failure.
|
|
||||||
} finally {
|
|
||||||
parser.close()
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
|
||||||
fun currentTag(): String? {
|
|
||||||
val locales = AppCompatDelegate.getApplicationLocales()
|
|
||||||
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
|
||||||
fun apply(tag: String?) {
|
|
||||||
val locales = if (tag == null) {
|
|
||||||
LocaleListCompat.getEmptyLocaleList()
|
|
||||||
} else {
|
|
||||||
LocaleListCompat.forLanguageTags(tag)
|
|
||||||
}
|
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The autonym for a tag — the language's own name in its own script, e.g.
|
|
||||||
* "Deutsch", "English", "Français" — so users find their language regardless
|
|
||||||
* of the current UI language. Capitalised per the language's own rules.
|
|
||||||
*/
|
|
||||||
fun displayName(tag: String): String {
|
|
||||||
val locale = Locale.forLanguageTag(tag)
|
|
||||||
return locale.getDisplayName(locale)
|
|
||||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,8 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -22,7 +20,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -52,10 +49,8 @@ import androidx.compose.material.icons.rounded.Circle
|
|||||||
import androidx.compose.material.icons.rounded.Flag
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material.icons.rounded.Percent
|
import androidx.compose.material.icons.rounded.Percent
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -84,16 +79,21 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
|
|
||||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
import de.jeanlucmakiola.agendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.AboutLink
|
||||||
import de.jeanlucmakiola.agendula.ui.common.OptionPicker
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.agendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
||||||
import de.jeanlucmakiola.agendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.agendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
@@ -163,7 +163,30 @@ private fun SettingsHub(
|
|||||||
onOpenSection: (SettingsSection) -> Unit,
|
onOpenSection: (SettingsSection) -> Unit,
|
||||||
) {
|
) {
|
||||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||||
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
Box(Modifier.padding(horizontal = 16.dp)) {
|
||||||
|
AboutCard(
|
||||||
|
logo = { AppLogo() },
|
||||||
|
appName = stringResource(R.string.app_name),
|
||||||
|
author = stringResource(R.string.settings_about_author),
|
||||||
|
primaryLinks = listOf(
|
||||||
|
AboutLink(
|
||||||
|
icon = Icons.Default.Code,
|
||||||
|
label = stringResource(R.string.settings_about_source),
|
||||||
|
url = stringResource(R.string.about_source_url),
|
||||||
|
),
|
||||||
|
AboutLink(
|
||||||
|
icon = Icons.Default.Gavel,
|
||||||
|
label = stringResource(R.string.settings_license),
|
||||||
|
url = stringResource(R.string.about_license_url),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
highlightLink = AboutLink(
|
||||||
|
icon = Icons.Default.Favorite,
|
||||||
|
label = stringResource(R.string.settings_about_support),
|
||||||
|
url = stringResource(R.string.about_support_url),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -187,46 +210,19 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
||||||
onClick = { onOpenSection(SettingsSection.Reminders) },
|
onClick = { onOpenSection(SettingsSection.Reminders) },
|
||||||
)
|
)
|
||||||
LanguageRow(position = Position.Middle)
|
LanguagePickerRow(
|
||||||
|
position = Position.Middle,
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
autoLabel = stringResource(R.string.settings_language_auto),
|
||||||
|
localesConfig = R.xml.locales_config,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||||
|
)
|
||||||
ReportProblemRow(position = Position.Bottom)
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
AppVersionText()
|
AppVersionText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** App-language picker row; setting a locale recreates the activity. */
|
|
||||||
@Composable
|
|
||||||
private fun LanguageRow(position: Position) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
// Mirror the choice locally so the row updates instantly, before recreation.
|
|
||||||
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
|
||||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
|
||||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
|
||||||
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_language),
|
|
||||||
summary = languageLabel(current),
|
|
||||||
position = position,
|
|
||||||
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
|
||||||
onClick = { showDialog = true },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (showDialog) {
|
|
||||||
OptionPicker(
|
|
||||||
title = stringResource(R.string.settings_language),
|
|
||||||
options = options,
|
|
||||||
selected = current,
|
|
||||||
label = { languageLabel(it) },
|
|
||||||
onSelect = {
|
|
||||||
current = it
|
|
||||||
AppLanguage.apply(it)
|
|
||||||
},
|
|
||||||
onDismiss = { showDialog = false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opens the project's issue tracker; no data leaves the device until submitted. */
|
/** Opens the project's issue tracker; no data leaves the device until submitted. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReportProblemRow(position: Position) {
|
private fun ReportProblemRow(position: Position) {
|
||||||
@@ -241,10 +237,6 @@ private fun ReportProblemRow(position: Position) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun languageLabel(tag: String?): String =
|
|
||||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Sub-screens
|
// Sub-screens
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -346,7 +338,7 @@ private fun TaskFormScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_add_subtask_row),
|
title = stringResource(R.string.settings_add_subtask_row),
|
||||||
summary = stringResource(R.string.settings_add_subtask_row_hint),
|
summary = stringResource(R.string.settings_add_subtask_row_hint),
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.settings.showAddSubtaskRow,
|
checked = state.settings.showAddSubtaskRow,
|
||||||
@@ -355,6 +347,18 @@ private fun TaskFormScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
|
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_bottom_add_bar),
|
||||||
|
summary = stringResource(R.string.settings_bottom_add_bar_hint),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.settings.bottomAddBar,
|
||||||
|
onCheckedChange = viewModel::setBottomAddBar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDefaultList) {
|
if (showDefaultList) {
|
||||||
@@ -452,8 +456,8 @@ private fun RemindersScreen(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = perListExpanded,
|
visible = perListExpanded,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandEnter(),
|
||||||
exit = shrinkVertically() + fadeOut(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
lists.forEachIndexed { index, list ->
|
lists.forEachIndexed { index, list ->
|
||||||
@@ -479,10 +483,10 @@ private fun RemindersScreen(
|
|||||||
if (showOffset) {
|
if (showOffset) {
|
||||||
ReminderLeadPicker(
|
ReminderLeadPicker(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
selected = ListReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
allowNone = false,
|
allowNone = false,
|
||||||
onSelect = { if (it is ListReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
|
||||||
onDismiss = { showOffset = false },
|
onDismiss = { showOffset = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -500,84 +504,28 @@ private fun RemindersScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
/** The stored override for [listId], as a picker choice (absent → inherit). */
|
||||||
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ListReminderOverride {
|
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
|
||||||
val map = state.settings.perListReminderOverride
|
val map = state.settings.perListReminderOverride
|
||||||
return when {
|
return when {
|
||||||
!map.containsKey(listId) -> ListReminderOverride.Inherit
|
!map.containsKey(listId) -> ReminderOverride.Inherit
|
||||||
map[listId] == null -> ListReminderOverride.None
|
map[listId] == null -> ReminderOverride.None
|
||||||
else -> ListReminderOverride.Minutes(map.getValue(listId)!!)
|
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row summary for a list: its override, or the inherited global default. */
|
/** Row summary for a list: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun listOverrideSummary(choice: ListReminderOverride, globalDefault: Int): String = when (choice) {
|
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) {
|
||||||
ListReminderOverride.Inherit ->
|
ReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||||
ListReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ListReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// About + shared building blocks
|
// About + shared building blocks
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AboutCard() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val sourceUrl = stringResource(R.string.about_source_url)
|
|
||||||
val licenseUrl = stringResource(R.string.about_license_url)
|
|
||||||
val supportUrl = stringResource(R.string.about_support_url)
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
AppLogo()
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_about_author),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { openUrl(context, sourceUrl) },
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.settings_about_source))
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { openUrl(context, licenseUrl) },
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Gavel, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.settings_license))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
FilledTonalButton(onClick = { openUrl(context, supportUrl) }, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.settings_about_support))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppLogo() {
|
private fun AppLogo() {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ 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.prefs.ListReminderOverride
|
|
||||||
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.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -44,6 +44,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
|
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
|
||||||
fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
|
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
|
||||||
|
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
|
||||||
|
|
||||||
/** Toggle whether [field] shows by default on a new task's edit form. */
|
/** Toggle whether [field] shows by default on a new task's edit form. */
|
||||||
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
|
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
|
||||||
@@ -51,7 +52,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.setDefaultEditFields(if (enabled) current + field else current - field)
|
prefs.setDefaultEditFields(if (enabled) current + field else current - field)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */
|
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
fun setListReminderOverride(listId: Long, override: ListReminderOverride) =
|
fun setListReminderOverride(listId: Long, override: ReminderOverride) =
|
||||||
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
|
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import androidx.compose.foundation.layout.FlowRow
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.isImeVisible
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.rounded.Check
|
|||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
@@ -67,6 +74,7 @@ import kotlinx.coroutines.delay
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
@@ -81,19 +89,18 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.agendula.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import de.jeanlucmakiola.agendula.domain.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.agendula.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.agendula.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
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.ListNameChip
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
import de.jeanlucmakiola.agendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||||
import de.jeanlucmakiola.agendula.ui.common.formatDateTime
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.agendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.agendula.ui.common.positionOf
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@@ -115,7 +122,13 @@ fun TaskListScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
val listName = (state as? TaskListUiState.Content)?.listName
|
val content = state as? TaskListUiState.Content
|
||||||
|
val listName = content?.listName
|
||||||
|
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||||
|
// One add affordance, never two: a real list with the setting on gets a pinned
|
||||||
|
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||||
|
// target list) gets the floating "New task" button.
|
||||||
|
val showBottomAddBar = content?.bottomAddBar == true && listId != null
|
||||||
|
|
||||||
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
|
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
|
||||||
// gives an undo window: Undo restores it, otherwise the delete commits. A
|
// gives an undo window: Undo restores it, otherwise the delete commits. A
|
||||||
@@ -150,11 +163,18 @@ fun TaskListScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
ExtendedFloatingActionButton(
|
if (!showBottomAddBar) {
|
||||||
onClick = onNewTask,
|
ExtendedFloatingActionButton(
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
onClick = onNewTask,
|
||||||
text = { Text(stringResource(R.string.new_task)) },
|
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||||
)
|
text = { Text(stringResource(R.string.new_task)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomAddBar) {
|
||||||
|
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -163,16 +183,24 @@ fun TaskListScreen(
|
|||||||
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
||||||
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
|
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
|
||||||
}
|
}
|
||||||
UndoChip(
|
// A FAB-height band anchored at the bottom-start with the FAB's own
|
||||||
visible = undoTarget != null,
|
// margin; centring the chip in it lines the chip up beside the bottom-end
|
||||||
onUndo = {
|
// FAB at exactly its height, rather than sitting a touch above it.
|
||||||
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
Box(
|
||||||
undoTarget = null
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomStart)
|
||||||
.padding(bottom = inner.calculateBottomPadding() + 24.dp),
|
.padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp)
|
||||||
)
|
.height(56.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
UndoChip(
|
||||||
|
visible = undoTarget != null,
|
||||||
|
onUndo = {
|
||||||
|
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
||||||
|
undoTarget = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,20 +279,34 @@ private fun TaskListBody(
|
|||||||
val childrenByParent = remember(state.tasks) {
|
val childrenByParent = remember(state.tasks) {
|
||||||
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
|
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
|
||||||
}
|
}
|
||||||
|
// In a smart list a parent's subtasks are usually filtered out (they're undated
|
||||||
|
// or due other days), so we pull them on demand. Report only expanded parents
|
||||||
|
// whose full child set isn't already in this view; the VM then observes those
|
||||||
|
// children live. In a real list that set is empty, so no extra queries run.
|
||||||
|
val tasksById = remember(state.tasks) { state.tasks.associateBy { it.taskId } }
|
||||||
|
val parentsNeedingChildren = expandedParents.filter { id ->
|
||||||
|
val parent = tasksById[id]
|
||||||
|
parent != null && (childrenByParent[id]?.size ?: 0) != parent.subtaskTotal
|
||||||
|
}.toSet()
|
||||||
|
LaunchedEffect(parentsNeedingChildren) { viewModel.setLazyChildParents(parentsNeedingChildren) }
|
||||||
|
val lazyChildren by viewModel.lazyChildren.collectAsStateWithLifecycle()
|
||||||
|
// A parent's full child set: the rows already in this view when we hold them
|
||||||
|
// all, otherwise the lazily-loaded set fetched for smart lists.
|
||||||
|
fun childrenOf(task: Task): List<Task> {
|
||||||
|
val inList = childrenByParent[task.taskId].orEmpty()
|
||||||
|
return if (inList.size == task.subtaskTotal) inList else lazyChildren[task.taskId].orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the bottom quick-add bar is shown it already lives in the scaffold's
|
||||||
|
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
|
||||||
|
val bottomBarShown = state.bottomAddBar && listId != null
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
top = inner.calculateTopPadding() + 8.dp,
|
top = inner.calculateTopPadding() + 8.dp,
|
||||||
bottom = inner.calculateBottomPadding() + 96.dp,
|
bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
if (listId != null) {
|
|
||||||
item(key = "inline-add") {
|
|
||||||
InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.tasks.isEmpty()) {
|
if (state.tasks.isEmpty()) {
|
||||||
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
|
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
|
||||||
}
|
}
|
||||||
@@ -295,19 +337,21 @@ private fun TaskListBody(
|
|||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
val topLevel = section.tasks
|
val topLevel = section.tasks
|
||||||
topLevel.forEachIndexed { p, task ->
|
topLevel.forEachIndexed { p, task ->
|
||||||
val children = childrenByParent[task.taskId].orEmpty()
|
// The full child set — already in this view for a real list,
|
||||||
// Only expandable when we hold every child: true in a real
|
// or fetched on demand for a smart list (childrenOf). A parent
|
||||||
// list, but a smart list may omit children due on other
|
// is expandable whenever it has children; until a smart list's
|
||||||
// days, and a half-shown set would mislead.
|
// set has loaded the group simply shows nothing extra.
|
||||||
val canExpand = task.subtaskTotal > 0 && children.size == task.subtaskTotal
|
val children = childrenOf(task)
|
||||||
|
val canExpand = task.subtaskTotal > 0
|
||||||
val isExpanded = canExpand && task.taskId in expandedParents
|
val isExpanded = canExpand && task.taskId in expandedParents
|
||||||
|
val hasExpansionContent = children.isNotEmpty() || state.showAddSubtaskRow
|
||||||
// Top-level tasks keep their own connected run — full top
|
// Top-level tasks keep their own connected run — full top
|
||||||
// only on the first, full bottom only on the last — so the
|
// only on the first, full bottom only on the last — so the
|
||||||
// task after an expanded one stays mid-run (small top). An
|
// task after an expanded one stays mid-run (small top). An
|
||||||
// expanded parent's bottom opens (small) to meet its subtasks.
|
// expanded parent's bottom opens (small) to meet its subtasks.
|
||||||
val parentPosition = cornerPosition(
|
val parentPosition = cornerPosition(
|
||||||
topFull = p == 0,
|
topFull = p == 0,
|
||||||
bottomFull = if (isExpanded) false else p == topLevel.lastIndex,
|
bottomFull = if (isExpanded && hasExpansionContent) false else p == topLevel.lastIndex,
|
||||||
)
|
)
|
||||||
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
|
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
@@ -331,8 +375,13 @@ private fun TaskListBody(
|
|||||||
}
|
}
|
||||||
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
|
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
|
||||||
val lastInRun = index == rows.lastIndex
|
val lastInRun = index == rows.lastIndex
|
||||||
|
// animateItem so an (un)expanding group fades its subtasks in/out
|
||||||
|
// and slides the rows below — clean whether the children were
|
||||||
|
// already loaded (real list) or arrive a frame later (smart list).
|
||||||
|
val rowModifier = Modifier.animateItem()
|
||||||
when (val row = r.row) {
|
when (val row = r.row) {
|
||||||
is ListRow.Parent -> TaskRow(
|
is ListRow.Parent -> TaskRow(
|
||||||
|
modifier = rowModifier,
|
||||||
task = row.task,
|
task = row.task,
|
||||||
position = r.position,
|
position = r.position,
|
||||||
lastInRun = lastInRun,
|
lastInRun = lastInRun,
|
||||||
@@ -356,6 +405,7 @@ private fun TaskListBody(
|
|||||||
onClick = { onOpenTask(row.task.taskId) },
|
onClick = { onOpenTask(row.task.taskId) },
|
||||||
)
|
)
|
||||||
is ListRow.Sub -> SubtaskRow(
|
is ListRow.Sub -> SubtaskRow(
|
||||||
|
modifier = rowModifier,
|
||||||
task = row.task,
|
task = row.task,
|
||||||
position = r.position,
|
position = r.position,
|
||||||
lastInRun = lastInRun,
|
lastInRun = lastInRun,
|
||||||
@@ -363,6 +413,7 @@ private fun TaskListBody(
|
|||||||
onClick = { onOpenTask(row.task.taskId) },
|
onClick = { onOpenTask(row.task.taskId) },
|
||||||
)
|
)
|
||||||
is ListRow.AddSub -> AddSubtaskRow(
|
is ListRow.AddSub -> AddSubtaskRow(
|
||||||
|
modifier = rowModifier,
|
||||||
position = r.position,
|
position = r.position,
|
||||||
lastInRun = lastInRun,
|
lastInRun = lastInRun,
|
||||||
onAdd = { viewModel.quickAddSubtask(row.parent, it) },
|
onAdd = { viewModel.quickAddSubtask(row.parent, it) },
|
||||||
@@ -375,9 +426,9 @@ private fun TaskListBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Inline "add a task" — title only, into the current list. */
|
/** Inline "add a task" — title only, into the current list. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun InlineAdd(onAdd: (String) -> Unit) {
|
private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) {
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
fun submit() {
|
fun submit() {
|
||||||
if (text.isNotBlank()) {
|
if (text.isNotBlank()) {
|
||||||
@@ -385,10 +436,25 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
|
|||||||
text = ""
|
text = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Compose keeps a TextField focused (cursor blinking) even after the keyboard
|
||||||
|
// is dismissed by a tap outside it — so when the IME hides, drop focus too.
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val imeVisible = WindowInsets.isImeVisible
|
||||||
|
LaunchedEffect(imeVisible) {
|
||||||
|
if (!imeVisible) focusManager.clearFocus()
|
||||||
|
}
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(22.dp),
|
shape = RoundedCornerShape(22.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
// Floating use (bottom quick-add) steps up to the brightest container tone
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
// — a real step above the surfaceContainerHigh task cards — and lifts off
|
||||||
|
// the list with a heavier shadow, so it doesn't blend into the rows.
|
||||||
|
color = if (elevated) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
},
|
||||||
|
shadowElevation = if (elevated) 8.dp else 0.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
TextField(
|
TextField(
|
||||||
value = text,
|
value = text,
|
||||||
@@ -416,6 +482,23 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bottom quick-add field: [InlineAdd] pinned as the scaffold's bottomBar, but
|
||||||
|
* with no bar plate of its own — the rounded field floats on the screen background
|
||||||
|
* (its own shadow lifting it off the list). Rides above the keyboard and clears the
|
||||||
|
* navigation bar (ime ∪ navigation-bar insets).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun QuickAddBar(onAdd: (String) -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)),
|
||||||
|
) {
|
||||||
|
InlineAdd(onAdd = onAdd, elevated = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One task row: a tonal grouped-card surface (corners from [position], morphing
|
* One task row: a tonal grouped-card surface (corners from [position], morphing
|
||||||
* rounder on press — the app's shape family) wrapped in a swipe box. Swiping
|
* rounder on press — the app's shape family) wrapped in a swipe box. Swiping
|
||||||
@@ -435,6 +518,7 @@ private fun TaskRow(
|
|||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dismissState = rememberSwipeToDismissBoxState(
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
confirmValueChange = { value ->
|
confirmValueChange = { value ->
|
||||||
@@ -455,7 +539,7 @@ private fun TaskRow(
|
|||||||
|
|
||||||
SwipeToDismissBox(
|
SwipeToDismissBox(
|
||||||
state = dismissState,
|
state = dismissState,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
modifier = modifier.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) },
|
||||||
@@ -482,10 +566,11 @@ private fun TaskRowContent(
|
|||||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val due = task.due?.formatDateTime(task.isAllDay)
|
val due = task.due?.formatDateTimeCompact(task.isAllDay)
|
||||||
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
|
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
|
||||||
val hasSupporting = due != null || listName != null || subtaskTotal > 0 ||
|
// Everything secondary collapses into one quiet supporting line below the title.
|
||||||
task.priority != Priority.NONE
|
val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
|
||||||
|
listName != null
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
@@ -526,29 +611,17 @@ private fun TaskRowContent(
|
|||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (hasSupporting) {
|
if (hasMeta) {
|
||||||
Spacer(Modifier.height(3.dp))
|
Spacer(Modifier.height(3.dp))
|
||||||
// Flow so the chips wrap to a further line when due date + list
|
TaskMetaLine(
|
||||||
// + subtask progress can't share one (e.g. a dated task with
|
priority = task.priority,
|
||||||
// subtasks), rather than squeezing the trailing chip.
|
due = due,
|
||||||
FlowRow(
|
subtaskDone = subtaskDone,
|
||||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
subtaskTotal = subtaskTotal,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
listName = listName,
|
||||||
itemVerticalAlignment = Alignment.CenterVertically,
|
listColor = task.effectiveColor,
|
||||||
) {
|
dark = dark,
|
||||||
if (task.priority != Priority.NONE) {
|
)
|
||||||
PriorityChip(task.priority, priorityLabel(task.priority))
|
|
||||||
}
|
|
||||||
if (due != null) {
|
|
||||||
Text(
|
|
||||||
text = due,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (listName != null) ListNameChip(listName, task.effectiveColor)
|
|
||||||
if (subtaskTotal > 0) SubtaskCountChip(subtaskDone, subtaskTotal)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Trailing area is only ever the expand control, so the row's right
|
// Trailing area is only ever the expand control, so the row's right
|
||||||
@@ -561,29 +634,83 @@ private fun TaskRowContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small neutral pill showing subtask progress, e.g. "3 / 5". */
|
/**
|
||||||
|
* The single low-emphasis supporting line under a task title: priority, due date,
|
||||||
|
* subtask progress and (in mixed views) the list name, joined by middots. The text
|
||||||
|
* is quiet `onSurfaceVariant` throughout; the only colour is the priority flag and
|
||||||
|
* the list name (tinted to its list) — so even a fully-populated row stays calm and
|
||||||
|
* one line instead of a stack of filled pills.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SubtaskCountChip(done: Int, total: Int) {
|
private fun TaskMetaLine(
|
||||||
Surface(
|
priority: Priority,
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
due: String?,
|
||||||
shape = RoundedCornerShape(50),
|
subtaskDone: Int,
|
||||||
|
subtaskTotal: Int,
|
||||||
|
listName: String?,
|
||||||
|
listColor: Int,
|
||||||
|
dark: Boolean,
|
||||||
|
) {
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val style = MaterialTheme.typography.bodySmall
|
||||||
|
val segments = buildList<@Composable () -> Unit> {
|
||||||
|
if (priority != Priority.NONE) {
|
||||||
|
add {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Flag,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = priorityAccent(priority, dark),
|
||||||
|
modifier = Modifier.size(13.dp),
|
||||||
|
)
|
||||||
|
Text(priorityLabel(priority), style = style, color = muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (due != null) add { Text(due, style = style, color = muted) }
|
||||||
|
if (subtaskTotal > 0) {
|
||||||
|
add {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Rounded.ListAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = muted,
|
||||||
|
modifier = Modifier.size(13.dp),
|
||||||
|
)
|
||||||
|
Text("$subtaskDone / $subtaskTotal", style = style, color = muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (listName != null) {
|
||||||
|
add {
|
||||||
|
Text(
|
||||||
|
listName,
|
||||||
|
style = style,
|
||||||
|
color = pastelize(listColor, dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowRow(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Row(
|
segments.forEachIndexed { i, segment ->
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
// A middot between segments — its own flow item, so the line can still
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
// wrap gracefully when truly everything is set on a narrow screen.
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
if (i > 0) {
|
||||||
) {
|
Text("·", style = style, color = muted, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
Icon(
|
}
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
|
segment()
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "$done / $total",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,6 +726,7 @@ private fun AddSubtaskRow(
|
|||||||
position: Position,
|
position: Position,
|
||||||
lastInRun: Boolean,
|
lastInRun: Boolean,
|
||||||
onAdd: (String) -> Unit,
|
onAdd: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
fun submit() {
|
fun submit() {
|
||||||
@@ -612,7 +740,7 @@ private fun AddSubtaskRow(
|
|||||||
shape = cardShape(position, 22.dp, 6.dp),
|
shape = cardShape(position, 22.dp, 6.dp),
|
||||||
// Same tone as the subtask rows it closes, so the group reads as one unit.
|
// Same tone as the subtask rows it closes, so the group reads as one unit.
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
|
||||||
@@ -725,6 +853,7 @@ private fun SubtaskRow(
|
|||||||
lastInRun: Boolean,
|
lastInRun: Boolean,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
@@ -738,7 +867,7 @@ private fun SubtaskRow(
|
|||||||
// reads as a distinct, recessive tone — the nesting cue alongside shape.
|
// reads as a distinct, recessive tone — the nesting cue alongside shape.
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
interactionSource = interaction,
|
interactionSource = interaction,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -36,6 +37,8 @@ sealed interface TaskListUiState {
|
|||||||
val listName: String? = null,
|
val listName: String? = null,
|
||||||
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
|
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
|
||||||
val showAddSubtaskRow: Boolean = true,
|
val showAddSubtaskRow: Boolean = true,
|
||||||
|
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
||||||
|
val bottomAddBar: Boolean = false,
|
||||||
) : TaskListUiState
|
) : TaskListUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +73,14 @@ class TaskListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
// Drop rows pending an undoable delete so the row vanishes on swipe
|
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||||
// while the actual provider delete waits for the snackbar to commit,
|
// while the actual provider delete waits for the snackbar to commit,
|
||||||
// and fold in the live "show add-subtask row" setting.
|
// and fold in the live UI settings (add-subtask row, bottom add bar).
|
||||||
val showAddSub = settingsPrefs.settings.map { it.showAddSubtaskRow }
|
val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar }
|
||||||
combine(content, pendingDeletes, showAddSub) { st, pending, showRow ->
|
combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) ->
|
||||||
if (st is TaskListUiState.Content) {
|
if (st is TaskListUiState.Content) {
|
||||||
st.copy(
|
st.copy(
|
||||||
tasks = st.tasks.filter { it.taskId !in pending },
|
tasks = st.tasks.filter { it.taskId !in pending },
|
||||||
showAddSubtaskRow = showRow,
|
showAddSubtaskRow = showRow,
|
||||||
|
bottomAddBar = bottomBar,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
st
|
st
|
||||||
@@ -87,6 +91,32 @@ class TaskListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parents the screen has expanded whose children it can't pull from the list
|
||||||
|
* itself — i.e. smart lists, where the date filter drops a parent's subtasks.
|
||||||
|
* For each we observe the full child set live (below), so the group can expand.
|
||||||
|
*/
|
||||||
|
private val lazyChildParents = MutableStateFlow<Set<Long>>(emptySet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The on-demand child sets, keyed by parent id, kept live. Empty for real
|
||||||
|
* lists (the screen reports no parents there, since it already holds every
|
||||||
|
* child) so no redundant queries run.
|
||||||
|
*/
|
||||||
|
val lazyChildren: StateFlow<Map<Long, List<Task>>> =
|
||||||
|
lazyChildParents
|
||||||
|
.flatMapLatest { ids ->
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
flowOf(emptyMap())
|
||||||
|
} else {
|
||||||
|
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
|
/** The screen reports which expanded parents need their children fetched. */
|
||||||
|
fun setLazyChildParents(ids: Set<Long>) { lazyChildParents.value = ids }
|
||||||
|
|
||||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||||
|
|
||||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||||
|
|||||||
@@ -1,48 +1,28 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.theme
|
package de.jeanlucmakiola.agendula.ui.theme
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
|
||||||
import androidx.compose.material3.MotionScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App theme. Honors:
|
* Agendula's theme: the family's [FloretExpressiveTheme] machinery (dynamic
|
||||||
* - System light/dark.
|
* colour on API 31+, system light/dark, the standard motion scheme) fed with
|
||||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
* Agendula's own identity — its seed-derived fallback schemes
|
||||||
* derived from [AgendulaSeed].
|
* ([AgendulaLightFallback] / [AgendulaDarkFallback]) and [AgendulaTypography].
|
||||||
*
|
* The mechanics live in floret-kit; the look stays here.
|
||||||
* Mirrors Calendula's theme: MaterialExpressiveTheme routes all component +
|
|
||||||
* custom motion through MaterialTheme.motionScheme. STANDARD over expressive()
|
|
||||||
* is the same deliberate choice — spring choreography without the overshoot.
|
|
||||||
*
|
|
||||||
* A Settings screen can later override dynamicColor and theme mode; the M0
|
|
||||||
* foundation just follows the system.
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendulaTheme(
|
fun AgendulaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
FloretExpressiveTheme(
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
lightScheme = AgendulaLightFallback,
|
||||||
val ctx = LocalContext.current
|
darkScheme = AgendulaDarkFallback,
|
||||||
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
darkTheme = darkTheme,
|
||||||
}
|
dynamicColor = dynamicColor,
|
||||||
darkTheme -> AgendulaDarkFallback
|
|
||||||
else -> AgendulaLightFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialExpressiveTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = AgendulaTypography,
|
typography = AgendulaTypography,
|
||||||
motionScheme = MotionScheme.standard(),
|
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,7 @@
|
|||||||
<string name="settings_report_problem">Report a problem</string>
|
<string name="settings_report_problem">Report a problem</string>
|
||||||
<string name="settings_report_problem_hint">Open the issue tracker</string>
|
<string name="settings_report_problem_hint">Open the issue tracker</string>
|
||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string>
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string>
|
||||||
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string>
|
||||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||||
@@ -197,6 +198,8 @@
|
|||||||
<string name="settings_default_list_first">First available list</string>
|
<string name="settings_default_list_first">First available list</string>
|
||||||
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
|
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
|
||||||
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
|
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
|
||||||
|
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
||||||
|
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
||||||
|
|
||||||
<!-- Reminder lead times (custom amounts) -->
|
<!-- Reminder lead times (custom amounts) -->
|
||||||
<plurals name="reminder_minutes">
|
<plurals name="reminder_minutes">
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package de.jeanlucmakiola.agendula.domain
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
class DayWindowTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `today spans exactly 24h in UTC and contains now`() {
|
|
||||||
val zone = ZoneId.of("UTC")
|
|
||||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
|
||||||
val (start, end) = DayWindow.today(now, zone)
|
|
||||||
|
|
||||||
assertThat(end.toEpochMilliseconds() - start.toEpochMilliseconds()).isEqualTo(86_400_000L)
|
|
||||||
assertThat(now >= start).isTrue()
|
|
||||||
assertThat(now < end).isTrue()
|
|
||||||
// In UTC, midnight aligns to a multiple of one day since the epoch.
|
|
||||||
assertThat(start.toEpochMilliseconds() % 86_400_000L).isEqualTo(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `start is local midnight in a non-UTC zone`() {
|
|
||||||
val zone = ZoneId.of("Europe/Berlin")
|
|
||||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
|
||||||
val (start, end) = DayWindow.today(now, zone)
|
|
||||||
|
|
||||||
val startLocal = java.time.Instant.ofEpochMilli(start.toEpochMilliseconds()).atZone(zone)
|
|
||||||
assertThat(startLocal.hour).isEqualTo(0)
|
|
||||||
assertThat(startLocal.minute).isEqualTo(0)
|
|
||||||
assertThat(now >= start).isTrue()
|
|
||||||
assertThat(now < end).isTrue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
floret-kit
Submodule
1
floret-kit
Submodule
Submodule floret-kit added at 566caf4305
@@ -11,9 +11,10 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins {
|
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
// No Gradle Java-toolchain auto-download resolver plugin: it can fetch a JDK at
|
||||||
}
|
// build time, which an offline / reproducible F-Droid build scanner rejects.
|
||||||
|
// Modules set jvmTarget directly, so no toolchain resolver is needed.
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
@@ -25,3 +26,4 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "Agendula"
|
rootProject.name = "Agendula"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
includeBuild("floret-kit")
|
||||||
|
|||||||
Reference in New Issue
Block a user