Skip to content

Repository files navigation

Kapy Notes

Offline-first notes where every line is also a live calculator. Write freely; any line that looks like arithmetic is worked out as you type and its result appears in a gutter beside it.

Lisbon trip budget

Flights for two
flights = 412 eur              412.00 EUR
flights to usd                 478.29 USD

Food and getting around        // rough guess
daily = 55 eur                  55.00 EUR
daily * 7                      385.00 EUR

total                        2,288.00 EUR

One Flutter codebase, four platforms: macOS, Windows, iOS, Android.

Running it

flutter pub get
flutter run -d macos      # or: windows, <ios device>, <android device>
flutter test

Built on Flutter 3.47.2 against the standalone material_ui package rather than package:flutter/material.dart. Material and Cupertino moved out of the SDK in 3.44 and the in-SDK libraries are formally deprecated in the November 2026 release, so this is where new code belongs; it also means design fixes arrive on the package's own schedule instead of the quarterly SDK train. cupertino_ui comes in transitively and backs the .adaptive widgets — there is no direct import, so it is not listed as a dependency.

Verified on Flutter 3.47.2. Android needs JDK 17+ (flutter config --jdk-dir) and SDK platform 36; iOS needs Xcode's iOS platform installed (xcodebuild -downloadPlatform iOS). Goldens under test/goldens/ render with macOS system fonts — regenerate them on macOS with flutter test --update-goldens.

Releasing

Desktop releases are cut by tagging. The tag must match version: in pubspec.yaml, and a job checks that before anything expensive runs:

git tag v1.0.1 && git push origin v1.0.1

.github/workflows/desktop-release.yml then builds a notarised DMG on a macOS runner and the Inno Setup installer on a Windows one, uploads both to the kapynotes R2 bucket under downloads/, and cuts a GitHub Release. The landing page pins both filenames, so bump them in website/src/pages/index.astro and redeploy — the release job prints this reminder in its summary.

Tags rather than pushes because the artifacts are named after the version and served immutable for a year: republishing one filename would leave Cloudflare edges serving the old bytes indefinitely.

.github/workflows/desktop-ci.yml runs on every push instead, building both desktop platforms unsigned and keeping them as artifacts. Windows has to be built there — flutter build windows needs a Windows host with Visual Studio's C++ workload, and macOS does not offer the subcommand at all. Note the billing multipliers on a private repo: Windows 2x, macOS 10x.

Local builds still work exactly as before:

packaging/release.sh mac-direct    # notarised DMG, using the keychain profile
packaging/release.sh mac-unsigned  # same image, no certificate; what CI builds
packaging/release.sh ios           # .ipa for App Store Connect
packaging/preflight_ios.sh --submission  # complete iOS readiness check

The app ships as Kapy Notes under bundle ID com.kapybara.kapynotes, signed by Kapybara LLC (team 96V66447C6). SKIP_NOTARIZE=1 builds a DMG without contacting Apple, for local testing. packaging/SUBMISSION.md covers the certificates, the notarisation credential and the App Store metadata.

The DMG window layout

Finder writes the installer window's background and icon positions into a .DS_Store, and scripting Finder needs a desktop session no CI runner has. So the layout is captured once and committed as packaging/dmg/DS_Store — without the leading dot, which .gitignore would swallow. build_disk_image copies it in and refuses to build if it is missing, rather than quietly shipping Finder's default window.

Regenerate it after changing the window geometry constants in release.sh or the artwork, then commit the result:

packaging/release.sh dmg-template

The installer's artwork

The Windows wizard would otherwise wear Inno Setup's own pictures. Two replace them, both committed under packaging/windows/: wizard-small.png is the mark in the top right of every page, and wizard-image.png the tall panel down the left of the Setup Completed page. The .exe's icon is separate again, and already the app's — that is SetupIconFile.

They are drawn by AppKit, from the same palette and the same waving Kapy as the disk image, so the two downloads read as one product. Which also means they cannot be drawn on the Windows runner that builds the installer, so the output is committed and iscc only ever reads it:

swift tool/generate_windows_installer_art.swift

