PullRead is a read-only, mobile-first reader for note repositories. It clones a Git repository (GitHub, Gitee, GitCode/AtomGit, GitLab, self-hosted Gitea, …) onto the device and renders it natively: Markdown and Jupyter notebooks, syntax-highlighted code, article TOC, original directory tree, tags, bookmarks and full-text search. There is no backend, no build step and no hosting to maintain.
This file is the engineering contract for everyone (human or agent) changing the code. It is descriptive of the system and prescriptive: constraints marked must exist because ignoring them caused real regressions or data loss. Keep it in sync with the code in the same commit (see "Maintenance").
In scope
- Clone a whole repository, then read it comfortably. Rendering quality is the number-one priority.
- Browse the original directory structure. Never flatten the tree.
- Global search across repositories and local folders.
- Local folders (SAF on Android / directory picker on desktop) with the same reading pipeline as Git repositories, plus a Markdown editor that writes back.
- Reading progress, bookmarks, tags, link checking and update awareness.
Out of scope — do not add
- Any edit/commit/push capability for Git repositories (removed by the user in 2026-10). Local-folder editing is the only write path and never touches Git.
- Backlinks/graphs, collaboration, private repositories, TTS, PDF, reading stats, deployment/CI/static-site generation.
- Platform REST APIs. See "Platform adaptation".
Non-negotiables
- Rendering must never be worse than a typical blog.
- Sync conflicts or content loss are unacceptable. Any change to the Git layer must be self-reviewed against this rule before commit.
- Framework: Flutter, Dart 3 / Flutter 3.47+. iOS + Android are the product targets; the Windows desktop target exists for local preview only. iOS work cannot be verified on this machine (no macOS).
- Package id:
com.intersetwq.pullreadfor AndroidapplicationId/namespaceand the iOS bundle id. KotlinMainActivitylives inandroid/app/src/main/kotlin/com/intersetwq/pullread/. Renamed fromcom.intersetwq.readerin 2026-10: the old package is not upgraded in place and its data is not migrated. - No new heavy dependencies without justification. The Git layer is already the riskiest part of the project.
| Path | Responsibility |
|---|---|
lib/main.dart |
Startup, _bootstrap() (libgit2 + CA), theme, routing to HomePage |
lib/src/git/ |
libgit2 FFI wrapper (sync_service.dart), repo metadata (repo_meta.dart) |
lib/src/notes/ |
Note scanning/index, Markdown source, notebook conversion, TOC, tags, link check, code outline |
lib/src/search/ |
Filename/full-text search, snippets, search models |
lib/src/local/ |
SAF/desktop folder authorization and incremental mirroring |
lib/src/ui/ |
Pages and shared widgets (reader, code view, image cache/actions, settings, …) |
lib/src/theme/ |
AppPrefs, app theme |
lib/src/utils/ |
Text encoding, directory size, small helpers |
test/, integration_test/ |
Unit/widget tests, real-device render baseline |
docs/ |
Perf baseline, UX audit, settings redesign proposal |
These cut across features. Violating any of them has already caused a bug in this project or would cause data loss.
- Git is read-only. Clone/pull only; never commit, checkout user changes or rewrite history.
- Never delete outside the intended target. Repo names and target paths must be derived safely (see "Git and sync"), and deletion helpers must refuse roots.
- All Git work runs in a background isolate, and native errors must be converted to plain Dart errors before crossing the isolate boundary.
- The UI isolate never does recursive filesystem scans. Use isolates plus the on-disk index cache.
- Long documents are lazily laid out. Eager layout is only for capture/export.
- Reading progress is written only for user-initiated scrolls, never for programmatic restore/animations.
- Server data is never rendered as executable/inline payloads: notebook images go to a side store, not into Markdown source as base64.
- No secrets in code or logs. Tokens, if ever added, stay on device.
- Startup shows exactly one splash image and never a spinner.
- Pending local edits win over a stale source folder. Never overwrite the mirror with older source content.
Uses libgit2 through FFI (git2dart). Only clone/fetch/pull capabilities are used.
Change this layer in small, verifiable steps.
- Derive the repository name from
Uri.parse(url).pathSegments, ignoring empty segments; strip only a trailing.gitsuffix (neverreplaceAll('.git', '')). - Validate the name: non-empty, not
./.., does not start with.(at minimum reject.meta), and contains no path separators or Windows-illegal characters (: * ? " < > |). - Before cloning, verify that the target's parent is exactly the repos root, using path segments rather than string prefixes.
_prepareTarget()must refuse to delete the repos root, the metadata directory, the local-mirror root or the application documents root. A trailing-slash URL once produced an empty name and would have recursively deleted every cloned repo plus.meta(reading progress, bookmarks, favorites, update markers).- Tests for URL/path handling must use temporary directories only, never real user data directories.
git2dart_binaries statically links OpenSSL, which cannot see the Android system
certificate store. In _bootstrap() (lib/main.dart) the order must be:
WidgetsFlutterBinding.ensureInitialized()Libgit2.version(load the native library)AndroidSSLHelper.initialize()Libgit2.setSSLCertLocations(file: certPath)
Otherwise HTTPS clones fail with the SSL certificate is invalid. The CA bundle is
shipped with git2dart_binaries; do not add one. This bootstrap runs after
runApp (see "Startup") so the first frame is not blocked.
- Clone and pull use
Isolate.spawn+ReceivePort;checkRemoteusesIsolate.runbecause it needs no progress. Never run Git on the UI isolate. - Inside an isolate, catch
git2dartnative exceptions and rethrow a normalException(or send a string). Native error objects cannot cross isolates; sending them leaves the returnedFutureincomplete and the UI spinning forever. - Clone has a 5-minute timeout that must
Isolate.killthe worker and clean up the target directory.Isolate.run(...).timeout()only times out the future; the isolate keeps writing and leaves a half-cloned directory that makes retries fail with "local directory already exists". - Clone reports progress through the port as
[0, receivedObjects, totalObjects, receivedBytes]; the final message istrueon success or an error string.CloneCancelToken.cancel()takes effect on the next progress message because the transfer itself is a blocking native call. Cancellation reuses the kill + cleanup path. - Never pass
checkoutBranchto clone. Guessing the default branch (mainvsmaster) causes libgit2 to wipe and re-clone the whole repository. Let libgit2 check out the remote HEAD, then store the real branch withreadHeadBranch.
checkRemote()fetches in an isolate, then counts commits behind withRevWalk(push remote head, hide local head) without moving the local branch. The repository page shows the count before the user confirms a pull.RepoMeta.syncedAtis updated on clone (writeClone) and successful pull (touchSynced); repository cards show "synced at …".- Divergence:
checkRemote()also walks in reverse to detect local commits the remote lacks. On a non-fast-forward pull (SyncException.diverged), ask the user before deleting and re-cloning from the recorded URL. Reading progress, bookmarks and update markers live outside the repo and survive. - Change markers: after a fast-forward pull (single, "update all", and silent
background pull), diff old head → new head inside the isolate and record
added/modified files (not deletions) in
RepoMeta.recentChanges(cap 200; clear the whole list when exceeded). File rows show "new/updated"; opening the file clears its marker. - Pull progress:
pull()uses the same spawn/port protocol as clone and reportsRemote.fetch(transferProgress:). Pull is not cancellable (killing a blocking fetch can leave a truncated packfile), so show progress without a cancel button. Both the single-repo sync and "update all" userunWithPullProgress; the batch path must also writesyncedAtandrecentChanges. - Update inbox (
updates_page.dart): the home inbox icon shows unread counts, aggregated fromrecentChangesof repos and local mirrors, grouped by source. Opening an item marks it read; "mark all read" is supported. All openings go throughopen_note.dart, which dispatches to reader/gallery/code viewer, clears the marker and records the recent file.
INTERNETmust be declared inandroid/app/src/main/AndroidManifest.xml. The Flutter template only adds it to debug/profile; a release build then fails all HTTPS requests withGIT_ERROR_NET: No address associated with hostname.- The release launcher entry lives on
activity-aliaswidgets (five icon variants). Flutter tooling only recognizesMAIN/LAUNCHERon<activity>, soandroid/app/src/debug/AndroidManifest.xmlandandroid/app/src/profile/AndroidManifest.xmloverride the manifest: they attach the launcher intent-filter toMainActivityand remove the five aliases. Release builds do not read those directories, which means icon switching can only be verified on a release build. Do not touch those two manifests while working on icons.
- Renderer:
markdown_widgetplus syntax highlighting. GFM alerts (> [!NOTE]) and.ipynbfiles are converted to Markdown before rendering. - Math pipes in tables (
lib/src/notes/math_render.dart): GFM splits table cells on|before inline parsing, so$|x|$would truncate the cell (issue #2).protectTableMathPipes()replaces pipes inside table math with private-use placeholders andMathNoderestores them. Apply it before everyMarkdownGenerator.buildWidgetscall (reader and editor preview); do not drop raw|from formulas. - Notebook rich output (
lib/src/notes/ipynb.dart,ipynb_images.dart):image/png|jpeg|gif|svgoutputs are decoded into an in-memory store keyed by content hash (IpynbImageStore, 64 MB cap, approximate LRU). The body keeps only a shortpullread-ipynb://<hash>reference — never put base64 in the body, or copy-all, in-text find and TOC line numbers all get polluted. When images exist, drop matplotlib's<Figure ...>text placeholder. Simpletext/htmltables (no colspan/rowspan, ≤100 rows, ≤30 columns) become Markdown tables; otherwise fall back totext/plain. Full-text search usestoMarkdown(includeImages: false)and never decodes images. - Lazy long-document layout (
lib/src/ui/markdown_viewer.dart): body blocks go into aSliverListso only blocks near the viewport are built. Baseline numbers (reproduce withdocs/perf.md): 5000 lines first frame 2.3s → 0.46s and +150 MB → +7 MB memory; 10000 lines 4.5s → 0.8s. Two hard constraints:- Long-image export must switch back to the whole-document, one-shot layout
(
_eagerForCapture); otherwiseRepaintBoundaryonly captures built blocks. - TOC jumps depend on contiguous item indices from
scroll_to_index: every block carries anAutoScrollTag, and headings record their slot in_headingSlots. Sparse heading-only anchors miscalculate long-distance jumps.
- Long-image export must switch back to the whole-document, one-shot layout
(
- Remote image cache (
lib/src/ui/image_cache.dart): network images read the disk cache first and render immediately (offline-friendly, no 30s timeout). URLs are hashed with two 32-bit values into<docs>/imgcache; evict by write time above 200 MB. Cache entries older than 7 days are refreshed silently in the background; failures fall back to the cached copy. Refreshes and re-entry use conditional requests: storeETag/Last-Modifiedin a.metasidecar next to<key>.img, sendIf-None-Match/If-Modified-Since; a 304 reuses the cache and resets the freshness window without re-downloading bytes. Manual retry skips conditional headers. - SVG (
repo_image.dart,markdown_source.dart): local.svgusesSvgPicture.file; network bytes are sniffed and useSvgPicture.memory, scaled to available width by viewBox.normalizeHtmlImagesonly drops shields.io / contrib.rocks / star-history badges — do not drop all.svgURLs. Saving or sharing rasterizes viasvgToPng(longest edge ≤2048). - Image decoding width in the reader is capped by screen width × devicePixelRatio (320–2048) to avoid decoding tens of MB per image. The gallery still uses the original resolution for zooming.
- Image save/share (
image_actions.dart): long-press in the body and the gallery menu/long-press share one path (save to gallery / share / copy URL).loadRepoImageByteshandles local files, network cache/download and notebook in-memory images. A failed gallery save only warns; sharing still works. SVG is converted to PNG first.
- Non-Markdown files open in a syntax-highlighted read-only viewer
(
code_viewer.dart+code_view.dart+notes/code_outline.dart), matching GitHub mobile while adding an outline and notebook support. - Shared rendering:
CodeView/CodeViewControllerare used by both the code file page and Markdown "Raw" mode. They share highlighting, line numbers, find, outline, wrapping and line selection. Raw mode fixes font size at 13, does not wrap by default, and uses its own scroll controller (never the reader's_scrollController, so view switches don't leak positions or corrupt progress). File reads always go throughdecodeTextBytes(strict UTF-8 first, GBK fallback). - Outline: regex heuristics for
class/mixin/enum/extension,def,function/constarrow functions,fun,func,fn, shell functions,# sectioncomments and top-level YAML keys; cap 500 entries. The panel can jump to a line and highlights the current entry (_currentLinederived from scroll offset). Raw Markdown reusesextractToc(skipping fenced code);.ipynbraw text is treated as JSON. - Find: reuses
findTextMatches, shows match count and previous/next. Per-character highlighting is intentionally not implemented becauseHighlightViewdoes not expose text offsets. - Line selection copy: long-press anchors the start line, tap extends to the end
line, and the whole line range is highlighted. The overlay lives in the scrolled
content coordinate space (a viewport-fixed overlay drifts after scrolling). A top
action bar offers copy/cancel. Wrapping mode does not support line selection
(line heights vary, so a
dycannot be mapped back to a line). Line height must be measured withTextPainterusing the real font (_measureLineHeight), neverfontSize × height: JetBrains Mono at 13px / 1.5 renders at 20.0, not 19.5; hard-coding 19.5 shifts the highlight by 0.5px per line and visibly accumulates over dozens of lines. Line numbers useTextScaler.noScalingto match theRichTextinsideHighlightView. Regression test:test/code_selection_test.dart. - Wrapping is an AppBar toggle; enabling it hides the line-number gutter because wrapped rows no longer align with line numbers.
- Open and resume: after the first layout, restore the last position (heading anchor first, fraction fallback) and offer "read from start" in a SnackBar. Do not show a modal on every open (old behavior).
- Write progress only for user scrolls: restore uses
animateTo/scrollToIndex, which also fire theScrollControllerlistener. Confirm the round of scrolling was user-initiated (ScrollStartNotification.dragDetails != null, reset onScrollEndNotification) before persisting. Otherwise opening and immediately leaving overwrites progress with an intermediate animation position (observed: 87% became 55%). - Bookmarks are paragraph-level: record heading + whole-document fraction in
RepoMeta.bookmarks(100 per repo, dedupe within 1% of the same file). The in-article panel can jump/delete. Home shows the five most recent;BookmarksPagegroups all by repository. Opening usesMarkdownViewerPage.initialFraction(lower precedence thaninitialFindQuery, and it skips resume). - Previous/next in the same directory reuse
NoteIndexCache; opening an article must not trigger a recursive scan. If the cache is missing, scan in the background and write it back. - In-text find: the AppBar search icon opens a find bar (Raw mode uses
CodeView's source find). Matches are reported by line in the same coordinate system asTocEntry.line(both based on the post-front-matter / converted body). Jumps currently locate the heading that contains the match; documents without headings scroll by character ratio. Match highlighting is per character viaMarkdownGenerator.textGenerator(_FindHighlightNode), case-insensitive, and only applies to plain-text nodes (not links/code blocks). - Search entry: opening from a body search result passes
initialFindQueryso the find bar opens, counts matches and jumps to the first one. - Recent reading (
RepoMeta.pushRecent/readRecentEntries): recorded per source root, not per repo. Git repos share<reposRoot>/.meta/recents.json; local mirrors must passrecentsRoot: <docs>/localso they share<docs>/local/.meta/recents.json. Entries carry an ISO-8601 timestamp; home merges both roots, sorts newest first and dedupes per source. Never let each local mirror write its own recents file. - Repository reading status: file rows show "read N% / finished" (<0.5% is not
shown); the toolbar's "unread only" filter hides only files that already have
reading records. Read all records at once with
RepoMeta.readAllProgressand refresh when returning from the reader.
- Index cache (
lib/src/notes/note_index.dart): repository file lists are scanned in a background isolate (never recursivelistSyncon the UI thread), cached to<reposRoot>/.meta/index/<repoName>.json, and rendered stale-while-revalidate. Remove the cache when deleting a repo/local folder. - Full-text search (
lib/src/search/): the repo page can switch between filename and body search; the home search icon openslib/src/ui/global_search_page.dartacross repositories and local folders (sequential per source, results stream in per group; local sources are labeled本地 · <name>so they stay distinct from same-named repos). Supports search history (persisted inAppPrefs, max 12; tap to search, ✕ to delete), source filters (multi-select; none = all) and "body / title only" modes. Title-only matches filenames/paths without reading files and does not open with a find query. - Body search reads md/ipynb inside an isolate (max 2 MB per file) and does linear
matching: split the query on whitespace (including full-width spaces), require
all terms (AND), and produce per-term highlight ranges with
SearchSnippet.highlightsfor the UI. Sort order: path matches all terms → match count → path. - Deliberately no inverted index: for Chinese 2-grams the postings are the same
order of magnitude as the text, often larger after serialization, while note repos
hold only a few MB. Page cache +
indexOfis fast and has no index staleness.
- Tags (
lib/src/notes/tag_index.dart,lib/src/ui/tag_page.dart): the home tag icon opens a page aggregating front-mattertagsfrom all repos and local mirrors (reads only the first 8 KB of each file, scans in an isolate). Selecting multiple tags requires ALL of them (AND) across sources. Implicit directory tags are a toggle (default on, top-right of the tag page): each directory level is merged into the tag set, so repos without front matter are still navigable. - Link checking (
lib/src/notes/link_check.dart): repo page "more → check broken links" validates relative links/images in bulk, skipping external URLs, anchors and code blocks, decoding percent-encoding and resolving../. Results are grouped by source file and copyable. Strictly read-only. - Settings (
lib/src/ui/settings/): two-level Material 3 layout. The root has six navigation rows (reading, appearance, repositories, file display, storage/data, about) with current values; each subpage owns 2–4 card groups. Sliders keep a local draft and persist only ononChangeEnd; the fold threshold is disabled while long code folding is off; desktop icon switching is Android-only; every subpage has "restore this page". Shared theme selection is intheme_mode_selector.dart. - Storage page (
lib/src/ui/storage_page.dart,lib/src/utils/dir_size.dart): settings → Storage & data → Storage shows disk usage forrepos,local,imgcacheand.meta(recursive sizes summed in a background isolate). Image cache can be cleared (delete files, keep the directory); the metadata section can clear reading history. Repo/mirror deletion stays on the home cards. - Storage & data settings (
lib/src/ui/settings/data_settings_page.dart): lists pending write-backs with a conflict badge and clears search history; reading history and favorites live under a separate destructive section with counts. Clearing them covers both source roots (<docs>/reposand<docs>/local) and counts come fromRepoMeta.countStars, so local folders are never skipped.
- Local folder reading (
lib/src/local/local_folder_service.dart): Android uses thesafpackage to authorize a tree URI (persisted; no real path, sodart:iocannot read it) and incrementally mirrors it to<docs>/local/<name>, then reusesRepoPageand the whole rendering pipeline — treat it as a repository, do not invent a second content-source abstraction. Desktop usesfile_selectorwith a real path. A.pullread_localmarker distinguishes local sources; "sync" means re-mirror. Mirror only md/ipynb/code/images (skip files >32 MB); append a numeric suffix on name collisions. - Incremental sync compares source-side
(size, mtime)snapshots stored in.pullread_manifest.jsonat the mirror root. - SAF authorization must request write permission
(
pickDirectory(writePermission: true)), otherwise the editor cannot write back. .txtis mirrored and opened as an ordinary code file, with no special case. Text decoding goes throughlib/src/utils/text_encoding.dart(strict UTF-8 first, GBK fallback, pure Dart).- Local Markdown editor (
lib/src/ui/markdown_editor.dart): opened from the reader's "edit" action for mirrored Markdown. Source/preview toggle, manual save, unsaved-changes prompt on exit. Saving writes both the mirror (dart:io) and the original folder (Android SAFwriteFileBytes(overwrite:), resolving each path segment withchild(); desktop writes the real path). Returning to the reader reloads automatically. Repository files remain read-only and are never committed. - If write-back fails, record the file in
.pullread_pending.jsonnext to.pullread_manifest.json. Later incremental syncs skip those files and retry the write-back; never overwrite the mirror with stale source content. Settings → Storage & data → "pending write-backs" lists them, allows retry, or force-overwrites from the mirror.
Clone only needs the full URL the user provides. The GitPlatform enum is for
display and for turning "username/repo" into a URL; business code must never call
platform REST APIs. The old RepositoryAdapter / adapter factory / four platform
adapters were deleted in 2026-10. Adding a platform means adding an enum value and a
guessPlatform case — do not reintroduce an HTTP abstraction layer.
Authentication: tokens, if ever added, stay on device only — never hardcode them or log them. Only public repositories are supported (the user explicitly does not want private-repo support).
The native splash paints only the brand paper color (launch_background plus v31
windowSplashScreenBackground; windowSplashScreenAnimatedIcon points at a
transparent placeholder). It must not draw a logo. The only startup logo is
Flutter's BrandSplash (lib/src/ui/brand_splash.dart), shared by main()'s
bootstrap page and HomePage's first repo-list load so sizes/positions match and the
user sees a single splash.
Hard constraints:
main()must callrunAppimmediately. libgit2/CA initialization happens in_bootstrap()after the first frame; doing it beforerunAppshows a black surface for hundreds of ms to seconds.- All five
activity-aliasentries must useandroid:theme="@style/LaunchTheme"; otherwise a release build (launched from an alias) shows black in dark mode. NormalThememust also use@drawable/launch_backgroundaswindowBackground; Flutter switches to it inonCreate.- Never use
?android:colorBackgroundfor the splash background — it turns black in dark mode. - The home loading state must not use a
CircularProgressIndicator; it would overlap the splash tail and look like a green dot in the middle of the screen. UseBrandSplashinstead. - The launch/normal themes set the status bar and navigation bar to
@color/brand_paperwithwindowLightStatusBar=true. Without this, Android adds a gray scrim behind light status-bar icons, which looks like a flash during startup.
- Desktop icon is selectable in settings (five designs: open book, page-as-arrow,
pull-page arrow, converge-pages arrow, page-stack arrow; pine green on paper
white). Android implements this with five
activity-aliasentries (MainActivityBook/Page/Arrow/Converge/Tray); exactly one is enabled at a time and switching goes throughPackageManager.setComponentEnabledSettingover theapp_iconMethodChannel. Adaptive icon foregrounds aredrawable-xxxhdpi/ic_launcher_fg_*.png(the mark is normalized to 50% of 432px; XML inset must be 0% — ~66% fills the canvas with no padding, ~33% with a 16% inset occupies only about 1/9), with@color/brand_paperbackground and the same paper color for the splash. - iOS/Windows icon switching is not implemented (the iOS alternate-icon API needs
real-device verification; Windows has no such concept). On platforms without the
channel,
setAppIconpersists the preference and silently does nothing. - In-app logo: transparent
assets/icon/app_logo.pngis used in the home header and the settings "about" page. Never use a white-background version in-app (it looks wrong in dark mode). Larger marks are generated losslessly from the SVG sources intemp/icon-design/with the logo-design skill renderer. assets/icon/app_icon.pngandapp_icon_foreground.png(book, 1024px) are theflutter_launcher_iconssources for the iOS AppIcon set and Windowsicon.ico. Setandroid: falsein pubspec before running the tool and restore it afterwards: the tool overwrites the hand-maintainedmipmap-anydpi-v26/ic_launcher.xml(0% inset + foreground) and related default launcher resources. The five alias-specific mipmap/fg files have distinct names and are not touched.assets/icons/holds five opaque desktop icon PNGs (settings preview + launcher mipmap source). Never replace them with transparent versions — Android launcher icons must not be transparent.
- Commits: English Conventional Commits (
feat/fix/chore/docs/test/…), atomic. Push requires explicit user approval. - Temporary files go in
temp/(git-ignored), not the system temp directory. - No TDD/SDD process. When a change touches behavior already covered by tests, update or extend those tests in the same commit.
- On Windows, Python file reads must pass
encoding="utf-8"explicitly. - Comments and UI copy are Chinese; identifiers and commit messages are English.
flutter pub get
flutter run # device/emulator; see the alias note for dev manifests
flutter analyze --fatal-infos
flutter test
# Release APK: arm64 only, fixed command
flutter build apk --split-per-abi --target-platform android-arm64
# Long-document render baseline (real device, profile mode)
flutter drive --profile -d <deviceId> \
--driver=test_driver/integration_test.dart \
--target=integration_test/render_perf_test.dart
# Single size / cleaner memory: add --dart-define=PERF_SIZES=10000APK packaging: only arm64-v8a (mainstream phones). Output is
build/app/outputs/flutter-apk/app-arm64-v8a-release.apk (~32 MB; the fat APK is
~79 MB). Do not use gradle ndk.abiFilters to restrict ABIs — it does not affect
Flutter-injected .so files and conflicts with --split-per-abi. x86_64 emulators
cannot install this APK; use a debug build for emulator work.
- Every change:
flutter analyze --fatal-infosandflutter test. - Git-layer changes: add isolated tests (temp dirs only) and manually reason about data-loss and fast-forward behavior.
- UI changes that depend on gestures, SAF write-back, icon switching or volume keys need real-device verification; widget tests are not enough. Icon switching in particular can only be tested on a release build.
- Startup changes: verify cold start light and dark, and confirm there is exactly one splash image and no green dot.
.github/workflows/ci.yml runs flutter analyze --fatal-infos + flutter test on
PRs and pushes to main, plus a separate build-apk job that builds a debug arm64
APK. Manifest merging, icon resources and plugin compatibility only surface in a real
assemble, so keep that job. Release builds are still triggered only by v* tags via
release.yml.
- Official releases come only from GitHub (
interset-wq/pullread). Push av*tag;.github/workflows/release.ymlvalidates that the tag matches the pubspecversion, builds the arm64 APK and creates the GitHub Release. Never upload an APK manually — it would diverge from the tagged source. - Flow: bump
pubspec.yamlversion → commit → push the matching tag. - Prerelease: a SemVer prerelease suffix (e.g. tag
v1.12.0-rc1, pubspec1.12.0-rc1+21) uses the same build but is marked prerelease on GitHub, meaning it has not passed real-device acceptance yet. Tags without-are stable releases. Version comparison ignores the+buildsuffix. - Actions → Release → Run workflow (
workflow_dispatch) performs a build + signature smoke test only and creates no Release (version validation and publishing are tag-gated). Use it after upgrading actions or the build environment. - Signing: local release builds use a different key from the official APK, so
INSTALL_FAILED_UPDATE_INCOMPATIBLEis expected. For local installs, use a temporaryapplicationIdSuffixand never commit it.
test/widget_test.dartinjects a fakePathProviderPlatformpointing at a temp directory. Never let tests touch the real~/Documents(or any user data directory), and avoid real IO inside fake-async widget tests.- Prefer
pumpwith fixed durations overpumpAndSettlewhen pages contain image decoding or indefinite animations. - Rendering baselines and their definitions live in
docs/perf.md. - The UX audit is reference material, not a binding spec:
docs/user-feedback.md. Settings redesign phases 1–2 are implemented underlib/src/ui/settings/; phase 3 items remain optional and are tracked indocs/settings-redesign.md.
If project structure, build/test commands, architecture boundaries, conventions or any other fact recorded here changes, update this file in the same commit. Outdated instructions are worse than no instructions.