animateItem() was applied to every non-dragged item unconditionally, so a
fast fling to the top of the list animated item placement during plain
scrolling. Gate it on an active drag so only the displaced items animate.
rememberDragDropListState captures its callbacks once via keyless remember,
so the external confirmReorder kept invoking the first composition's closure.
That closure indexed into a stale event list, so every reorder after the
first moved the wrong item. Route the callback through rememberUpdatedState
so the remembered state always calls the latest closure.
DragDropList tracked the last external list in a plain `var` initialized
from `remember { items }`, so the value was frozen at first composition
and reassignment never persisted. That left the reset guard permanently
true after the first committed reorder, clobbering the in-progress drag
preview on every recompose and snapping items back to their committed
order. Make the tracker state-backed so the guard fires only on a real
external change.
The Android MpScrollBarList dropped its modifier parameter, so the
Alignment.CenterEnd passed by the project list never reached the Canvas.
The bar fell back to the Box default alignment (TopStart) and rendered
on the left. Thread the modifier through to the Canvas.
The Outline Overview and add buttons auto-hide on scroll-down and only
reappeared on scroll-up. Once the list became non-scrollable (e.g. all
groups collapsed) while hidden, nothing could flip them back. Tie footer
visibility to canScrollForward so it always shows when the list can't
scroll down to it.
Exiting a project with auto-sync on runs requestClose(), which queued
CloseConfirm.Sync and tore down open editors before the sync ran. Scenes,
notes, and encyclopedia entries flagged unsaved edits via shouldConfirmClose(),
but TimeLineComponent returned emptySet(), so an in-progress timeline event
edit was silently discarded with no warning.
Wire TimeLineComponent.shouldConfirmClose() to the existing isEditingAndDirty()
check and add a CloseConfirm.Timeline confirmation dialog on Android/common and
desktop, mirroring the notes/encyclopedia pattern.
Closes#588
The scene tree emitted every node as a LazyColumn item and hid collapsed
descendants with AnimatedVisibility, leaving them as zero-height rows. On a
large, mostly-collapsed tree the LazyColumn composes through those trailing
zero-height rows trying to fill its viewport, and combined with animateItem()
the last visible row's placement never settles -- it flickers.
Compute the visible nodes (everything except the root scene and anything under
a collapsed ancestor) and emit only those as items. Collapsed subtrees are
absent rather than zero-height, so there is nothing for the list to churn over.
Drag/drop is keyed by node id, not list index, so reordering is unaffected; it
also no longer sees zero-height rows as drop targets.
Scene and group titles are stored wrapped as `order~name~id`, so a leading
dot or a Windows reserved word (CON, PRN, COM0-9, LPT0-9, ...) can never
collide on disk for them — yet name validation rejected them as if the title
were a raw filesystem basename. Project names, which become directories
verbatim, keep the strict rules.
- ProjectNameValidator.validate gains usedAsRawFilename (default true); the
leading-dot and reserved-name checks now only apply to raw filenames.
Trailing dot/space stay rejected for everyone (the on-disk encoder strips
them). Reserved set extended with COM0/LPT0.
- Thread the flag through ProjectsRepository.validateFileName and
SceneRepository.validateSceneName.
- Consolidate the six naming dialogs onto a shared rememberNameValidation
hook keyed by NameKind (Project vs SceneItem), so strictness lives in one
place instead of a per-call-site boolean.
Mark the scene-tree state types @Immutable/@Stable and move them onto
kotlinx.collections.immutable so Compose can skip recomposition when the
tree is unchanged: TreeValue.children becomes ImmutableList, SceneSummary
.hasDirtyBuffer a PersistentSet (sourced as such from SceneContentRepository),
and SceneList.State.archivedScenes an ImmutableList. Also cache ImmutableTree
.nodeIndex/hashCode lazily and gate compose-compiler stability reports behind
the composeCompilerReports property.
* Harden onSceneBufferUpdate to reduce from oldState
Read the scene summary from the getAndUpdate lambda's oldState argument
instead of a snapshot captured before the CAS, so the reducer stays a pure
function of its input and composes correctly if buffer updates ever run off
the main dispatcher.
FileKit's Compose remember*Launcher runs the native picker on a shared
Dispatchers.IO thread and calls CoInitializeEx(STA), which throws
"CoInitializeEx failed" (RPC_E_CHANGED_MODE) when that pooled thread was
already COM-initialized MTA. Intermittent and crashes the app.
Add retryingFileDialog and route every desktop-reachable picker through
FileKit's suspend API instead of the Compose launchers, retrying past the
transient COM failure and returning null rather than crashing. iOS/Android
keep the Compose launchers.
- Tablet variants: new TabletPreviewSurface provides a wide ScreenCharacteristics
so screens render their expanded layout; @Preview(widthDp/heightDp) sizes the
canvas to a landscape tablet. Added for ProjectStats, ProjectSettings,
TimeLineOverview, BrowseEntries, BrowseNotes, and ViewEntry.
- Naming: prefix every top-level screen preview (phone and tablet) with "Screen"
so they group together and are easy to pick out from component previews.
- Backgrounds: the shared Padded wrapper now applies the app theme + surface
background; ProjectCard and SceneItem previews now render against the app
background instead of the renderer's transparent default.
The compose-preview renderer invokes @Preview functions reflectively and
cannot access private top-level functions, so most existing previews failed
to render with IllegalAccessException. Drop `private` from every @Preview
function.
Also fix the remaining render failures:
- Wrap encyclopedia/scene previews that touched Koin in KoinApplicationPreview.
- Register a minimal coil3 ImageLoader in the preview Koin graph so AsyncImage
screens (ViewEntry, CreateEntry) resolve it.
Result: 88/88 previews render, 0 errors (was 19/88).
MarkdownEditField injects SpellCheckRepository, so every editor-based
preview (CreateNoteUi, CreateTimeLineEventUi, ...) crashed at render with
a missing Koin definition. Register a real SpellCheckRepository backed by
in-memory settings datasources in KoinApplicationPreview's base module.
Lazy single, so non-editor previews are unaffected.
Cover the destination screens behind the project-home, notes, timeline,
and story-editor routers with @Preview fixtures:
- ProjectStatsUi, ProjectSettingsUi (project home)
- CreateNoteUi (notes)
- TimeLineOverviewUi, CreateTimeLineEventUi, ViewTimeLineEventUi (timeline)
- OutlineOverviewUi (story editor dialog)
Each preview hand-rolls a fake component implementing the Decompose
interface so the screen renders without runtime dependencies.
The markdown editor only exposed inline styles through format-bar buttons;
the underlying composetexteditor library handles editing/navigation shortcuts
(Ctrl+C/V/X/Z/Y, arrows, etc.) but has no bindings for bold/italic, so those
keys were simply dropped.
Bump compileSdk/targetSdk to 37 and AGP to 9.1.1 (9.0.x maxes out at
API 36.1 and can't resolve the minor-versioned android-37.0 platform).
Handle the two breaking behavior changes for API 37 targets:
- Cleartext traffic: usesCleartextTraffic is now ignored without a
network security config. Self-hosted servers can be plain HTTP
(ServerSettings.ssl = false), so add network_security_config.xml
permitting cleartext and reference it from both manifests.
- Local network access: declare ACCESS_LOCAL_NETWORK and request it at
runtime via a new expect/actual RequestLocalNetworkPermission,
triggered when the server-setup dialog opens so the grant resolves
before any LAN connection. No-op on desktop/iOS.
Restores the "store projects in public storage" feature, gated to F-Droid builds (the required MANAGE_EXTERNAL_STORAGE permission is disallowed on Google Play).
- Expose the build channel at runtime via BuildConfig.FDROID in the common module.
- Declare the storage permissions only in src/fdroid/AndroidManifest.xml, swapped in for F-Droid builds.
- Restore the storage-location toggle + file-access UI, gated on BuildConfig.FDROID; reconcile the toggle with the real location on open.
- Build the GitHub release APK as the F-Droid flavor.
- Extract the directory move into a tested FileSystem.moveDirectory() helper (fixes the same-path data-loss crash; runs off the UI thread).
- Read the fdroid flag consistently across settings.gradle.kts and module scripts.
- Document the F-Droid build flag in DEVELOPMENT.md.
* Fix flaky NotesWorkflowTest create-note race
createNoteThenOpenIt clicked the create confirm button immediately after
typeIntoEditor. The markdown editor reports text changes through an async
editOperations flow that waitForIdle() doesn't await, so noteText could
still be empty at confirm time -> createNote("") returns NoteError.EMPTY,
the create screen never dismisses, and the browse grid's note-card- nodes
never reappear -> 10s timeout.
Wait for the word/char counter to reflect the typed body before confirming.
Tag the counter (NOTES_CREATE_META_TAG) and add a textOf() harness helper to
read it. Verified on emulator (4/4 runs green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Guard project list load against a concurrently-deleted project
loadProjectList lists project dirs then reads each one's metadata in a
parallelMap. If a project is deleted between the listing and the read
(another window, or a refresh racing a delete), loadMetadata's recovery
path tries to recreate project.toml in a directory that no longer exists
and throws FileNotFoundException from inside its own catch block, failing
the entire list load (and crashing the instrumented test that exposed it).
Catch the per-project load failure and skip the vanished project - this
also makes the previously-dead `if (metadata != null)` branch live, which
was the original intent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sync, background jobs, API/email send, and UI boundaries legitimately
catch broadly; annotate each with @Suppress and a reason, and log the
exception where it was previously swallowed.
* Rename IdRepository to IdAllocator
It is a special foundational primative
* Rename SyncDataRepository to SyncJournal
It is a special foundational primative
* Rename GlobalSettingsRepository to GlobalSettingsStore
It is a special foundational primitive
* docs: add Foundation primitives tier to the architecture doc
Document IdAllocator, SyncJournal, and GlobalSettingsStore as a fixed set of
stateful, cross-cutting primitives that the whole data layer may depend on —
acyclic leaves (GlobalSettingsStore <- SyncJournal <- IdAllocator) that named
the dependency reality instead of treating it as a no-sibling violation.
Add a reusable EditorTestHarness (seed a project via Koin, launch straight
into ProjectRootActivity, navigate, tear down without racing the scope flush)
and one happy-path instrumented test per feature area: navigation smoke,
scene list/editor, notes, encyclopedia, timeline, project home, and global
search.
To make the UI addressable, add optional testTag params to shared design-system
components (FormField, MarkdownEditField, HdHairlineField/TagField/SearchField/
TypePicker, HdBottomBar/HdNavRail) and colocated testTag consts on the relevant
screens. The custom text editor consumes key events rather than Compose SetText
semantics, so the harness types into it via injected keystrokes.
All 9 new tests pass on an api-34 emulator.
ProjectLifecycleTest launches the real ProjectSelectActivity, creates a project,
waits for it to appear in the list, and confirms opening it launches
ProjectRootActivity (via ActivityMonitor). Cleans up in @After.
- Wire jetbrains-compose ui-test-junit4 into the androidTest source set.
- Tag the create-project affordance (CreateProjectButtonTestTag); "Create
Project" otherwise appears as three separate on-screen texts.
- Run the instrumented suite on an emulator in CI (android-emulator-runner) with
AVD snapshot caching.
- Convert HashTest from JUnit Jupiter to JUnit4 so the AndroidJUnit4 runner can
execute it on-device (it had "no runnable methods" otherwise); use assertEquals
since assert() is a no-op when assertions are disabled on a device. It now
verifies EntityHasher's golden vector on Android ART.
- Fix a scope-close crash: getSceneBufferDirectory used a non-recursive
createDirectory, so closing a project whose scenes/ dir is absent threw on the
teardown path and crashed the process. Use createDirectories (matching its
siblings) and order the test teardown so it doesn't delete the project mid-close.
The phone project-list row showed created/opened/words as one flowing mono
line that wrapped at arbitrary points. Replace it with equal-weight
label/value columns so the fields align down the whole list, with faint
captions over muted values to keep the title the only bright anchor.