Kapy himself comes from design/mascot/ in the kapynotes repository beside this one; the script fails rather than draw the panel without him. Both images are written at Inno's 250% DPI sizes — 534x1022 and 159x159 — and Inno scales them down, as far as 202x386 on a 100% DPI screen. The small image keeps its own transparent inset because Inno anchors the image control to the top-right edge of the window. The panel carries no body copy: a tagline survives the scaling at about eight pixels tall.

The Welcome page, which would show the panel at the start rather than the end, is off by default in Inno 6 and left that way. It is a page whose only job is to be looked at, and skipping it is one less click.

In-app updates

Desktop builds schedule a quiet check of dl.kapynotes.com/latest.json five seconds after launch and resume. The checker makes a network request at most once every 24 hours after a successful response; failures retry after two hours. The check is only a few hundred bytes of JSON.

When it finds a newer release, the app downloads it in the background — the Download updates automatically switch in Settings → Updates, on unless turned off — and checks its signature. Nothing is asked of the user until then. Once it is ready, an Update and restart row appears in the notes list above Settings, and that one click installs the release and relaunches into it. With the switch off, Settings offers Download instead, and the same row appears when that finishes. A failed download says why in Settings and tries again two hours later.

UpdateChecker in lib/data/update_checker.dart owns all of that; the platform half is an UpdateInstaller:

  • macOS: Sparkle, driven directly by macos/Runner/AppUpdater.swift over the kapynotes/updater channel. Sparkle's own scheduler stays off, because it puts its update panel on screen the moment it finds something. Instead the app calls checkForUpdatesInBackground with automatic downloads allowed, which runs Sparkle's automatic driver: it downloads, verifies and prepares the release with no window, then hands the app an immediateInstallationBlock through willInstallUpdateOnQuit. Update and restart invokes that block, and Sparkle installs and relaunches without showing anything. A prepared release also installs whenever the app quits, clicked or not. Automatic downloads need SUAllowsAutomaticUpdates in Info.plist: with automatic checks off, Sparkle refuses them otherwise and falls back to its panel.
  • Windows: the app itself. WinSparkle cannot download without its own window on screen, so WindowsUpdateInstaller does what it did, quietly: it downloads the installer named in latest.json's windows block into %LOCALAPPDATA%\com.kapybara\Kapy Notes\Updates (resuming a download that was cut off), checks the same DSA signature WinSparkle checked against the key compiled into the app (lib/data/update_signature.dart), and on the click runs it with WinSparkle's arguments and quits.

