Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc70ed3a9f | |||
| 3f166ef5f0 | |||
| 41bd49826a | |||
| 05c75bafa7 | |||
| cfa25b9730 | |||
| 9d7fc64b0b | |||
| 4aa65edb45 | |||
| a9843e25b7 | |||
| 5f4711dabe | |||
| 6d3fbc05c1 | |||
| 721b411579 | |||
| 046b8f7e9e | |||
| 06cc9b1c8b | |||
| 2d366a7be3 | |||
| 52aeebcb53 | |||
| 8f6b85008c | |||
| eb1e530ce7 |
@@ -399,21 +399,39 @@ jobs:
|
|||||||
sed -i -e '/./,$!d' release-notes.md
|
sed -i -e '/./,$!d' release-notes.md
|
||||||
fi
|
fi
|
||||||
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
|
||||||
|
# Forgejo 500s on POST /releases when the tag ALREADY exists, and a
|
||||||
|
# bare tag is not a release — so GET /releases/tags then 404s and the
|
||||||
|
# upsert has no id to fall back on ("Could not resolve Codeberg
|
||||||
|
# release id", the 0.3.1 failure). Pushing the tag first therefore
|
||||||
|
# guarantees the 500 rather than avoiding it; 0.3.0 published because
|
||||||
|
# it POSTed while the tag was still absent. So: POST with the tag
|
||||||
|
# ABSENT and let the API mint tag + release together from
|
||||||
|
# target_commitish. Branches mirror reliably, so the commit is already
|
||||||
|
# on Codeberg — only a tag the mirror raced in ahead of us is in the
|
||||||
|
# way, and clearing it is safe precisely because no release owns it.
|
||||||
|
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||||
|
if [ -z "$ID" ]; then
|
||||||
|
curl -s -o /dev/null -w "codeberg tag DELETE HTTP %{http_code}\n" -X DELETE \
|
||||||
|
-H "Authorization: token $TOKEN" "$API/tags/$TAG"
|
||||||
|
fi
|
||||||
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-payload.json
|
python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > cb-payload.json
|
||||||
import json, sys
|
import json, sys
|
||||||
|
tag, sha, pre = sys.argv[1:4]
|
||||||
print(json.dumps({
|
print(json.dumps({
|
||||||
"tag_name": sys.argv[1],
|
"tag_name": tag,
|
||||||
"target_commitish": sys.argv[2],
|
# Recreate the tag as part of the release; the commit is on
|
||||||
"name": sys.argv[1],
|
# Codeberg already via the branch mirror.
|
||||||
|
"target_commitish": sha,
|
||||||
|
"name": tag,
|
||||||
"body": open("release-notes.md").read(),
|
"body": open("release-notes.md").read(),
|
||||||
"draft": False,
|
"draft": False,
|
||||||
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
# Pre-1.0 releases are flagged as pre-releases (see detect job).
|
||||||
"prerelease": sys.argv[3] == "true",
|
"prerelease": pre == "true",
|
||||||
}))
|
}))
|
||||||
PY
|
PY
|
||||||
# Upsert (re-run safe). POST also creates the tag at target_commitish
|
# Upsert (re-run safe): a release that already exists is PATCHed in
|
||||||
# if the push mirror hasn't synced it yet.
|
# place — the delete above is skipped in that case, so re-running
|
||||||
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
# never disturbs a published release.
|
||||||
if [ -n "$ID" ]; then
|
if [ -n "$ID" ]; then
|
||||||
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
|
||||||
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
|||||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -7,23 +7,43 @@ All notable changes to this project are documented here. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.2] - 2026-07-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Releases reach the Codeberg download channel again. 0.3.1 published to
|
||||||
|
F-Droid but never appeared on Codeberg, so if you install from there — or
|
||||||
|
through Obtainium — this is the release that finally carries 0.3.0's
|
||||||
|
launch-crash fix. The app itself is unchanged from 0.3.1.
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-07-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
|
||||||
|
release build stripped a constructor that the background-work scheduler needs
|
||||||
|
to open its database, and that happens before the app draws anything.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-07-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- M5 reminders onboarding & polish: a one-time reminder onboarding step after the
|
- Reminders: Agendula now delivers your due reminders itself. A one-time setup
|
||||||
provider grant explains that Agendula delivers due reminders itself and requests
|
step explains this and asks for notification access, and a master switch in
|
||||||
`POST_NOTIFICATIONS` (API 33+). A new **Settings** screen (gear on the lists
|
Settings turns the whole thing off again.
|
||||||
overview), structured after Calendula as a category hub with sliding sub-screens
|
- A Settings screen, from the gear on the overview: appearance and theme, which
|
||||||
(`CollapsingScaffold` + grouped category rows + full-screen `OptionPicker`):
|
fields the task form shows, your default list, and reminder defaults.
|
||||||
an About card, **Appearance** (theme, dynamic colour), **Task form** (which
|
- The overview leads with Today — a progress ring showing how much of today
|
||||||
optional edit-form fields show by default, default list, the add-a-subtask-row
|
you've finished — followed by a live preview of what's coming up next.
|
||||||
opt-out), and **Reminders** (a master enable switch that re-requests the
|
- Search across every task, open or completed, from the top bar.
|
||||||
notification permission, a "when to remind" default offset, and — on Android 12
|
- A proper app icon.
|
||||||
only — an exact-alarm status row that deep-links to system settings).
|
|
||||||
- The reminders master switch gates the whole engine: turning it off clears every
|
### Changed
|
||||||
scheduled alarm and suppresses any that fire.
|
- A tidier top bar: no app title, with search and settings pinned to the right.
|
||||||
- Expressive top-bar actions: a reusable `ShapedActionButton` wraps an action icon
|
|
||||||
in a tonal M3 Expressive `MaterialShapes` container (the settings entry uses the
|
## [0.2.2] - 2026-07-19
|
||||||
faceted Gem shape, tertiary tones), with a small press scale/rotate flourish.
|
|
||||||
Future top-bar actions pick their own shape from the `ActionShapes` registry.
|
### Fixed
|
||||||
|
- Release automation now reliably mirrors each release to the Codeberg mirror
|
||||||
|
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
|
||||||
|
had already been synced to Codeberg.
|
||||||
|
|
||||||
## [0.2.1] - 2026-07-19
|
## [0.2.1] - 2026-07-19
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ android {
|
|||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
||||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||||
versionCode = 201
|
versionCode = 302
|
||||||
versionName = "0.2.1"
|
versionName = "0.3.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/proguard-rules.pro
vendored
15
app/proguard-rules.pro
vendored
@@ -2,5 +2,20 @@
|
|||||||
-keep class dagger.hilt.** { *; }
|
-keep class dagger.hilt.** { *; }
|
||||||
-keep @dagger.hilt.android.HiltAndroidApp class *
|
-keep @dagger.hilt.android.HiltAndroidApp class *
|
||||||
|
|
||||||
|
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
|
||||||
|
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
|
||||||
|
# nothing calls it directly — Room then throws InstantiationException, reported
|
||||||
|
# as "Failed to create an instance of ...". We pull Room in transitively via
|
||||||
|
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
|
||||||
|
# at startup, so the app died on launch in every minified build (issue #1).
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||||
|
|
||||||
|
# WorkManager likewise looks its workers up by name and calls this constructor
|
||||||
|
# reflectively — same pruning, but it only bites once a worker actually runs
|
||||||
|
# (Glance's widget updates), so keep it explicitly rather than wait for it.
|
||||||
|
-keep class * extends androidx.work.ListenableWorker {
|
||||||
|
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||||
|
}
|
||||||
|
|
||||||
# Compose Compiler may keep its own; defaults are fine
|
# Compose Compiler may keep its own; defaults are fine
|
||||||
-dontwarn org.jetbrains.annotations.**
|
-dontwarn org.jetbrains.annotations.**
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ import androidx.graphics.shapes.RoundedPolygon
|
|||||||
* its own [shape] and [containerColor], so a row of them reads as a set of
|
* its own [shape] and [containerColor], so a row of them reads as a set of
|
||||||
* distinct little tokens rather than identical grey glyphs.
|
* distinct little tokens rather than identical grey glyphs.
|
||||||
*
|
*
|
||||||
* Pressing springs the icon down a touch and gives it a small turn — a light
|
* Pressing springs the whole shape down and gives it a turn — the scalloped
|
||||||
* expressive flourish, no library motion APIs needed.
|
* container is what dips and spins, while the glyph inside stays upright (a
|
||||||
|
* spinning magnifier or list icon would just read as wrong). [spinIcon] opts a
|
||||||
|
* glyph into turning too, for icons that read well mid-spin like the gear.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -46,15 +48,25 @@ fun ShapedActionButton(
|
|||||||
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
|
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
size: Dp = 40.dp,
|
size: Dp = 40.dp,
|
||||||
iconSize: Dp = 22.dp,
|
iconSize: Dp = 22.dp,
|
||||||
|
spinIcon: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
val scale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "actionScale")
|
// The shape (the scalloped cookie) turns and dips on press.
|
||||||
val rotation by animateFloatAsState(if (pressed) 24f else 0f, label = "actionRotation")
|
val shapeRotation by animateFloatAsState(if (pressed) 40f else 0f, label = "shapeRotation")
|
||||||
|
val scale by animateFloatAsState(if (pressed) 0.82f else 1f, label = "shapeScale")
|
||||||
|
// The glyph's *net* turn: 0 keeps it upright, spinIcon gives it a quarter turn.
|
||||||
|
val iconRotation by animateFloatAsState(
|
||||||
|
if (pressed && spinIcon) 90f else 0f,
|
||||||
|
label = "iconRotation",
|
||||||
|
)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.size(size),
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.scale(scale)
|
||||||
|
.rotate(shapeRotation),
|
||||||
shape = shape.toShape(),
|
shape = shape.toShape(),
|
||||||
color = containerColor,
|
color = containerColor,
|
||||||
contentColor = contentColor,
|
contentColor = contentColor,
|
||||||
@@ -64,10 +76,11 @@ fun ShapedActionButton(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
|
// Counter the container's turn so the glyph's net rotation is just
|
||||||
|
// [iconRotation] — upright by default, a quarter turn for the gear.
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(iconSize)
|
.size(iconSize)
|
||||||
.scale(scale)
|
.rotate(iconRotation - shapeRotation),
|
||||||
.rotate(rotation),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,4 +95,7 @@ fun ShapedActionButton(
|
|||||||
object ActionShapes {
|
object ActionShapes {
|
||||||
/** Settings — a 4-sided cookie (rounded, scalloped square). */
|
/** Settings — a 4-sided cookie (rounded, scalloped square). */
|
||||||
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
|
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
|
||||||
|
|
||||||
|
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
|
||||||
|
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
package de.jeanlucmakiola.agendula.ui.lists
|
package de.jeanlucmakiola.agendula.ui.lists
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
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
|
||||||
@@ -12,96 +20,152 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
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
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
import androidx.compose.material.icons.automirrored.rounded.ListAlt
|
||||||
import androidx.compose.material.icons.rounded.Add
|
import androidx.compose.material.icons.rounded.Add
|
||||||
|
import androidx.compose.material.icons.rounded.ChevronRight
|
||||||
|
import androidx.compose.material.icons.rounded.Close
|
||||||
import androidx.compose.material.icons.rounded.ErrorOutline
|
import androidx.compose.material.icons.rounded.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
import androidx.compose.material.icons.rounded.Today
|
import androidx.compose.material.icons.rounded.Today
|
||||||
import androidx.compose.material.icons.rounded.Upcoming
|
import androidx.compose.material.icons.rounded.Upcoming
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.MediumTopAppBar
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.Priority
|
||||||
import de.jeanlucmakiola.agendula.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
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.floret.components.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.priorityAccent
|
||||||
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
|
* Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All
|
||||||
* counts, then the user's lists grouped by account. Tapping a card or row opens
|
* 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's
|
||||||
* that task list; the FAB starts a new task.
|
* lists grouped by account. Tapping a tile opens that smart list, an upcoming row
|
||||||
|
* opens that task, and the FAB starts a new task. The search action in the top bar
|
||||||
|
* slides open into a full-width field that filters every task by title.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ListsScreen(
|
fun ListsScreen(
|
||||||
onOpenFilter: (TaskFilter) -> Unit,
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
onNewTask: () -> Unit,
|
onNewTask: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ListsViewModel = hiltViewModel(),
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val closeSearch = {
|
||||||
|
query = ""
|
||||||
|
searchActive = false
|
||||||
|
}
|
||||||
|
// System back closes search before leaving the screen.
|
||||||
|
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
MediumTopAppBar(
|
HomeTopBar(
|
||||||
title = { Text(stringResource(R.string.app_name)) },
|
searchActive = searchActive,
|
||||||
actions = {
|
query = query,
|
||||||
ShapedActionButton(
|
onQueryChange = { query = it },
|
||||||
shape = ActionShapes.Settings,
|
onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
|
||||||
icon = Icons.Rounded.Settings,
|
onOpenSettings = onOpenSettings,
|
||||||
contentDescription = stringResource(R.string.settings_title),
|
|
||||||
onClick = onOpenSettings,
|
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
size = 48.dp,
|
|
||||||
iconSize = 26.dp,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
|
// The FAB would otherwise float over the search results.
|
||||||
|
if (!searchActive) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = onNewTask,
|
onClick = onNewTask,
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||||
text = { Text(stringResource(R.string.new_task)) },
|
text = { Text(stringResource(R.string.new_task)) },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
|
||||||
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner)
|
ListsUiState.Failure ->
|
||||||
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter)
|
CenteredMessage(stringResource(R.string.lists_failure), PaddingValues(0.dp))
|
||||||
|
is ListsUiState.Content -> {
|
||||||
|
ListsContent(
|
||||||
|
state = s,
|
||||||
|
onOpenFilter = onOpenFilter,
|
||||||
|
onOpenTask = onOpenTask,
|
||||||
|
topPadding = 0.dp,
|
||||||
|
bottomPadding = inner.calculateBottomPadding() + 96.dp,
|
||||||
|
)
|
||||||
|
// While searching with a non-blank query, results cover the home
|
||||||
|
// content; an empty query leaves the home content visible behind
|
||||||
|
// the open field.
|
||||||
|
if (searchActive) {
|
||||||
|
SearchResults(
|
||||||
|
query = query,
|
||||||
|
allTasks = s.allTasks,
|
||||||
|
onOpenTask = { taskId ->
|
||||||
|
closeSearch()
|
||||||
|
onOpenTask(taskId)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,17 +173,46 @@ fun ListsScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ListsContent(
|
private fun ListsContent(
|
||||||
state: ListsUiState.Content,
|
state: ListsUiState.Content,
|
||||||
inner: PaddingValues,
|
|
||||||
onOpenFilter: (TaskFilter) -> Unit,
|
onOpenFilter: (TaskFilter) -> Unit,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
|
topPadding: androidx.compose.ui.unit.Dp,
|
||||||
|
bottomPadding: androidx.compose.ui.unit.Dp,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
top = inner.calculateTopPadding(),
|
top = topPadding,
|
||||||
bottom = inner.calculateBottomPadding() + 96.dp,
|
bottom = bottomPadding,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
item { SmartGrid(state.smartCounts, onOpenFilter) }
|
item {
|
||||||
|
TodayHero(
|
||||||
|
done = state.todayDone,
|
||||||
|
total = state.todayTotal,
|
||||||
|
onClick = { onOpenFilter(TaskFilter.Smart(SmartList.TODAY)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Overdue + All sit below the Today hero as a quieter 2-up; Upcoming is no
|
||||||
|
// longer a count tile — it becomes the live preview further down.
|
||||||
|
item {
|
||||||
|
SmartPairRow(
|
||||||
|
counts = state.smartCounts.filter {
|
||||||
|
it.smart == SmartList.OVERDUE || it.smart == SmartList.ALL
|
||||||
|
},
|
||||||
|
onOpenFilter = onOpenFilter,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.upcoming.isNotEmpty()) {
|
||||||
|
item { SectionHeader(stringResource(R.string.smart_upcoming)) }
|
||||||
|
item {
|
||||||
|
UpcomingPreview(
|
||||||
|
tasks = state.upcoming,
|
||||||
|
onOpenTask = onOpenTask,
|
||||||
|
onViewAll = { onOpenFilter(TaskFilter.Smart(SmartList.UPCOMING)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.groups.isEmpty()) {
|
if (state.groups.isEmpty()) {
|
||||||
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
|
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
|
||||||
@@ -150,25 +243,370 @@ private fun ListsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home top bar. There is no title — the launcher icon already says which app
|
||||||
|
* this is. Settings is pinned at the right; the search action sits just left of it
|
||||||
|
* and stays put. Tapping search unfurls a pill to its left (the field grows out
|
||||||
|
* from the icon's side) while the search icon remains as the bar's fixed trailing
|
||||||
|
* icon, so neither action moves.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SmartGrid(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
|
private fun HomeTopBar(
|
||||||
Column(
|
searchActive: Boolean,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
query: String,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
onQueryChange: (String) -> Unit,
|
||||||
|
onToggleSearch: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.height(72.dp)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
counts.chunked(2).forEach { row ->
|
// Search occupies the flexible space left of settings. Pulled into its
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
// own composable so AnimatedVisibility resolves to the plain overload —
|
||||||
row.forEach { smart ->
|
// a RowScope receiver in here would make that call ambiguous.
|
||||||
|
SearchSlot(
|
||||||
|
searchActive = searchActive,
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
onToggleSearch = onToggleSearch,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
ShapedActionButton(
|
||||||
|
shape = ActionShapes.Settings,
|
||||||
|
icon = Icons.Rounded.Settings,
|
||||||
|
contentDescription = stringResource(R.string.settings_title),
|
||||||
|
onClick = onOpenSettings,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
size = 48.dp,
|
||||||
|
iconSize = 26.dp,
|
||||||
|
spinIcon = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search action and the field it unfurls. The magnifier is pinned to the end
|
||||||
|
* (right) of this slot and never moves; tapping it toggles search. When active a
|
||||||
|
* pill expands leftward from it ([expandHorizontally] anchored at the end) holding
|
||||||
|
* the query field, so the icon reads as the bar's fixed trailing icon.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchSlot(
|
||||||
|
searchActive: Boolean,
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onToggleSearch: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier, contentAlignment = Alignment.CenterEnd) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = searchActive,
|
||||||
|
enter = expandHorizontally(tween(300), expandFrom = Alignment.End) + fadeIn(tween(280)),
|
||||||
|
exit = shrinkHorizontally(tween(220), shrinkTowards = Alignment.End) + fadeOut(tween(140)),
|
||||||
|
) {
|
||||||
|
SearchPill(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Always on top, at the end — the bar's fixed search icon and toggle.
|
||||||
|
ShapedActionButton(
|
||||||
|
shape = ActionShapes.Search,
|
||||||
|
icon = Icons.Rounded.Search,
|
||||||
|
contentDescription = stringResource(R.string.home_search_hint),
|
||||||
|
onClick = onToggleSearch,
|
||||||
|
size = 48.dp,
|
||||||
|
iconSize = 26.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanding search input pill: the query field (auto-focused on open) and a
|
||||||
|
* clear button once there is text. Its trailing 48dp is left empty for the search
|
||||||
|
* icon that [SearchSlot] overlays at the end.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchPill(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = modifier.height(52.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 18.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
BasicTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(),
|
||||||
|
modifier = Modifier.weight(1f).focusRequester(focusRequester),
|
||||||
|
decorationBox = { innerField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (query.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.home_search_hint),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerField()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Close,
|
||||||
|
contentDescription = stringResource(R.string.home_search_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Space reserved for the search icon SearchSlot overlays at the end.
|
||||||
|
Spacer(Modifier.width(48.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search results overlaying the home content: tasks whose title matches the query,
|
||||||
|
* across every list (completed included), open ones first. A blank query renders
|
||||||
|
* nothing so the home content shows through behind the open field.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchResults(
|
||||||
|
query: String,
|
||||||
|
allTasks: List<Task>,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
if (query.isBlank()) return
|
||||||
|
val results = remember(query, allTasks) {
|
||||||
|
val q = query.trim()
|
||||||
|
allTasks
|
||||||
|
.filter { it.title.contains(q, ignoreCase = true) }
|
||||||
|
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
|
||||||
|
}
|
||||||
|
Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.TopCenter) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.home_search_empty, query.trim()),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(results, key = { it.id }) { task ->
|
||||||
|
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The day's momentum: a wavy progress ring over "x of y done" for tasks due today.
|
||||||
|
* Tapping opens the Today list. When nothing is due today it drops the ring and
|
||||||
|
* reads as a calm, finished state rather than an empty 0.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) {
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
|
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "todayCorner")
|
||||||
|
val left = total - done
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = RoundedCornerShape(corner),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
interactionSource = interaction,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(140.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Rounded.Today, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Text(stringResource(R.string.smart_today), style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
val headline = when {
|
||||||
|
total == 0 -> stringResource(R.string.home_today_empty)
|
||||||
|
left == 0 -> stringResource(R.string.home_today_all_done)
|
||||||
|
else -> stringResource(R.string.home_today_progress, done, total)
|
||||||
|
}
|
||||||
|
Text(headline, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
if (total > 0 && left > 0) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.home_today_remaining, left),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total > 0) {
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) {
|
||||||
|
CircularWavyProgressIndicator(
|
||||||
|
progress = { done.toFloat() / total },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f),
|
||||||
|
)
|
||||||
|
Text("$done/$total", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overdue + All as a 2-up row of the existing tonal tiles. */
|
||||||
|
@Composable
|
||||||
|
private fun SmartPairRow(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
counts.forEach { smart ->
|
||||||
SmartCard(
|
SmartCard(
|
||||||
count = smart,
|
count = smart,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
|
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (row.size == 1) Spacer(Modifier.weight(1f))
|
if (counts.size == 1) Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A grouped card previewing the next few upcoming tasks, with a "view all" tail. */
|
||||||
|
@Composable
|
||||||
|
private fun UpcomingPreview(
|
||||||
|
tasks: List<Task>,
|
||||||
|
onOpenTask: (Long) -> Unit,
|
||||||
|
onViewAll: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(22.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
tasks.forEach { task ->
|
||||||
|
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
|
||||||
|
}
|
||||||
|
Surface(onClick = onViewAll, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.home_upcoming_view_all),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One slim upcoming row: list-colour avatar, title, then a quiet meta line of the
|
||||||
|
* relative due date and (if set) a tinted priority flag — the same calm one-line
|
||||||
|
* treatment as the task list, minus the swipe machinery.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UpcomingRow(task: Task, onClick: () -> Unit) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val metaStyle = MaterialTheme.typography.bodySmall
|
||||||
|
Surface(onClick = onClick, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
ListColorChip(task.effectiveColor)
|
||||||
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
task.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
upcomingDueLabel(task)?.let { Text(it, style = metaStyle, color = muted) }
|
||||||
|
if (task.priority != Priority.NONE) {
|
||||||
|
Text("·", style = metaStyle, color = muted)
|
||||||
|
Icon(
|
||||||
|
Icons.Rounded.Flag,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = priorityAccent(task.priority, dark),
|
||||||
|
modifier = Modifier.size(13.dp),
|
||||||
|
)
|
||||||
|
Text(priorityLabel(task.priority), style = metaStyle, color = muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today" / "Tomorrow" for the near dates, else the compact date. */
|
||||||
|
@Composable
|
||||||
|
private fun upcomingDueLabel(task: Task): String? {
|
||||||
|
val due = task.due ?: return null
|
||||||
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
|
val today = remember { LocalDate.now(zone) }
|
||||||
|
val dueDate = remember(due) {
|
||||||
|
java.time.Instant.ofEpochMilli(due.toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||||
|
}
|
||||||
|
return when (dueDate) {
|
||||||
|
today -> stringResource(R.string.home_due_today)
|
||||||
|
today.plusDays(1) -> stringResource(R.string.home_due_tomorrow)
|
||||||
|
else -> due.formatDateTimeCompact(task.isAllDay)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
|
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
|
||||||
|
|||||||
@@ -29,9 +29,18 @@ sealed interface ListsUiState {
|
|||||||
data class Content(
|
data class Content(
|
||||||
val smartCounts: List<SmartCount>,
|
val smartCounts: List<SmartCount>,
|
||||||
val groups: List<AccountGroup>,
|
val groups: List<AccountGroup>,
|
||||||
|
/** Completed vs. total tasks *due today* — drives the progress ring. */
|
||||||
|
val todayDone: Int,
|
||||||
|
val todayTotal: Int,
|
||||||
|
/** The next few open tasks (due tomorrow onward) for the inline preview. */
|
||||||
|
val upcoming: List<Task>,
|
||||||
|
/** Every task (open and completed) — the corpus the home search filters. */
|
||||||
|
val allTasks: List<Task>,
|
||||||
) : ListsUiState
|
) : ListsUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val UPCOMING_PREVIEW = 3
|
||||||
|
|
||||||
/** The home overview: smart lists with live counts, then user lists by account. */
|
/** The home overview: smart lists with live counts, then user lists by account. */
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel @Inject constructor(
|
||||||
@@ -42,12 +51,19 @@ class ListsViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.taskLists(),
|
repository.taskLists(),
|
||||||
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
|
||||||
) { lists, openTasks ->
|
// Open smart lists drop completed tasks, but the Today ring needs the
|
||||||
buildContent(lists, openTasks) as ListsUiState
|
// ones already ticked off to show "x of y done", so read them too.
|
||||||
|
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
|
||||||
|
) { lists, openTasks, completedTasks ->
|
||||||
|
buildContent(lists, openTasks, completedTasks) as ListsUiState
|
||||||
}.catch { emit(ListsUiState.Failure) }
|
}.catch { emit(ListsUiState.Failure) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
|
||||||
|
|
||||||
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content {
|
private fun buildContent(
|
||||||
|
lists: List<TaskList>,
|
||||||
|
openTasks: List<Task>,
|
||||||
|
completedTasks: List<Task>,
|
||||||
|
): ListsUiState.Content {
|
||||||
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
|
||||||
// Count only top-level tasks: a subtask is represented by its parent (and
|
// Count only top-level tasks: a subtask is represented by its parent (and
|
||||||
// its progress chip), and an open subtask under a *completed* parent must
|
// its progress chip), and an open subtask under a *completed* parent must
|
||||||
@@ -62,6 +78,21 @@ class ListsViewModel @Inject constructor(
|
|||||||
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
|
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
|
||||||
SmartCount(SmartList.ALL, topLevel.size),
|
SmartCount(SmartList.ALL, topLevel.size),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Today ring: completed vs. total tasks *due today*. The numerator is the
|
||||||
|
// top-level completed tasks whose due date falls in today's window; the
|
||||||
|
// denominator adds the still-open ones (the Today smart count above).
|
||||||
|
val openToday = count(SmartList.TODAY)
|
||||||
|
val completedDueToday = completedTasks.count {
|
||||||
|
!it.isSubtask && it.due != null && it.due >= todayStart && it.due < todayEnd
|
||||||
|
}
|
||||||
|
val todayTotal = openToday + completedDueToday
|
||||||
|
|
||||||
|
// Upcoming preview: the next handful of open tasks due tomorrow onward,
|
||||||
|
// already sorted by the repository's default ordering.
|
||||||
|
val upcoming = topLevel
|
||||||
|
.filter { TaskFiltering.matches(it, TaskFilter.Smart(SmartList.UPCOMING), todayStart, todayEnd) }
|
||||||
|
.take(UPCOMING_PREVIEW)
|
||||||
val openByList = topLevel.groupingBy { it.listId }.eachCount()
|
val openByList = topLevel.groupingBy { it.listId }.eachCount()
|
||||||
val groups = lists
|
val groups = lists
|
||||||
.groupBy { it.accountName }
|
.groupBy { it.accountName }
|
||||||
@@ -72,6 +103,13 @@ class ListsViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sortedBy { it.accountName.lowercase() }
|
.sortedBy { it.accountName.lowercase() }
|
||||||
return ListsUiState.Content(smartCounts, groups)
|
return ListsUiState.Content(
|
||||||
|
smartCounts = smartCounts,
|
||||||
|
groups = groups,
|
||||||
|
todayDone = completedDueToday,
|
||||||
|
todayTotal = todayTotal,
|
||||||
|
upcoming = upcoming,
|
||||||
|
allTasks = openTasks + completedTasks,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
|||||||
composable(Dest.LISTS) {
|
composable(Dest.LISTS) {
|
||||||
ListsScreen(
|
ListsScreen(
|
||||||
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
||||||
|
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
|
||||||
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
||||||
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
|
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,22 +1,89 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
Agendula launcher icon foreground — PLACEHOLDER.
|
Agendula launcher icon foreground.
|
||||||
|
|
||||||
A simple rounded check mark inside the 108dp adaptive-icon canvas
|
Converted from design/icon/agendula_icon.svg (512x512 viewport): a rounded
|
||||||
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two
|
line-art task card with a check mark, plus a small Calendula bloom badge in
|
||||||
apps never look alike. Replace with real branding (a stylized agendula)
|
the bottom-right corner — the sibling of Calendula's calendar mark.
|
||||||
when the design lands — see docs/PLAN.md §9.
|
|
||||||
|
Strokes render in off-white (#FAF6F0) over the plum background drawable
|
||||||
|
(drawable/ic_launcher_background.xml = @color/ic_launcher_background, the
|
||||||
|
hue-rotated counterpart of Calendula's slate). The same vector fills the
|
||||||
|
<monochrome> slot so Android 13+ themed-icon launchers can recolour it.
|
||||||
|
|
||||||
|
Centering / scale:
|
||||||
|
- Scale 0.66 about the canvas centre (matching Calendula's ~2.8dp stroke
|
||||||
|
weight and generous padding).
|
||||||
|
- The eye centres on the task CARD, not the card+bloom bounding box (the
|
||||||
|
bloom is a small badge that overhangs the bottom-right). So vertically we
|
||||||
|
centre the card itself: its geometric centre is y=242.76, so we pivot the
|
||||||
|
Y-scale there and translate +13.24 to drop that centre onto the canvas
|
||||||
|
centre (256). Horizontally the card already sits centred, so X scales
|
||||||
|
about 256 untouched.
|
||||||
-->
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="512"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="512">
|
||||||
|
<group
|
||||||
|
android:pivotX="256"
|
||||||
|
android:pivotY="242.76"
|
||||||
|
android:scaleX="0.66"
|
||||||
|
android:scaleY="0.66"
|
||||||
|
android:translateY="13.24">
|
||||||
|
<!-- Task card (rounded square, opening at the bottom-right for the bloom) -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#00000000"
|
android:strokeColor="#FFFAF6F0"
|
||||||
android:strokeColor="#FFFFFF"
|
android:strokeWidth="20"
|
||||||
android:strokeWidth="9"
|
android:strokeMiterLimit="12"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:strokeLineJoin="round"
|
android:strokeLineJoin="round"
|
||||||
android:pathData="M36,55 l13,13 l25,-27" />
|
android:pathData="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" />
|
||||||
|
<!-- Check mark -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="20"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M193 245.569L231.822 287L320 199" />
|
||||||
|
<!-- Calendula bloom: petals around the centre -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" />
|
||||||
|
<!-- Bloom centre -->
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFAF6F0"
|
||||||
|
android:strokeWidth="16"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" />
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -129,6 +129,23 @@
|
|||||||
<string name="smart_all">All</string>
|
<string name="smart_all">All</string>
|
||||||
<string name="open_count">%1$d open</string>
|
<string name="open_count">%1$d open</string>
|
||||||
|
|
||||||
|
<!-- Home: Today progress hero -->
|
||||||
|
<string name="home_today_progress">%1$d of %2$d done</string>
|
||||||
|
<string name="home_today_remaining">%1$d left</string>
|
||||||
|
<string name="home_today_all_done">All done 🎉</string>
|
||||||
|
<string name="home_today_empty">Nothing due today 🎉</string>
|
||||||
|
|
||||||
|
<!-- Home: Upcoming preview -->
|
||||||
|
<string name="home_upcoming_view_all">View all</string>
|
||||||
|
<string name="home_due_today">Today</string>
|
||||||
|
<string name="home_due_tomorrow">Tomorrow</string>
|
||||||
|
|
||||||
|
<!-- Home: search -->
|
||||||
|
<string name="home_search_hint">Search tasks</string>
|
||||||
|
<string name="home_search_clear">Clear search</string>
|
||||||
|
<string name="home_search_close">Close search</string>
|
||||||
|
<string name="home_search_empty">No tasks match “%1$s”</string>
|
||||||
|
|
||||||
<!-- Reminders -->
|
<!-- Reminders -->
|
||||||
<string name="reminder_due_at">Due %1$s</string>
|
<string name="reminder_due_at">Due %1$s</string>
|
||||||
<string name="reminder_channel_name">Task reminders</string>
|
<string name="reminder_channel_name">Task reminders</string>
|
||||||
|
|||||||
11
design/icon/agendula_icon.svg
Normal file
11
design/icon/agendula_icon.svg
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="512" height="512" fill="white"/>
|
||||||
|
<path d="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" stroke="black" stroke-width="20" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M193 245.569L231.822 287L320 199" stroke="black" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
5
fastlane/metadata/android/en-US/changelogs/202.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/202.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
### Fixed
|
||||||
|
- Release automation now reliably mirrors each release to the Codeberg mirror
|
||||||
|
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
|
||||||
|
had already been synced to Codeberg.
|
||||||
|
|
||||||
14
fastlane/metadata/android/en-US/changelogs/300.txt
Normal file
14
fastlane/metadata/android/en-US/changelogs/300.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
### Added
|
||||||
|
- Reminders: Agendula now delivers your due reminders itself. A one-time setup
|
||||||
|
step explains this and asks for notification access, and a master switch in
|
||||||
|
Settings turns the whole thing off again.
|
||||||
|
- A Settings screen, from the gear on the overview: appearance and theme, which
|
||||||
|
fields the task form shows, your default list, and reminder defaults.
|
||||||
|
- The overview leads with Today — a progress ring showing how much of today
|
||||||
|
you've finished — followed by a live preview of what's coming up next.
|
||||||
|
- Search across every task, open or completed, from the top bar.
|
||||||
|
- A proper app icon.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A tidier top bar: no app title, with search and settings pinned to the right.
|
||||||
|
|
||||||
5
fastlane/metadata/android/en-US/changelogs/301.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/301.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
### Fixed
|
||||||
|
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
|
||||||
|
release build stripped a constructor that the background-work scheduler needs
|
||||||
|
to open its database, and that happens before the app draws anything.
|
||||||
|
|
||||||
6
fastlane/metadata/android/en-US/changelogs/302.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/302.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Fixed
|
||||||
|
- Releases reach the Codeberg download channel again. 0.3.1 published to
|
||||||
|
F-Droid but never appeared on Codeberg, so if you install from there — or
|
||||||
|
through Obtainium — this is the release that finally carries 0.3.0's
|
||||||
|
launch-crash fix. The app itself is unchanged from 0.3.1.
|
||||||
|
|
||||||
Reference in New Issue
Block a user