There are two appcasts because the two frameworks disagree about what sparkle:version means — Sparkle compares it against CFBundleVersion (the +N half of pubspec's version), WinSparkle against the ProductVersion string in windows/runner/Runner.rc. The Windows one is only read by builds from before the app downloaded its own updates, which still update through WinSparkle; keep publishing it until nobody runs those. The release job writes both, plus latest.json, with a five-minute cache header; they are the only mutable objects in the bucket.

Each appcast and the in-app update notice point a release at its own kapynotes.com/changelog/<version> page. WinSparkle, in those older builds, embeds that page in its update dialog, so it shows only what changes in the version being offered. That focused view uses the release's short highlights; Settings keeps the complete changes for the browsable history. The release job refuses to publish a feed until that page is live.

Because macOS compares build numbers, a release that forgets to bump +N would tell every Mac it is already current. The verify job fails the release rather than let that ship.

Signing keys — already set up. Both releases are signed and the public halves are compiled into the app, so a hijacked feed cannot ship a payload. The EdDSA public key is SUPublicEDKey in macos/Runner/Info.plist; the DSA one is windows/runner/resources/dsa_pub.pem, copied into lib/data/update_signature.dart — a test fails if the two ever differ, and another checks the verifier against the signature 1.28.0 actually shipped with. Their private halves are the SPARKLE_ED_PRIVATE_KEY and WINSPARKLE_DSA_PRIVATE_KEY repository secrets, and the release job fails loudly if either is missing.

The Sparkle private key also lives in the login keychain of the Mac it was generated on, which is the only copy that can be re-exported. Print the public key any time to check the plist still matches:

macos/Pods/Sparkle/bin/generate_keys -p        # needs `flutter build macos` first
macos/Pods/Sparkle/bin/generate_keys -x key.txt  # re-export for a new CI secret

Note sign_update's -s flag is deprecated and now fails; the release job uses --ed-key-file. The Windows key is plain OpenSSL DSA and can be regenerated anywhere (then copy the new public key into lib/data/update_signature.dart too):

openssl dsaparam -out dsaparam.pem 2048
openssl gendsa -out dsa_priv.pem dsaparam.pem
openssl dsa -in dsa_priv.pem -pubout -out windows/runner/resources/dsa_pub.pem

Rotating either key means the release after it cannot be installed by anyone still running an older build — their copy only trusts the key it shipped with.

The macOS app is sandboxed, which forbids it from replacing its own bundle, so installation goes through Sparkle's Installer.xpc. That needs SUEnableInstallerLauncherService in Info.plist and the two mach-lookup.global-name temporary exceptions in Runner/*.entitlements — remove either and updates fail at install time, after the download.

On Windows, the app runs the Inno installer with /VERYSILENT, as WinSparkle did, which skips the [Run] entry the Setup Completed checkbox lives on; a second [Run] entry guarded by Check: WizardSilent brings the app back instead. It cannot be left to Restart Manager, whose restart only reaches applications that called RegisterApplicationRestart. The install is per-user, so it raises no UAC prompt — but see below for what SmartScreen still does.

Closing the running copy is the app's job, not just the installer's. Setup uses Restart Manager, which asks a window to end with WM_QUERYENDSESSION, then WM_ENDSESSION, and only falls back to WM_CLOSE for an app still running after both. Flutter's runner answers none of the first two, and with "keep running in the background" on — which is the default on desktop — window_manager answers the third with -1 and the app hides to the tray. So the window vanished, the process survived, its DLLs stayed locked and the install failed on them.

windows/runner/flutter_window.cpp now answers all three. WM_QUERYENDSESSION returns TRUE and nothing else, because Windows asks every application before it tells any of them to go and one of the others can still call it off. WM_ENDSESSION is the one that means it: it sends kapynotes/system_shutdown to Dart, where DesktopIntegration.quit() does the same orderly exit the tray's Quit does. The WM_CLOSE that follows is swallowed rather than acted on — that quit is already writing, and it ends by destroying the window itself. If it has not in ten seconds the runner leaves anyway, and CloseApplications=force lets Setup terminate a copy that still has not, which is what every build older than this one is.

Staying signed in across an update

An update replaces the app, not the account. The session token and the master key live in the platform keystore — Keychain on macOS, and on Windows a DPAPI-sealed file under %APPDATA%\com.kapybara\Kapy Notes, a path derived from the CompanyName and ProductName in windows/runner/Runner.rc and so stable across versions. The Inno installer only ever writes to %LOCALAPPDATA%\Programs\Kapy Notes, and Sparkle only swaps the bundle, so neither goes near either store.

What did sign people out was the app itself. Account.restore asked the server who the stored token belonged to and read every failure as a revocation — offline, DNS, a timeout, a 502 — and deleted the master key on the way out. So a launch with no network came back signed out and asking for a passphrase, and an update is exactly when that happens: the installer relaunches the app the instant it lets go, and the app opens at login by default, often before the network is up.

AuthApi.checkSession now answers with three states rather than two, and only SessionRejected — the server actually saying no — clears anything. SessionUnreachable keeps the session and carries on. That needs the account to be known offline, so the signed-in user is cached beside the token in the keystore and cleared with it; without that, a session restored offline would be valid and belong to nobody.

Windows signing

The installer is not Authenticode-signed — there is no certificate yet — so SmartScreen shows "unknown publisher", and because reputation attaches to the certificate rather than the file, that will not improve across releases. Buying one needs a cloud HSM (e.g. Azure Trusted Signing) to sign from CI, since code-signing keys must now live on certified hardware.

Builds that still update through WinSparkle run that unsigned installer, and SmartScreen can warn each time. The app's own updater writes the installer itself, so it carries no Mark of the Web, and starts it directly rather than through the shell, so SmartScreen should have nothing to say about it — but that has not yet been tried on a real Windows machine. macOS has no equivalent problem — the DMG is Developer ID signed and notarised.

How it works

The calculation engine (lib/calc/)

A purpose-built expression engine rather than a general maths library, because the interesting behaviour is in the natural phrasing, the unit algebra and the refusal to guess.

File Role
lexer.dart Text → tokens. Absorbs grouped and radix numbers, currency symbols, quoted annotations and line comments.
parser.dart Tokens → AST. Recursive descent; of/off/on/to/in/per are operators.
evaluator.dart AST → value, against a scope that carries down the note.
unit.dart, unit_registry.dart Dimensional algebra over ~110 units plus live currencies.
temporal.dart Strict wall-clock and named-time-zone expressions that stay out of ordinary prose.
notation.dart Binary, octal, hexadecimal and scientific result formats.
engine.dart Runs a whole note top to bottom.
format.dart Compact display text and full-precision copy text.
highlight.dart Re-runs the lexer to colour the note.

Percentages are a value type, not a text rewrite. 20% evaluates to a PercentValue, so 1250 + 8% means "add 8 percent of 1250" while 0.08 + 1250 still means what it says. The same rule gives 20% of 80, 20% off 50 and 25 as a % of 200 without any of them being special-cased in a regular expression.

Units carry dimensions. 100 km / 2 h produces 50 km/h because the unit is a product of exponents, not a label. Conversions are offset-aware, so 100 degC to degF is 212 °F, while 20 degC + 5 degC treats the right side as a difference and gives 25 °C.

A line that does not parse produces nothing. Someone mid-sentence is not someone with a syntax error, and "I have 3 apples" must not render a result. Lines are only attempted when they carry an arithmetic signal, and anything that fails to parse is left as plain text.

A colon is an explicit label boundary. The description on its left is never evaluated, even when it contains model numbers or other amounts. Thus 7KvA Solar System : 12000rs is exactly 12,000.00 INR, and nested labels use the rightmost colon. Compact clock times and ratios such as 12:30 and 1:2 remain punctuation rather than labels. Highlighting follows the same rule, so numbers inside the description stay visually quiet.

A label followed by a marked amount is a value, even without a colon. Coffee $4.50, Lunch 12 usd and Run 5 km each read as the amount and join the running total, so a budget needs no = on every line. The marker carries the whole rule: a bare trailing number is refused, because Lunch 12 and Room 12 are the same shape and nothing in the text separates them. Writing a currency or a unit is the user saying which one they meant. This is tried only after the whole line has failed to parse, so 100 km / 2 h is still a division.

A number may say what it counts, and still be a number. 20 domains, 20 domains * 2, 3 users * $10 and $2/mailbox all give the amount, because a word the calculator has no meaning for is a label rather than an error. The labels are taken out and what is left is evaluated as ordinary arithmetic.

Two rules keep that from swallowing prose, and both are about position. The line has to open with something the calculator understands, which is what separates 20 domains from I have 3 apples and Room 12. And a word only counts as a label directly after an amount, or after another label — which is what separates 12 mangoes, where the word is what the number counts, from 10 min break, where the amount has already said what it is and everything after it is prose. A word after an operator is an operand, so 12 + mangoes stays a line still being typed. $2/mailbox is the one shape that needs more: the label is what the rate is per, so the divide goes with it — while $120 / 3 months, whose denominator means something, stays a rate.

x between two amounts multiplies. 3 x 4, 1920 x 1080, 2 x 3 widgets. Only between two amounts, and never in a note that has assigned x itself, because x is also the first name anyone gives a variable.

Running scope. A variable assigned on one line is available below it, and prev, sum, total and avg accumulate as the note is read downward. A line that is only total reports the total without adding itself to it.

The compact language also includes natural operators (plus, without, times, divided by), implicit parenthesis multiplication, bitwise operations, base literals, written number scales, bracketless functions, factorials, mixed measurements, square and cubic units, case-sensitive SI and data prefixes, CSS px/pt/em with configurable ppi, reverse percentage questions such as 5% on what is 105, calendar arithmetic, Unix timestamps, and DST-aware time-zone conversion. Quoted text is an inline annotation, so $275 "Model 227" calculates the price without treating 227 as another value.

The editor (lib/ui/editor/)

Flutter can style a text field's content directly, so this is one real, editable, syntax-coloured TextField — not the transparent-textarea-over-a- mirrored-div stack a browser forces on you.

Results are aligned by laying the note out a second time with the same width, style and strut the field uses, and reading each line's offset from it. The gutter and the field share one ScrollController. That combination is what keeps a result pinned to its line through wrapping, scrolling and text scaling — and it is asserted directly in test/note_editor_test.dart, which compares chip positions against the field's own RenderEditable geometry.

Clicking a result copies it at full precision.

Typed and pasted web addresses stay ordinary note text, but appear as links. Tap one on touch devices, or Command-click on Apple platforms and Control-click elsewhere, to open it. Selecting or long-pressing a link exposes a dedicated Copy Link action while leaving the platform's normal text copy and paste intact.

Currency codes can sit directly beside an amount, such as 10usd or 10eur; rs is accepted as an INR shorthand, so 10rs and 10inr are equivalent. Lines that start with // are treated and styled as quiet comments.

Markdown in notes (Settings → General, off by default) reads a note as CommonMark with GitHub's tables, strikethrough and task lists. The parser is dart_markdown, the one Dart parser that reports where every node and marker sits in the source, which is what styling an editable field needs. A note is drawn the way GitHub renders one, and edited in place like a word processor's page: the markers stay in the text but are hidden, so a heading is large and bold with no # in sight, a list has bullets and checkboxes that tick with a tap, code sits on a tinted panel, a table is a grid, and a link is just its words. Inline markers show, quietly, only while the caret has been moved against them (** either side of a bold word, a link's address), and a code block's fences or a table's pipes only while the caret is in it. The structure at the start of a line (## , > , - [ ] ) is never shown: the caret steps over it, Backspace at the start of the words takes the formatting off, and # , - , 1. , > or [] typed at the start of a line make one. Bold switched on with nothing selected applies to the next word typed, Enter continues lists and quotes, and the formatting buttons and shortcuts write markdown rather than styles kept beside the text. Nothing in a note is converted either way, and off, the editor is exactly what it was.

Typing / at the beginning of a line opens the searchable insert menu. It can make headings, lists, quotes, dividers and code blocks, or enter the existing image, video and voice-note flows. Table opens a compact grid picker and writes a plain GFM table in one undoable edit, with its first header ready to replace. Markdown-only commands say so and ask before enabling Markdown for every note; they never change that preference silently. On touch devices the footer's + opens the same menu. A slash anywhere else stays ordinary text, so division and web addresses never compete with commands.

Hidden markers are laid out at a size too small to take room or be seen, which the editor's fixed line height keeps from moving the line they sit on; bullets, boxes and table grids are painted behind the field (markdown_backdrop.dart) in the room their own characters keep, so a click still lands where it looks as though it does.

The calculator reads a markdown note the way it is drawn. A list marker is a bullet, not a minus sign or a running tally; code blocks and struck-through text are not calculated; emphasis is read for its words, so **5 + 3** is 8; and a * between two operands is always multiplication, never emphasis, so 2*3*4 and (2+3)*4*(5) stay arithmetic. A long note is parsed in pieces the parser is guaranteed to read the same way on their own, and only the piece that changed is read again (markdown_syntax.dart); test/markdown_syntax_test.dart checks the pieces against a whole-note parse over thousands of generated notes and edits.

Daily separator timestamps follow the time zone selected in Settings. The default follows the device, while an explicit city uses bundled IANA rules so day boundaries and daylight-saving changes are based on the original edit instant. Existing separators are plain note text and are not rewritten when the setting changes.

The sidebar uses that same time zone for each note's compact updated date and time. Notes are kept newest-updated-first on load and move to the top as soon as they are edited. During a search, the timestamp is temporarily replaced by the matching line so body-only results still have context.

Split view (lib/data/editor_workspace.dart, lib/ui/editor_panes.dart)

A desktop-width window can put up to three notes side by side. Each pane holds exactly one note, and a note is only ever open in one pane: choosing a note that is already on screen focuses its pane rather than opening it twice. There are no tabs piling up behind a pane.

  • The split button in the title bar (⌘\ or Ctrl+\) opens an empty pane beside the focused note, and the next note chosen from the list goes into it.
  • A note's menu in the list has Open to the Side; Option-click (Alt-click) does the same.
  • A note dragged from the list, or a pane dragged by its title bar, lands beside a pane when dropped on its outer part and in that pane's place when dropped in its middle, swapping with it if the note was already open. The highlight says which before anything moves. With three panes open there is no room beside, so an edge means the pane itself.
  • ⌘1, ⌘2 and ⌘3 (Ctrl on Windows and Linux) focus a pane and ⌘W closes the focused one. All of them can be changed under Settings › Shortcuts › Split view. Closing a pane never archives or deletes its note.

A single pane has no title bar, so the editor looks exactly as it did before panes existed. Panes share the row evenly whenever one opens or closes; dragging a divider resizes the two panes beside it, and double-clicking one evens them all out again. The panes, their widths and which one is focused come back at the next launch; an empty pane does not. A window too narrow for the notes list beside a note shows only the focused pane's note, and the rest return when it widens. Only the focused pane reports presence in a shared note, and a recording is delivered before a change would take its note off the screen.

Storage (lib/data/)

Everything is one JSON file in the platform's application-support directory. No account, no sync, no server.

On iOS and Android, disk lookup is not on the launch critical path. The first Flutter frame is a focused plain-text capture field with no calculator, theme, font, image, or network setup ahead of it. Saved notes hydrate in the background. If someone types before hydration finishes, that text becomes a normal new note during the handoff, without dropping focus or the keyboard.

Writes are coalesced into a 250 ms window and forced out whenever the app is backgrounded or asked to exit, with a write-then-rename so a crash mid-write cannot truncate the file. The in-memory copy is always current, so nothing on screen ever waits for the disk. Large JSON reads and all JSON writes are moved off the UI isolate so a long note history cannot interrupt typing.

Exchange rates are the only networked feature. The cached snapshot is published before the full calculator mounts, so currency maths is available as soon as hydration completes and keeps working offline; a failed refresh changes nothing. Refreshes use Frankfurter's v2 USD rates first and try the ExchangeRate-API open endpoint only when the primary response fails validation. Snapshots are never combined, and their source is cached for accurate attribution. Network client creation and stale refreshes wait until after the editor is ready. Rates then refresh on launch, resume, and every six hours while the app stays open. With no rates ever fetched, currency lines simply stay plain text.

Notable deviations from the original spec

The spec described an Electron build. These changed for Flutter:

Spec Here Why
mathjs Purpose-built engine No Dart equivalent with unit support, and percentages want to be a value type.
Layered textarea + mirrored div One TextField with a custom controller Flutter styles editable text natively.
Separate highlight tokenizer The parser's own lexer Colouring cannot drift from evaluation if it is the same code.
Regex preprocess() Lexer and parser max(1,250) and 1,250 need context to tell apart; a regex cannot.
localStorage, write per keystroke JSON file, coalesced writes A synchronous disk write per keystroke is the wrong trade off the web.
URL ?note=<id> Desktop selection plus mobile edit recency Restores the right note across native app restarts.
React Query RatesRepository One cached resource does not need a query layer.
Two-pane only Desktop two-pane; mobile editor with a notes drawer Note taking stays one tap away on a phone.

Platform notes

  • macOS — hidden title bar with the traffic lights inset into the sidebar; the toolbar's inert stretch is the window drag region. Sandboxed, with network.client added for the rate refresh.

  • Windows / Linux — native caption retained.

  • iOS / Android: accepts text on the first Flutter frame, then opens the most recently edited note when no launch text was entered. A launch draft becomes a new note, unless the app was opened from a widget, which carries it into the note being written instead. Search and the full note list are built only when the hamburger drawer first opens; the gutter is fixed-width and the divider is hidden.

  • The widgets (iOS / Android) — two of them, offering three actions: Write, Dictate and Capture. The square widget is one cell and one action, chosen when it is placed and changed afterwards — iOS through Edit Widget, Android through a small configuration screen — and defaulting to Write, which is what the widget did when Write was all it could do. The wide widget is three cells with all three actions side by side, one tap target each. The square one is also offered to the Lock Screen, and on iOS 18 each action is a control for Control Centre and the Action button.

    All of them show an icon and a word and deliberately no note text: a widget that previewed what somebody wrote would have to read the note store, keep itself refreshed against a system budget, and show that writing to whoever picks up a locked phone. Showing only the action costs none of that, and means a widget is drawn once and then left alone.

    Every action opens the last note at its end, keyboard up, rather than making a new one — a way back into the notebook, not a way to fill it with one-line fragments, and not something that spends a plan's note allowance on a mis-tap. What each adds on arrival is the rest of it: Capture opens the image picker over that note, and Dictate will start a recording in it once there is a recorder to start (see docs/voice-notes.md in the monorepo — until then a Dictate tap is a Write tap, and HomePage._startVoiceRecording is the one place it will be filled in).

    The platform reports which action a launch came through over a single method channel, kapynotes/quick_capture, and answers once: whoever asks first gets it. Dart asks on the way up, and again on every resume, so a tap that reaches an app already running is acted on too. The only Dart that knows is lib/core/quick_capture.dart; everything else about the launch is unchanged. Android names its own intent action per widget; iOS opens kapynotes://write, kapynotes://dictate or kapynotes://capture and the scene delegate parks it.

  • Every platform: Ready to type on open is on by default. The latest note opens focused on a fresh line, and returning to the app starts another append position without saving empty lines. It can be disabled in Settings › General.

  • Shortcuts: ⌘/Ctrl N new note, ⌘/Ctrl F search, ⌘/Ctrl S toggle sidebar, ⇧⌘⌫ / Ctrl+Shift+Delete archive the note. Two more are registered with the OS and answer from inside any other app — which also means every other app loses them while this one runs, so they sit on chords little else uses: ⇧⌥⌘X / Alt+Shift+X summons the window, ⇧⌥⌘N / Alt+Shift+N summons it onto a blank note. All of them are rebindable in Settings › Shortcuts.

  • Closing the window quits on Windows and leaves the process running on macOS, each platform's own convention. Keep running in the tray (Settings › General, off by default) makes both hide to a tray icon instead, so the system-wide shortcuts go on answering. The icon carries Open, New Note and Quit — the only way out of an app whose close button no longer closes it — and exists only while the setting does. Windows takes a single-instance lock to go with it: an app that looks shut is one whose desktop icon gets clicked a second time, and two copies share one notes file.

Window chrome

macOS hides the title bar so the toolbar can act as window chrome, which means the OS draws the traffic lights straight over whatever is in the window's top-left corner. Which widget that is depends on state: the sidebar when it is showing, the toolbar when it is collapsed. WindowChrome owns the geometry and each pane asks for the inset it needs — the sidebar is tall enough to start below the buttons, while a one-row toolbar has to step around them.

On a touch device the same corner belongs to the status bar and the bottom edge to the home indicator. The compact layout's Scaffold handles its top chrome, while the editor folds the bottom inset into the padding the text and the results gutter share — insetting only one would pull them apart.

Two alignment traps

Both were caught on a real device after the widget tests passed, and both now have tests of their own.

TextField merges the style you give it over the Material text theme, so any metric property you leave unset — letterSpacing especially — is inherited. The field then renders wider than the same string measured with your style alone, wraps somewhere else, and every result below the wrap sits on the wrong line. EditorMetrics.textStyle pins all of them.

RenderEditable also wraps text inside width - (1px + cursorWidth), holding that sliver back for the caret. It is smaller than one monospace glyph, so it only bites for lines that land in the gap — which is exactly what makes it easy to ship. EditorMetrics.textLayoutWidth applies it to both sides.

About

Offline-first notes where every line is also a live calculator.

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages