Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802eab4ef9 | ||
|
|
3b4fe310b8 | ||
|
|
4149229988 | ||
|
|
3829d52e4d | ||
|
|
c614e6859a | ||
|
|
d682ae026d | ||
|
|
a682d6d225 | ||
|
|
42db4cde6b | ||
|
|
82aa5ba7b6 | ||
|
|
d9f755b128 | ||
|
|
d6757fc0fc | ||
|
|
b019172d47 | ||
|
|
888c6410f0 | ||
|
|
e75e3d37d0 | ||
|
|
05c82c2362 | ||
|
|
646a115701 | ||
|
|
b8f13cfc4a | ||
|
|
e2e0740b06 | ||
|
|
7eacd0569c | ||
|
|
fd1d50662f | ||
|
|
442cca4398 | ||
|
|
5f3cfe8bb7 | ||
|
|
2e2714a50b | ||
|
|
4f5459cb94 | ||
|
|
89cd1c76bb | ||
|
|
63955bbe97 | ||
|
|
5e8c6dc7bf | ||
|
|
2e7db21b21 | ||
|
|
aa36b43dc3 | ||
|
|
75928c7afd | ||
|
|
2e2d8667dd | ||
|
|
ca242e59a6 | ||
|
|
f53d377766 | ||
|
|
f100e5ef85 | ||
|
|
3dd0b44cb9 | ||
|
|
28fa8badcb | ||
|
|
df85535ea2 | ||
|
|
5989ffc1c6 | ||
|
|
ac4427f834 | ||
|
|
5b11490858 | ||
|
|
2b2ceaa82e | ||
|
|
efb141e555 | ||
|
|
09239ee3c7 | ||
|
|
38da5160a0 | ||
|
|
535331c5b2 | ||
|
|
af0389ed13 | ||
|
|
8db9f3685b | ||
|
|
86409e02b0 | ||
|
|
1a2f71e381 | ||
|
|
4d61b34b85 | ||
|
|
be200641cd | ||
|
|
732fd7a827 | ||
|
|
7bc8e04518 | ||
|
|
c5f93cf9f1 | ||
|
|
6d082b2ad8 | ||
|
|
81cd719327 | ||
|
|
256fba3610 | ||
|
|
fe6f0746b2 | ||
|
|
a706644455 | ||
|
|
f806e35d41 | ||
|
|
fb469ed73a | ||
|
|
72968897ab | ||
|
|
53d51ce01c | ||
|
|
cf2854a029 | ||
|
|
62338bb0a4 | ||
|
|
1a41373347 | ||
|
|
729d0dadf1 | ||
|
|
23a61365da | ||
|
|
6c0153be1e | ||
|
|
10ea15bef0 | ||
|
|
42e06da576 | ||
|
|
c8318c323a | ||
|
|
cc50812a86 | ||
|
|
1e54b80f15 | ||
|
|
550a34d8e2 | ||
|
|
193dfb9e94 | ||
|
|
8c7553d619 | ||
|
|
d838b27518 | ||
|
|
a69159e562 | ||
|
|
c40916699b | ||
|
|
ef418a8c92 | ||
|
|
fd1e4ae487 | ||
|
|
8a75e5f340 | ||
|
|
d2f9d316cf | ||
|
|
ff6e99eb62 | ||
|
|
ef8aa9340f | ||
|
|
f992439588 | ||
|
|
544cf72735 | ||
|
|
6e524ec616 | ||
|
|
c2fdc05e5c | ||
|
|
fa43c2f4e9 | ||
|
|
a0c789b3ba | ||
|
|
22a9a279b1 | ||
|
|
0ab7d94294 | ||
|
|
6e891357ff | ||
|
|
85ead4d66b | ||
|
|
c5044339a1 | ||
|
|
c268ae4f23 | ||
|
|
b7e0e5dbba | ||
|
|
e14d9d340a | ||
|
|
fa89da1fab | ||
|
|
13a88179b8 | ||
|
|
b91091caca | ||
|
|
f50204a98b | ||
|
|
1a49ae7ea9 | ||
|
|
e7af7a4b77 | ||
|
|
396e91e609 | ||
|
|
3f0eef145b | ||
|
|
4f351c10ca | ||
|
|
d9e5753dc2 | ||
|
|
8c22425e91 | ||
|
|
9810a75564 | ||
|
|
606e345580 | ||
|
|
ad48d30c68 | ||
|
|
23fd2da91e | ||
|
|
3d490bb6f3 | ||
|
|
86f1e4a08f | ||
|
|
c255b170d4 | ||
|
|
1d3cc7bcd4 | ||
|
|
ae2053d2ed | ||
|
|
47f108c9c8 | ||
|
|
fe18aaa956 | ||
|
|
20e9d535de | ||
|
|
988e1d3f00 | ||
|
|
6fbee27f9c | ||
|
|
cddaf35280 | ||
|
|
6f173b166b | ||
|
|
f92a3d0a99 | ||
|
|
1e2b42af25 | ||
|
|
a48b034a94 | ||
|
|
ee47a61270 | ||
|
|
68f851110f | ||
|
|
96a6f6e691 | ||
|
|
44b3bcb2b2 | ||
|
|
a45a44ef11 | ||
|
|
56264a9220 | ||
|
|
a88f7c2dd0 | ||
|
|
32ec29fc4a | ||
|
|
ae17b8a8e7 | ||
|
|
eeca4d48c2 | ||
|
|
b2435d97b6 | ||
|
|
9a3c4ec377 | ||
|
|
315c5f19e6 | ||
|
|
1a66d9c3a8 | ||
|
|
77b1a87712 | ||
|
|
68b2a5dc8d | ||
|
|
3cab054684 | ||
|
|
fe1f72ae1b | ||
|
|
761c3b5e82 | ||
|
|
32dafca148 | ||
|
|
668f7faf03 | ||
|
|
d0e3e48943 | ||
|
|
1045db318b | ||
|
|
65af37d159 | ||
|
|
8257e1035c | ||
|
|
bca9e16bd0 | ||
|
|
9ea2a2f9b6 | ||
|
|
ce6a1093a3 | ||
|
|
2707054563 | ||
|
|
24685556b7 | ||
|
|
50e2d308ea | ||
|
|
77c5422951 | ||
|
|
c851b901df | ||
|
|
abe01da5f7 | ||
|
|
09b5f874b6 | ||
|
|
a85c53ba2c | ||
|
|
2141a0ac45 | ||
|
|
1aca294b95 | ||
|
|
7033995975 | ||
|
|
de72d27bd4 | ||
|
|
c99cbb3e14 | ||
|
|
6f21db85a1 | ||
|
|
924ddb20db | ||
|
|
95aa10c2c3 | ||
|
|
6d778f26a7 | ||
|
|
33e9278975 | ||
|
|
c46a4a7709 | ||
|
|
229076c82d | ||
|
|
ad21eac5bc | ||
|
|
bc22f8e249 | ||
|
|
982d24c83b | ||
|
|
bacedea8a3 | ||
|
|
b6152ec18b | ||
|
|
16f86bef93 | ||
|
|
81695fa0c8 | ||
|
|
0cf77336d4 | ||
|
|
010e9a2f85 | ||
|
|
43ebb6eceb | ||
|
|
e6da720e6b | ||
|
|
d77a79859c | ||
|
|
6589be2b0f | ||
|
|
cae9888eb9 | ||
|
|
d0a9c73bf9 | ||
|
|
f38864088b | ||
|
|
8f13dc2e2c | ||
|
|
785ebdba59 | ||
|
|
39170b715c | ||
|
|
5680f046e3 | ||
|
|
452c66c8ef | ||
|
|
64542ed6cb | ||
|
|
65d8f5f9c6 | ||
|
|
750d11d32e | ||
|
|
cf0ce382a0 | ||
|
|
64e016f32d | ||
|
|
eb3dc3d893 | ||
|
|
c8af808432 | ||
|
|
5eab2dd0b3 | ||
|
|
dee71dffb3 | ||
|
|
5d0de7a682 | ||
|
|
3d3df1beb0 | ||
|
|
f179928c57 | ||
|
|
20907abf6e | ||
|
|
e7937ea87e | ||
|
|
b3309e29f8 | ||
|
|
f90b9203a7 | ||
|
|
9f981ca47e | ||
|
|
e696b23417 | ||
|
|
0a7480cf9b | ||
|
|
c28f2bc00e | ||
|
|
40cb463be7 | ||
|
|
641999de58 | ||
|
|
c8c8ec4b4e | ||
|
|
67b9ea2938 | ||
|
|
18a58fb5da | ||
|
|
e8d6a4f423 | ||
|
|
1f140c7457 | ||
|
|
be0eb94225 | ||
|
|
f5837cd985 | ||
|
|
e7ee16c6cf | ||
|
|
d6646a64fb | ||
|
|
3a1496e5fa | ||
|
|
659237ccc6 | ||
|
|
c883fd2eb6 | ||
|
|
5c1ae574f6 | ||
|
|
2cfe049f9c | ||
|
|
edf52da97f | ||
|
|
c1464228df | ||
|
|
505904b1e5 | ||
|
|
13e48672c0 | ||
|
|
8b6dfab3a7 | ||
|
|
d8b0cd9b96 | ||
|
|
6f47af8d96 | ||
|
|
acff95f920 | ||
|
|
3ca3eba6d5 |
+33
-4
@@ -1,4 +1,4 @@
|
|||||||
# ThoughtSync production settings. Copy to `.env` and edit:
|
# Inkwell production settings. Copy to `.env` and edit:
|
||||||
#
|
#
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
#
|
#
|
||||||
@@ -29,20 +29,49 @@ POSTGRES_PASSWORD=
|
|||||||
#
|
#
|
||||||
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
|
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
|
||||||
# check which branch it actually landed on before assuming a bug.
|
# check which branch it actually landed on before assuming a bug.
|
||||||
#THOUGHTSYNC_TAG=latest
|
#INKWELL_TAG=latest
|
||||||
|
|
||||||
# The host port the app is published on.
|
# The host port the app is published on.
|
||||||
#THOUGHTSYNC_PORT=5000
|
#INKWELL_PORT=5000
|
||||||
|
|
||||||
# Which interface to bind. The default (all interfaces) is what lets desktop
|
# Which interface to bind. The default (all interfaces) is what lets desktop
|
||||||
# clients on your network reach the server. Behind a reverse proxy, set this to
|
# clients on your network reach the server. Behind a reverse proxy, set this to
|
||||||
# 127.0.0.1 so only the proxy can talk to it.
|
# 127.0.0.1 so only the proxy can talk to it.
|
||||||
#THOUGHTSYNC_BIND=0.0.0.0
|
#INKWELL_BIND=0.0.0.0
|
||||||
|
|
||||||
|
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
|
||||||
|
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
|
||||||
|
# reverse proxy terminating HTTPS) and belongs there because it is something you may
|
||||||
|
# need to change while the server is running, alongside the sign-in limits.
|
||||||
|
|
||||||
|
# How much the app says. Credential events (sign-ins, failures, throttles, new
|
||||||
|
# accounts, device tokens issued) are logged at INFO and read with
|
||||||
|
# `docker compose logs app`.
|
||||||
|
#INKWELL_LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Database identity. Changing these AFTER the first start does not rename anything
|
# Database identity. Changing these AFTER the first start does not rename anything
|
||||||
# that already exists — the volume keeps whatever the first run created.
|
# that already exists — the volume keeps whatever the first run created.
|
||||||
|
#POSTGRES_USER=inkwell
|
||||||
|
#POSTGRES_DB=inkwell
|
||||||
|
|
||||||
|
# --- Upgrading from ThoughtSync ---------------------------------------------
|
||||||
|
#
|
||||||
|
# Inkwell was called ThoughtSync, and a deployment installed under that name has
|
||||||
|
# its data in volumes and a database named for it. Point at them instead of
|
||||||
|
# renaming anything. Leaving these unset on such a deployment starts Inkwell
|
||||||
|
# against EMPTY volumes; the old data is untouched, but you would not see it.
|
||||||
|
#
|
||||||
|
# Use the full names `docker volume ls` prints — compose prefixes them with the
|
||||||
|
# project name, so they usually look like `thoughtsync_thoughtsync-db`:
|
||||||
|
#INKWELL_DB_VOLUME=thoughtsync_thoughtsync-db
|
||||||
|
#INKWELL_DATA_VOLUME=thoughtsync_thoughtsync-data
|
||||||
|
#
|
||||||
|
# And the database identity the first start created, which a volume keeps:
|
||||||
#POSTGRES_USER=thoughtsync
|
#POSTGRES_USER=thoughtsync
|
||||||
#POSTGRES_DB=thoughtsync
|
#POSTGRES_DB=thoughtsync
|
||||||
|
#
|
||||||
|
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
|
||||||
|
# names are no longer read.
|
||||||
|
|
||||||
# --- a note on HTTPS --------------------------------------------------------
|
# --- a note on HTTPS --------------------------------------------------------
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
name: Android
|
||||||
|
|
||||||
|
# The native Kotlin/Compose client over the shared Rust core (M12).
|
||||||
|
#
|
||||||
|
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
|
||||||
|
# builds, not that Android has a lane: the UI is Compose, and the store and sync
|
||||||
|
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
|
||||||
|
# uniffi.
|
||||||
|
#
|
||||||
|
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
||||||
|
# about feel, touch or on-device correctness is an operator pass on an emulator or
|
||||||
|
# phone.
|
||||||
|
#
|
||||||
|
# The artifact is a SIGNED RELEASE APK when the keystore secret is present, and an
|
||||||
|
# unsigned debug one when it is not. That distinction is not cosmetic: two builds
|
||||||
|
# signed with different keys cannot replace one another, and bridging that gap
|
||||||
|
# means uninstalling first — which deletes the app's database and every local note
|
||||||
|
# with it (Scribe issue 2803).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
|
||||||
|
# See desktop.yml for why, and 85ead4d for what the duplication cost.
|
||||||
|
branches: [dev, main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: android-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Silences the JDK 22+ "restricted method in java.lang.System has been called"
|
||||||
|
# warning that Gradle 9.1's bundled native-platform jar trips at launch. This
|
||||||
|
# targets the LAUNCHER JVM, which is why org.gradle.jvmargs in
|
||||||
|
# gradle.properties is not enough on its own (Minstrel hit the same thing).
|
||||||
|
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Does the APK need rebuilding, or is the channel already serving this source?
|
||||||
|
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
|
||||||
|
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
|
||||||
|
#
|
||||||
|
# The guard runs here so it covers the skip path too (§6.3).
|
||||||
|
#
|
||||||
|
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches
|
||||||
|
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
|
||||||
|
# happens — and that is correct, because ci.yml's `gate` stands down only when the
|
||||||
|
# push touched Android's files, which is the same condition that makes this build.
|
||||||
|
# The two decisions agree because they read the same fact; they are still two
|
||||||
|
# readers of it, which is why the gate's grep carries a comment pointing here.
|
||||||
|
decide:
|
||||||
|
name: Build, or is the channel already serving this?
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
outputs:
|
||||||
|
build: ${{ steps.d.outputs.build }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide
|
||||||
|
id: d
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable ;;
|
||||||
|
*) channel=dev ;;
|
||||||
|
esac
|
||||||
|
sh packaging/guard-forward.sh android "$channel"
|
||||||
|
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# The core ships inside the APK (through android/ffi), so its checks have to gate
|
||||||
|
# the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
|
||||||
|
# there cannot stop this lane publishing (rule 177, #5237).
|
||||||
|
#
|
||||||
|
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the
|
||||||
|
# core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
|
||||||
|
# image has OpenSSL vendored for the Android targets alone (run 8663 failed at
|
||||||
|
# `openssl-sys` trying this there). Same Rust pin, same Cargo.lock.
|
||||||
|
rust:
|
||||||
|
name: Core and FFI clippy and tests
|
||||||
|
needs: [decide]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --locked -p inkwell-core -p inkwell-ffi --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --locked -p inkwell-core -p inkwell-ffi
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Kotlin + Rust (APK)
|
||||||
|
needs: [decide, rust]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
|
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
||||||
|
# proven-working label that can pull our container images.
|
||||||
|
runs-on: flutter-ci
|
||||||
|
container:
|
||||||
|
# The image repurposed from ci-tauri-android in M12 step 3: Rust + the four
|
||||||
|
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: android
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Derives a version, so it needs the whole history — see the note in
|
||||||
|
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache Gradle and Cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
~/.kotlin
|
||||||
|
target
|
||||||
|
key: android-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts', 'Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
android-
|
||||||
|
|
||||||
|
# Everything downstream keys off this: the variant to build, the Cargo
|
||||||
|
# profile to build it with, and the version it carries. Decided once so no
|
||||||
|
# two Gradle invocations in this run can disagree and force a second
|
||||||
|
# four-minute cross-compile.
|
||||||
|
- name: Signing key, variant and version
|
||||||
|
id: build
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
|
||||||
|
# same code?", so it comes from the COMMIT and a dev build and the main
|
||||||
|
# build of one commit read identically. The CODE answers "may this be
|
||||||
|
# installed over that?" and must be monotonic BY CONSTRUCTION, because
|
||||||
|
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
|
||||||
|
# leaves a channel you cannot get out of — so it comes from BUILD time,
|
||||||
|
# which cannot go backwards. Commit time can.
|
||||||
|
version="$(sh ../packaging/version.sh display android)"
|
||||||
|
code="$(sh ../packaging/version.sh key android)"
|
||||||
|
echo "name=$version" >> $GITHUB_OUTPUT
|
||||||
|
echo "code=$code" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/inkwell-release.jks
|
||||||
|
echo "variant=Release" >> $GITHUB_OUTPUT
|
||||||
|
echo "label=release" >> $GITHUB_OUTPUT
|
||||||
|
# DEBUG profile, in a release APK, deliberately — see the note above
|
||||||
|
# the cargoNdk task. The release profile strips the symbols uniffi
|
||||||
|
# reads its metadata out of, so `generateUniffiBindings` fails
|
||||||
|
# outright (run 4077). Unpicking that is worth doing and is not worth
|
||||||
|
# blocking signed builds on.
|
||||||
|
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||||
|
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
|
||||||
|
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
||||||
|
echo "Signed release build — $version (versionCode $code)"
|
||||||
|
else
|
||||||
|
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
|
||||||
|
echo "variant=Debug" >> $GITHUB_OUTPUT
|
||||||
|
echo "label=debug" >> $GITHUB_OUTPUT
|
||||||
|
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||||
|
echo "keystore=" >> $GITHUB_OUTPUT
|
||||||
|
echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Make gradlew executable
|
||||||
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
|
# Fails loudly here if the wrapper and the image's JDK disagree, rather
|
||||||
|
# than thirty seconds into a compile with an opaque version message.
|
||||||
|
- name: Gradle wrapper check
|
||||||
|
run: ./gradlew --version
|
||||||
|
|
||||||
|
# Cross-compiles the core for four ABIs and generates the Kotlin bindings
|
||||||
|
# from the built .so. Run as its own step so a Rust failure is legible as a
|
||||||
|
# Rust failure instead of arriving inside a Gradle stack trace.
|
||||||
|
- name: Build the native library and bindings
|
||||||
|
run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||||
|
|
||||||
|
# The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both
|
||||||
|
# (M12 step 3) precisely so this lane needs no second image, and going
|
||||||
|
# through Gradle plugins would mean a second version of each tool resolved
|
||||||
|
# at build time and kept in lockstep with the image's by hand.
|
||||||
|
#
|
||||||
|
# Scoped to src/main: the generated uniffi bindings live under build/ and
|
||||||
|
# are not ours to style.
|
||||||
|
- name: ktlint
|
||||||
|
run: ktlint "app/src/main/**/*.kt"
|
||||||
|
|
||||||
|
- name: detekt
|
||||||
|
run: detekt --build-upon-default-config --config config/detekt.yml --input app/src/main/java
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
# Host-JVM tests only. Anything touching the core needs an Android
|
||||||
|
# runtime to load the .so, so those are instrumented tests and belong on
|
||||||
|
# an emulator, not here — the Rust side is the `rust` job, which this
|
||||||
|
# one needs.
|
||||||
|
#
|
||||||
|
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
|
||||||
|
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
|
||||||
|
# not exist (run 4082). It costs one extra Kotlin compile and buys the
|
||||||
|
# type-check on the debug variant, which is the one an emulator build
|
||||||
|
# would use.
|
||||||
|
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||||
|
|
||||||
|
- name: Assemble the APK
|
||||||
|
env:
|
||||||
|
# Empty on the unsigned path, which build.gradle.kts reads as "no
|
||||||
|
# signing config" rather than as a path to a missing file.
|
||||||
|
ANDROID_KEYSTORE_FILE: ${{ steps.build.outputs.keystore }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
./gradlew assemble${{ steps.build.outputs.variant }} \
|
||||||
|
-PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
||||||
|
-PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \
|
||||||
|
-PINKWELL_VERSION_CODE=${{ steps.build.outputs.code }}
|
||||||
|
|
||||||
|
# Prints the certificate the APK was actually signed with, so the operator
|
||||||
|
# can compare it against the fingerprint recorded when the key was
|
||||||
|
# generated. Signing with the WRONG key produces a perfectly valid APK that
|
||||||
|
# simply refuses to install over the app already on the phone — a failure
|
||||||
|
# that otherwise only shows up on the device, after the run is green.
|
||||||
|
- name: Show the signing certificate
|
||||||
|
if: steps.build.outputs.keystore != ''
|
||||||
|
run: |
|
||||||
|
apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)"
|
||||||
|
"$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk"
|
||||||
|
|
||||||
|
# Staged with a STABLE name plus the sidecar the server reads its version
|
||||||
|
# out of — an APK keeps that in a binary manifest Python cannot parse, and
|
||||||
|
# `aapt` is not on a Quart server. Computed here, where the real values are
|
||||||
|
# already known.
|
||||||
|
- name: Stage the client for distribution
|
||||||
|
if: steps.build.outputs.keystore != ''
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
cp "app/build/outputs/apk/release/app-release.apk" dist/inkwell.apk
|
||||||
|
size="$(wc -c < dist/inkwell.apk | tr -d ' ')"
|
||||||
|
sha="$(sha256sum dist/inkwell.apk | cut -d' ' -f1)"
|
||||||
|
cat > dist/inkwell-android.json <<JSON
|
||||||
|
{
|
||||||
|
"version_name": "${{ steps.build.outputs.name }}",
|
||||||
|
"version_code": ${{ steps.build.outputs.code }},
|
||||||
|
"size": $size,
|
||||||
|
"sha256": "$sha"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
cat dist/inkwell-android.json
|
||||||
|
|
||||||
|
# The rolling channel for this branch, the same fixed-tag releases the desktop
|
||||||
|
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
|
||||||
|
# fetch target; a release asset has a permanent URL. Only ever a SIGNED build —
|
||||||
|
# publishing an unsigned APK would offer people something they cannot install
|
||||||
|
# over what they already have.
|
||||||
|
#
|
||||||
|
# `stable` from main is new in M314 step 3, and it is what lets the server image
|
||||||
|
# bake in a client that matches its own channel: a :latest image fetches the APK
|
||||||
|
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
|
||||||
|
# all and every image — stable included — baked in the dev one.
|
||||||
|
- name: Publish to the channel for this branch
|
||||||
|
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
|
||||||
|
working-directory: .
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||||
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
|
esac
|
||||||
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
|
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||||
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
|
echo "Publishing the APK to the $RELEASE_TAG channel."
|
||||||
|
bash desktop/packaging/publish-release.sh
|
||||||
|
|
||||||
|
- name: Upload the APK
|
||||||
|
# Stock action: it works on this forge since the runner moved to
|
||||||
|
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||||
|
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
||||||
|
# success while Gitea serves artifacts back only through the v4 API, so the
|
||||||
|
# upload is stored and invisible (Scribe 2270).
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
# The APK's variant, NOT the Cargo profile — those are the same word
|
||||||
|
# for different things and the profile is pinned to debug (#2810).
|
||||||
|
name: inkwell-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
||||||
|
path: ${{ steps.build.outputs.apk }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
server-image:
|
||||||
|
name: Build the server image
|
||||||
|
needs: [decide, rust, build]
|
||||||
|
if: always() && needs.decide.outputs.build == 'true' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||||
|
runs-on: flutter-ci
|
||||||
|
container:
|
||||||
|
# The image the dispatch has always run in, so its curl is a known quantity.
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
steps:
|
||||||
|
# The server image bakes in whatever client the dev release holds, so it has
|
||||||
|
# to be built AFTER this lane, not alongside it. `ci.yml` stands down on any
|
||||||
|
# push that touches the Android app (its `gate` job) and waits to be called
|
||||||
|
# from here — that is the other half of this.
|
||||||
|
#
|
||||||
|
# `always()`: a FAILED Android build — or failed core checks, which skip the
|
||||||
|
# build job entirely — must still let the server image through. There is no
|
||||||
|
# new client in that case, so it bakes in the previous one, which is exactly
|
||||||
|
# right — the alternative is a broken Android lane silently blocking server
|
||||||
|
# delivery. Its own job for that reason: a step inside `build` never runs
|
||||||
|
# when `build` is skipped.
|
||||||
|
#
|
||||||
|
# Not `if: success()` and not skipped on tags either: every ref that builds an
|
||||||
|
# image needs the call, or nothing builds one at all.
|
||||||
|
- name: Build the server image now the client is published
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
# Loud on failure rather than `|| true`: if this call stops working, the
|
||||||
|
# symptom is server images silently never being built for Android pushes,
|
||||||
|
# which is invisible until someone wonders why the app never updates.
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ref":"${{ github.ref_name }}"}' \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/ci.yml/dispatches"
|
||||||
|
echo "Dispatched ci.yml on ${{ github.ref_name }}."
|
||||||
+283
-40
@@ -1,12 +1,21 @@
|
|||||||
# CI runs first; build only proceeds if lint + typecheck pass.
|
# CI runs first; build only proceeds if lint + typecheck pass.
|
||||||
#
|
#
|
||||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
# Push to dev: typecheck + lint + test + build :dev
|
||||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
|
||||||
#
|
#
|
||||||
# main is the production line, so a merge to main rebuilds and moves :latest to its
|
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
|
||||||
# tip (family rule 46) — no version release required. The :<sha> image is the
|
# nothing pins one — verified by looking for a consumer, not for whether one is
|
||||||
# immutable rollback unit for every build.
|
# imaginable — and the git release tag is a different object in a different system
|
||||||
|
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
|
||||||
|
#
|
||||||
|
# A `v*` tag builds nothing at all. The merge to main already published everything,
|
||||||
|
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
|
||||||
|
# which rule 145 forbids even when they match.
|
||||||
|
#
|
||||||
|
# main is the production line, so a merge moves :latest to its tip (family rule 46)
|
||||||
|
# — no version release required. :<sha> is the immutable rollback unit, and it is
|
||||||
|
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
|
||||||
|
# accumulating forever, for a channel whose entire contract is that it moves.
|
||||||
#
|
#
|
||||||
# Required secret (repo -> Settings -> Secrets -> Actions):
|
# Required secret (repo -> Settings -> Secrets -> Actions):
|
||||||
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
||||||
@@ -16,35 +25,131 @@ name: CI & Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
|
||||||
|
# the image ALWAYS builds. Two reasons:
|
||||||
|
#
|
||||||
|
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
|
||||||
|
# production commit is addressable. A path filter quietly broke that promise
|
||||||
|
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
|
||||||
|
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
|
||||||
|
# is a floating tag and this can face the internet), and building every push
|
||||||
|
# picks those updates up. That is why note 3127 §4's base tension does not
|
||||||
|
# bite here — the one artifact it would apply to never skips.
|
||||||
|
#
|
||||||
|
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes
|
||||||
|
# for the clients, which is why THEY skip and this does not.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
tags: ["v*"]
|
# Dispatched by the Android lane once it has published a client, so the image
|
||||||
paths:
|
# that bakes it in is built AFTER the APK exists rather than racing it. See the
|
||||||
- "src/**"
|
# `gate` job below for the other half.
|
||||||
- "frontend/**"
|
workflow_dispatch:
|
||||||
- "tests/**"
|
|
||||||
- "pyproject.toml"
|
|
||||||
- "alembic/**"
|
|
||||||
- "alembic.ini"
|
|
||||||
- "Dockerfile"
|
|
||||||
- ".forgejo/workflows/ci.yml"
|
|
||||||
|
|
||||||
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
|
# Cancel older runs on the same branch when a newer push lands.
|
||||||
# own group implicitly and are never cancelled.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.fabledsword.com
|
REGISTRY: git.fabledsword.com
|
||||||
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
|
IMAGE: git.fabledsword.com/bvandeusen/inkwell
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Should this push build an image now, or is the Android lane about to publish a
|
||||||
|
# client that the image ought to contain?
|
||||||
|
#
|
||||||
|
# A push touching the Android app runs BOTH workflows at once. Building here
|
||||||
|
# would bake in the PREVIOUS client and then, when the new one landed, there
|
||||||
|
# would be no second build — `:<sha>` is the immutable rollback unit (rule 46)
|
||||||
|
# and rebuilding it with different content would make it neither.
|
||||||
|
#
|
||||||
|
# So on such a push this workflow stands down, and the Android lane dispatches it
|
||||||
|
# when it is finished. Exactly one image per commit, containing the client from
|
||||||
|
# that commit.
|
||||||
|
#
|
||||||
|
# The path list below MUST match android.yml's trigger. Two places holding one
|
||||||
|
# decision is the recurring failure in this repo (issues 2181-2183); it is here
|
||||||
|
# because a workflow cannot read another's filters, and it is a `git diff` rather
|
||||||
|
# than a config so at least it is inspectable in the log.
|
||||||
|
gate:
|
||||||
|
name: Build now, or wait for Android?
|
||||||
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
outputs:
|
||||||
|
build: ${{ steps.decide.outputs.build }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# Full history: the diff below spans the whole PUSHED RANGE, not just the
|
||||||
|
# tip. A push of three commits whose Android change sits in the first
|
||||||
|
# would otherwise look Android-free, and the race this job exists to
|
||||||
|
# prevent would happen anyway — silently, which is the worst version.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide
|
||||||
|
id: decide
|
||||||
|
run: |
|
||||||
|
# A dispatched run IS the Android lane calling back. Always build.
|
||||||
|
if [ "${{ github.event_name }}" != "push" ]; then
|
||||||
|
echo "Dispatched by the Android lane — building."
|
||||||
|
echo "build=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No parent (first commit, or a force-push that orphaned it) — nothing to
|
||||||
|
# compare, so build rather than stall.
|
||||||
|
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
|
||||||
|
echo "No parent commit to diff against — building."
|
||||||
|
echo "build=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The whole push, not just its tip. `before` is what the ref pointed at
|
||||||
|
# beforehand; it is absent or all-zeros for a brand-new branch, and may
|
||||||
|
# be unreachable after a force-push — fall back to the tip commit then.
|
||||||
|
before="${{ github.event.before }}"
|
||||||
|
if [ -n "$before" ] \
|
||||||
|
&& [ "$before" != "0000000000000000000000000000000000000000" ] \
|
||||||
|
&& git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||||
|
range="$before..HEAD"
|
||||||
|
else
|
||||||
|
range="HEAD^..HEAD"
|
||||||
|
fi
|
||||||
|
echo "Comparing $range"
|
||||||
|
|
||||||
|
changed="$(git diff --name-only $range)"
|
||||||
|
echo "Changed in this push:"
|
||||||
|
echo "$changed" | sed 's/^/ /'
|
||||||
|
|
||||||
|
# MUST match android's file set in packaging/version.sh. `packaging/` was
|
||||||
|
# missing here after step 4 added it there — so a packaging-only push had
|
||||||
|
# the Android lane rebuild and dispatch while this gate ALSO let the image
|
||||||
|
# build, producing two images for one commit and, on main, a second push of
|
||||||
|
# the same :<sha> with different bytes. Rule 145's exact prohibition.
|
||||||
|
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
|
||||||
|
echo ""
|
||||||
|
echo "This push also changes the Android client. Standing down: the"
|
||||||
|
echo "Android lane will publish a new APK and dispatch this workflow,"
|
||||||
|
echo "so the image is built once, with the client from this commit."
|
||||||
|
echo "build=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "No Android change — the newest published client is already the"
|
||||||
|
echo "right one to bake in. Building."
|
||||||
|
echo "build=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The web's whole verification: the type check, then its unit tests. Both in the
|
||||||
|
# job `build` already needs, so a red web test blocks the image like any other
|
||||||
|
# lane (rule 177). The unit tests arrived with #5166; before them the web copy of
|
||||||
|
# the note grammar, mirrored in Rust and Python, was guarded by nothing.
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript typecheck
|
name: Web typecheck and unit tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -55,13 +160,19 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
# tsconfig.test.json, not the default: the default leaves the unit tests out
|
||||||
|
# so the Docker build (which has only frontend/) can type-check the app.
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npx vue-tsc --noEmit
|
run: npx vue-tsc --noEmit -p tsconfig.test.json
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Python lint
|
name: Python lint
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -74,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -88,15 +199,103 @@ jobs:
|
|||||||
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: /opt/venv/bin/python -m pytest tests/ -q
|
# DB-free by design. Anything needing a real Postgres is marked `integration`
|
||||||
|
# and runs in the job below.
|
||||||
|
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||||
|
|
||||||
|
# Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran
|
||||||
|
# for the first time when the operator's container started — 26 revisions, none of
|
||||||
|
# them ever executed by CI — and the schema the migrations build had never been
|
||||||
|
# checked against the models that read it.
|
||||||
|
#
|
||||||
|
# Gates the build, along with every other lane — see the `build` job's `needs`.
|
||||||
|
#
|
||||||
|
# Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner
|
||||||
|
# derives the service-container name from the truncated job display name, and the
|
||||||
|
# discovery step below filters `docker ps` by it. Service hostnames are not routable
|
||||||
|
# on this runner (rule 79), so the step resolves the container's bridge IP.
|
||||||
|
integration:
|
||||||
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# Same image the production compose runs, so the schema is proven against the
|
||||||
|
# Postgres it will actually meet.
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: inkwell
|
||||||
|
POSTGRES_PASSWORD: ci_integration
|
||||||
|
POSTGRES_DB: inkwell_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U inkwell"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Create virtual environment
|
||||||
|
run: uv venv /opt/venv
|
||||||
|
|
||||||
|
# Same install as the unit lane — the two must agree on versions, or
|
||||||
|
# "unit green, integration red" stops being a signal about the code.
|
||||||
|
- name: Install package with dev deps
|
||||||
|
run: uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Integration suite (resolve service IP, migrate, test)
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
echo "=== container landscape (diagnostic for the name filter) ==="
|
||||||
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
|
# Only THIS job's services. The runner's docker daemon is shared, so another
|
||||||
|
# repo's job can be running beside this one, and a job-name filter matches its
|
||||||
|
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
|
||||||
|
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
|
||||||
|
# <n> from our own container (its id is in the /etc/hostname bind mount) and
|
||||||
|
# require exactly one match.
|
||||||
|
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
||||||
|
SELF=${SELF:-$(hostname)}
|
||||||
|
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
|
||||||
|
test -n "$TASK"
|
||||||
|
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=postgres:16-alpine" -q)
|
||||||
|
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
|
||||||
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
|
test -n "$PG_IP"
|
||||||
|
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
|
||||||
|
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
|
||||||
|
# no bash /dev/tcp — so use the Python that is always present here.
|
||||||
|
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||||
|
import socket, sys, time
|
||||||
|
for _ in range(30):
|
||||||
|
try:
|
||||||
|
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
sys.exit("postgres did not become reachable")
|
||||||
|
PY
|
||||||
|
# Real migrations build the schema, never metadata.create_all (rule 82) —
|
||||||
|
# testing a schema no deployment has ever seen would prove nothing. This
|
||||||
|
# step IS the migration test: a broken revision fails the job here.
|
||||||
|
/opt/venv/bin/alembic upgrade head
|
||||||
|
/opt/venv/bin/python -m pytest tests/ -v -m integration
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build & push image
|
name: Build & push image
|
||||||
# Build gates on lint + typecheck. The `test` job runs in parallel for
|
# Every lane gates the build. This once stopped at lint + typecheck, on the
|
||||||
# visibility but does not block dev image builds (DB-backed integration
|
# reasoning that DB-backed testing happened manually against the dev image
|
||||||
# testing happens against the dev image manually, not on every push).
|
# rather than on every push — true until 6f21db8 added the integration lane,
|
||||||
needs: [typecheck, lint]
|
# and false since.
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
#
|
||||||
|
# What that gap cost: run 4293 failed `test` and published :dev and :<sha>
|
||||||
|
# anyway, so the deployed server ran a build whose test lane was red. An image
|
||||||
|
# tag is the rollback substrate (family rule 46); one that can be published
|
||||||
|
# from a failing run is not a substrate you can roll back TO.
|
||||||
|
needs: [gate, typecheck, lint, test, integration]
|
||||||
|
if: needs.gate.outputs.build == 'true'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -105,27 +304,35 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
|
||||||
|
# and produces a too-low value silently, with the lane green (§6.1).
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate image tags and version
|
- name: Generate image tags and version
|
||||||
id: tags
|
id: tags
|
||||||
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
||||||
# NOT bash `[[ ]]`.
|
# NOT bash `[[ ]]`.
|
||||||
run: |
|
run: |
|
||||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
# The image's version is DERIVED from its own shipped files — including the
|
||||||
BUILD_VERSION="dev"
|
# Android client it bakes in, which is why an APK-only change re-versions
|
||||||
|
# it. One value and no ordering key: nothing compares a server image, so
|
||||||
|
# §2 says do not invent one just because the other artifacts have one.
|
||||||
|
#
|
||||||
|
# This was a short sha on main and the literal "dev" elsewhere, which could
|
||||||
|
# not answer "how old is this instance?" — the question that actually gets
|
||||||
|
# asked of a self-hosted app running in several places.
|
||||||
|
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
||||||
case "${{ github.ref }}" in
|
case "${{ github.ref }}" in
|
||||||
refs/heads/dev)
|
refs/heads/dev)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="${{ env.IMAGE }}:dev"
|
||||||
;;
|
;;
|
||||||
refs/heads/main)
|
refs/heads/main)
|
||||||
# Production line: :latest tracks main's tip (rule 46). No :main tag;
|
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
|
||||||
# the :<sha> above is the rollback unit. Version label = short sha.
|
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
|
||||||
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
|
|
||||||
;;
|
;;
|
||||||
refs/tags/*)
|
*)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
echo "::error::This lane builds images for dev and main only."
|
||||||
BUILD_VERSION="${{ github.ref_name }}"
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||||
@@ -136,6 +343,42 @@ jobs:
|
|||||||
docker system prune -af || true
|
docker system prune -af || true
|
||||||
docker builder prune --keep-storage 5g -f || true
|
docker builder prune --keep-storage 5g -f || true
|
||||||
|
|
||||||
|
# Bake EVERY client in, on every image build, so a self-hoster gets a working
|
||||||
|
# app for their machine from the server holding their notes — without an
|
||||||
|
# account on this forge, which is private (issue 2091) and is why serving them
|
||||||
|
# from a release page was never an option for anybody but the operator.
|
||||||
|
#
|
||||||
|
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
||||||
|
# the price of the product being complete (rule 23), and the AppImage is not
|
||||||
|
# optional within it: it is the ONLY bundle that can replace itself in place,
|
||||||
|
# so a server without one cannot serve in-app updates to anyone.
|
||||||
|
#
|
||||||
|
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
|
||||||
|
# token used inside a build lands in the context or a layer.
|
||||||
|
#
|
||||||
|
# NEVER fails the build — see the script. A platform with nothing published
|
||||||
|
# means the server advertises nothing for it and the UI hides that download,
|
||||||
|
# which is a supported state and the only one available before that platform's
|
||||||
|
# first build has ever published.
|
||||||
|
- name: Fetch the clients to bake in
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||||
|
# :latest serves stable ones. This read `download/dev` unconditionally
|
||||||
|
# until M314 step 3, on every branch — so every stable server shipped a
|
||||||
|
# dev-channel APK to anyone who downloaded the client from it. Not a
|
||||||
|
# versioning gap; a plain defect, and the reason the channel is chosen here
|
||||||
|
# rather than inside the script: the caller is what knows which image it is
|
||||||
|
# building.
|
||||||
|
case "${{ github.ref_name }}" in
|
||||||
|
main) channel=stable ;;
|
||||||
|
*) channel=dev ;;
|
||||||
|
esac
|
||||||
|
sh packaging/fetch-clients.sh "$channel" client
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
|||||||
+319
-110
@@ -1,6 +1,13 @@
|
|||||||
# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: this is a heavy
|
# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: a Rust + AppImage
|
||||||
# Rust + AppImage build (~20-40 min) that should NOT run on backend/frontend-only
|
# build that shouldn't run on server-only pushes. Produces the .deb and .AppImage.
|
||||||
# pushes. Scoped to desktop/** (+ this file). Produces the .deb and .AppImage.
|
#
|
||||||
|
# It DOES run on frontend changes. tauri's generate_context! embeds the built
|
||||||
|
# frontend in the binary, so a frontend commit that never triggers this ships to
|
||||||
|
# the web and silently never reaches the desktop app — and desktop, web and Android
|
||||||
|
# are peer surfaces held to one quality bar, not a primary and its fallbacks. The
|
||||||
|
# filter was once narrowed to the adapter/bridge directories against a "~20-40 min"
|
||||||
|
# build; measured runs are 4-5 minutes, so the cost that justified the narrowing
|
||||||
|
# isn't there.
|
||||||
#
|
#
|
||||||
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli);
|
# Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli);
|
||||||
# runs-on is just a registered scheduling label (Label Model B), not a per-purpose
|
# runs-on is just a registered scheduling label (Label Model B), not a per-purpose
|
||||||
@@ -9,20 +16,20 @@ name: Desktop (Tauri)
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
|
||||||
|
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
|
||||||
|
# from it within a day (85ead4d). The `decide` job below reads the real set and
|
||||||
|
# skips in seconds when nothing moved — one definition, one reader (§3).
|
||||||
|
#
|
||||||
|
# The cost is that this workflow starts on every push rather than on a matching
|
||||||
|
# one. That is a ~15s container for a decision, against a lane that cannot
|
||||||
|
# silently fail to run.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
tags: ["v*"]
|
|
||||||
paths:
|
|
||||||
- "desktop/**"
|
|
||||||
# The desktop app embeds the frontend, and the data seam / Tauri bridge are
|
|
||||||
# what the offline core rides on — rebuild the app when those change too.
|
|
||||||
- "frontend/src/adapters/**"
|
|
||||||
- "frontend/src/desktop/**"
|
|
||||||
- ".forgejo/workflows/desktop.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: desktop-${{ github.ref }}
|
group: desktop-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# write (not read) so the tag build can publish a Release with the bundles
|
# write (not read) so the tag build can publish a Release with the bundles
|
||||||
@@ -31,9 +38,115 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Does anything need building at all?
|
||||||
|
#
|
||||||
|
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
|
||||||
|
# the `paths:` filters that used to state the same fact a second time. They drifted
|
||||||
|
# from it within a day: `packaging/` was added to the sets and not to the filters,
|
||||||
|
# so the commit fixing a derivation bug never ran on the two lanes it fixed
|
||||||
|
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
|
||||||
|
#
|
||||||
|
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
|
||||||
|
# Skipping because "the channel already serves this version" is indistinguishable
|
||||||
|
# from "we derived a stale value that happens to match" unless something checks.
|
||||||
|
decide:
|
||||||
|
name: Build, or is the channel already serving this?
|
||||||
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
outputs:
|
||||||
|
build: ${{ steps.d.outputs.build }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# Derives a version — depth-1 is silently wrong (§6.1).
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide
|
||||||
|
id: d
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable ;;
|
||||||
|
*) channel=dev ;;
|
||||||
|
esac
|
||||||
|
sh packaging/guard-forward.sh desktop "$channel"
|
||||||
|
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
|
||||||
|
# them (rule 177: nothing publishes on red).
|
||||||
|
#
|
||||||
|
# They used to be steps inside `build`, which gated the Linux publish and nothing
|
||||||
|
# else. `windows` needed only `decide`, so on run 8411 clippy failed, the Linux job
|
||||||
|
# stopped, and the Windows installer built and published to the dev release in the
|
||||||
|
# same minute (#5184). An edge from each publisher to this job is the gate; a
|
||||||
|
# verdict inside a sibling job is only a report.
|
||||||
|
#
|
||||||
|
# Both publishers need it directly rather than through each other, so a Windows
|
||||||
|
# failure still never blocks the Linux bundles, and neither waits on the other's
|
||||||
|
# bundling. No `always()` or `continue-on-error` anywhere on this path: a skipped
|
||||||
|
# or failed verify must leave both publishers skipped.
|
||||||
|
verify:
|
||||||
|
name: Web tests, clippy, Rust tests and rustfmt
|
||||||
|
needs: [decide]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
|
steps:
|
||||||
|
# No version is derived here, so the default shallow checkout is enough.
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# tauri's generate_context! embeds the built frontend at compile time, so the
|
||||||
|
# frontend must exist before clippy or the tests can compile the desktop crate.
|
||||||
|
- name: Build the shared frontend
|
||||||
|
run: npm ci && npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
# The web's unit tests, HERE as well as in ci.yml. The installers embed this
|
||||||
|
# frontend, and ci.yml's verdict is invisible to this workflow — run there only,
|
||||||
|
# a red web test would still let both installers publish (rule 177).
|
||||||
|
- name: Web unit tests
|
||||||
|
run: npm test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the
|
||||||
|
# run if Cargo.toml and the committed Cargo.lock disagree, instead of silently
|
||||||
|
# re-resolving (issue 2102). Both publishing jobs need this job, so neither
|
||||||
|
# bundles a commit whose lockfile drifted.
|
||||||
|
#
|
||||||
|
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
|
||||||
|
#
|
||||||
|
# These three steps used to run inside the desktop crate, which was right when
|
||||||
|
# it was the only Rust in the repo. After the core was extracted (M12 step 1)
|
||||||
|
# it silently stopped being right: cargo scoped to the desktop PACKAGE, so the
|
||||||
|
# core's 89 tests stopped running and nothing lints the Android uniffi shim at
|
||||||
|
# all. Both crates are dependencies of the desktop, so they still COMPILED —
|
||||||
|
# which is exactly why the gap was invisible, and why a green run kept meaning
|
||||||
|
# less than it looked like it meant.
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --locked --workspace
|
||||||
|
|
||||||
|
# Deliberately AFTER clippy + test, not before.
|
||||||
|
#
|
||||||
|
# It's the cheapest check, so fail-fast ordering would normally put it first —
|
||||||
|
# but there is no Rust toolchain on the workstation (the desktop lane is
|
||||||
|
# verified entirely here), so a formatting nit failing first SKIPS clippy and
|
||||||
|
# the tests, and one CI cycle teaches nothing but whitespace. Running it here
|
||||||
|
# means every push reports its real problems too. Still before either bundle
|
||||||
|
# build, so a fmt failure doesn't burn one.
|
||||||
|
- name: Rust format check
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Tauri desktop (Linux)
|
name: Tauri desktop (Linux)
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
needs: [decide, verify]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
@@ -44,34 +157,21 @@ jobs:
|
|||||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||||
|
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||||
|
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||||
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
|
# if it is ever dropped.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# tauri's generate_context! embeds the built frontend at compile time, so the
|
# tauri's generate_context! embeds the built frontend at compile time, so the
|
||||||
# frontend must exist before any cargo compile (clippy/test/build), not just
|
# frontend must exist before cargo compiles anything, not just at bundle time.
|
||||||
# at bundle time.
|
|
||||||
- name: Build the shared frontend
|
- name: Build the shared frontend
|
||||||
run: npm ci && npm run build
|
run: npm ci && npm run build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Clippy
|
|
||||||
run: cargo clippy --all-targets -- -D warnings
|
|
||||||
working-directory: desktop/src-tauri
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: cargo test
|
|
||||||
working-directory: desktop/src-tauri
|
|
||||||
|
|
||||||
# Deliberately AFTER clippy + test, not before.
|
|
||||||
#
|
|
||||||
# It's the cheapest check, so fail-fast ordering would normally put it first —
|
|
||||||
# but there is no Rust toolchain on the workstation (the desktop lane is
|
|
||||||
# verified entirely here), so a formatting nit failing first SKIPS clippy and
|
|
||||||
# the tests, and one CI cycle teaches nothing but whitespace. Running it here
|
|
||||||
# means every push reports its real problems too. Still before the ~20-40 min
|
|
||||||
# bundle build, so a fmt failure doesn't burn that.
|
|
||||||
- name: Rust format check
|
|
||||||
run: cargo fmt --check
|
|
||||||
working-directory: desktop/src-tauri
|
|
||||||
|
|
||||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||||
#
|
#
|
||||||
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
|
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
|
||||||
@@ -91,8 +191,20 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
|
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
|
||||||
fi
|
fi
|
||||||
version="$(sh ../packaging/build-version.sh)"
|
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||||
echo "Building version $version"
|
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||||
|
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||||
|
# separate value and arrives with the UI that shows it (#3181).
|
||||||
|
version="$(sh ../../packaging/version.sh key desktop)"
|
||||||
|
echo "Building desktop ordering key $version"
|
||||||
|
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||||
|
# A different value for a different audience: this is the one a person
|
||||||
|
# quotes in a bug report, the key above is the one only a comparator
|
||||||
|
# sees. Exported rather than passed as a flag because the macro that
|
||||||
|
# reads it is in Rust source, not in Tauri's config.
|
||||||
|
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||||
|
export INKWELL_DISPLAY_VERSION
|
||||||
|
echo "Baking display version $INKWELL_DISPLAY_VERSION"
|
||||||
cargo tauri build \
|
cargo tauri build \
|
||||||
--config '{"build":{"beforeBuildCommand":""}}' \
|
--config '{"build":{"beforeBuildCommand":""}}' \
|
||||||
--config "{\"version\":\"$version\"}" \
|
--config "{\"version\":\"$version\"}" \
|
||||||
@@ -108,6 +220,28 @@ jobs:
|
|||||||
- name: De-bundle AppImage graphics libraries
|
- name: De-bundle AppImage graphics libraries
|
||||||
run: bash desktop/packaging/appimage/debundle-graphics.sh
|
run: bash desktop/packaging/appimage/debundle-graphics.sh
|
||||||
|
|
||||||
|
# MUST run after de-bundling, not before. The step above DELETES the AppImage
|
||||||
|
# and repackages it, so the signature tauri produced during the build now
|
||||||
|
# describes a file that no longer exists. Publishing that stale .sig would make
|
||||||
|
# every Linux update fail verification — and the error names a signature
|
||||||
|
# mismatch, which points nowhere near "a later build step rewrote the file".
|
||||||
|
# Windows needs no equivalent: nothing post-processes the NSIS installer.
|
||||||
|
- name: Re-sign the de-bundled AppImage
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||||
|
echo "No signing key — the build produced no signature to replace."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
appimage="$(find target/release/bundle/appimage -name '*.AppImage' -type f | head -1)"
|
||||||
|
[ -n "$appimage" ] || { echo "ERROR: no AppImage found to re-sign" >&2; exit 1; }
|
||||||
|
rm -f "$appimage.sig"
|
||||||
|
cargo tauri signer sign "$appimage"
|
||||||
|
[ -s "$appimage.sig" ] || { echo "ERROR: re-signing produced no .sig" >&2; exit 1; }
|
||||||
|
echo "Re-signed $(basename "$appimage")"
|
||||||
|
|
||||||
# install.sh hands the .deb to every Debian/Ubuntu user, so the package's
|
# install.sh hands the .deb to every Debian/Ubuntu user, so the package's
|
||||||
# Depends must be right BEFORE a release exists. Prints the generated
|
# Depends must be right BEFORE a release exists. Prints the generated
|
||||||
# control file and cross-checks it against what the ELF actually needs
|
# control file and cross-checks it against what the ELF actually needs
|
||||||
@@ -131,52 +265,62 @@ jobs:
|
|||||||
run: bash desktop/packaging/arch/package-prebuilt.sh
|
run: bash desktop/packaging/arch/package-prebuilt.sh
|
||||||
|
|
||||||
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
||||||
# continue-on-error: the Forgejo artifact backend may not be configured yet; a
|
# Stock action: it works on this forge since the runner moved to
|
||||||
# failed upload must not fail the build itself.
|
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||||
# Forgejo doesn't support the v4 artifact protocol (@actions/artifact v2+),
|
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
|
||||||
# so pin v3, which uses the older protocol the instance accepts.
|
# something Gitea stores but will never serve back (it returns artifacts only
|
||||||
|
# through the v4 API, which filters on content_encoding='application/zip').
|
||||||
|
# No continue-on-error: a swallowed upload failure is exactly how 110
|
||||||
|
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
|
||||||
- name: Upload bundles
|
- name: Upload bundles
|
||||||
continue-on-error: true
|
uses: actions/upload-artifact@v7
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
with:
|
||||||
name: thoughtsync-linux
|
name: inkwell-linux
|
||||||
path: |
|
path: |
|
||||||
desktop/src-tauri/target/release/bundle/appimage/*.AppImage
|
target/release/bundle/appimage/*.AppImage
|
||||||
desktop/src-tauri/target/release/bundle/deb/*.deb
|
target/release/bundle/deb/*.deb
|
||||||
desktop/src-tauri/target/release/bundle/arch/*.pkg.tar.*
|
target/release/bundle/arch/*.pkg.tar.*
|
||||||
if-no-files-found: warn
|
# error, not warn: a build that bundles nothing should report as a
|
||||||
|
# failure, not as a green run with an empty artifact.
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
# Tag builds only: publish a real, versioned Fabled-Git Release with the
|
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
||||||
# AppImage + .deb attached — the stable fetch target the install script and
|
# are releases whose tag never moves, so the updater has a permanent URL to
|
||||||
# the in-app updater consume (Actions artifacts above are ephemeral/test).
|
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
|
||||||
# Cutting the tag is the operator's action (rule 2); this only publishes a
|
# cannot be named in a URL.
|
||||||
# Release for a tag that already exists. Dormant on dev/main pushes.
|
#
|
||||||
- name: Publish release
|
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
# M314 step 3 this job built on main and published nothing, so the stable
|
||||||
env:
|
# channel moved only when somebody cut a tag — that section's diagnostic
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
# failing outright: main publishing was not sufficient for a user to receive
|
||||||
run: bash desktop/packaging/publish-release.sh
|
# the build.
|
||||||
|
|
||||||
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
|
|
||||||
# the updater has a permanent URL to read — Forgejo has no
|
|
||||||
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
|
|
||||||
#
|
#
|
||||||
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
||||||
# the secrets context isn't reliably available to step conditions. Publishing
|
# the secrets context isn't reliably available to step conditions. Publishing
|
||||||
# bundles the app would then refuse to verify is worse than publishing nothing:
|
# bundles the app would then refuse to verify is worse than publishing nothing:
|
||||||
# it looks like a working feed.
|
# it looks like a working feed.
|
||||||
- name: Publish to the dev channel
|
- name: Publish to the channel for this branch
|
||||||
if: github.ref == 'refs/heads/dev'
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
RELEASE_TAG: dev
|
|
||||||
RELEASE_PRERELEASE: "true"
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
|
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||||
|
# `prerelease` is true for dev so it does not read as a supported build,
|
||||||
|
# and false for stable, which is the real thing.
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||||
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
|
esac
|
||||||
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
|
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||||
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
|
echo "Publishing to the $RELEASE_TAG channel."
|
||||||
bash desktop/packaging/publish-release.sh
|
bash desktop/packaging/publish-release.sh
|
||||||
|
|
||||||
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
|
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
|
||||||
@@ -194,12 +338,21 @@ jobs:
|
|||||||
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
||||||
windows:
|
windows:
|
||||||
name: Windows installer (cross-compiled)
|
name: Windows installer (cross-compiled)
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
needs: [decide, verify]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||||
|
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||||
|
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||||
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
|
# if it is ever dropped.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# Same reason as the Linux job: generate_context! embeds the built frontend
|
# Same reason as the Linux job: generate_context! embeds the built frontend
|
||||||
# at compile time, so it must exist before cargo runs.
|
# at compile time, so it must exist before cargo runs.
|
||||||
@@ -216,6 +369,15 @@ jobs:
|
|||||||
run: cargo tauri icon app-icon.png
|
run: cargo tauri icon app-icon.png
|
||||||
working-directory: desktop/src-tauri
|
working-directory: desktop/src-tauri
|
||||||
|
|
||||||
|
# This lane's own lockfile check (`verify` has already run `cargo clippy
|
||||||
|
# --locked` for the workspace). It has to be its own step here because the build is this job's
|
||||||
|
# only crate-graph command, and discovering the drift 30 minutes into a
|
||||||
|
# cross-compile is the expensive way to learn it. Fetching for the Windows
|
||||||
|
# target also pre-warms exactly the crates the build will want.
|
||||||
|
- name: Verify the lockfile and fetch dependencies
|
||||||
|
run: cargo fetch --locked --target x86_64-pc-windows-msvc
|
||||||
|
working-directory: desktop/src-tauri
|
||||||
|
|
||||||
# --runner cargo-xwin swaps cargo for the cross-compiling driver (it supplies
|
# --runner cargo-xwin swaps cargo for the cross-compiling driver (it supplies
|
||||||
# the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link).
|
# the MSVC CRT/SDK, pre-warmed into the image, and links with lld-link).
|
||||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||||
@@ -224,8 +386,20 @@ jobs:
|
|||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
version="$(sh ../packaging/build-version.sh)"
|
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||||
echo "Building version $version"
|
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||||
|
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||||
|
# separate value and arrives with the UI that shows it (#3181).
|
||||||
|
version="$(sh ../../packaging/version.sh key desktop)"
|
||||||
|
echo "Building desktop ordering key $version"
|
||||||
|
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||||
|
# A different value for a different audience: this is the one a person
|
||||||
|
# quotes in a bug report, the key above is the one only a comparator
|
||||||
|
# sees. Exported rather than passed as a flag because the macro that
|
||||||
|
# reads it is in Rust source, not in Tauri's config.
|
||||||
|
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||||
|
export INKWELL_DISPLAY_VERSION
|
||||||
|
echo "Baking display version $INKWELL_DISPLAY_VERSION"
|
||||||
updater='{}'
|
updater='{}'
|
||||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||||
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
||||||
@@ -239,43 +413,51 @@ jobs:
|
|||||||
--config "$updater"
|
--config "$updater"
|
||||||
working-directory: desktop/src-tauri
|
working-directory: desktop/src-tauri
|
||||||
|
|
||||||
|
# Stock action — see the Linux job's Upload bundles step for why.
|
||||||
- name: Upload installer
|
- name: Upload installer
|
||||||
continue-on-error: true
|
uses: actions/upload-artifact@v7
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
with:
|
||||||
name: thoughtsync-windows
|
name: inkwell-windows
|
||||||
path: desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||||
if-no-files-found: warn
|
if-no-files-found: error
|
||||||
|
|
||||||
# Publishes to the SAME release as the Linux job. Safe to run twice: the
|
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
||||||
# script reuses an existing release (409) and nullglob means each job uploads
|
# are releases whose tag never moves, so the updater has a permanent URL to
|
||||||
# only the bundles present in its own workspace.
|
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
|
||||||
- name: Publish release
|
# cannot be named in a URL.
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
#
|
||||||
env:
|
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
# M314 step 3 this job built on main and published nothing, so the stable
|
||||||
run: bash desktop/packaging/publish-release.sh
|
# channel moved only when somebody cut a tag — that section's diagnostic
|
||||||
|
# failing outright: main publishing was not sufficient for a user to receive
|
||||||
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
|
# the build.
|
||||||
# the updater has a permanent URL to read — Forgejo has no
|
|
||||||
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
|
|
||||||
#
|
#
|
||||||
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
||||||
# the secrets context isn't reliably available to step conditions. Publishing
|
# the secrets context isn't reliably available to step conditions. Publishing
|
||||||
# bundles the app would then refuse to verify is worse than publishing nothing:
|
# bundles the app would then refuse to verify is worse than publishing nothing:
|
||||||
# it looks like a working feed.
|
# it looks like a working feed.
|
||||||
- name: Publish to the dev channel
|
- name: Publish to the channel for this branch
|
||||||
if: github.ref == 'refs/heads/dev'
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
RELEASE_TAG: dev
|
|
||||||
RELEASE_PRERELEASE: "true"
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
|
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||||
|
# `prerelease` is true for dev so it does not read as a supported build,
|
||||||
|
# and false for stable, which is the real thing.
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||||
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
|
esac
|
||||||
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
|
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||||
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
|
echo "Publishing to the $RELEASE_TAG channel."
|
||||||
bash desktop/packaging/publish-release.sh
|
bash desktop/packaging/publish-release.sh
|
||||||
|
|
||||||
# The updater manifest, written AFTER both bundle jobs — they run in separate
|
# The updater manifest, written AFTER both bundle jobs — they run in separate
|
||||||
@@ -289,12 +471,20 @@ jobs:
|
|||||||
manifest:
|
manifest:
|
||||||
name: Update manifest
|
name: Update manifest
|
||||||
needs: [build, windows]
|
needs: [build, windows]
|
||||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||||
|
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||||
|
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||||
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
|
# if it is ever dropped.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Write and publish latest.json
|
- name: Write and publish latest.json
|
||||||
env:
|
env:
|
||||||
@@ -306,20 +496,39 @@ jobs:
|
|||||||
echo "manifest to write. Add the secret to enable in-app updates."
|
echo "manifest to write. Add the secret to enable in-app updates."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# The SAME helper the bundles were built with — a second derivation here
|
# The SAME helper AND the same request the bundles were built with — a
|
||||||
# could drift, and a manifest whose version doesn't match the binary it
|
# second derivation here could drift, and a manifest whose version doesn't
|
||||||
# points at is an updater that never settles.
|
# match the binary it points at is an updater that never settles. It must
|
||||||
version="$(sh desktop/packaging/build-version.sh)"
|
# be `key`: this value is matched against bundle filenames.
|
||||||
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
|
version="$(sh packaging/version.sh key desktop)"
|
||||||
export RELEASE_TAG=dev
|
# The version a PERSON reads, published beside the manifest as
|
||||||
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
|
# `inkwell-desktop.json`. The image build reads it to describe the
|
||||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
|
||||||
else
|
# anything from its own checkout — which would be a different commit
|
||||||
export RELEASE_TAG="${GITHUB_REF_NAME}"
|
# whenever the desktop did not rebuild.
|
||||||
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
|
display="$(sh packaging/version.sh display desktop)"
|
||||||
# Twice: once onto the versioned release itself, and once onto the
|
# Both channels are rolling: the manifest lands on the same release that
|
||||||
# permanent `stable` pointer the app actually reads. Same manifest both
|
# holds the bundles, and the previous build's bundles are dropped once it
|
||||||
# times — its URLs point at the versioned assets either way.
|
# points at this one. Nothing can reach them, and they are ~100 MB a push.
|
||||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
#
|
||||||
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh
|
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
|
||||||
fi
|
# triggers release.yml, which writes a changelog and builds nothing.
|
||||||
|
case "${GITHUB_REF_NAME}" in
|
||||||
|
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
|
||||||
|
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
|
||||||
|
*) RELEASE_TAG="$(sh packaging/channel-tag.sh dev)"
|
||||||
|
# TEMPORARY one-shot bridge (Scribe #2184). Desktop apps installed
|
||||||
|
# before the rename have .../download/dev/latest.json compiled in,
|
||||||
|
# so the manifest is also written to the old `dev` release; its
|
||||||
|
# URLs name dev-rolling assets, so those apps update once into a
|
||||||
|
# build that reads the new tag. Delete this line together with the
|
||||||
|
# old `dev` release and tag.
|
||||||
|
export BRIDGE_TAG=dev
|
||||||
|
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
|
||||||
|
esac
|
||||||
|
# Assigned bare above so a failing channel-tag.sh fails this step;
|
||||||
|
# `export X="$(...)"` would swallow its exit status.
|
||||||
|
export RELEASE_TAG
|
||||||
|
export PRUNE_OLD_ASSETS=true
|
||||||
|
APP_VERSION="$version" DISPLAY_VERSION="$display" \
|
||||||
|
bash desktop/packaging/write-manifest.sh
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
|
||||||
|
#
|
||||||
|
# The merge to `main` already published everything a user can receive: the server
|
||||||
|
# image as `:latest` + `:<sha>`, the desktop bundles and the APK to the `stable`
|
||||||
|
# channel, and the updater manifest that advertises them. A tag rebuilding that same
|
||||||
|
# source would produce identical artifacts under identical names, and would re-push
|
||||||
|
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
|
||||||
|
#
|
||||||
|
# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying
|
||||||
|
# what was in it. Note 3127 §5 — there are two halves to "what am I running", and
|
||||||
|
# the version answers only the first:
|
||||||
|
#
|
||||||
|
# which build is this? the footer, /api/config, the APK's versionName
|
||||||
|
# what changed since the one ← this
|
||||||
|
# I was running last month?
|
||||||
|
#
|
||||||
|
# Cutting the tag is the operator's act (rule 2). This only responds to one.
|
||||||
|
#
|
||||||
|
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
|
||||||
|
# tags at all. The image is addressed by channel or by commit; the release by date.
|
||||||
|
# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different
|
||||||
|
# system.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notes:
|
||||||
|
name: Write the changelog
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# The whole history AND every tag: the notes are the commit range between
|
||||||
|
# this tag and the previous `v*` one, and neither end exists in a shallow
|
||||||
|
# clone. A depth-limited checkout here does not fail — it produces a
|
||||||
|
# shorter changelog, which is the kind of wrong nobody notices.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Publish the release notes
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
notes="$(sh packaging/release-notes.sh "$GITHUB_REF_NAME")"
|
||||||
|
echo "$notes"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
# JSON-escaped HERE rather than in publish-release.sh, which cannot assume
|
||||||
|
# python3 is on PATH in the three images that call it. `json.dumps` then
|
||||||
|
# strip the surrounding quotes — the script supplies those.
|
||||||
|
RELEASE_BODY_JSON="$(printf '%s' "$notes" \
|
||||||
|
| python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])')"
|
||||||
|
export RELEASE_BODY_JSON
|
||||||
|
|
||||||
|
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
|
||||||
|
# release that is only ever POSTed keeps whatever body its first run
|
||||||
|
# wrote (#2182), so re-tagging or re-running must rewrite it. No bundles
|
||||||
|
# exist in this workspace, so its asset globs match nothing and it
|
||||||
|
# uploads none — which is the intended behaviour, not a side effect.
|
||||||
|
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
|
||||||
+37
@@ -174,3 +174,40 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
|
||||||
|
# Rust workspace build output (one target dir for core + desktop + android)
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Android / Gradle build output.
|
||||||
|
#
|
||||||
|
# `local.properties` holds the machine's SDK path — it is per-workstation and
|
||||||
|
# must never be committed; CI gets the SDK from ANDROID_HOME in the image.
|
||||||
|
# The wrapper JAR is deliberately NOT ignored: it is how a clean checkout gets
|
||||||
|
# the right Gradle without one installed first.
|
||||||
|
android/.gradle/
|
||||||
|
android/build/
|
||||||
|
android/app/build/
|
||||||
|
android/local.properties
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# Locally-downloaded APKs for emulator/device testing.
|
||||||
|
#
|
||||||
|
# CI builds these and attaches them to the run as artifacts; a copy sitting in
|
||||||
|
# the working tree is a convenience, never a source. Ignored because they are
|
||||||
|
# ~57 MB and `git add -A` would otherwise put one in history forever.
|
||||||
|
*.apk
|
||||||
|
|
||||||
|
# Signing material. NEVER committed — an Android signing key cannot be rotated
|
||||||
|
# without the original (v3 lineage needs it), so a leaked or lost one means every
|
||||||
|
# install has to be removed and replaced by hand. Listed before any keystore
|
||||||
|
# exists so that generating one in this directory cannot go wrong.
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
*.p12
|
||||||
|
*.b64
|
||||||
|
|
||||||
|
# The Android client CI bakes into the server image. Fetched fresh on every image
|
||||||
|
# build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so
|
||||||
|
# the Dockerfile's COPY always has a directory to copy.
|
||||||
|
client/inkwell.apk
|
||||||
|
client/inkwell-android.json
|
||||||
|
|||||||
Generated
+6331
File diff suppressed because it is too large
Load Diff
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Rust workspace. The framework-free client core, and the two shims that wrap it:
|
||||||
|
# the Tauri desktop app and the uniffi bindings the native Android client loads.
|
||||||
|
# Neither shim owns the core — that is the reason it is a crate at all rather than a
|
||||||
|
# module inside the desktop app (Scribe note 2730).
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
|
||||||
|
|
||||||
|
# Shared pins, so two consumers of the core cannot drift onto different versions of
|
||||||
|
# the same dependency and resolve differently.
|
||||||
|
[workspace.dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
|
# Tauri's default release profile: smaller, faster shipped binaries.
|
||||||
|
#
|
||||||
|
# At the WORKSPACE root, not in the desktop member: cargo ignores profiles declared
|
||||||
|
# by a non-root package, so leaving it there would silently drop lto/strip/opt-level
|
||||||
|
# from every release build with only a warning to say so.
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = true
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
+21
-2
@@ -20,10 +20,29 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
|||||||
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
|
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
|
||||||
# points at /app/src so the runtime imports this source tree (with static/ present),
|
# points at /app/src so the runtime imports this source tree (with static/ present),
|
||||||
# not the pip-installed copy.
|
# not the pip-installed copy.
|
||||||
COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
COPY --from=build-frontend /build/dist/ src/inkwell/static/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
|
|
||||||
|
# The clients this server hands out — the APK and all four desktop bundles. CI
|
||||||
|
# fetches the newest published build of each into ./client immediately before this
|
||||||
|
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
|
||||||
|
# `docker compose pull` delivers new ones with no file copying by hand.
|
||||||
|
#
|
||||||
|
# ~104 MB of this image is that set, almost all of it the AppImage.
|
||||||
|
#
|
||||||
|
# Fetched by the JOB rather than here on purpose: the releases are private, and a
|
||||||
|
# token used inside a build ends up in the build context or a layer.
|
||||||
|
#
|
||||||
|
# LAST of the COPYs, deliberately: this directory changes on every build, so
|
||||||
|
# putting it above the `pip install` layer would invalidate that layer every time.
|
||||||
|
#
|
||||||
|
# The directory is tracked (client/.keep) so this COPY cannot fail on a tree where
|
||||||
|
# that step never ran. An image with no clients — or with some and not others — is
|
||||||
|
# a supported state: the server advertises what it has and the web UI hides the
|
||||||
|
# rest (client_dist.py).
|
||||||
|
COPY client/ src/inkwell/client/
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|
||||||
ARG BUILD_VERSION=dev
|
ARG BUILD_VERSION=dev
|
||||||
@@ -33,4 +52,4 @@ EXPOSE 5000
|
|||||||
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
||||||
# slow/unready database from crash-looping the container. Family convention
|
# slow/unready database from crash-looping the container. Family convention
|
||||||
# (rule 82): schema is built by real migrations, never metadata.create_all.
|
# (rule 82): schema is built by real migrations, never metadata.create_all.
|
||||||
CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ThoughtSync
|
# Inkwell
|
||||||
|
|
||||||
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
||||||
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
||||||
@@ -13,7 +13,7 @@ under a second, designed to grow into a lightweight second brain (labels, search
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
|
src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
|
||||||
alembic/ async migrations (schema built via `alembic upgrade head`)
|
alembic/ async migrations (schema built via `alembic upgrade head`)
|
||||||
tests/ DB-free unit tests (pytest)
|
tests/ DB-free unit tests (pytest)
|
||||||
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
|
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
|
||||||
@@ -23,12 +23,12 @@ docker-compose.yml local app + Postgres stack
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
|
Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
|
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend (proxies `/api` to `:5000`):
|
Frontend (proxies `/api` to `:5000`):
|
||||||
@@ -64,44 +64,51 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: thoughtsync
|
POSTGRES_USER: inkwell
|
||||||
POSTGRES_PASSWORD: CHANGE_ME # change this
|
POSTGRES_PASSWORD: CHANGE_ME # change this
|
||||||
POSTGRES_DB: thoughtsync
|
POSTGRES_DB: inkwell
|
||||||
volumes:
|
volumes:
|
||||||
- thoughtsync-db:/var/lib/postgresql/data
|
- inkwell-db:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
|
test: ["CMD-SHELL", "pg_isready -U inkwell"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
|
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
|
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
|
||||||
volumes:
|
volumes:
|
||||||
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
|
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
thoughtsync-db:
|
inkwell-db:
|
||||||
thoughtsync-data:
|
inkwell-data:
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
||||||
|
|
||||||
- **Only `THOUGHTSYNC_DATABASE_URL` is required.** `THOUGHTSYNC_SECRET_KEY` is optional; if
|
- **Only `INKWELL_DATABASE_URL` is required.** `INKWELL_SECRET_KEY` is optional; if
|
||||||
unset, a signing key is generated and persisted in the database (sessions survive restarts).
|
unset, a signing key is generated and persisted in the database (sessions survive restarts).
|
||||||
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
|
- Uploaded images live under the `inkwell-data` volume at `/var/inkwell`.
|
||||||
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
||||||
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
|
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
|
||||||
`:<git-sha>` (immutable, for pinning / rollback).
|
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
|
||||||
- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the
|
no version-shaped tags: nothing pins one, and the build reports its own version
|
||||||
|
at `/api/config` and `/health`.
|
||||||
|
- **Putting it on the public internet:** there are four things to do first — close
|
||||||
|
registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app
|
||||||
|
port, and back up the attachment volume as well as the database. See
|
||||||
|
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
|
||||||
|
hardens on its own and what it deliberately doesn't.
|
||||||
|
- **Install as an app (PWA):** Inkwell is installable ("Add to Home Screen" / the
|
||||||
browser's install button) for an app-like window. Browsers only offer install over a
|
browser's install button) for an app-like window. Browsers only offer install over a
|
||||||
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
||||||
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
||||||
|
|||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
# Alembic single-database async configuration for ThoughtSync.
|
# Alembic single-database async configuration for Inkwell.
|
||||||
|
|
||||||
[alembic]
|
[alembic]
|
||||||
script_location = %(here)s/alembic
|
script_location = %(here)s/alembic
|
||||||
prepend_sys_path = . src
|
prepend_sys_path = . src
|
||||||
path_separator = os
|
path_separator = os
|
||||||
|
|
||||||
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py).
|
# Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
|
||||||
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync
|
sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
|
||||||
|
|
||||||
|
|
||||||
[loggers]
|
[loggers]
|
||||||
|
|||||||
+3
-3
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
|||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
from thoughtsync.models import Base
|
from inkwell.models import Base
|
||||||
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata
|
import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
|
|||||||
|
|
||||||
config.set_main_option(
|
config.set_main_option(
|
||||||
"sqlalchemy.url",
|
"sqlalchemy.url",
|
||||||
os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
|
os.environ.get("INKWELL_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
|
||||||
)
|
)
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
|
||||||
|
|
||||||
|
Revision ID: 0023
|
||||||
|
Revises: 0022
|
||||||
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
|
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
|
||||||
|
the note and every inbound link stops matching. The old answer was to rewrite the
|
||||||
|
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
|
||||||
|
title existed to hold still, untenable once a note's name is just its first body
|
||||||
|
line (M13).
|
||||||
|
|
||||||
|
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
|
||||||
|
that doesn't exist yet is a supported way to create one.
|
||||||
|
|
||||||
|
The backfill is safe to run bluntly because note_links is DERIVED data — every row
|
||||||
|
is recomputed from the source body on the next save regardless.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0023"
|
||||||
|
down_revision = "0022"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"note_links",
|
||||||
|
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_note_links_target",
|
||||||
|
"note_links",
|
||||||
|
"notes",
|
||||||
|
["target_id"],
|
||||||
|
["id"],
|
||||||
|
# A deleted target un-resolves its inbound links rather than deleting them:
|
||||||
|
# the link text is still in the source's body, and it should read as pointing
|
||||||
|
# at something that isn't there — which is also what lets it re-resolve if a
|
||||||
|
# note of that name appears again.
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
|
||||||
|
|
||||||
|
# Resolve what can be resolved right now, scoped to the source's owner so a link
|
||||||
|
# can never bind to another user's note.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE note_links AS nl
|
||||||
|
SET target_id = t.id
|
||||||
|
FROM notes AS src, notes AS t
|
||||||
|
WHERE nl.source_id = src.id
|
||||||
|
AND t.owner_id = src.owner_id
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND lower(btrim(t.display_title)) = nl.target_norm
|
||||||
|
AND t.id <> src.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_note_links_target_id", table_name="note_links")
|
||||||
|
op.drop_constraint("fk_note_links_target", "note_links", type_="foreignkey")
|
||||||
|
op.drop_column("note_links", "target_id")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""drop note_links — [[wiki-links]] are removed (note 2897)
|
||||||
|
|
||||||
|
Revision ID: 0024
|
||||||
|
Revises: 0023
|
||||||
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
|
ThoughtSync is an intermediary surface for capture and recall; a linking system is
|
||||||
|
organization, which is not what it is for. Backlinks, the graph and the name index
|
||||||
|
went with it.
|
||||||
|
|
||||||
|
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
|
||||||
|
than deleted. It shipped in an image and may already be applied, and removing an
|
||||||
|
applied revision would strand a database's alembic_version pointer. So the column is
|
||||||
|
dropped here along with the table it lived on, and the history stays honest about the
|
||||||
|
fact that it existed for a day.
|
||||||
|
|
||||||
|
No down-migration data concern: note_links was always DERIVED from note bodies. The
|
||||||
|
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
|
||||||
|
lost by this.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0024"
|
||||||
|
down_revision = "0023"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("note_links")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"note_links",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"source_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"target_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("target_norm", sa.Text(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_note_links_target", "note_links", ["target_norm"])
|
||||||
|
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
|
||||||
|
|
||||||
|
Revision ID: 0025
|
||||||
|
Revises: 0024
|
||||||
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
|
`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared
|
||||||
|
against a hardcoded ("text", "list") tuple in six places. `note_items` was always an
|
||||||
|
ordinary child table keyed by note_id, serialization always emitted `items` whatever
|
||||||
|
the kind, and the Android editor already toggled between the two losslessly. The
|
||||||
|
storage has modelled "a body plus optional checkable items" the whole time; only the
|
||||||
|
gates forbade it.
|
||||||
|
|
||||||
|
Nothing is lost. Items were already rows in their own table, and a note that was
|
||||||
|
`kind = 'list'` keeps every one of them — it just stops being a different sort of
|
||||||
|
thing from the note next to it.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0025"
|
||||||
|
down_revision = "0024"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("notes", "kind")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# server_default so existing rows get a value; every note comes back as 'text',
|
||||||
|
# which is right — a restored note with items would previously have hidden its
|
||||||
|
# body, and there is no record of which ones were once lists.
|
||||||
|
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""drop notes.title and note_revisions.title — a note's name is its first line
|
||||||
|
|
||||||
|
Revision ID: 0026
|
||||||
|
Revises: 0025
|
||||||
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
|
M13 step 3. A note is a body plus optional checkable items; its NAME is the first
|
||||||
|
non-empty line of that body, falling back to its first checklist item. There is no
|
||||||
|
separate field to type into, and `display_title` (already persisted, already what
|
||||||
|
search results and export filenames read) carries the name.
|
||||||
|
|
||||||
|
## The search vector has to be rebuilt, not just left alone
|
||||||
|
|
||||||
|
`notes.search_vector` is a STORED GENERATED column whose expression names `title`
|
||||||
|
(migration 0005, weight A) — Postgres will refuse to drop a column another generated
|
||||||
|
column depends on, and even if it didn't, the weighting would be wrong. So it is
|
||||||
|
dropped and recreated over `display_title` instead, which keeps the original
|
||||||
|
intent: the note's NAME ranks above the rest of its body.
|
||||||
|
|
||||||
|
Rebuilding a stored generated column re-computes every row, and the GIN index is
|
||||||
|
rebuilt with it. On a personal instance that is milliseconds; it is worth knowing
|
||||||
|
before running this against something large.
|
||||||
|
|
||||||
|
## What happens to existing titles
|
||||||
|
|
||||||
|
Nothing preserves them, deliberately: `display_title` was already derived from the
|
||||||
|
title when one was set, so every note keeps the NAME it had. What is lost is the
|
||||||
|
distinction between "this note has an explicit title" and "this note's first line is
|
||||||
|
its name" — which is the distinction being removed.
|
||||||
|
|
||||||
|
Imports are the exception and are handled in code, not here: a Keep note's title, or
|
||||||
|
one in an export taken before this, is folded in as the note's first body line rather
|
||||||
|
than dropped (see `_create_imported_note`).
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0026"
|
||||||
|
down_revision = "0025"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Order matters: the generated column depends on `title`, so it goes first.
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||||
|
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||||
|
|
||||||
|
op.drop_column("notes", "title")
|
||||||
|
op.drop_column("note_revisions", "title")
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
setweight(to_tsvector('english', coalesce(display_title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||||
|
) STORED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_notes_search")
|
||||||
|
op.execute("ALTER TABLE notes DROP COLUMN IF EXISTS search_vector")
|
||||||
|
|
||||||
|
# Comes back empty. The text is not gone — it is the first line of every body —
|
||||||
|
# but which notes once had an explicit title is not recorded anywhere.
|
||||||
|
op.add_column("notes", sa.Column("title", sa.Text(), nullable=True))
|
||||||
|
op.add_column("note_revisions", sa.Column("title", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE notes ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', coalesce(body, '')), 'B')
|
||||||
|
) STORED
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX ix_notes_search ON notes USING GIN (search_vector)")
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""fold note_items into the note body and drop the table
|
||||||
|
|
||||||
|
Revision ID: 0027
|
||||||
|
Revises: 0026
|
||||||
|
Create Date: 2026-08-24
|
||||||
|
|
||||||
|
M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items`
|
||||||
|
goes. The reason is positional, not cosmetic: a row had a position in a table and no
|
||||||
|
position in the text, so a separate list could only ever render AFTER the prose. With
|
||||||
|
the items in the body, a list can sit between two paragraphs — which is the thing that
|
||||||
|
could not be built before and no amount of restyling would have delivered.
|
||||||
|
|
||||||
|
## This migration rewrites note bodies
|
||||||
|
|
||||||
|
Every note that has items gets its body appended to. The rules below are strict
|
||||||
|
because rewriting somebody's text deserves it — not, as an earlier draft of this
|
||||||
|
docstring claimed, because this instance holds imported Google Keep notes. It does
|
||||||
|
not; note 2916's headline is that nothing here is anyone's work but the operator's
|
||||||
|
test data. What 2916 actually says about imports is conditional — text arriving from
|
||||||
|
another app WOULD be real, and any import path has to treat it that way — and the
|
||||||
|
importer this migration shares a format with is one nobody here has run.
|
||||||
|
|
||||||
|
Careful was still the right call. It cost little, and the same care is what the rule
|
||||||
|
demands the day someone does import something:
|
||||||
|
|
||||||
|
* Rows are read BEFORE the table is dropped, in this one transaction.
|
||||||
|
* The existing body is never rewritten, only appended to.
|
||||||
|
* The layout — a blank line between prose and the list, nothing between consecutive
|
||||||
|
items — is byte-for-byte what `_note_markdown` has always exported and what
|
||||||
|
`derive::append_item` produces on every client. All three landing on the same text
|
||||||
|
is what lets the clients migrate their own SQLite stores independently and still
|
||||||
|
agree with the server, with no sync required to reconcile them.
|
||||||
|
|
||||||
|
## The fold is inlined on purpose
|
||||||
|
|
||||||
|
`notes/checklist.py` has this same function and this migration deliberately does not
|
||||||
|
import it. A migration has to keep producing what it produced the day it ran; if the
|
||||||
|
app's spacing rule ever changes, this file must not change with it.
|
||||||
|
|
||||||
|
## `updated_at` is left alone, and that is load-bearing
|
||||||
|
|
||||||
|
Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters
|
||||||
|
more than the first. Every client folds the same rows the same way, so the new body is
|
||||||
|
news to nobody. And a client holding an UNPUSHED body edit still has the newer
|
||||||
|
`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead
|
||||||
|
of the migration silently winning.
|
||||||
|
|
||||||
|
The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every
|
||||||
|
migrated note becomes pullable once. That is wanted: it is what makes a client whose
|
||||||
|
local fold somehow differed converge on the server's text.
|
||||||
|
|
||||||
|
## The downgrade is not a true inverse, and says so
|
||||||
|
|
||||||
|
It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost —
|
||||||
|
every item is still there as text, which is where this migration put it — but the old
|
||||||
|
code would show those notes as prose with no checklist. A faithful inverse is not
|
||||||
|
possible: once the items are lines, nothing distinguishes a line this migration wrote
|
||||||
|
from one somebody typed, and a downgrade that guessed would eat hand-written task
|
||||||
|
lists. The real rollback is a database restore.
|
||||||
|
|
||||||
|
Recreating the table is not decoration, though. Migration 0015's downgrade runs
|
||||||
|
`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS`
|
||||||
|
covers the trigger, not the table — against a missing table that statement errors. So
|
||||||
|
this is what keeps the migration chain runnable all the way back down.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0027"
|
||||||
|
down_revision = "0026"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TASK_RE = re.compile(r"^\s*[-*] +\[[ xX]\](?: +.*)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def _append_item(body: str, text: str, checked: bool) -> str:
|
||||||
|
mark = "x" if checked else " "
|
||||||
|
text = (text or "").strip()
|
||||||
|
line = f"- [{mark}] {text}" if text else f"- [{mark}]"
|
||||||
|
trimmed = (body or "").rstrip("\n")
|
||||||
|
if not trimmed.strip():
|
||||||
|
return line
|
||||||
|
follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1]))
|
||||||
|
return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text("SELECT note_id, text, checked FROM note_items ORDER BY note_id, position, created_at")
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
grouped: dict = {}
|
||||||
|
for note_id, text, checked in rows:
|
||||||
|
grouped.setdefault(note_id, []).append((text, bool(checked)))
|
||||||
|
|
||||||
|
for note_id, items in grouped.items():
|
||||||
|
body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar()
|
||||||
|
# An item whose note is already gone has nothing to fold into. The foreign key
|
||||||
|
# should make this impossible; skipping costs nothing and failing here would
|
||||||
|
# leave the database half-migrated.
|
||||||
|
if body is None:
|
||||||
|
continue
|
||||||
|
for text, checked in items:
|
||||||
|
body = _append_item(body, text, checked)
|
||||||
|
bind.execute(sa.text("UPDATE notes SET body = :body WHERE id = :id"), {"body": body, "id": note_id})
|
||||||
|
|
||||||
|
op.drop_table("note_items")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Column-for-column as migration 0006 created it, index name included: 0015's
|
||||||
|
# downgrade names both the table and its trigger, so a near-enough copy is not
|
||||||
|
# good enough.
|
||||||
|
op.create_table(
|
||||||
|
"note_items",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("checked", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_note_items_note", "note_items", ["note_id"])
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""lift standalone #tags out of note bodies
|
||||||
|
|
||||||
|
Revision ID: 0028
|
||||||
|
Revises: 0027
|
||||||
|
Create Date: 2026-08-26
|
||||||
|
|
||||||
|
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
|
||||||
|
and with the chip moved to the top of the card the text is redundant. This removes it,
|
||||||
|
but only from notes where the tag was standing on its own.
|
||||||
|
|
||||||
|
## This migration rewrites note bodies
|
||||||
|
|
||||||
|
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
|
||||||
|
applies from here on:
|
||||||
|
|
||||||
|
* A line containing nothing but tags and whitespace is REMOVED.
|
||||||
|
* Every other line is left exactly as written.
|
||||||
|
|
||||||
|
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
|
||||||
|
looser reading — also stripping a trailing tag off a prose line — was rejected because
|
||||||
|
the text does not say which kind it is: `buy milk #grocery` is filing, `remember to
|
||||||
|
call #mom` is the sentence's object, and lifting the second leaves "remember to call".
|
||||||
|
Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is
|
||||||
|
the worst possible place to make it.
|
||||||
|
|
||||||
|
Two guards, both of which cost a note nothing:
|
||||||
|
|
||||||
|
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
|
||||||
|
snippet somebody pasted, and deleting it would eat a line of their example.
|
||||||
|
* A note that is NOTHING but tags keeps its text. Lifting would leave a blank card,
|
||||||
|
which is worse than the duplication this fixes.
|
||||||
|
|
||||||
|
## The label rows have to graduate in the same transaction
|
||||||
|
|
||||||
|
A `via_tag` row means "this label is backed by text still in the body". Once the text
|
||||||
|
is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags`
|
||||||
|
detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag
|
||||||
|
on its very next save. The flip to `via_tag = false` is what makes the label the record
|
||||||
|
instead — and what makes the chip's × appear in both editors, which is now the only way
|
||||||
|
to remove a tag whose text no longer exists.
|
||||||
|
|
||||||
|
## The transform is inlined, like 0027's
|
||||||
|
|
||||||
|
`split_body_tags` is deliberately NOT imported. A migration has to keep producing what
|
||||||
|
it produced the day it ran; if the app's rule is ever loosened, this file must not
|
||||||
|
loosen with it and start eating prose it previously left alone.
|
||||||
|
|
||||||
|
`_display_title` is inlined for the same reason, and is only recomputed for a note whose
|
||||||
|
body actually moved — a note named after a `#todo` line needs a new name, and reading it
|
||||||
|
from the app would couple this migration to a rule that has already changed once (M13).
|
||||||
|
|
||||||
|
## `updated_at` is left alone, and that is load-bearing
|
||||||
|
|
||||||
|
Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit
|
||||||
|
keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps
|
||||||
|
its edit instead of the migration silently winning.
|
||||||
|
|
||||||
|
The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes
|
||||||
|
pullable once and clients converge on the server's text. That is wanted here: unlike
|
||||||
|
0027, the clients do NOT yet apply this rule locally, so the server's copy is the only
|
||||||
|
correct one until they do.
|
||||||
|
|
||||||
|
## The downgrade is not a true inverse, and says so
|
||||||
|
|
||||||
|
It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that
|
||||||
|
was never there, and putting one back would be guessing at where in the note it went.
|
||||||
|
|
||||||
|
Nothing is lost, though, which is why that is acceptable: the tag still exists as a
|
||||||
|
label on the note, and the chip still shows it. What a downgrade cannot restore is the
|
||||||
|
DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back
|
||||||
|
to `via_tag = true` would be actively harmful: the text that flag claims to be backed by
|
||||||
|
is gone, so the next save would detach the label and lose the tag for real. So the
|
||||||
|
downgrade leaves both alone. The real rollback is a database restore.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0028"
|
||||||
|
down_revision = "0027"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Frozen copies. See "The transform is inlined" above — these must not follow the app.
|
||||||
|
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
|
||||||
|
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
|
||||||
|
_TASK_RE = re.compile(r"^(?P<indent>\s*)(?P<bullet>[-*]) +\[(?P<mark>[ xX])\](?: +(?P<text>.*))?$")
|
||||||
|
_DISPLAY_TITLE_CAP = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tag(name: str) -> bool:
|
||||||
|
"""A tag must contain a letter, so #2024 and #_ are not tags — and a line holding
|
||||||
|
only those is therefore not a tag-only line and is left alone."""
|
||||||
|
return any(c.isalpha() for c in name)
|
||||||
|
|
||||||
|
|
||||||
|
def _split(body: str) -> tuple[list[str], str]:
|
||||||
|
"""(standalone tag names, body with their lines removed)."""
|
||||||
|
standalone: list[str] = []
|
||||||
|
kept: list[str] = []
|
||||||
|
in_fence = False
|
||||||
|
for line in body.split("\n"):
|
||||||
|
if _FENCE_RE.match(line):
|
||||||
|
in_fence = not in_fence
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
matches = [m for m in _TAG_RE.finditer(line) if _is_tag(m.group(1))]
|
||||||
|
remainder = line
|
||||||
|
for m in reversed(matches):
|
||||||
|
remainder = remainder[: m.start()] + remainder[m.end() :]
|
||||||
|
if in_fence or not matches or remainder.strip():
|
||||||
|
kept.append(line)
|
||||||
|
else:
|
||||||
|
standalone.extend(m.group(1) for m in matches)
|
||||||
|
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
|
||||||
|
if body.strip() and not lifted.strip():
|
||||||
|
return [], body # nothing but tags: keep the note readable
|
||||||
|
# A tag still written in prose somewhere keeps its text, so it stays derived.
|
||||||
|
still_in_prose = {m.group(1).lower() for m in _TAG_RE.finditer(lifted) if _is_tag(m.group(1))}
|
||||||
|
return [n for n in standalone if n.lower() not in still_in_prose], lifted
|
||||||
|
|
||||||
|
|
||||||
|
def _display_title(body: str) -> str:
|
||||||
|
for line in body.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
match = _TASK_RE.match(stripped)
|
||||||
|
text = (match.group("text") or "") if match else stripped
|
||||||
|
text = text.strip()
|
||||||
|
if text:
|
||||||
|
return text[:_DISPLAY_TITLE_CAP]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(sa.text("SELECT id, body FROM notes WHERE body LIKE '%#%'")).fetchall()
|
||||||
|
|
||||||
|
flip = sa.text(
|
||||||
|
"UPDATE note_labels nl SET via_tag = false "
|
||||||
|
"FROM labels l "
|
||||||
|
"WHERE nl.label_id = l.id AND nl.note_id = :nid AND nl.via_tag = true "
|
||||||
|
"AND lower(l.name) IN :names"
|
||||||
|
).bindparams(sa.bindparam("names", expanding=True))
|
||||||
|
|
||||||
|
for note_id, body in rows:
|
||||||
|
if not body:
|
||||||
|
continue
|
||||||
|
standalone, lifted = _split(body)
|
||||||
|
if lifted != body:
|
||||||
|
bind.execute(
|
||||||
|
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
|
||||||
|
{"body": lifted, "title": _display_title(lifted), "id": note_id},
|
||||||
|
)
|
||||||
|
# Even when the body did not move, a tag can be standalone only in the sense
|
||||||
|
# that its line was already removed by an earlier pass — so the flip is driven
|
||||||
|
# by the tag list, not by whether the text changed.
|
||||||
|
if standalone:
|
||||||
|
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""Deliberately empty — see the module docstring.
|
||||||
|
|
||||||
|
Restoring the deleted lines would be guessing, and flipping the rows back to
|
||||||
|
`via_tag = true` would be worse than doing nothing: the text that flag claims backs
|
||||||
|
them is gone, so the next save would detach the label and lose the tag for real.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""drop notes.color — a card is one neutral surface, colour lives on the tag
|
||||||
|
|
||||||
|
Revision ID: 0029
|
||||||
|
Revises: 0028
|
||||||
|
Create Date: 2026-08-28
|
||||||
|
|
||||||
|
M315 step 3. A note's colour was set by a picker and read by three card renderers.
|
||||||
|
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
|
||||||
|
the only coloured thing on a board is a tag. This drops the column that nothing has
|
||||||
|
been reading since, and the picker goes with it.
|
||||||
|
|
||||||
|
`labels.color` is untouched. That is the colour that survived, and the one the whole
|
||||||
|
milestone was about keeping.
|
||||||
|
|
||||||
|
## What is lost, and why that is the change rather than a cost of it
|
||||||
|
|
||||||
|
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
|
||||||
|
it would be preserved in is the one being dropped — and nothing renders it, so a
|
||||||
|
preserved value would be a column kept warm for a feature that was deliberately
|
||||||
|
removed. A note that had a colour now takes its identity from its tags, which is what
|
||||||
|
the operator asked for: "strip color from the cards ... and keep the color for tags
|
||||||
|
just on the tag."
|
||||||
|
|
||||||
|
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
|
||||||
|
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
|
||||||
|
defined on the model that lost one would be an invitation to put the column back.
|
||||||
|
|
||||||
|
## The saved-filter sweep is not optional
|
||||||
|
|
||||||
|
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
|
||||||
|
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
|
||||||
|
there forever, and `clean_params` only guards what is written FROM here on. A view that
|
||||||
|
silently filters on a field the app no longer has is worse than one that visibly lost a
|
||||||
|
criterion, so the stored rows are swept too.
|
||||||
|
|
||||||
|
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
|
||||||
|
try-cast: one malformed blob would abort the whole migration, and these rows are
|
||||||
|
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
|
||||||
|
holds and lets every other row be fixed.
|
||||||
|
|
||||||
|
## Search is not affected
|
||||||
|
|
||||||
|
`notes.search_vector` is a stored generated column over `display_title` and `body`
|
||||||
|
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
|
||||||
|
here to tear down and recreate.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Restores the column, empty, at its old default. The values are not recoverable — see
|
||||||
|
above. It is the schema that comes back, not the data.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0029"
|
||||||
|
down_revision = "0028"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("notes", "color")
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text("SELECT id, params FROM saved_filters WHERE params LIKE '%color%'")
|
||||||
|
).fetchall()
|
||||||
|
for sf_id, params in rows:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(params)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# A blob that does not parse cannot be edited safely. Leaving it is
|
||||||
|
# correct: it was already unreadable by the app, and this migration is not
|
||||||
|
# the place to decide what it should have said.
|
||||||
|
continue
|
||||||
|
if not isinstance(parsed, dict) or "color" not in parsed:
|
||||||
|
continue
|
||||||
|
parsed.pop("color")
|
||||||
|
bind.execute(
|
||||||
|
sa.text("UPDATE saved_filters SET params = :p WHERE id = :id"),
|
||||||
|
{"p": json.dumps(parsed), "id": sf_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Comes back at the default every note would have had anyway. Which notes once
|
||||||
|
# carried a chosen colour is not recorded anywhere after the upgrade.
|
||||||
|
op.add_column(
|
||||||
|
"notes",
|
||||||
|
sa.Column("color", sa.Text(), nullable=False, server_default="default"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""a stored site_name of the old default follows the rename to Inkwell
|
||||||
|
|
||||||
|
Revision ID: 0030
|
||||||
|
Revises: 0029
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
|
||||||
|
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name`
|
||||||
|
registry default moves with it. On its own that only reaches servers whose settings
|
||||||
|
table has no `site_name` row — and most servers have one, because the Settings page
|
||||||
|
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
|
||||||
|
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
|
||||||
|
|
||||||
|
So the row is rewritten, but only when it holds exactly the old default. A name the
|
||||||
|
admin actually typed is theirs and is left alone; the old default is the one value we
|
||||||
|
can be sure nobody chose.
|
||||||
|
|
||||||
|
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
|
||||||
|
`'"ThoughtSync"'` is the whole of what it looks for.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Puts the old default back on a row holding exactly the new one. A server where the
|
||||||
|
admin typed "Inkwell" themselves between the two cannot be told apart from one this
|
||||||
|
migration rewrote; on downgrade both read "ThoughtSync", which is what the old code
|
||||||
|
would have shown for either.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0030"
|
||||||
|
down_revision = "0029"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_OLD = '"ThoughtSync"'
|
||||||
|
_NEW = '"Inkwell"'
|
||||||
|
|
||||||
|
|
||||||
|
def _swap(old: str, new: str) -> None:
|
||||||
|
op.get_bind().execute(
|
||||||
|
sa.text("UPDATE settings SET value = :new WHERE key = 'site_name' AND value = :old"),
|
||||||
|
{"old": old, "new": new},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_swap(_OLD, _NEW)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_swap(_NEW, _OLD)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""a link preview's insert, update or delete bumps its note's sync revision
|
||||||
|
|
||||||
|
Revision ID: 0031
|
||||||
|
Revises: 0030
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
0015 made every child table bump its parent note's `sync_revision`, so a note syncs
|
||||||
|
as a whole. `note_link_previews` arrived later (0020) and was never added, and two
|
||||||
|
things have been missing on every linked device since:
|
||||||
|
|
||||||
|
- A preview fetched in the background after a save (`unfurl_queue.py`) never reached
|
||||||
|
a device that had already pulled the note. The note's revision was assigned when
|
||||||
|
the TEXT was saved, before the preview existed, and nothing moved it afterwards.
|
||||||
|
- A preview dismissed on the web stayed on every other device, for the same reason.
|
||||||
|
|
||||||
|
The trigger is the same function 0015 installs on the other child tables.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the trigger. Previews go back to not propagating; nothing is lost.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0031"
|
||||||
|
down_revision = "0030"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER trg_note_link_previews_bump_note AFTER INSERT OR UPDATE OR DELETE "
|
||||||
|
"ON note_link_previews FOR EACH ROW EXECUTE PROCEDURE ts_bump_parent_note_revision()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS trg_note_link_previews_bump_note ON note_link_previews")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""invites: single-use, expiring registration links an admin issues
|
||||||
|
|
||||||
|
Revision ID: 0032
|
||||||
|
Revises: 0031
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Until now the only way to add a second person was to re-open registration to the
|
||||||
|
whole internet while they signed up (#2939 §1). An invite lets one person register
|
||||||
|
while registration stays closed. Only the token's SHA-256 hash is stored.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table. Accounts created through invites are untouched; the record of who
|
||||||
|
invited them goes with it.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import CITEXT, UUID
|
||||||
|
|
||||||
|
revision = "0032"
|
||||||
|
down_revision = "0031"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"invites",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("email", CITEXT(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("redeemed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("invites")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""password resets: an admin-issued, one-hour link; sessions an account can outlive
|
||||||
|
|
||||||
|
Revision ID: 0033
|
||||||
|
Revises: 0032
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
A forgotten password used to need a hand on the database (#2939 §2), and the app has
|
||||||
|
no mail path to send a reset by. An admin makes a reset link for the account and
|
||||||
|
hands it over (#5173). Only the token's SHA-256 hash is stored.
|
||||||
|
|
||||||
|
`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions
|
||||||
|
are signed cookies held by the browser, so the server can't delete them; each one
|
||||||
|
carries the epoch it was signed in under, and a reset moves the account's epoch on.
|
||||||
|
It starts at 0, the value a cookie from before this migration is read as, so nobody
|
||||||
|
is signed out by the upgrade itself.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table and the column. Outstanding reset links stop working; sessions keep
|
||||||
|
working, since nothing checks an epoch any more.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0033"
|
||||||
|
down_revision = "0032"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
op.create_table(
|
||||||
|
"password_resets",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_password_resets_user_id", table_name="password_resets")
|
||||||
|
op.drop_table("password_resets")
|
||||||
|
op.drop_column("users", "session_epoch")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""shares: one grant per note and person, and only the permissions the app knows
|
||||||
|
|
||||||
|
Revision ID: 0034
|
||||||
|
Revises: 0033
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Nothing wrote a share until #5174, so the table never needed these. Now the Share
|
||||||
|
dialog does:
|
||||||
|
- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is
|
||||||
|
the target, so sharing a note with someone twice updates the one grant rather than
|
||||||
|
stacking two (the same for a group, ahead of step 15).
|
||||||
|
- A check that `permission` is `view` or `edit`.
|
||||||
|
- An index on `shared_with_user_id` for "Shared with me".
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the indexes and the check. The rows stay.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0034"
|
||||||
|
down_revision = "0033"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"uq_shares_resource_user",
|
||||||
|
"shares",
|
||||||
|
["resource_type", "resource_id", "shared_with_user_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="shared_with_user_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_shares_resource_group",
|
||||||
|
"shares",
|
||||||
|
["resource_type", "resource_id", "shared_with_group_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="shared_with_group_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_index("ix_shares_user", "shares", ["shared_with_user_id"])
|
||||||
|
op.create_check_constraint("ck_shares_permission", "shares", "permission IN ('view', 'edit')")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_shares_permission", "shares", type_="check")
|
||||||
|
op.drop_index("ix_shares_user", table_name="shares")
|
||||||
|
op.drop_index("uq_shares_resource_group", table_name="shares")
|
||||||
|
op.drop_index("uq_shares_resource_user", table_name="shares")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""share_revocations: telling a recipient's devices a shared note has left them
|
||||||
|
|
||||||
|
Revision ID: 0035
|
||||||
|
Revises: 0034
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
The change feed carries every note a person can see, including notes shared with
|
||||||
|
them (#5175). When a share ends, the note stops being visible, so it simply stops
|
||||||
|
appearing in the feed. A device that already holds a copy would keep it forever. A
|
||||||
|
revocation is that missing signal: one row per (note, person) who lost the note,
|
||||||
|
stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits
|
||||||
|
on the one cursor every client already pages by.
|
||||||
|
|
||||||
|
Sharing the note with that person again deletes the row, so a device that never
|
||||||
|
heard of the revocation never gets told to delete a note it is meant to have.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table. Devices that missed a revocation keep their copy.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0035"
|
||||||
|
down_revision = "0034"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"share_revocations",
|
||||||
|
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"sync_revision",
|
||||||
|
sa.BigInteger(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("nextval('sync_revision_seq')"),
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint("note_id", "user_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_share_revocations_user_revision", "share_revocations", ["user_id", "sync_revision"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_share_revocations_user_revision", table_name="share_revocations")
|
||||||
|
op.drop_table("share_revocations")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""note_user_state: a recipient's own pin, archive and place for a shared note
|
||||||
|
|
||||||
|
Revision ID: 0036
|
||||||
|
Revises: 0035
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Pin, archive and board order are personal organization, and until now they were
|
||||||
|
columns on `notes`, so on a shared note they could only ever mean the owner's
|
||||||
|
(#5176). This table holds them for everyone else: one row per (note, person it is
|
||||||
|
shared with) who has pinned, archived or moved it. The owner keeps the note's own
|
||||||
|
columns; a recipient with no row sees the note unpinned, unarchived, in the owner's
|
||||||
|
order.
|
||||||
|
|
||||||
|
`sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes
|
||||||
|
and labels use (0015), so a recipient's change reaches their own devices on the one
|
||||||
|
cursor they already page by, and never moves the note on anyone else's.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table. Recipients lose their own pins and archives; the notes are
|
||||||
|
untouched.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0036"
|
||||||
|
down_revision = "0035"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"note_user_state",
|
||||||
|
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("sync_revision", sa.BigInteger(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("note_id", "user_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_note_user_state_user_revision", "note_user_state", ["user_id", "sync_revision"])
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER trg_note_user_state_sync_revision BEFORE INSERT OR UPDATE ON note_user_state "
|
||||||
|
"FOR EACH ROW EXECUTE PROCEDURE ts_set_sync_revision()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS trg_note_user_state_sync_revision ON note_user_state")
|
||||||
|
op.drop_index("ix_note_user_state_user_revision", table_name="note_user_state")
|
||||||
|
op.drop_table("note_user_state")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""users: drop email_verified and avatar_path, which nothing used
|
||||||
|
|
||||||
|
Revision ID: 0037
|
||||||
|
Revises: 0036
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Both came with the foundation (0001) for features that were never built (#5178).
|
||||||
|
Nothing ever set `email_verified`, so it was false for every account on the server
|
||||||
|
while the offline desktop reported it as true; nothing read or wrote `avatar_path`.
|
||||||
|
A field whose value means nothing is worse than no field: a later feature would
|
||||||
|
trust it. If address verification is ever built, it adds its own column, with a
|
||||||
|
migration that says what the existing accounts are.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Adds both columns back empty: every account unverified, no avatar. That is what
|
||||||
|
they held before.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0037"
|
||||||
|
down_revision = "0036"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("users", "email_verified")
|
||||||
|
op.drop_column("users", "avatar_path")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("avatar_path", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""saved_filters: drop the table, saved views are gone
|
||||||
|
|
||||||
|
Revision ID: 0038
|
||||||
|
Revises: 0037
|
||||||
|
Create Date: 2026-10-08
|
||||||
|
|
||||||
|
Saved views were removed in #5180 (note 2897: recall first, organizing second). They
|
||||||
|
lived only on the web; the desktop kept its own set that never synced, and Android
|
||||||
|
had none. Tags in the drawer already give one-click recall.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Recreates the table empty, as 0021 made it. The views that were stored are not
|
||||||
|
recoverable.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0038"
|
||||||
|
down_revision = "0037"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_index("ix_saved_filters_owner", table_name="saved_filters")
|
||||||
|
op.drop_table("saved_filters")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"saved_filters",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("params", sa.Text(), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_saved_filters_owner", "saved_filters", ["owner_id", "position"])
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*.{kt,kts}]
|
||||||
|
# ktlint's standard function-naming rule doesn't know about Compose, where
|
||||||
|
# PascalCase @Composable functions are the universal convention — every
|
||||||
|
# mainstream Compose codebase would fail it. This is ktlint's own supported
|
||||||
|
# exemption, and it mirrors the equivalent detekt override in config/detekt.yml.
|
||||||
|
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||||
|
|
||||||
|
# 120 rather than ktlint's looser default: this is a phone UI with deeply nested
|
||||||
|
# Compose calls, and a hard-ish ceiling is what keeps the nesting from becoming
|
||||||
|
# unreadable rather than merely long.
|
||||||
|
max_line_length = 120
|
||||||
|
indent_size = 4
|
||||||
|
insert_final_newline = true
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Cargo workspace root — two levels up from android/app.
|
||||||
|
val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
|
||||||
|
|
||||||
|
// The ABIs a release APK carries. arm64 is essentially every real device; armv7
|
||||||
|
// covers older 32-bit hardware; the two x86 targets are what emulators run on, and
|
||||||
|
// dropping them would make the app untestable on a desktop emulator (the reason
|
||||||
|
// x86_64 was added to the old Tauri lane in task 1864).
|
||||||
|
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting
|
||||||
|
* `.so` into jniLibs, where AGP packages it.
|
||||||
|
*
|
||||||
|
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
|
||||||
|
* Gradle 9, and reaching for `project` at execution time is also what breaks the
|
||||||
|
* configuration cache this build has enabled.
|
||||||
|
*/
|
||||||
|
abstract class CargoNdkBuild : DefaultTask() {
|
||||||
|
@get:Inject
|
||||||
|
abstract val execOps: ExecOperations
|
||||||
|
|
||||||
|
@get:InputFiles
|
||||||
|
abstract val rustSources: ConfigurableFileCollection
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val abis: ListProperty<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val cargoProfile: Property<String>
|
||||||
|
|
||||||
|
@get:Internal
|
||||||
|
abstract val workspaceDir: DirectoryProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val jniLibsDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun build() {
|
||||||
|
val args = mutableListOf("ndk")
|
||||||
|
abis.get().forEach { abi ->
|
||||||
|
args += "-t"
|
||||||
|
args += abi
|
||||||
|
}
|
||||||
|
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "inkwell-ffi")
|
||||||
|
// --locked so an Android build cannot silently re-resolve the workspace
|
||||||
|
// lockfile the desktop lanes are gated on.
|
||||||
|
args += "--locked"
|
||||||
|
if (cargoProfile.get() == "release") args += "--release"
|
||||||
|
|
||||||
|
execOps.exec {
|
||||||
|
commandLine(listOf("cargo") + args)
|
||||||
|
workingDir = workspaceDir.get().asFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the Kotlin bindings FROM the freshly built `.so`.
|
||||||
|
*
|
||||||
|
* `--library` mode reads uniffi's metadata straight out of the compiled artifact,
|
||||||
|
* so the bindings can never describe a different version of the Rust than the one
|
||||||
|
* being packaged — which is the failure the whole in-workspace generator setup
|
||||||
|
* exists to prevent.
|
||||||
|
*/
|
||||||
|
abstract class UniffiBindgen : DefaultTask() {
|
||||||
|
@get:Inject
|
||||||
|
abstract val execOps: ExecOperations
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
abstract val libraryFile: RegularFileProperty
|
||||||
|
|
||||||
|
@get:Internal
|
||||||
|
abstract val workspaceDir: DirectoryProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
val out = outputDir.get().asFile
|
||||||
|
out.deleteRecursively()
|
||||||
|
out.mkdirs()
|
||||||
|
execOps.exec {
|
||||||
|
commandLine(
|
||||||
|
"cargo",
|
||||||
|
"run",
|
||||||
|
"--locked",
|
||||||
|
"-p",
|
||||||
|
"inkwell-uniffi-bindgen",
|
||||||
|
"--",
|
||||||
|
"generate",
|
||||||
|
"--library",
|
||||||
|
libraryFile.get().asFile.absolutePath,
|
||||||
|
"--language",
|
||||||
|
"kotlin",
|
||||||
|
"--out-dir",
|
||||||
|
out.absolutePath,
|
||||||
|
)
|
||||||
|
workingDir = workspaceDir.get().asFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the Rust that actually affects the .so. Deliberately NOT the workspace
|
||||||
|
// directory: that would make Gradle hash target/, which is gigabytes.
|
||||||
|
val rustInputs =
|
||||||
|
files(
|
||||||
|
workspaceRoot.dir("core/src"),
|
||||||
|
workspaceRoot.dir("android/ffi/src"),
|
||||||
|
workspaceRoot.file("core/Cargo.toml"),
|
||||||
|
workspaceRoot.file("android/ffi/Cargo.toml"),
|
||||||
|
workspaceRoot.file("Cargo.toml"),
|
||||||
|
workspaceRoot.file("Cargo.lock"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Cargo profile the `.so` is built with.
|
||||||
|
*
|
||||||
|
* A property rather than a debug/release task PAIR, deliberately. This runner has
|
||||||
|
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold
|
||||||
|
* cross-compile of four ABIs costs about four minutes — and a lane that both
|
||||||
|
* type-checks and packages would pay that twice if the two used different
|
||||||
|
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
|
||||||
|
* the run.
|
||||||
|
*
|
||||||
|
* CI currently passes `debug` even for a release APK, which is not where this
|
||||||
|
* should end up: an unoptimised store and sync engine is a real difference on a
|
||||||
|
* phone, not a theoretical one. The blocker is that the workspace's release
|
||||||
|
* profile sets `strip = true`, which removes the symbols uniffi reads its
|
||||||
|
* interface metadata from — `generateUniffiBindings` then fails with "No UniFFI
|
||||||
|
* metadata found" (run 4077). Fixing it means either an Android-specific profile
|
||||||
|
* that keeps symbols or generating the bindings from a separate unstripped
|
||||||
|
* build, and neither is worth holding signed APKs up for. Scribe #2810.
|
||||||
|
*/
|
||||||
|
val rustProfile =
|
||||||
|
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||||
|
?: "debug"
|
||||||
|
|
||||||
|
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
||||||
|
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
||||||
|
|
||||||
|
val cargoNdk =
|
||||||
|
tasks.register<CargoNdkBuild>("cargoNdk") {
|
||||||
|
description = "Cross-compile inkwell-ffi for the Android ABIs."
|
||||||
|
rustSources.from(rustInputs)
|
||||||
|
abis.set(androidAbis)
|
||||||
|
cargoProfile.set(rustProfile)
|
||||||
|
workspaceDir.set(workspaceRoot)
|
||||||
|
jniLibsDir.set(jniLibsOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
val generateBindings =
|
||||||
|
tasks.register<UniffiBindgen>("generateUniffiBindings") {
|
||||||
|
description = "Generate the Kotlin bindings from the compiled .so."
|
||||||
|
dependsOn(cargoNdk)
|
||||||
|
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
||||||
|
// reading one is cheaper than reading four.
|
||||||
|
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libinkwell_ffi.so") })
|
||||||
|
workspaceDir.set(workspaceRoot)
|
||||||
|
outputDir.set(bindingsOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.fabledsword.inkwell"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.fabledsword.inkwell"
|
||||||
|
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
|
||||||
|
// room to spare.
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
// Injected by CI from the git tag + commit count for a release; "dev"
|
||||||
|
// locally so the About screen reads honestly rather than claiming 1.0.
|
||||||
|
val nameOverride =
|
||||||
|
(project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||||
|
val codeOverride =
|
||||||
|
(project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull()
|
||||||
|
versionCode = codeOverride ?: 1
|
||||||
|
versionName = nameOverride ?: "dev"
|
||||||
|
|
||||||
|
// Package ONLY the ABIs we build for.
|
||||||
|
//
|
||||||
|
// Without this the APK also carries armeabi, mips and mips64 — dead
|
||||||
|
// architectures Android dropped years ago, which arrive because JNA's
|
||||||
|
// .aar still ships a libjnidispatch.so for each. They can never be
|
||||||
|
// loaded on any device this app supports, so they are pure payload.
|
||||||
|
ndk {
|
||||||
|
abiFilters += androidAbis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signing key reaches this build only through the environment: CI decodes
|
||||||
|
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path.
|
||||||
|
// It is never in the repo and never in this file. Generated by the operator
|
||||||
|
// and never seen by an agent session, because an Android signing key cannot be
|
||||||
|
// rotated without the original — v3 lineage needs it — so a leaked or lost one
|
||||||
|
// means every install has to be removed and replaced by hand.
|
||||||
|
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
|
||||||
|
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (keystoreFile != null && keystorePassword != null) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = File(keystoreFile)
|
||||||
|
storePassword = keystorePassword
|
||||||
|
// Hardcoded, and NOT a secret: the alias is fixed for the life of
|
||||||
|
// this app and is written into the certificate every install
|
||||||
|
// already carries. Hiding it would buy nothing and stop this file
|
||||||
|
// describing its own signing setup. It still says `thoughtsync`
|
||||||
|
// after the rename to Inkwell, because it names the key inside the
|
||||||
|
// existing keystore, and renaming it would only stop that key being
|
||||||
|
// found. Same key, so the signing certificate did not change either.
|
||||||
|
keyAlias = "thoughtsync"
|
||||||
|
// PKCS12 cannot hold a key password distinct from the store
|
||||||
|
// password — keytool refuses to set one — so this is the same
|
||||||
|
// value by necessity rather than by shortcut.
|
||||||
|
keyPassword = keystorePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
// Null when no keystore reached this build, which leaves the APK
|
||||||
|
// unsigned and therefore uninstallable. `android.yml` builds debug in
|
||||||
|
// that case rather than producing an artifact nobody can put on a
|
||||||
|
// phone.
|
||||||
|
signingConfig = signingConfigs.findByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the `.so` and the generated bindings as GENERATED sources.
|
||||||
|
*
|
||||||
|
* NOT `sourceSets { ... srcDir(task) }`: AGP 9 rejects a Provider there outright,
|
||||||
|
* because it cannot tell whether the directory holds generated (read-only) or
|
||||||
|
* hand-written (read-write) files — a distinction the IDE needs. The Variant API
|
||||||
|
* is the supported route and, unlike a bare path, `addGeneratedSourceDirectory`
|
||||||
|
* carries the task dependency, so Kotlin cannot compile before the bindings
|
||||||
|
* exist and the APK cannot package a stale `.so`.
|
||||||
|
*/
|
||||||
|
androidComponents {
|
||||||
|
onVariants { variant ->
|
||||||
|
variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir)
|
||||||
|
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdkBuild::jniLibsDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.androidx.work.runtime)
|
||||||
|
|
||||||
|
// Required by the uniffi bindings — see the catalog note on the @aar
|
||||||
|
// classifier; the plain jar builds fine and fails at runtime.
|
||||||
|
implementation(variantOf(libs.jna) { artifactType("aar") })
|
||||||
|
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.ui.graphics)
|
||||||
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.compose.material.icons.core)
|
||||||
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# JNA reaches the native library reflectively, so R8 must not rename or strip
|
||||||
|
# either it or the uniffi bindings that ride on it. Without these a minified
|
||||||
|
# build fails at runtime with UnsatisfiedLinkError — and only in release, which
|
||||||
|
# is the worst possible time to learn it.
|
||||||
|
-keep class com.sun.jna.** { *; }
|
||||||
|
-keepclassmembers class * extends com.sun.jna.** { public *; }
|
||||||
|
-keep class com.fabledsword.inkwell.core.** { *; }
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
INTERNET is requested but nothing uses it until the user links a server.
|
||||||
|
The app is local-first: the store, capture and the whole board work with
|
||||||
|
this permission never exercised.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Only to answer "is this connection metered?" before the app downloads its own
|
||||||
|
update in the background. Normal permission, no prompt, no location. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Four more permissions are NOT declared here and still reach the merged
|
||||||
|
manifest, contributed by WorkManager for the automatic sync:
|
||||||
|
|
||||||
|
RECEIVE_BOOT_COMPLETED reschedules the periodic sync after a restart,
|
||||||
|
instead of it silently stopping until the app is
|
||||||
|
next opened by hand
|
||||||
|
ACCESS_NETWORK_STATE evaluates the "needs a network" constraint, so a
|
||||||
|
run is not attempted with no route to the server
|
||||||
|
WAKE_LOCK holds the device awake for the seconds a sync
|
||||||
|
takes, so it is not suspended mid-request
|
||||||
|
FOREGROUND_SERVICE used only for expedited work; nothing here asks
|
||||||
|
for it, and it arrives with the library
|
||||||
|
|
||||||
|
Verified against the built APK's merged manifest, not assumed. Noted here
|
||||||
|
because all four appear in the app's permission list and nothing else in
|
||||||
|
this file would explain where they came from.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
usesCleartextTraffic, deliberately.
|
||||||
|
|
||||||
|
Android blocks plain HTTP by default from API 28, and the core explicitly
|
||||||
|
supports a self-hosted server on a LAN — `http://192.168.1.10:8000` is a
|
||||||
|
case it has a test for. Leaving the platform default would make this app
|
||||||
|
unusable for exactly the people it is built for, with a transport error
|
||||||
|
they could do nothing about.
|
||||||
|
|
||||||
|
Scoped by the fact that the app talks to ONE host: the server the user
|
||||||
|
typed in. There is no ad SDK, no analytics, nothing else making requests.
|
||||||
|
A network-security-config would be tighter in principle, but it matches on
|
||||||
|
domains and IP literals rather than CIDR ranges, so it cannot express
|
||||||
|
"any address on my own network" — the case that actually matters here.
|
||||||
|
|
||||||
|
The trade is not made silently: the sync screen shows an unmissable
|
||||||
|
warning when the probed address is http://, BEFORE any credential field
|
||||||
|
appears. See SyncScreen.kt.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
Reminders.
|
||||||
|
|
||||||
|
POST_NOTIFICATIONS is a runtime permission from API 33. It is asked for in
|
||||||
|
context — the first time the app opens holding a reminder that could fire,
|
||||||
|
never at launch on an empty board, where there would be nothing to explain
|
||||||
|
why it is being asked.
|
||||||
|
|
||||||
|
SCHEDULE_EXACT_ALARM rather than USE_EXACT_ALARM. USE_EXACT_ALARM is granted
|
||||||
|
at install with no prompt, and is reserved for apps whose whole purpose is an
|
||||||
|
alarm clock or calendar; a note app claiming it would be claiming something
|
||||||
|
untrue. SCHEDULE_EXACT_ALARM is the one the person can grant or refuse, and
|
||||||
|
refusing costs precision, not the feature — see Reminders.scheduleNext.
|
||||||
|
|
||||||
|
RECEIVE_BOOT_COMPLETED already arrives via WorkManager (below), but is
|
||||||
|
declared here too because ReminderReceiver now depends on it directly. A
|
||||||
|
permission this file relies on should be visible in this file.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
Updating this app from the server it syncs with (M12 step 7).
|
||||||
|
|
||||||
|
REQUEST_INSTALL_PACKAGES lets the app hand an APK to the system installer at
|
||||||
|
all. It is NOT what makes an install look suspicious to on-device heuristics
|
||||||
|
— Mihon declares it too — the legacy ACTION_VIEW install intent was, and this
|
||||||
|
app uses a PackageInstaller session instead. See AppUpdate.kt and Scribe note
|
||||||
|
2437. The person must additionally grant "install unknown apps" in system
|
||||||
|
settings; the update card asks before downloading anything.
|
||||||
|
|
||||||
|
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) is what removes the install
|
||||||
|
confirmation on the UPDATE path, and only there — Android will not let an app
|
||||||
|
silently put a NEW package on a device, which is correct. It also only applies
|
||||||
|
when the new build is signed with the same key as the installed one, which is
|
||||||
|
why signing had to land before any of this could work.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".InkwellApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Inkwell"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<!--
|
||||||
|
launchMode="singleTop" exists for the SHARE filters below.
|
||||||
|
|
||||||
|
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
|
||||||
|
intent, so onNewIntent already worked for that one. A share intent is
|
||||||
|
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
|
||||||
|
and nothing here can add a flag to it. Without singleTop declared on the
|
||||||
|
activity itself, every share while the app is running would stack a
|
||||||
|
second MainActivity on top of the first: a second view model, a second
|
||||||
|
board, and a back press that lands on a stale copy of the same app.
|
||||||
|
-->
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.Inkwell">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Capture without opening the app first: Share → Inkwell from
|
||||||
|
anywhere, and the selection toolbar in any text field.
|
||||||
|
|
||||||
|
Text, and images one at a time or several at once. A shared image
|
||||||
|
becomes a note carrying it as an attachment, stored on the phone
|
||||||
|
and uploaded on the next sync that reaches a server (#5169).
|
||||||
|
|
||||||
|
image/* rather than */*: a photo or a screenshot is what people
|
||||||
|
share into a notes app. Claiming every type would put Inkwell in the
|
||||||
|
share sheet for APKs, contacts and calendar entries, where it would
|
||||||
|
be noise. Other files attach from inside the editor, whose picker
|
||||||
|
takes any type.
|
||||||
|
-->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The label is what appears in the text-selection menu beside Copy and
|
||||||
|
Share, where "Inkwell" would say who rather than what.
|
||||||
|
-->
|
||||||
|
<intent-filter android:label="@string/capture_process_text">
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Not exported: every intent that reaches it is one this app created, with
|
||||||
|
an explicit component. Exporting would let any app on the device mark
|
||||||
|
someone's reminders as done.
|
||||||
|
|
||||||
|
The two system broadcasts are the exception and need the filter, because
|
||||||
|
the system is the sender. Both exist for the same reason — pending alarms
|
||||||
|
do not survive either a reboot or an app update, so without this a phone
|
||||||
|
that restarts overnight would quietly stop reminding anyone of anything.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
Where the system reports what happened to an install we committed. Not
|
||||||
|
exported: the only sender is the PendingIntent this app handed to
|
||||||
|
PackageInstaller. Without it a failed install would be indistinguishable
|
||||||
|
from someone declining the dialog (Scribe #2438).
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
Hands an attachment to the app that opens its type. Not exported, as a
|
||||||
|
FileProvider must not be: access is granted one URI at a time, on the
|
||||||
|
intent that opens it. It serves only the cache copies listed in
|
||||||
|
res/xml/file_paths.xml, never the note store.
|
||||||
|
-->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.files"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".UpdateReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ReminderReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentSender
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replacing this app with a newer build of itself.
|
||||||
|
*
|
||||||
|
* ## A PackageInstaller session, not an install intent
|
||||||
|
*
|
||||||
|
* The obvious route — `ACTION_VIEW` on the APK with
|
||||||
|
* `application/vnd.android.package-archive` — is the one on-device install
|
||||||
|
* heuristics are tuned against, and it is what produces the "bypassing Android
|
||||||
|
* security" warning the operator saw on Minstrel (Scribe note 2437). It also never
|
||||||
|
* tells the OS that this app is the legitimate updater of its own package, and it
|
||||||
|
* returns nothing: a failed install is indistinguishable from a person dismissing
|
||||||
|
* the dialog.
|
||||||
|
*
|
||||||
|
* A session says who is doing what. On Android 12+ it can also declare that no user
|
||||||
|
* action is required, which — paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION` —
|
||||||
|
* removes the confirmation dialog entirely on the UPDATE path. Not on a first
|
||||||
|
* install: the OS will not let an app quietly put a NEW package on a device, which
|
||||||
|
* is right.
|
||||||
|
*
|
||||||
|
* Two things from that same research that are NOT done here, deliberately:
|
||||||
|
* `setRequestUpdateOwnership` was chased and turned out to be a red herring, and
|
||||||
|
* `REQUEST_INSTALL_PACKAGES` is not the differentiator either — Mihon declares it
|
||||||
|
* too. The mechanism was the whole difference.
|
||||||
|
*
|
||||||
|
* ## The outcome comes back
|
||||||
|
*
|
||||||
|
* `commit` takes an `IntentSender`; the system reports the result to
|
||||||
|
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
|
||||||
|
*/
|
||||||
|
object AppUpdate {
|
||||||
|
private const val TAG = "InkwellUpdate"
|
||||||
|
|
||||||
|
/** This build's versionCode — what the server's is compared against. */
|
||||||
|
fun installedVersionCode(context: Context): Long =
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
|
||||||
|
}.getOrDefault(0L)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this app may install packages at all.
|
||||||
|
*
|
||||||
|
* A separate grant from anything in the manifest, and one only the person can
|
||||||
|
* give. Checked before offering an update rather than after downloading 55 MiB.
|
||||||
|
*/
|
||||||
|
fun canInstall(context: Context): Boolean = context.packageManager.canRequestPackageInstalls()
|
||||||
|
|
||||||
|
/** The settings page where that grant lives, scoped to this app. */
|
||||||
|
fun installPermissionSettings(context: Context): Intent =
|
||||||
|
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
|
||||||
|
.setData(Uri.fromParts("package", context.packageName, null))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is wifi somebody is not paying by the megabyte for.
|
||||||
|
*
|
||||||
|
* The app fetches its own update in the background, and fifty-odd megabytes over
|
||||||
|
* mobile data is a bill nobody agreed to. Anywhere else it simply waits — the
|
||||||
|
* update is found, nothing is downloaded, and nothing is said until it can be.
|
||||||
|
*
|
||||||
|
* BOTH conditions, deliberately. Wifi alone would still download over a tethered
|
||||||
|
* hotspot, which is mobile data wearing a different hat and the exact bill this
|
||||||
|
* avoids. Unmetered alone would download over an unmetered cellular plan, which
|
||||||
|
* is not what "on wifi" means to the person who asked for it.
|
||||||
|
*
|
||||||
|
* Every uncertain answer is `false`: the cautious one costs nothing.
|
||||||
|
*/
|
||||||
|
fun onWifi(context: Context): Boolean {
|
||||||
|
val caps =
|
||||||
|
context
|
||||||
|
.getSystemService(ConnectivityManager::class.java)
|
||||||
|
?.let { manager -> manager.activeNetwork?.let(manager::getNetworkCapabilities) }
|
||||||
|
return caps != null &&
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a download goes: app-private, so no storage permission is involved. */
|
||||||
|
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the APK to the system installer.
|
||||||
|
*
|
||||||
|
* Streamed into the session rather than passed as a path or a content URI —
|
||||||
|
* the session takes bytes, which is also why no FileProvider is needed here.
|
||||||
|
*
|
||||||
|
* Returns the failure to show, or null when the install was handed over
|
||||||
|
* successfully. "Handed over" is the honest word: the real outcome arrives
|
||||||
|
* later at [UpdateReceiver], because a commit that the system accepts can still
|
||||||
|
* fail afterwards.
|
||||||
|
*/
|
||||||
|
fun install(
|
||||||
|
context: Context,
|
||||||
|
apk: File,
|
||||||
|
): String? {
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
var sessionId = -1
|
||||||
|
return try {
|
||||||
|
val params =
|
||||||
|
PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
// Only honoured on an UPDATE of an app signed with the same key —
|
||||||
|
// exactly our case, and the reason the signing work had to land
|
||||||
|
// first. Android ignores it for anything else rather than failing,
|
||||||
|
// so there is no need to guard on which it is.
|
||||||
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionId = installer.createSession(params)
|
||||||
|
installer.openSession(sessionId).use { session ->
|
||||||
|
writeApk(session, apk)
|
||||||
|
session.commit(statusSender(context))
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Broad on purpose: createSession throws IOException, openWrite throws,
|
||||||
|
// and the framework raises SecurityException for a revoked grant. All of
|
||||||
|
// them mean one thing to the person — it did not install — and none of
|
||||||
|
// them should take the app down.
|
||||||
|
Log.w(TAG, "could not start the install session", e)
|
||||||
|
if (sessionId != -1) runCatching { installer.abandonSession(sessionId) }
|
||||||
|
e.message ?: "The update could not be installed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the APK into the session.
|
||||||
|
*
|
||||||
|
* Its own function only because the two nested `use` blocks read badly inline —
|
||||||
|
* and detekt agreed, which is fair: a stream inside a session inside a try is
|
||||||
|
* three things to hold at once.
|
||||||
|
*/
|
||||||
|
private fun writeApk(
|
||||||
|
session: PackageInstaller.Session,
|
||||||
|
apk: File,
|
||||||
|
) {
|
||||||
|
session.openWrite(WRITE_NAME, 0, apk.length()).use { out ->
|
||||||
|
apk.inputStream().use { it.copyTo(out) }
|
||||||
|
// Before close: the session must have the bytes on disk, not sitting in
|
||||||
|
// a buffer, or commit can be handed a short file.
|
||||||
|
session.fsync(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusSender(context: Context): IntentSender {
|
||||||
|
val intent =
|
||||||
|
Intent(context, UpdateReceiver::class.java).setAction(UpdateReceiver.ACTION_INSTALLED)
|
||||||
|
// MUTABLE, and this is the one place it is correct: the system fills the
|
||||||
|
// result extras in before delivering it. An immutable one would arrive with
|
||||||
|
// no status at all.
|
||||||
|
val flags =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
android.app.PendingIntent.FLAG_UPDATE_CURRENT or
|
||||||
|
android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
} else {
|
||||||
|
android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
return android.app.PendingIntent
|
||||||
|
.getBroadcast(context, 0, intent, flags)
|
||||||
|
.intentSender
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val WRITE_NAME = "inkwell-update"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `versionName` of the INSTALLED package, or null when it cannot be read.
|
||||||
|
*
|
||||||
|
* From the package manager rather than from `BuildConfig`: this reports what is
|
||||||
|
* actually on the phone, which is the question both callers are asking — a bug
|
||||||
|
* report reading the foot of Sync, and a server log reading the client header. It
|
||||||
|
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
|
||||||
|
*
|
||||||
|
* Returns null rather than a fallback string, because the two callers want
|
||||||
|
* different ones: the UI wants a localized "unknown" from string resources, the
|
||||||
|
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
|
||||||
|
* with no version tags, the artifact's self-report is the only answer to "which
|
||||||
|
* build is this?", so a missing name must read as missing and never as a plausible
|
||||||
|
* default that nothing can contradict.
|
||||||
|
*/
|
||||||
|
fun Context.installedVersionName(): String? =
|
||||||
|
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.core.setClientAgent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the shared Rust core once, for the process lifetime.
|
||||||
|
*
|
||||||
|
* The store is a single SQLite file behind a mutex, so one handle is both
|
||||||
|
* sufficient and correct — a second would be two connections racing for the same
|
||||||
|
* lock. This mirrors how the desktop manages it as Tauri app state.
|
||||||
|
*
|
||||||
|
* [filesDir] is app-private storage: readable by this app and nothing else,
|
||||||
|
* removed on uninstall, and never on external media. The core does not guess at
|
||||||
|
* platform paths; Android is the only thing that knows where this is.
|
||||||
|
*/
|
||||||
|
class InkwellApplication : Application() {
|
||||||
|
/**
|
||||||
|
* Null only if the store could not be opened — a corrupt or unwritable
|
||||||
|
* database. The UI reports that honestly rather than crashing on first
|
||||||
|
* touch, because a user whose notes won't open needs a message, not a
|
||||||
|
* stack trace.
|
||||||
|
*/
|
||||||
|
var core: Inkwell? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
var openFailure: String? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
// Introduce this app to any server it links to, before anything can sync.
|
||||||
|
// The core cannot name us — the same crate is compiled into the desktop app,
|
||||||
|
// and it used to announce every phone as `inkwell-desktop` carrying the
|
||||||
|
// core crate's own version. "unknown" rather than a guess when the package
|
||||||
|
// manager will not say (note 3127 §5).
|
||||||
|
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val handle = Inkwell(filesDir.absolutePath)
|
||||||
|
core = handle
|
||||||
|
Log.i(TAG, "local store ready — ${handle.summary()}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Deliberately broad: whatever went wrong, the app still has to
|
||||||
|
// start and say so. Narrowing this would mean an unanticipated
|
||||||
|
// failure mode takes the process down at launch instead.
|
||||||
|
openFailure = e.message ?: e.toString()
|
||||||
|
Log.e(TAG, "could not open the local store", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "Inkwell"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.ui.AttachmentFiles
|
||||||
|
import com.fabledsword.inkwell.ui.BoardScreen
|
||||||
|
import com.fabledsword.inkwell.ui.BoardSync
|
||||||
|
import com.fabledsword.inkwell.ui.BoardUpdate
|
||||||
|
import com.fabledsword.inkwell.ui.BoardViewModel
|
||||||
|
import com.fabledsword.inkwell.ui.ForegroundTransitions
|
||||||
|
import com.fabledsword.inkwell.ui.InkwellTheme
|
||||||
|
import com.fabledsword.inkwell.ui.LocalAttachmentFiles
|
||||||
|
import com.fabledsword.inkwell.ui.NoteEditorScreen
|
||||||
|
import com.fabledsword.inkwell.ui.ShareSheet
|
||||||
|
import com.fabledsword.inkwell.ui.ShareViewModel
|
||||||
|
import com.fabledsword.inkwell.ui.StoreUnavailableScreen
|
||||||
|
import com.fabledsword.inkwell.ui.SyncScreen
|
||||||
|
import com.fabledsword.inkwell.ui.SyncState
|
||||||
|
import com.fabledsword.inkwell.ui.SyncViewModel
|
||||||
|
import com.fabledsword.inkwell.ui.TagsScreen
|
||||||
|
import com.fabledsword.inkwell.ui.TagsViewModel
|
||||||
|
import com.fabledsword.inkwell.ui.UpdateViewModel
|
||||||
|
import com.fabledsword.inkwell.ui.olderThan
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
/**
|
||||||
|
* The note a reminder notification asked for, waiting to be opened.
|
||||||
|
*
|
||||||
|
* Held on the Activity rather than passed to `setContent` once, because a tap
|
||||||
|
* on a notification while the app is already running arrives at [onNewIntent],
|
||||||
|
* not [onCreate] — the composition is long since built by then and the only
|
||||||
|
* way in is a piece of state it is already reading.
|
||||||
|
*/
|
||||||
|
private val requestedNote = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text or files shared into the app from elsewhere, waiting to become a note.
|
||||||
|
*
|
||||||
|
* Same shape and same reason as [requestedNote]: a share that arrives while
|
||||||
|
* the app is already running lands in [onNewIntent], long after the
|
||||||
|
* composition was built, so a piece of state it is already reading is the only
|
||||||
|
* way in. The activity is `singleTop` in the manifest precisely so that this
|
||||||
|
* path exists for an intent another app built.
|
||||||
|
*/
|
||||||
|
private val shared = mutableStateOf<SharedIn?>(null)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val app = application as InkwellApplication
|
||||||
|
requestedNote.value = takeRequestedNote(intent)
|
||||||
|
shared.value = takeShared(intent)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
InkwellTheme {
|
||||||
|
val core = app.core
|
||||||
|
if (core == null) {
|
||||||
|
// The store never opened. There is no board to show and no
|
||||||
|
// action that would help, so say what happened plainly rather
|
||||||
|
// than render an empty board that looks like data loss.
|
||||||
|
StoreUnavailableScreen(reason = app.openFailure)
|
||||||
|
} else {
|
||||||
|
// Application context inside, so holding it for the
|
||||||
|
// composition's life cannot leak this activity.
|
||||||
|
val files = remember(core) { AttachmentFiles(this, core) }
|
||||||
|
CompositionLocalProvider(LocalAttachmentFiles provides files) {
|
||||||
|
App(core, files, requestedNote, shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
requestedNote.value = takeRequestedNote(intent)
|
||||||
|
shared.value = takeShared(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the note a notification asked for, and CONSUME it.
|
||||||
|
*
|
||||||
|
* The removal is the point. The Activity keeps the intent it was launched
|
||||||
|
* with, so without this, rotating the phone would replay it — reopening a note
|
||||||
|
* the person had tapped through and then closed, over and over, with no way to
|
||||||
|
* tell where it kept coming from.
|
||||||
|
*/
|
||||||
|
private fun takeRequestedNote(intent: Intent?): String? {
|
||||||
|
val id = intent?.getStringExtra(Reminders.EXTRA_NOTE_ID) ?: return null
|
||||||
|
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read what a share or a text selection brought in, and CONSUME it.
|
||||||
|
*
|
||||||
|
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
|
||||||
|
* intent it was launched with, so without removing the extras a rotation would
|
||||||
|
* replay the share and mint the same note again, with nothing on screen to
|
||||||
|
* explain where the duplicates were coming from.
|
||||||
|
*/
|
||||||
|
private fun takeShared(intent: Intent?): SharedIn? {
|
||||||
|
val incoming =
|
||||||
|
when (intent?.action) {
|
||||||
|
Intent.ACTION_SEND -> SharedIn(intent.takeSendText(), intent.takeStreams())
|
||||||
|
Intent.ACTION_SEND_MULTIPLE -> SharedIn(intent.takeSendText(), intent.takeStreams())
|
||||||
|
Intent.ACTION_PROCESS_TEXT -> SharedIn(intent.takeProcessText(), emptyList())
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return incoming?.takeIf { !it.text.isNullOrBlank() || it.files.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a share brought: words, files, or both — a photo shared from the gallery
|
||||||
|
* often comes with a caption.
|
||||||
|
*
|
||||||
|
* The files are content URIs the sending app granted this one read access to. The
|
||||||
|
* grant lasts while this activity does, which is longer than reading them takes.
|
||||||
|
*/
|
||||||
|
data class SharedIn(
|
||||||
|
val text: String?,
|
||||||
|
val files: List<Uri>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The file or files a SEND or SEND_MULTIPLE carried, consumed like the text. */
|
||||||
|
private fun Intent.takeStreams(): List<Uri> {
|
||||||
|
val streams =
|
||||||
|
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||||
|
IntentCompat.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java).orEmpty()
|
||||||
|
} else {
|
||||||
|
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java))
|
||||||
|
}
|
||||||
|
removeExtra(Intent.EXTRA_STREAM)
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared text, with a subject line above it when the sender gave one.
|
||||||
|
*
|
||||||
|
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
|
||||||
|
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
|
||||||
|
* the core names a note by its first line — so this is not decoration, it is what
|
||||||
|
* turns a board of identical-looking links into a board you can scan.
|
||||||
|
*
|
||||||
|
* `distinct` because plenty of apps put the same string in both, and a note that
|
||||||
|
* says the URL twice is worse than one that says it once.
|
||||||
|
*/
|
||||||
|
private fun Intent.takeSendText(): String? {
|
||||||
|
val body = getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
removeExtra(Intent.EXTRA_TEXT)
|
||||||
|
removeExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
return listOfNotNull(subject, body)
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selection from another app's text field, via the selection toolbar. */
|
||||||
|
private fun Intent.takeProcessText(): String? {
|
||||||
|
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
|
||||||
|
removeExtra(Intent.EXTRA_PROCESS_TEXT)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which screen is up. Exactly one at a time. */
|
||||||
|
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole app, once the store is open.
|
||||||
|
*
|
||||||
|
* One screen composed at a time, never stacked: the editor and the sync screen
|
||||||
|
* both cover the display completely, so keeping the board's two-column grid
|
||||||
|
* measuring and recomposing underneath one would be pure waste.
|
||||||
|
*
|
||||||
|
* Still no navigation library. Four destinations, each entered from exactly one
|
||||||
|
* place and left by back — a nav graph would be ceremony around an enum, and the
|
||||||
|
* state that actually matters (which note is open, whether this device is linked)
|
||||||
|
* already lives in view models.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun App(
|
||||||
|
core: Inkwell,
|
||||||
|
files: AttachmentFiles,
|
||||||
|
requestedNote: MutableState<String?>,
|
||||||
|
shared: MutableState<SharedIn?>,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val board: BoardViewModel =
|
||||||
|
viewModel(
|
||||||
|
factory =
|
||||||
|
BoardViewModel.factory(core, readFile = files::read) {
|
||||||
|
// Any store write can have moved the next reminder. Called on
|
||||||
|
// the IO dispatcher by the view model, which is where it has to
|
||||||
|
// be — this reads every note carrying a reminder.
|
||||||
|
Reminders.refresh(context, core)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Consumed, not just read: without clearing it, every later recomposition
|
||||||
|
// would reopen the same note and make the editor impossible to leave.
|
||||||
|
LaunchedEffect(requestedNote.value) {
|
||||||
|
requestedNote.value?.let {
|
||||||
|
board.openNoteById(it)
|
||||||
|
requestedNote.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleared the same way and for the same reason: without it every later
|
||||||
|
// recomposition would capture the share again as a new note.
|
||||||
|
LaunchedEffect(shared.value) {
|
||||||
|
shared.value?.let {
|
||||||
|
board.captureShared(it.text, it.files)
|
||||||
|
shared.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReminderAlarms(core)
|
||||||
|
// A pull can rewrite every note the board is holding, so a sync that changed
|
||||||
|
// anything tells it to reload. Wired here, at the one place that owns both.
|
||||||
|
val sync: SyncViewModel =
|
||||||
|
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
||||||
|
|
||||||
|
// Screen visibility is view STATE, not view-model state: it is about what is on
|
||||||
|
// the display, and nothing in the store cares. Saveable so a rotation does not
|
||||||
|
// close it.
|
||||||
|
//
|
||||||
|
// The capture sheet used to keep its own flag here too. It is gone: the + button
|
||||||
|
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||||
|
// same surface with the same toolbar.
|
||||||
|
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showingTags by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Tag writes reach the board two ways at once: the drawer lists tags, and the
|
||||||
|
// board may be LOOKING at one that a delete or a merge just removed. Both are
|
||||||
|
// `refreshLabels`, which also leaves a lens whose tag stopped existing.
|
||||||
|
val tags: TagsViewModel =
|
||||||
|
viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels))
|
||||||
|
|
||||||
|
// A share landing changes the note's `shared` flag, which the board's card chip
|
||||||
|
// reads, so the board reloads after one.
|
||||||
|
val share: ShareViewModel =
|
||||||
|
viewModel(factory = ShareViewModel.factory(core, onStoreChanged = board::refresh))
|
||||||
|
|
||||||
|
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||||
|
val settings = remember(context) { SyncSettings(context) }
|
||||||
|
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||||
|
|
||||||
|
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
||||||
|
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
|
||||||
|
|
||||||
|
val editing = board.state.editing
|
||||||
|
val screen =
|
||||||
|
when {
|
||||||
|
showingSync -> Screen.SYNC
|
||||||
|
// Above the editor: tags are reached only from the board's drawer, so
|
||||||
|
// there is never an open note underneath one to go back to.
|
||||||
|
showingTags -> Screen.TAGS
|
||||||
|
editing != null -> Screen.EDITOR
|
||||||
|
else -> Screen.BOARD
|
||||||
|
}
|
||||||
|
|
||||||
|
when (screen) {
|
||||||
|
Screen.SYNC ->
|
||||||
|
SyncScreen(
|
||||||
|
state = sync.state,
|
||||||
|
onClose = { showingSync = false },
|
||||||
|
onProbe = sync::probe,
|
||||||
|
onClearProbe = sync::clearProbe,
|
||||||
|
onLink = sync::link,
|
||||||
|
onSyncNow = sync::syncNow,
|
||||||
|
onUnlink = sync::unlink,
|
||||||
|
onDismissRevokeNotice = sync::dismissRevokeNotice,
|
||||||
|
automatic = automatic,
|
||||||
|
onAutomaticChange = {
|
||||||
|
automatic = it
|
||||||
|
settings.automatic = it
|
||||||
|
},
|
||||||
|
update = update.state,
|
||||||
|
onCheckUpdate = update::check,
|
||||||
|
onInstallUpdate = update::downloadAndInstall,
|
||||||
|
onDismissUpdateError = update::dismissError,
|
||||||
|
onInstallOutcome = update::consumeInstallOutcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
Screen.TAGS ->
|
||||||
|
TagsScreen(
|
||||||
|
state = tags.state,
|
||||||
|
onClose = { showingTags = false },
|
||||||
|
onCreate = tags::create,
|
||||||
|
onRename = tags::rename,
|
||||||
|
onColour = tags::setColour,
|
||||||
|
onDelete = tags::remove,
|
||||||
|
onMerge = tags::merge,
|
||||||
|
onDismissError = tags::dismissError,
|
||||||
|
)
|
||||||
|
|
||||||
|
Screen.EDITOR ->
|
||||||
|
NoteEditorScreen(
|
||||||
|
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||||
|
note = requireNotNull(editing) { "the editor screen needs a note" },
|
||||||
|
sessionKey = board.state.editingSession,
|
||||||
|
labels = board.state.labels,
|
||||||
|
saving = board.state.saving,
|
||||||
|
error = board.state.error,
|
||||||
|
onShare = { share.open(editing.id) },
|
||||||
|
// The one seam between the editor and the store. Exhaustive at the
|
||||||
|
// other end, so a new action cannot be added without being handled.
|
||||||
|
onAction = { board.onEditorAction(editing, it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Screen.BOARD -> {
|
||||||
|
BoardScreen(
|
||||||
|
state = board.state,
|
||||||
|
onOpen = board::open,
|
||||||
|
onOpenNote = board::openNote,
|
||||||
|
sync =
|
||||||
|
BoardSync(
|
||||||
|
summary = syncSummary(sync),
|
||||||
|
error = sync.state.syncError,
|
||||||
|
refreshing = sync.state.syncing,
|
||||||
|
// Only a linked device has anywhere to pull FROM. The
|
||||||
|
// board never learns this itself — one owner for the fact.
|
||||||
|
canRefresh = sync.state.linked,
|
||||||
|
onRefresh = sync::syncNow,
|
||||||
|
onDismissError = sync::dismissSyncError,
|
||||||
|
),
|
||||||
|
onOpenSync = { showingSync = true },
|
||||||
|
onManageTags = { showingTags = true },
|
||||||
|
onSearch = board::search,
|
||||||
|
onFilter = board::filter,
|
||||||
|
onReorder = board::reorder,
|
||||||
|
onCompose = board::compose,
|
||||||
|
onToggleItem = board::toggleItem,
|
||||||
|
// The SAME seam the editor uses. `onEditorAction` is already the
|
||||||
|
// exhaustive dispatcher for every action a note has, and it takes
|
||||||
|
// the note to act on rather than reading the open one — so the board
|
||||||
|
// can hand it a card without a second dispatcher existing to drift.
|
||||||
|
onNoteAction = board::onEditorAction,
|
||||||
|
// Null unless there is genuinely something to say — the board is
|
||||||
|
// handed a decision, not a state to interpret.
|
||||||
|
update =
|
||||||
|
update.state.available
|
||||||
|
?.takeIf { update.state.nagging }
|
||||||
|
?.let {
|
||||||
|
BoardUpdate(
|
||||||
|
version = it.version,
|
||||||
|
busy = update.state.busy,
|
||||||
|
onInstall = update::downloadAndInstall,
|
||||||
|
onDismiss = update::dismissNag,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismissError = board::dismissError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over whichever screen opened it; a ModalBottomSheet handles its own back.
|
||||||
|
if (share.state.noteId != null) {
|
||||||
|
ShareSheet(
|
||||||
|
state = share.state,
|
||||||
|
onShare = share::share,
|
||||||
|
onUnshare = share::unshare,
|
||||||
|
onDismiss = share::close,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sync screen has no back handler of its own, so one lives here. The
|
||||||
|
// editor keeps its own, because it has to save the open note before leaving.
|
||||||
|
BackHandler(enabled = showingSync) { showingSync = false }
|
||||||
|
BackHandler(enabled = showingTags) { showingTags = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeping the alarm current, and asking to be allowed to ring it.
|
||||||
|
*
|
||||||
|
* The refresh runs on every return to the foreground rather than once at launch:
|
||||||
|
* a reminder can have been set on the desktop and pulled in while this app was
|
||||||
|
* backgrounded, and the alarm is derived from the store, not from what the UI last
|
||||||
|
* saw. It is cheap and idempotent by construction — see [Reminders.refresh].
|
||||||
|
*
|
||||||
|
* The permission is asked for on the first launch where a reminder actually
|
||||||
|
* exists. Android gives an app essentially one chance at this dialog, so spending
|
||||||
|
* it at first launch on an empty board — before the person has any idea what
|
||||||
|
* notifications this app would send — is spending it on nothing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ReminderAlarms(core: Inkwell) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
// Off the main thread: this reads every note that has a reminder, and a phone
|
||||||
|
// holding a few hundred would drop frames doing it during a resume.
|
||||||
|
val refresh = { scope.launch(Dispatchers.IO) { Reminders.refresh(context, core) } }
|
||||||
|
|
||||||
|
val prompt =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||||
|
// Whatever the answer, re-derive: if it was yes, the reminders that
|
||||||
|
// could not be shown a moment ago can be shown now.
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
|
||||||
|
// it, notifications are granted at install and there is nothing to ask.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
|
||||||
|
val due = withContext(Dispatchers.IO) { Reminders.promptToNotifyDue(context, core) }
|
||||||
|
if (due) {
|
||||||
|
Reminders.markPromptShown(context)
|
||||||
|
prompt.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looking for an app update without being asked.
|
||||||
|
*
|
||||||
|
* Until this existed, `check()` had exactly one caller: a button on the sync screen.
|
||||||
|
* So a new build was found only by someone who went looking for one, and the operator
|
||||||
|
* had to remember to go looking — which is the same as not being told.
|
||||||
|
*
|
||||||
|
* On coming forward rather than on a timer: it is the moment the person is present,
|
||||||
|
* and the view model rate-limits so flicking between two apps is not a re-check.
|
||||||
|
* Unlinked devices are skipped entirely — updates come from a linked server, and
|
||||||
|
* there is nothing to ask.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AutomaticUpdate(
|
||||||
|
linked: Boolean,
|
||||||
|
onCheck: () -> Unit,
|
||||||
|
) {
|
||||||
|
var wanted by remember { mutableStateOf(false) }
|
||||||
|
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
|
||||||
|
|
||||||
|
LaunchedEffect(wanted, linked) {
|
||||||
|
if (!wanted || !linked) return@LaunchedEffect
|
||||||
|
// Consumed here, so this fires once per trip to the foreground however many
|
||||||
|
// times the effect restarts. There is no suspension point before the call, so
|
||||||
|
// the block completes before the recomposition that would cancel it.
|
||||||
|
wanted = false
|
||||||
|
onCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncing without being asked.
|
||||||
|
*
|
||||||
|
* Three moments, and they are not the same job:
|
||||||
|
*
|
||||||
|
* - **Coming to the front.** Someone opening the app expects what they are
|
||||||
|
* looking at to be true. Rate-limited by [STALE_MINUTES] so flicking between
|
||||||
|
* two apps is not a request for fresh notes.
|
||||||
|
* - **Going away with unsent work.** Handed to WorkManager rather than run
|
||||||
|
* inline, because the process is about to stop being a priority and a sync
|
||||||
|
* started here would be killed halfway.
|
||||||
|
* - **Every fifteen minutes.** The background heartbeat, so a phone in a pocket
|
||||||
|
* is roughly current before it is picked up.
|
||||||
|
*
|
||||||
|
* Being unlinked or having the switch off makes all three no-ops, and cancels the
|
||||||
|
* scheduled work rather than merely skipping it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AutomaticSync(
|
||||||
|
state: SyncState,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSync: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// DECLARED as a function of two facts rather than toggled from the places
|
||||||
|
// that change them. There are four routes to "should not be syncing on its
|
||||||
|
// own" — never linked, just unlinked, switch off, switch off then unlink —
|
||||||
|
// and a call at each is four chances to leave a phone quietly syncing after
|
||||||
|
// it was told to stop.
|
||||||
|
LaunchedEffect(state.linked, enabled) {
|
||||||
|
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wanted by remember { mutableStateOf(false) }
|
||||||
|
ForegroundTransitions(
|
||||||
|
onForeground = { wanted = true },
|
||||||
|
onBackground = {
|
||||||
|
// Unsent work follows the person out of the app. Without this, a note
|
||||||
|
// written on a phone that then goes into a pocket for the night does
|
||||||
|
// not reach the desktop until the app is opened again by hand.
|
||||||
|
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keyed on `loading` so the decision waits for the stored link to be READ. At
|
||||||
|
// first composition `linked` is still false because nothing has looked in the
|
||||||
|
// database yet, and acting on that would skip the sync on every cold start.
|
||||||
|
LaunchedEffect(wanted, state.loading) {
|
||||||
|
if (!wanted || state.loading) return@LaunchedEffect
|
||||||
|
// Consumed here, so this fires exactly once per trip to the foreground
|
||||||
|
// however many times the effect restarts. Writing a key from inside the
|
||||||
|
// effect does restart it — but there is no suspension point between here
|
||||||
|
// and the call below, so the block runs to completion before the
|
||||||
|
// recomposition that would cancel it can be scheduled.
|
||||||
|
wanted = false
|
||||||
|
val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
|
||||||
|
if (enabled && state.linked && worthIt) onSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How stale the last sync has to be before opening the app triggers another.
|
||||||
|
*
|
||||||
|
* Not zero. Stepping out to copy a link and stepping back is not a request for
|
||||||
|
* fresh notes, and syncing on every app switch spends someone's mobile data to
|
||||||
|
* tell them what they are already looking at. Five minutes is short enough that
|
||||||
|
* coming back to the phone after doing something else gets current data, and
|
||||||
|
* long enough that flicking between two apps does not.
|
||||||
|
*
|
||||||
|
* Unsent local changes bypass this entirely — those go out at the first chance.
|
||||||
|
*/
|
||||||
|
private const val STALE_MINUTES = 5L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line of sync state for the drawer, or null when there is nothing to say.
|
||||||
|
*
|
||||||
|
* Deliberately silent while the status is still loading and when the device is
|
||||||
|
* simply unlinked-and-idle — an "Off" badge on a local-first app would frame its
|
||||||
|
* normal resting state as something switched off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun syncSummary(sync: SyncViewModel): String? {
|
||||||
|
val state = sync.state
|
||||||
|
return when {
|
||||||
|
state.loading -> null
|
||||||
|
!state.linked -> null
|
||||||
|
state.pending -> stringResource(R.string.sync_badge_unsent)
|
||||||
|
else -> stringResource(R.string.sync_badge_on)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a due reminder looks like in the shade.
|
||||||
|
*
|
||||||
|
* Split from [Reminders] because the two answer different questions and change for
|
||||||
|
* different reasons: that one decides WHEN something should be said, this one
|
||||||
|
* decides how it is said and what can be done about it without opening the app.
|
||||||
|
*/
|
||||||
|
internal object ReminderNotification {
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL,
|
||||||
|
context.getString(R.string.reminder_channel),
|
||||||
|
// HIGH so a reminder can interrupt. Someone who asked to be
|
||||||
|
// reminded at a time has already said this may interrupt them;
|
||||||
|
// DEFAULT would leave it silent in the shade until next unlock.
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply { description = context.getString(R.string.reminder_channel_description) }
|
||||||
|
context
|
||||||
|
.getSystemService(NotificationManager::class.java)
|
||||||
|
?.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post one reminder. Returns whether it actually reached the shade. */
|
||||||
|
fun show(
|
||||||
|
context: Context,
|
||||||
|
note: Note,
|
||||||
|
): Boolean {
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
// Not marked as announced when this is false, so a reminder is not silently
|
||||||
|
// burned by being "delivered" to a device that cannot show it — turning
|
||||||
|
// notifications on later still surfaces it.
|
||||||
|
if (!manager.areNotificationsEnabled()) return false
|
||||||
|
|
||||||
|
val body = note.body.trim().takeIf { it.isNotEmpty() && it != note.displayTitle }
|
||||||
|
val builder =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(context, CHANNEL)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(note.displayTitle)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(openIntent(context, note))
|
||||||
|
.addAction(
|
||||||
|
0,
|
||||||
|
context.getString(R.string.reminder_done),
|
||||||
|
action(context, note, ReminderReceiver.ACTION_DONE),
|
||||||
|
).addAction(
|
||||||
|
0,
|
||||||
|
context.getString(R.string.reminder_snooze_hour),
|
||||||
|
action(context, note, ReminderReceiver.ACTION_SNOOZE),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (body != null) {
|
||||||
|
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
manager.notify(note.id.hashCode(), builder.build())
|
||||||
|
true
|
||||||
|
}.getOrElse {
|
||||||
|
// POST_NOTIFICATIONS can be revoked between the check and the post.
|
||||||
|
Log.w(TAG, "could not post reminder", it)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take a reminder off the shade, once it has been acted on. */
|
||||||
|
fun dismiss(
|
||||||
|
context: Context,
|
||||||
|
noteId: String,
|
||||||
|
) = NotificationManagerCompat.from(context).cancel(noteId.hashCode())
|
||||||
|
|
||||||
|
private fun openIntent(
|
||||||
|
context: Context,
|
||||||
|
note: Note,
|
||||||
|
): PendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
note.id.hashCode(),
|
||||||
|
Intent(context, MainActivity::class.java)
|
||||||
|
.setAction(Intent.ACTION_VIEW)
|
||||||
|
.putExtra(Reminders.EXTRA_NOTE_ID, note.id)
|
||||||
|
// Reuse the running task rather than stacking a second copy of the
|
||||||
|
// app on top of itself; MainActivity picks the id up in onNewIntent.
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun action(
|
||||||
|
context: Context,
|
||||||
|
note: Note,
|
||||||
|
what: String,
|
||||||
|
): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
// Distinct per note AND per action, or the two would share one
|
||||||
|
// PendingIntent and Snooze would quietly perform Done.
|
||||||
|
(note.id + what).hashCode(),
|
||||||
|
Intent(context, ReminderReceiver::class.java)
|
||||||
|
.setAction(what)
|
||||||
|
.putExtra(Reminders.EXTRA_NOTE_ID, note.id),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val CHANNEL = "reminders"
|
||||||
|
private const val TAG = "InkwellReminders"
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that happens to a reminder while the app is not on screen.
|
||||||
|
*
|
||||||
|
* Four arrivals, one ending: whatever came in, the reminder picture is recomputed
|
||||||
|
* and the next alarm is set. That is deliberate — it means no path here has to
|
||||||
|
* remember to reschedule, and the one that fires an alarm cannot leave the device
|
||||||
|
* with no alarm pending.
|
||||||
|
*
|
||||||
|
* - **[ACTION_DUE]** — the alarm went off. Announce what is due.
|
||||||
|
* - **[ACTION_DONE] / [ACTION_SNOOZE]** — a notification button. Write it to the
|
||||||
|
* store, drop the notification.
|
||||||
|
* - **`BOOT_COMPLETED`** — alarms do not survive a restart, so every reminder on
|
||||||
|
* the device would silently stop existing without this.
|
||||||
|
* - **`MY_PACKAGE_REPLACED`** — an app update cancels them the same way. This
|
||||||
|
* device installs by APK from its own server, so updates are routine.
|
||||||
|
*
|
||||||
|
* ## Threading
|
||||||
|
*
|
||||||
|
* `onReceive` runs on the main thread and the store is blocking SQLite, so the
|
||||||
|
* work goes to [Dispatchers.IO] under [goAsync]. Without `goAsync` the process
|
||||||
|
* becomes killable the moment `onReceive` returns, which for a reminder firing at
|
||||||
|
* 3am is precisely when nothing is holding it up.
|
||||||
|
*/
|
||||||
|
class ReminderReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(
|
||||||
|
context: Context,
|
||||||
|
intent: Intent,
|
||||||
|
) {
|
||||||
|
val core = (context.applicationContext as? InkwellApplication)?.core ?: return
|
||||||
|
val action = intent.action
|
||||||
|
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
|
||||||
|
val app = context.applicationContext
|
||||||
|
|
||||||
|
val pending = goAsync()
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
when (action) {
|
||||||
|
ACTION_DONE -> noteId?.let { Reminders.complete(app, core, it) }
|
||||||
|
ACTION_SNOOZE -> noteId?.let { Reminders.snooze(app, core, it) }
|
||||||
|
// The alarm and the two system broadcasts all want the same
|
||||||
|
// thing, which is simply: look at the store and act on it.
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
Reminders.refresh(app, core)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Broad on purpose. Nobody is present, so an escaping exception is
|
||||||
|
// a crash report for something the person never initiated — and
|
||||||
|
// every path in here has already logged its own failure.
|
||||||
|
Log.w(TAG, "reminder broadcast failed: $action", e)
|
||||||
|
} finally {
|
||||||
|
pending.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE"
|
||||||
|
const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE"
|
||||||
|
const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE"
|
||||||
|
private const val TAG = "InkwellReminders"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getting a reminder in front of someone at the time they asked for.
|
||||||
|
*
|
||||||
|
* ## One alarm, not one per reminder
|
||||||
|
*
|
||||||
|
* Only the EARLIEST future reminder is ever scheduled. When it fires, everything
|
||||||
|
* now due is announced and the next one is scheduled. A hundred reminders cost one
|
||||||
|
* alarm, and there is no bookkeeping to get wrong when a note is edited on another
|
||||||
|
* device and arrives by sync — [refresh] recomputes the whole picture from the
|
||||||
|
* store every time.
|
||||||
|
*
|
||||||
|
* ## AlarmManager, not WorkManager
|
||||||
|
*
|
||||||
|
* The background sync runs on WorkManager and is right to: nobody minds whether it
|
||||||
|
* happens at 3:05 or 3:19. A reminder minded very much. WorkManager's periodic
|
||||||
|
* floor is fifteen minutes and it batches work into maintenance windows, so
|
||||||
|
* "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder,
|
||||||
|
* it is a rebuke.
|
||||||
|
*
|
||||||
|
* Exactness is asked for and not depended on: on Android 12+ it is a permission
|
||||||
|
* the person can refuse, and refusing drops this to an inexact alarm rather than
|
||||||
|
* to nothing. A reminder a few minutes late still beats no reminder, and pestering
|
||||||
|
* someone into a settings screen before the feature works at all is the coercion
|
||||||
|
* this product does not do.
|
||||||
|
*/
|
||||||
|
object Reminders {
|
||||||
|
const val EXTRA_NOTE_ID = "note_id"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long after its time a missed reminder is still worth announcing.
|
||||||
|
*
|
||||||
|
* The web uses fifteen minutes, because a tab that is open has been checking
|
||||||
|
* every forty-five seconds and anything older than that was almost certainly
|
||||||
|
* already seen. A phone can be switched off all night, so the equivalent
|
||||||
|
* question here — "could this plausibly not have been seen yet?" — has a much
|
||||||
|
* longer answer. Beyond a day it stops being a reminder and starts being
|
||||||
|
* archaeology; the note is still on the board, still marked overdue in red.
|
||||||
|
*/
|
||||||
|
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
private const val SNOOZE_MINUTES = 60L
|
||||||
|
private const val TAG = "InkwellReminders"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce what is due, then schedule the next one.
|
||||||
|
*
|
||||||
|
* Safe to call as often as anything might have changed — after an edit, after
|
||||||
|
* a sync, at launch, at boot. It reads the whole reminder set each time and
|
||||||
|
* derives everything from it, so there is no incremental state to drift.
|
||||||
|
*/
|
||||||
|
fun refresh(
|
||||||
|
context: Context,
|
||||||
|
core: Inkwell,
|
||||||
|
) {
|
||||||
|
ReminderNotification.ensureChannel(context)
|
||||||
|
val notes =
|
||||||
|
runCatching { core.reminderNotes() }
|
||||||
|
.onFailure { Log.w(TAG, "could not read reminders", it) }
|
||||||
|
.getOrElse { return }
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val announced = Announced(context)
|
||||||
|
// Intersecting with what still exists prunes the record in the same step:
|
||||||
|
// a reminder that was completed, snoozed to a new time or deleted drops out
|
||||||
|
// on its own, so this set cannot grow without bound.
|
||||||
|
val live = notes.mapNotNull { key(it) }.toSet()
|
||||||
|
val seen = announced.keys().intersect(live).toMutableSet()
|
||||||
|
|
||||||
|
val due = notes.filter { at(it)?.let { ms -> ms <= now } == true }
|
||||||
|
if (!announced.primed) {
|
||||||
|
// First run on this device. Adopt everything already overdue SILENTLY:
|
||||||
|
// the storm case is linking a server and pulling months of history, and
|
||||||
|
// a hundred notifications the moment someone signs in is a good way to
|
||||||
|
// have them turn the feature off before it has ever been useful.
|
||||||
|
due.forEach { note -> key(note)?.let { seen += it } }
|
||||||
|
} else {
|
||||||
|
due.forEach { note ->
|
||||||
|
val k = key(note) ?: return@forEach
|
||||||
|
val overdueBy = now - (at(note) ?: return@forEach)
|
||||||
|
if (k !in seen && overdueBy <= MISSED_WINDOW_MS && ReminderNotification.show(context, note)) {
|
||||||
|
seen += k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
announced.write(seen)
|
||||||
|
scheduleNext(context, notes, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether it is worth putting Android's notification prompt in front of someone.
|
||||||
|
*
|
||||||
|
* True only when there is a reminder that could actually fire and we have not
|
||||||
|
* asked before. Asking at launch on an empty board would be a dialog with no
|
||||||
|
* visible cause, which is how people learn to dismiss dialogs unread; asking
|
||||||
|
* again after a refusal is nagging, and the Reminders view carries a standing
|
||||||
|
* notice for anyone who changes their mind.
|
||||||
|
*/
|
||||||
|
fun promptToNotifyDue(
|
||||||
|
context: Context,
|
||||||
|
core: Inkwell,
|
||||||
|
): Boolean =
|
||||||
|
!Announced(context).askedToNotify &&
|
||||||
|
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
|
||||||
|
runCatching { core.reminderNotes().isNotEmpty() }.getOrDefault(false)
|
||||||
|
|
||||||
|
/** Remember that Android's prompt has been shown, whatever the answer was. */
|
||||||
|
fun markPromptShown(context: Context) = Announced(context).markAsked()
|
||||||
|
|
||||||
|
/** Clear the reminder, as the notification's Done action. */
|
||||||
|
fun complete(
|
||||||
|
context: Context,
|
||||||
|
core: Inkwell,
|
||||||
|
noteId: String,
|
||||||
|
) {
|
||||||
|
runCatching { core.completeReminder(noteId) }
|
||||||
|
.onFailure { Log.w(TAG, "could not complete reminder", it) }
|
||||||
|
ReminderNotification.dismiss(context, noteId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push the reminder an hour out, as the notification's Snooze action. */
|
||||||
|
fun snooze(
|
||||||
|
context: Context,
|
||||||
|
core: Inkwell,
|
||||||
|
noteId: String,
|
||||||
|
) {
|
||||||
|
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
|
||||||
|
.onFailure { Log.w(TAG, "could not snooze reminder", it) }
|
||||||
|
ReminderNotification.dismiss(context, noteId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────── scheduling ───────────────────────────────
|
||||||
|
|
||||||
|
private fun scheduleNext(
|
||||||
|
context: Context,
|
||||||
|
notes: List<Note>,
|
||||||
|
now: Long,
|
||||||
|
) {
|
||||||
|
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
|
||||||
|
val fire =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(context, ReminderReceiver::class.java).setAction(ReminderReceiver.ACTION_DUE),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val next = notes.mapNotNull { at(it) }.filter { it > now }.minOrNull()
|
||||||
|
if (next == null) {
|
||||||
|
alarms.cancel(fire)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// RTC_WAKEUP: reminders are wall-clock times, and the point is to wake a
|
||||||
|
// sleeping phone. ELAPSED_REALTIME would drift against the clock the person
|
||||||
|
// actually set the reminder against.
|
||||||
|
runCatching {
|
||||||
|
if (canBeExact(alarms)) {
|
||||||
|
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire)
|
||||||
|
} else {
|
||||||
|
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
// setExact can still throw if the permission was revoked between the
|
||||||
|
// check and the call. Falling back beats losing the reminder entirely.
|
||||||
|
Log.w(TAG, "exact alarm refused, falling back to inexact", it)
|
||||||
|
runCatching { alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, next, fire) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this device will let us fire at the exact minute.
|
||||||
|
*
|
||||||
|
* Below Android 12 there was no permission and exact alarms always worked.
|
||||||
|
* From 12 it is grantable and from 13 it is denied by default, so this is a
|
||||||
|
* question with a real answer rather than a formality.
|
||||||
|
*/
|
||||||
|
fun canBeExact(alarms: AlarmManager): Boolean =
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()
|
||||||
|
|
||||||
|
// ────────────────────────────── bookkeeping ──────────────────────────────
|
||||||
|
|
||||||
|
/** Epoch millis of a note's reminder, or null if it has none we can read. */
|
||||||
|
private fun at(note: Note): Long? =
|
||||||
|
note.remindAt?.let {
|
||||||
|
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity of one OCCURRENCE, not of the note.
|
||||||
|
*
|
||||||
|
* The time is part of it so that snoozing — which rewrites `remind_at` — is a
|
||||||
|
* new thing to announce rather than one already dealt with. Same key the web
|
||||||
|
* store uses, for the same reason.
|
||||||
|
*/
|
||||||
|
private fun key(note: Note): String? = note.remindAt?.let { "${note.id}@$it" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which reminder occurrences have already been put in front of someone. */
|
||||||
|
private class Announced(
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
private val prefs =
|
||||||
|
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/** False only before the very first [Reminders.refresh] on this install. */
|
||||||
|
val primed: Boolean get() = prefs.getBoolean(KEY_PRIMED, false)
|
||||||
|
|
||||||
|
// Copied: the set from getStringSet must not be mutated, and the docs are
|
||||||
|
// explicit that doing so corrupts what is stored.
|
||||||
|
fun keys(): Set<String> = prefs.getStringSet(KEY_SEEN, emptySet())?.toSet().orEmpty()
|
||||||
|
|
||||||
|
fun write(keys: Set<String>) {
|
||||||
|
prefs
|
||||||
|
.edit()
|
||||||
|
.putStringSet(KEY_SEEN, keys)
|
||||||
|
.putBoolean(KEY_PRIMED, true)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Survives a restart, so the prompt is a one-off rather than once per launch. */
|
||||||
|
val askedToNotify: Boolean get() = prefs.getBoolean(KEY_ASKED, false)
|
||||||
|
|
||||||
|
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FILE = "inkwell-reminders"
|
||||||
|
const val KEY_SEEN = "announced"
|
||||||
|
const val KEY_PRIMED = "primed"
|
||||||
|
const val KEY_ASKED = "asked_to_notify"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the system should sync on its own.
|
||||||
|
*
|
||||||
|
* All of the policy lives here rather than being spread across the call sites,
|
||||||
|
* because the interesting question is not "how do I enqueue work" but "how often
|
||||||
|
* is often enough" — and that answer should be readable in one place.
|
||||||
|
*
|
||||||
|
* Two jobs, deliberately different:
|
||||||
|
*
|
||||||
|
* - **[enable]** is the heartbeat. Fifteen minutes is not a preference, it is
|
||||||
|
* WorkManager's floor for periodic work; asking for less silently gets you
|
||||||
|
* fifteen anyway. It keeps a phone that is sitting in a pocket roughly current
|
||||||
|
* so that opening the app is not a wait.
|
||||||
|
* - **[pushSoon]** is for the moment a person walks away from a note they just
|
||||||
|
* wrote. Waiting up to fifteen minutes to hand that to the server is the
|
||||||
|
* difference between "my notes are everywhere" and "my notes are on whichever
|
||||||
|
* device I used last", which is the whole point of the product.
|
||||||
|
*
|
||||||
|
* Both require a network. Without that constraint every run on a phone with no
|
||||||
|
* signal would wake the process, open SQLite, fail a connection and burn the
|
||||||
|
* retry budget for nothing.
|
||||||
|
*/
|
||||||
|
object SyncSchedule {
|
||||||
|
/** Every 15 minutes while linked. */
|
||||||
|
fun enable(context: Context) {
|
||||||
|
val request =
|
||||||
|
PeriodicWorkRequestBuilder<SyncWorker>(PERIOD_MINUTES, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(networkRequired())
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// UPDATE, not KEEP: this is called on every launch, and KEEP would ignore
|
||||||
|
// a changed interval forever on any device that had ever enqueued the old
|
||||||
|
// one. UPDATE applies the change WITHOUT resetting the next run, so
|
||||||
|
// opening the app repeatedly cannot push the sync further away each time.
|
||||||
|
WorkManager
|
||||||
|
.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(PERIODIC, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop syncing on our own: unlinked, or the person turned it off. */
|
||||||
|
fun disable(context: Context) {
|
||||||
|
WorkManager.getInstance(context).apply {
|
||||||
|
cancelUniqueWork(PERIODIC)
|
||||||
|
cancelUniqueWork(PUSH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whatever is unsent off this device, as soon as there is a network.
|
||||||
|
*
|
||||||
|
* Enqueued when the app goes to the background holding unsent changes, so a
|
||||||
|
* note survives being written on a phone that is then put away for the night.
|
||||||
|
*/
|
||||||
|
fun pushSoon(context: Context) {
|
||||||
|
val request =
|
||||||
|
OneTimeWorkRequestBuilder<SyncWorker>()
|
||||||
|
.setConstraints(networkRequired())
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// REPLACE rather than KEEP: if an earlier attempt is sitting in a long
|
||||||
|
// backoff, the person has just given us a reason to try again sooner.
|
||||||
|
WorkManager
|
||||||
|
.getInstance(context)
|
||||||
|
.enqueueUniqueWork(PUSH, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun networkRequired(): Constraints =
|
||||||
|
Constraints
|
||||||
|
.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private const val PERIODIC = "inkwell-periodic-sync"
|
||||||
|
private const val PUSH = "inkwell-push-pending"
|
||||||
|
|
||||||
|
/** WorkManager's own minimum for periodic work. Asking for less gets this. */
|
||||||
|
private const val PERIOD_MINUTES = 15L
|
||||||
|
private const val BACKOFF_SECONDS = 30L
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this device syncs on its own, and nothing else.
|
||||||
|
*
|
||||||
|
* Device-local on purpose. Every other piece of sync state — the server, the
|
||||||
|
* token, the cursor — lives in the core's SQLite file because it describes the
|
||||||
|
* PAIRING and has to survive a reinstall to the same account. This describes
|
||||||
|
* how one phone behaves, and a person who turns it off on their handset is not
|
||||||
|
* asking their laptop to stop.
|
||||||
|
*
|
||||||
|
* `SharedPreferences` rather than the store because the background worker reads
|
||||||
|
* it on a process the system started, where reaching for the core would mean
|
||||||
|
* depending on the store having opened successfully to answer a question that
|
||||||
|
* has nothing to do with the store.
|
||||||
|
*/
|
||||||
|
class SyncSettings(
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
// applicationContext: this outlives any Activity, and holding one here would
|
||||||
|
// leak the whole window when the phone rotates.
|
||||||
|
private val prefs =
|
||||||
|
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults to ON.
|
||||||
|
*
|
||||||
|
* Linking a server IS the consent — a person who paired this device and then
|
||||||
|
* had to find a second switch before anything moved would reasonably call
|
||||||
|
* that broken. Turning it off leaves manual sync working exactly as before.
|
||||||
|
*/
|
||||||
|
var automatic: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_AUTOMATIC, true)
|
||||||
|
set(value) {
|
||||||
|
prefs.edit().putBoolean(KEY_AUTOMATIC, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val FILE = "inkwell-sync"
|
||||||
|
const val KEY_AUTOMATIC = "automatic"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sync cycle, run by the system rather than by a person.
|
||||||
|
*
|
||||||
|
* WorkManager may start the process to run this, which means [InkwellApplication.onCreate]
|
||||||
|
* has already opened the store by the time [doWork] is called — the same handle
|
||||||
|
* the UI uses, so there is never a second SQLite connection racing the first.
|
||||||
|
*
|
||||||
|
* The automatic-sync switch is checked here as well as at scheduling time. That
|
||||||
|
* does NOT avoid opening the store — `onCreate` has already done it by the time
|
||||||
|
* any Worker runs — it avoids the network call and the writes.
|
||||||
|
*
|
||||||
|
* ## Why the outcome is thrown away
|
||||||
|
*
|
||||||
|
* A run that nobody asked for must not become a notification, a banner, or
|
||||||
|
* anything else that interrupts. If it pulled changes, the board reloads next
|
||||||
|
* time it is looked at; if it pushed them, they are gone from the outbox. The
|
||||||
|
* one thing the person can act on — "there are unsent notes" — is already told
|
||||||
|
* by the drawer badge, from `has_pending`, which does not care how the attempt
|
||||||
|
* was made.
|
||||||
|
*/
|
||||||
|
class SyncWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val core = (applicationContext as? InkwellApplication)?.core
|
||||||
|
|
||||||
|
// Both of these are "nothing to do", not "something went wrong", so both
|
||||||
|
// report success and let the run retire quietly:
|
||||||
|
// * automatic sync was switched off after this was enqueued — the
|
||||||
|
// schedule is cancelled then, but a run already handed to the system
|
||||||
|
// can still land;
|
||||||
|
// * the store never opened, which no retry fixes this launch and which
|
||||||
|
// the UI is already reporting to whoever is looking.
|
||||||
|
if (!SyncSettings(applicationContext).automatic || core == null) return Result.success()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// Unlinked since this was enqueued. Not a failure — retrying would
|
||||||
|
// burn the backoff schedule on a device that has no server.
|
||||||
|
if (core.syncStatus().linked) {
|
||||||
|
val outcome = core.syncNow()
|
||||||
|
Log.i(TAG, "background sync at ${outcome.status.lastSyncAt}")
|
||||||
|
// A pull can have brought in a reminder set on another device, or
|
||||||
|
// moved one this phone already knew about. The alarm is derived
|
||||||
|
// from the store, so it has to be re-derived whenever the store
|
||||||
|
// changed underneath it — otherwise a reminder made at a desk
|
||||||
|
// never rings on the phone until the app is next opened.
|
||||||
|
Reminders.refresh(applicationContext, core)
|
||||||
|
}
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Deliberately broad, and deliberately `retry` rather than `failure`:
|
||||||
|
// almost everything that goes wrong here is a flat tyre — no route to
|
||||||
|
// the server, a laptop asleep, a token being rotated. Retry hands it
|
||||||
|
// to WorkManager's exponential backoff; `failure` would drop the run
|
||||||
|
// for good and strand the notes until someone opens the app by hand.
|
||||||
|
Log.w(TAG, "background sync failed, will retry", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "InkwellWorker"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last thing the system said about an install, waiting to be shown.
|
||||||
|
*
|
||||||
|
* A process-wide holder because the two ends cannot reach each other any other
|
||||||
|
* way: [UpdateReceiver] is constructed by the system, and the view model that
|
||||||
|
* wants the answer is owned by the composition. The alternative — a bound service
|
||||||
|
* or a broadcast the UI also listens for — is more machinery for one nullable
|
||||||
|
* string.
|
||||||
|
*
|
||||||
|
* Safe as snapshot state: `onReceive` runs on the main thread, which is where
|
||||||
|
* Compose expects its state to be written.
|
||||||
|
*/
|
||||||
|
object UpdateOutcome {
|
||||||
|
/** `error == null` means it went through, or the person declined. */
|
||||||
|
data class Result(
|
||||||
|
val error: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Null until the system has said something about an install we committed. */
|
||||||
|
var latest: Result? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun report(error: String?) {
|
||||||
|
latest = Result(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once the UI has shown it, so a later install starts from silence. */
|
||||||
|
fun clear() {
|
||||||
|
latest = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the system says about an install we committed.
|
||||||
|
*
|
||||||
|
* Without this the app would `commit` and learn nothing — a failed install would
|
||||||
|
* look exactly like a person deciding not to go ahead, and the update card would
|
||||||
|
* sit there claiming an update is available with no explanation of why nothing
|
||||||
|
* happened. That was the specific complaint recorded against Minstrel's first
|
||||||
|
* attempt (Scribe #2438).
|
||||||
|
*
|
||||||
|
* The result is written to [UpdateOutcome] rather than notified: the app is on
|
||||||
|
* screen when this fires — someone just tapped Update — so the place to say it is
|
||||||
|
* the card they are looking at.
|
||||||
|
*/
|
||||||
|
class UpdateReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(
|
||||||
|
context: Context,
|
||||||
|
intent: Intent,
|
||||||
|
) {
|
||||||
|
if (intent.action != ACTION_INSTALLED) return
|
||||||
|
|
||||||
|
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, Int.MIN_VALUE)
|
||||||
|
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
|
||||||
|
when (status) {
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||||
|
// Android wants a confirmation. This is the ORDINARY path below API
|
||||||
|
// 31, and the path on 31+ whenever the OS declines to skip the
|
||||||
|
// dialog — which it may, and is entitled to.
|
||||||
|
val confirm =
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||||
|
if (confirm == null) {
|
||||||
|
UpdateOutcome.report("Android asked for confirmation but sent no way to give it.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// NEW_TASK because a receiver has no activity of its own to start
|
||||||
|
// from. The app is in the foreground, so this surfaces immediately.
|
||||||
|
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(confirm) }
|
||||||
|
.onFailure {
|
||||||
|
Log.w(TAG, "could not show the install confirmation", it)
|
||||||
|
UpdateOutcome.report("Android's install confirmation could not be shown.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> {
|
||||||
|
// Rarely seen: a successful self-update replaces this process, so
|
||||||
|
// the app is usually gone before it can act on this.
|
||||||
|
Log.i(TAG, "update installed")
|
||||||
|
UpdateOutcome.report(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
PackageInstaller.STATUS_FAILURE_ABORTED ->
|
||||||
|
// Someone declined. Not an error, and saying "install failed" for a
|
||||||
|
// deliberate choice is how an app sounds broken when it is not.
|
||||||
|
UpdateOutcome.report(null)
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Log.w(TAG, "install failed: status=$status message=$message")
|
||||||
|
UpdateOutcome.report(message ?: "The update did not install.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
|
||||||
|
private const val TAG = "InkwellUpdate"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.media.ExifInterface
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Attachment
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file picked or shared into the app, read and ready to attach — or the reason
|
||||||
|
* it could not be.
|
||||||
|
*/
|
||||||
|
sealed interface PickedFile {
|
||||||
|
// A plain class: a data class would compare `bytes` by identity, and nothing
|
||||||
|
// here compares two picked files anyway.
|
||||||
|
class Ready(
|
||||||
|
val name: String,
|
||||||
|
val mime: String,
|
||||||
|
val bytes: ByteArray,
|
||||||
|
) : PickedFile
|
||||||
|
|
||||||
|
data class Refused(
|
||||||
|
val reason: String,
|
||||||
|
) : PickedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */
|
||||||
|
sealed interface Opener {
|
||||||
|
data class Ready(
|
||||||
|
val intent: Intent,
|
||||||
|
) : Opener
|
||||||
|
|
||||||
|
data class Failed(
|
||||||
|
val message: String,
|
||||||
|
) : Opener
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app does with attachment FILES, as opposed to attachment rows:
|
||||||
|
* reading a picked file, decoding an image for display, and handing a file to
|
||||||
|
* another app to open.
|
||||||
|
*
|
||||||
|
* Holds the application context, never an Activity, so the board's view model can
|
||||||
|
* keep a reference to [read] without leaking a screen.
|
||||||
|
*/
|
||||||
|
class AttachmentFiles(
|
||||||
|
context: Context,
|
||||||
|
private val core: Inkwell,
|
||||||
|
) {
|
||||||
|
private val app = context.applicationContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded images, keyed by hash and size. Sized in bytes against an eighth of
|
||||||
|
* the heap, which is the platform's own guidance for an in-memory image cache.
|
||||||
|
*/
|
||||||
|
private val images =
|
||||||
|
object : LruCache<String, ImageBitmap>(cacheBytes()) {
|
||||||
|
override fun sizeOf(
|
||||||
|
key: String,
|
||||||
|
value: ImageBitmap,
|
||||||
|
): Int = value.width * value.height * BYTES_PER_PIXEL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a picked or shared file, all of it, with its name and type.
|
||||||
|
*
|
||||||
|
* Blocking; call it off the main thread. Refuses anything over
|
||||||
|
* [MAX_ATTACH_MB] — before reading it when the sender says how big it is — so
|
||||||
|
* a long video shared by mistake is a message rather than an out-of-memory
|
||||||
|
* crash.
|
||||||
|
*/
|
||||||
|
fun read(uri: Uri): PickedFile {
|
||||||
|
val (label, declared) = describe(uri)
|
||||||
|
val overDeclared = (declared ?: 0) > MAX_ATTACH_BYTES
|
||||||
|
val bytes = if (overDeclared) null else readCapped(uri)
|
||||||
|
return when {
|
||||||
|
overDeclared -> tooLarge(label)
|
||||||
|
bytes == null -> PickedFile.Refused(app.getString(R.string.attach_unreadable, label))
|
||||||
|
bytes.size > MAX_ATTACH_BYTES -> tooLarge(label)
|
||||||
|
else -> PickedFile.Ready(label, app.contentResolver.getType(uri) ?: GENERIC_MIME, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The image, decoded no larger than it will be drawn, or null when this device
|
||||||
|
* doesn't hold the file yet or it isn't an image Android can read.
|
||||||
|
*
|
||||||
|
* Blocking; call it off the main thread.
|
||||||
|
*/
|
||||||
|
fun image(
|
||||||
|
attachment: Attachment,
|
||||||
|
maxPx: Int,
|
||||||
|
): ImageBitmap? {
|
||||||
|
val sha = attachment.sha256 ?: return null
|
||||||
|
val key = "$sha@$maxPx"
|
||||||
|
return images.get(key)
|
||||||
|
?: core.blobPath(sha)?.let { decode(it, maxPx) }?.also { images.put(key, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An intent that hands the file to whatever app opens its type, or the reason
|
||||||
|
* there can't be one.
|
||||||
|
*
|
||||||
|
* Copied out of the blob store first, under its real name: the store names files
|
||||||
|
* by hash with no extension, and a viewer shown `3f2a…` cannot tell a PDF from a
|
||||||
|
* spreadsheet. The copy lives in the cache, which the system reclaims, and only
|
||||||
|
* that directory is shared (`res/xml/file_paths.xml`).
|
||||||
|
*
|
||||||
|
* Blocking; call it off the main thread, then start the intent from a screen.
|
||||||
|
*/
|
||||||
|
fun opener(attachment: Attachment): Opener {
|
||||||
|
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
|
||||||
|
if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here))
|
||||||
|
val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}")
|
||||||
|
val copy = File(dir, safeName(attachment.filename))
|
||||||
|
return try {
|
||||||
|
dir.mkdirs()
|
||||||
|
if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true)
|
||||||
|
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", copy)
|
||||||
|
val view =
|
||||||
|
Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, attachment.mime)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
Opener.Ready(view)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name the sender gives the file and the size it declares, when it does. */
|
||||||
|
private fun describe(uri: Uri): Pair<String, Long?> {
|
||||||
|
val row =
|
||||||
|
try {
|
||||||
|
app.contentResolver.query(uri, COLUMNS, null, null, null)?.use { firstRow(it) }
|
||||||
|
} catch (expected: SecurityException) {
|
||||||
|
// No grant to read it at all; the read that follows says so.
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val name = row?.first?.takeIf { it.isNotBlank() } ?: uri.lastPathSegment ?: FALLBACK_NAME
|
||||||
|
return name to row?.second
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file's bytes, at most one past the cap — enough to know it is over without
|
||||||
|
* reading the rest — or null when it can't be read.
|
||||||
|
*/
|
||||||
|
private fun readCapped(uri: Uri): ByteArray? =
|
||||||
|
try {
|
||||||
|
app.contentResolver.openInputStream(uri)?.use { it.readUpTo(MAX_ATTACH_BYTES + 1) }
|
||||||
|
} catch (expected: IOException) {
|
||||||
|
null
|
||||||
|
} catch (expected: SecurityException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tooLarge(label: String) =
|
||||||
|
PickedFile.Refused(app.getString(R.string.attach_too_large, label, MAX_ATTACH_MB))
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BYTES_PER_PIXEL = 4
|
||||||
|
const val CACHE_FRACTION = 8
|
||||||
|
const val OPEN_DIR = "open"
|
||||||
|
const val GENERIC_MIME = "application/octet-stream"
|
||||||
|
const val FALLBACK_NAME = "file"
|
||||||
|
val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||||
|
|
||||||
|
/** How far each EXIF orientation is turned from upright. */
|
||||||
|
val ROTATIONS =
|
||||||
|
mapOf(
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 to 90f,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 to 180f,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 to 270f,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** DISPLAY_NAME and SIZE from the first row of a [COLUMNS] query; either may be absent. */
|
||||||
|
fun firstRow(cursor: Cursor): Pair<String?, Long?>? =
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
cursor.getString(0) to (if (cursor.isNull(1)) null else cursor.getLong(1))
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheBytes(): Int =
|
||||||
|
(Runtime.getRuntime().maxMemory() / CACHE_FRACTION)
|
||||||
|
.coerceAtMost(Int.MAX_VALUE.toLong())
|
||||||
|
.toInt()
|
||||||
|
|
||||||
|
fun decode(
|
||||||
|
path: String,
|
||||||
|
maxPx: Int,
|
||||||
|
): ImageBitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(path, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
val options =
|
||||||
|
BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, maxPx)
|
||||||
|
}
|
||||||
|
return BitmapFactory.decodeFile(path, options)?.let { upright(it, path).asImageBitmap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera photos are stored sideways with a tag saying so; BitmapFactory ignores it. */
|
||||||
|
fun upright(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
path: String,
|
||||||
|
): Bitmap {
|
||||||
|
val orientation =
|
||||||
|
try {
|
||||||
|
ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
|
||||||
|
} catch (expected: IOException) {
|
||||||
|
// No readable EXIF: draw it as stored.
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val degrees = ROTATIONS[orientation] ?: return bitmap
|
||||||
|
val turn = Matrix().apply { postRotate(degrees) }
|
||||||
|
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, turn, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's [AttachmentFiles], for the card and the editor. Null in previews and
|
||||||
|
* anywhere it was never provided, where attachments draw as plain file rows.
|
||||||
|
*/
|
||||||
|
val LocalAttachmentFiles = staticCompositionLocalOf<AttachmentFiles?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest file the phone will attach. Read whole into memory and copied once
|
||||||
|
* into the core, so the cap is about the phone's heap. The server has its own
|
||||||
|
* limit (25 MB by default, `max_attachment_mb`); a file over that one is kept here
|
||||||
|
* and its upload reported as refused.
|
||||||
|
*/
|
||||||
|
const val MAX_ATTACH_MB = 50
|
||||||
|
private const val MAX_ATTACH_BYTES = MAX_ATTACH_MB * 1024 * 1024
|
||||||
|
|
||||||
|
/** `InputStream.readNBytes(int)` is API 33; this app supports 26. */
|
||||||
|
private fun InputStream.readUpTo(limit: Int): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var total = 0
|
||||||
|
while (total < limit) {
|
||||||
|
val read = read(buffer, 0, minOf(buffer.size, limit - total))
|
||||||
|
if (read < 0) break
|
||||||
|
out.write(buffer, 0, read)
|
||||||
|
total += read
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
// The attachment decisions that need no Android: which files draw as pictures, how
|
||||||
|
// far to scale a decode, what to name a copy, how to print a size. Kept apart from
|
||||||
|
// AttachmentFiles so the JVM unit tests can load them without a device.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a type is drawn as a picture rather than offered as a file. SVG is
|
||||||
|
* excluded on every surface: it is a document that can carry script (#1981).
|
||||||
|
* The same rule as `rendersInline` in the web's `notes/attachments.ts`.
|
||||||
|
*/
|
||||||
|
fun rendersInline(mime: String): Boolean {
|
||||||
|
val type = mime.lowercase().substringBefore(';').trim()
|
||||||
|
return type.startsWith("image/") && type != "image/svg+xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The power-of-two step BitmapFactory decodes at, so the result's longer side is
|
||||||
|
* still at least [maxPx]: never upscaled on screen, never decoded at full camera
|
||||||
|
* resolution for a thumbnail.
|
||||||
|
*/
|
||||||
|
fun sampleSize(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
maxPx: Int,
|
||||||
|
): Int {
|
||||||
|
val longest = max(width, height)
|
||||||
|
var sample = 1
|
||||||
|
while (longest / (sample * 2) >= maxPx) sample *= 2
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A name safe to create in the cache: no path separators, never empty. */
|
||||||
|
fun safeName(filename: String?): String {
|
||||||
|
val base =
|
||||||
|
filename
|
||||||
|
?.substringAfterLast('/')
|
||||||
|
?.substringAfterLast('\\')
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
return base.takeIf { it.isNotEmpty() && it != "." && it != ".." } ?: "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "12 KB", "3.4 MB" — the same rounding as `fmtSize` in the web's NoteEditor.vue. */
|
||||||
|
fun sizeLabel(bytes: Long): String =
|
||||||
|
when {
|
||||||
|
bytes < KIB -> "$bytes B"
|
||||||
|
bytes < KIB * KIB -> "${(bytes / KIB).roundToInt()} KB"
|
||||||
|
else -> "%.1f MB".format(Locale.ROOT, bytes / (KIB * KIB))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val KIB = 1024.0
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Attachment
|
||||||
|
import com.fabledsword.inkwell.core.LinkPreview
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private val ATTACHMENT_RADIUS = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded no larger than this on the card. A board column on a phone is around
|
||||||
|
* 180dp wide, which is about 540px at the common densities, so this is sharp on
|
||||||
|
* every screen the app runs on without decoding a 12-megapixel photo for it.
|
||||||
|
*/
|
||||||
|
private const val CARD_IMAGE_PX = 640
|
||||||
|
|
||||||
|
/** The editor draws images at the sheet's full width. */
|
||||||
|
private const val EDITOR_IMAGE_PX = 1440
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The narrowest an image may draw, as width over height. A tall screenshot drawn
|
||||||
|
* at its own ratio would push the rest of the card off the board, so it is cropped
|
||||||
|
* to 3:4 instead; anything wider than that draws whole.
|
||||||
|
*/
|
||||||
|
private const val MIN_ASPECT = 0.75f
|
||||||
|
|
||||||
|
/** How many file names the card lists before it says "and N more". */
|
||||||
|
private const val CARD_FILE_ROWS = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A note's attachments on its board card: the first image as a picture, then the
|
||||||
|
* other files by name.
|
||||||
|
*
|
||||||
|
* One picture, not a gallery. The card is a glance at the note, and a strip of
|
||||||
|
* thumbnails would make an image note the tallest thing on the board whatever its
|
||||||
|
* words say. The editor shows them all.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CardAttachments(attachments: List<Attachment>) {
|
||||||
|
val (images, files) = attachments.partition { rendersInline(it.mime) }
|
||||||
|
images.firstOrNull()?.let { first ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
AttachmentImage(attachment = first, maxPx = CARD_IMAGE_PX) {
|
||||||
|
FileRow(attachment = first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rest = images.drop(1) + files
|
||||||
|
rest.take(CARD_FILE_ROWS).forEach { file ->
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
FileRow(attachment = file)
|
||||||
|
}
|
||||||
|
if (rest.size > CARD_FILE_ROWS) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.attach_more, rest.size - CARD_FILE_ROWS),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every attachment, in the editor: images at full width, files as rows, each one
|
||||||
|
* opening with the system and each removable.
|
||||||
|
*
|
||||||
|
* A file the server refused says so under it, in its own words — the upload is
|
||||||
|
* not retried, so without this a file that never reaches the other devices would
|
||||||
|
* look exactly like one that did.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EditorAttachments(
|
||||||
|
attachments: List<Attachment>,
|
||||||
|
readOnly: Boolean,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
val open = rememberOpener()
|
||||||
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
attachments.forEach { attachment ->
|
||||||
|
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
|
||||||
|
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
|
if (rendersInline(attachment.mime)) {
|
||||||
|
AttachmentImage(
|
||||||
|
attachment = attachment,
|
||||||
|
maxPx = EDITOR_IMAGE_PX,
|
||||||
|
onClick = { open(attachment) },
|
||||||
|
) {
|
||||||
|
FileRow(attachment = attachment, onClick = { open(attachment) })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FileRow(attachment = attachment, onClick = { open(attachment) })
|
||||||
|
}
|
||||||
|
if (!readOnly) {
|
||||||
|
IconButton(
|
||||||
|
onClick = remove,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.attach_remove),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attachment.uploadError?.let { reason ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.attach_refused, reason),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's link previews in the editor, each dismissable.
|
||||||
|
*
|
||||||
|
* Dismissing is the only control: the server made the preview and will not make
|
||||||
|
* it again, so removing one is a decision about this note rather than a refresh.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EditorPreviews(
|
||||||
|
previews: List<LinkPreview>,
|
||||||
|
readOnly: Boolean,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
previews.forEach { preview ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
LinkPreviewCard(preview = preview, compact = true, modifier = Modifier.weight(1f))
|
||||||
|
if (!readOnly) {
|
||||||
|
IconButton(onClick = { onAction(EditorAction.RemovePreview(preview.id)) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.preview_remove),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loading, drawn, or not drawable (not downloaded yet, or not an image Android reads). */
|
||||||
|
private sealed interface ImageLoad {
|
||||||
|
data object Loading : ImageLoad
|
||||||
|
|
||||||
|
data class Ready(
|
||||||
|
val bitmap: ImageBitmap,
|
||||||
|
) : ImageLoad
|
||||||
|
|
||||||
|
data object Missing : ImageLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An attachment drawn as a picture, decoded off the main thread.
|
||||||
|
*
|
||||||
|
* [fallback] is what draws when it can't be: a file this device hasn't downloaded
|
||||||
|
* yet, or bytes that don't decode. It is a file row, so the note still shows that
|
||||||
|
* something is attached instead of a gap.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentImage(
|
||||||
|
attachment: Attachment,
|
||||||
|
maxPx: Int,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
fallback: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val files = LocalAttachmentFiles.current
|
||||||
|
val load by produceState<ImageLoad>(ImageLoad.Loading, attachment.sha256, maxPx, files) {
|
||||||
|
val bitmap = files?.let { withContext(Dispatchers.IO) { it.image(attachment, maxPx) } }
|
||||||
|
value = bitmap?.let { ImageLoad.Ready(it) } ?: ImageLoad.Missing
|
||||||
|
}
|
||||||
|
val shape = RoundedCornerShape(ATTACHMENT_RADIUS)
|
||||||
|
when (val state = load) {
|
||||||
|
ImageLoad.Loading ->
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(4f / 3f)
|
||||||
|
.clip(shape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
)
|
||||||
|
is ImageLoad.Ready -> {
|
||||||
|
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT)
|
||||||
|
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
|
||||||
|
Image(
|
||||||
|
bitmap = state.bitmap,
|
||||||
|
contentDescription = attachment.filename,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(ratio)
|
||||||
|
.clip(shape)
|
||||||
|
.then(tap),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ImageLoad.Missing -> fallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file by name and size, behind a paperclip. Tappable in the editor, not on the card. */
|
||||||
|
@Composable
|
||||||
|
private fun FileRow(
|
||||||
|
attachment: Attachment,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(ATTACHMENT_RADIUS)
|
||||||
|
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||||
|
.then(tap)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_attach),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = attachment.filename?.takeIf { it.isNotBlank() } ?: stringResource(R.string.attach_unnamed),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
attachment.size?.let { bytes ->
|
||||||
|
Text(
|
||||||
|
text = sizeLabel(bytes),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
// Clear of the editor's remove button, which sits over this corner.
|
||||||
|
modifier = Modifier.padding(start = 8.dp, end = if (onClick != null) 32.dp else 0.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an attachment with whatever app handles its type, or say why not.
|
||||||
|
*
|
||||||
|
* The copy out of the blob store runs on IO; the activity starts on the main
|
||||||
|
* thread, from the screen's own context, so the viewer opens over this app rather
|
||||||
|
* than in a task of its own.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberOpener(): (Attachment) -> Unit {
|
||||||
|
val files = LocalAttachmentFiles.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
return { attachment ->
|
||||||
|
if (files != null) {
|
||||||
|
scope.launch {
|
||||||
|
when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) {
|
||||||
|
is Opener.Ready -> start(context, opener)
|
||||||
|
is Opener.Failed -> toast(context, opener.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start(
|
||||||
|
context: Context,
|
||||||
|
opener: Opener.Ready,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
context.startActivity(opener.intent)
|
||||||
|
} catch (expected: ActivityNotFoundException) {
|
||||||
|
toast(context, context.getString(R.string.attach_no_app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(
|
||||||
|
context: Context,
|
||||||
|
message: String,
|
||||||
|
) = Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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.filled.Close
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's body, as fields and checkboxes rather than as markup.
|
||||||
|
*
|
||||||
|
* The point of the whole shape: a box you can tick while looking at the note, rather
|
||||||
|
* than `- [ ] ` to read and edit around. What the note IS never changed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BlockBody(
|
||||||
|
blocks: List<EditorBlock>,
|
||||||
|
readOnly: Boolean,
|
||||||
|
focus: Long?,
|
||||||
|
onChange: (List<EditorBlock>) -> Unit,
|
||||||
|
onFocus: (Long?) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Focus is addressed by block ID, never by position — the id is the only thing
|
||||||
|
// about a block that survives one being inserted above it. Hoisted to the caller
|
||||||
|
// rather than kept here, because the TOOLBAR also asks for a focus when its button
|
||||||
|
// appends an item, and two owners of one cursor is one too many.
|
||||||
|
val requesters = remember { mutableMapOf<Long, FocusRequester>() }
|
||||||
|
|
||||||
|
LaunchedEffect(focus) {
|
||||||
|
val id = focus ?: return@LaunchedEffect
|
||||||
|
// Honoured after the composition that created the field: a FocusRequester not
|
||||||
|
// yet attached to anything throws when asked.
|
||||||
|
requesters[id]?.requestFocus()
|
||||||
|
onFocus(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replace(
|
||||||
|
index: Int,
|
||||||
|
block: EditorBlock,
|
||||||
|
) = onChange(blocks.toMutableList().also { it[index] = block })
|
||||||
|
|
||||||
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
blocks.forEachIndexed { index, block ->
|
||||||
|
val requester = requesters.getOrPut(block.id) { FocusRequester() }
|
||||||
|
if (block.isTask) {
|
||||||
|
TaskBlock(
|
||||||
|
block = block,
|
||||||
|
readOnly = readOnly,
|
||||||
|
requester = requester,
|
||||||
|
onChange = { replace(index, it) },
|
||||||
|
onEnter = {
|
||||||
|
val next = blocks.nextId()
|
||||||
|
onChange(afterEnter(blocks, index, next))
|
||||||
|
// The new item if there was one; otherwise the block that just
|
||||||
|
// became prose, which keeps the caret where the person left it.
|
||||||
|
onFocus(if (blocks[index].value.text.isBlank()) block.id else next)
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
val remaining = blocks.withoutIndex(index)
|
||||||
|
onChange(remaining)
|
||||||
|
// The row above — or, for the FIRST row, whichever one takes
|
||||||
|
// its place. `index - 1` alone is -1 there, which left the
|
||||||
|
// keyboard up with nothing focused.
|
||||||
|
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProseBlock(
|
||||||
|
block = block,
|
||||||
|
readOnly = readOnly,
|
||||||
|
requester = requester,
|
||||||
|
onChange = { replace(index, it) },
|
||||||
|
onBlur = {
|
||||||
|
// Compared by IDENTITY, not equality: `promotingTasks` hands
|
||||||
|
// back the same list when there was nothing to promote, and a
|
||||||
|
// blur that changed nothing must not touch the state at all.
|
||||||
|
val promoted = blocks.promotingTasks(index)
|
||||||
|
if (promoted !== blocks) onChange(promoted)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
|
||||||
|
*
|
||||||
|
* Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see
|
||||||
|
* [promotingTasks] for why blur is the only safe moment to do that.
|
||||||
|
*
|
||||||
|
* `onFocusChanged` also fires with `isFocused = false` on the first composition, before
|
||||||
|
* the field has ever held focus. Deliberately not guarded: [splitBlocks] ran when the
|
||||||
|
* editor opened, so a prose block nobody has typed in cannot contain a task line, and
|
||||||
|
* the promotion is a no-op that the caller's identity check drops on the floor.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ProseBlock(
|
||||||
|
block: EditorBlock,
|
||||||
|
readOnly: Boolean,
|
||||||
|
requester: FocusRequester,
|
||||||
|
onChange: (EditorBlock) -> Unit,
|
||||||
|
onBlur: () -> Unit,
|
||||||
|
) {
|
||||||
|
BlockField(
|
||||||
|
value = block.value,
|
||||||
|
onValueChange = { onChange(block.copy(value = it)) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(requester)
|
||||||
|
.onFocusChanged { if (!it.isFocused) onBlur() },
|
||||||
|
enabled = !readOnly,
|
||||||
|
hint = R.string.editor_body_hint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One checklist item: a real box, and the item's text beside it.
|
||||||
|
*
|
||||||
|
* Single-line with [ImeAction.Next], which is what turns the keyboard's return key
|
||||||
|
* into "next item" — the reason a list can be typed straight through rather than a
|
||||||
|
* marker at a time.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TaskBlock(
|
||||||
|
block: EditorBlock,
|
||||||
|
readOnly: Boolean,
|
||||||
|
requester: FocusRequester,
|
||||||
|
onChange: (EditorBlock) -> Unit,
|
||||||
|
onEnter: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Material sizes every interactive component to a 48dp touch target, and on a
|
||||||
|
// checklist that IS the row height — which is why six items filled a phone screen
|
||||||
|
// even after the field's own padding came off.
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(
|
||||||
|
checked = block.checked == true,
|
||||||
|
onCheckedChange = { onChange(block.copy(checked = it)) },
|
||||||
|
enabled = !readOnly,
|
||||||
|
)
|
||||||
|
BlockField(
|
||||||
|
value = block.value,
|
||||||
|
onValueChange = { onChange(block.copy(value = it)) },
|
||||||
|
modifier = Modifier.weight(1f).focusRequester(requester),
|
||||||
|
enabled = !readOnly,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle =
|
||||||
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
// Struck through when done, matching the card and the web.
|
||||||
|
textDecoration =
|
||||||
|
if (block.checked == true) TextDecoration.LineThrough else null,
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { onEnter() }),
|
||||||
|
)
|
||||||
|
if (!readOnly) {
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.editor_remove_item),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The touch target for a checklist row's controls.
|
||||||
|
*
|
||||||
|
* Material's floor is 48dp and this is deliberately under it. That floor is sized for
|
||||||
|
* a control somebody has to find; a checklist box sits in a predictable column with an
|
||||||
|
* identical box directly above and below, and the cost of a near miss is ticking the
|
||||||
|
* neighbouring item — visible, and undone by tapping again. Trading twelve of those
|
||||||
|
* dp for a list that fits on a screen is what was asked for, twice.
|
||||||
|
*/
|
||||||
|
private val ROW_TOUCH = 36.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field a block is typed into.
|
||||||
|
*
|
||||||
|
* `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is
|
||||||
|
* density. Material's TextField puts 16dp above and below its text — padding that
|
||||||
|
* makes a FORM field comfortable to hit, and that on a checklist IS the row height. It
|
||||||
|
* made six items twice as tall as the six items, which is what the operator saw.
|
||||||
|
*
|
||||||
|
* Nothing is lost by dropping down a layer. `PlainTextField` exists to strip a
|
||||||
|
* container and an indicator; `BasicTextField` never had either, so there is no box
|
||||||
|
* here to drift back into existence. What it does not supply and this must:
|
||||||
|
*
|
||||||
|
* - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK — the same
|
||||||
|
* default that made the editor's toolbar invisible in dark mode. Set, not inherited.
|
||||||
|
* - the cursor brush, which would otherwise be black for the same reason.
|
||||||
|
* - the placeholder, which is a plain Text behind the field rather than a slot.
|
||||||
|
*
|
||||||
|
* `enabled = false` deliberately does not grey the text out: a trashed note renders
|
||||||
|
* read-only through this and its words are meant to be READ.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BlockField(
|
||||||
|
value: TextFieldValue,
|
||||||
|
onValueChange: (TextFieldValue) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
singleLine: Boolean = false,
|
||||||
|
@StringRes hint: Int? = null,
|
||||||
|
textStyle: TextStyle = MaterialTheme.typography.bodyLarge,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
) {
|
||||||
|
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
if (hint != null && value.text.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(hint),
|
||||||
|
style = style,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = enabled,
|
||||||
|
singleLine = singleLine,
|
||||||
|
textStyle = style,
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.PointerId
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
|
// Reordering the board by dragging a card, as on the web (#5176): hold a card, then
|
||||||
|
// move it. The hold is the same long press that opens the card's menu, so the menu
|
||||||
|
// appears first and closes as the card starts to move. Lifting without moving
|
||||||
|
// leaves the menu open, as before. Moving BEFORE the hold completes is a scroll.
|
||||||
|
//
|
||||||
|
// Watched on the Initial pass, ahead of the grid's scrolling and the card's own
|
||||||
|
// click handling. Nothing is consumed until the hold has completed and the finger
|
||||||
|
// has moved, so taps, long presses and scrolls all reach the grid and the card
|
||||||
|
// untouched. After that point every change is consumed, so the grid cannot scroll
|
||||||
|
// under a card that is being carried.
|
||||||
|
|
||||||
|
/** A card being carried. Every offset is in root coordinates. */
|
||||||
|
internal data class CardDrag(
|
||||||
|
val id: String,
|
||||||
|
/** Where the finger went down. */
|
||||||
|
val from: Offset,
|
||||||
|
/** Where the finger is now. */
|
||||||
|
val at: Offset,
|
||||||
|
/** Where the card's corner was when the drag began. */
|
||||||
|
val startTopLeft: Offset,
|
||||||
|
) {
|
||||||
|
/** Where the card's corner should be drawn, so it stays under the finger. */
|
||||||
|
val topLeft: Offset get() = startTopLeft + (at - from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where each card on screen was last laid out, in root coordinates.
|
||||||
|
*
|
||||||
|
* A plain map rather than snapshot state: only the gesture reads it, so a write on
|
||||||
|
* every layout pass must not invalidate any composition.
|
||||||
|
*/
|
||||||
|
internal class CardBounds {
|
||||||
|
private val bounds = HashMap<String, Rect>()
|
||||||
|
|
||||||
|
fun record(
|
||||||
|
id: String,
|
||||||
|
rect: Rect,
|
||||||
|
) {
|
||||||
|
bounds[id] = rect
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forget(id: String) {
|
||||||
|
bounds.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun get(id: String): Rect? = bounds[id]
|
||||||
|
|
||||||
|
/** The card under [point], other than [except]. */
|
||||||
|
fun idAt(
|
||||||
|
point: Offset,
|
||||||
|
except: String? = null,
|
||||||
|
): String? = bounds.entries.firstOrNull { (id, rect) -> id != except && rect.contains(point) }?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the board does as a card is carried. Every offset is in root coordinates. */
|
||||||
|
internal class CardCarrier(
|
||||||
|
val onStart: (id: String, at: Offset) -> Unit,
|
||||||
|
val onMove: (at: Offset) -> Unit,
|
||||||
|
/** Runs however the gesture finishes, cancelled included, so no card is left held. */
|
||||||
|
val onEnd: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry a card: hold, then move.
|
||||||
|
*
|
||||||
|
* [toRoot] maps a position in this element to root coordinates, which is where
|
||||||
|
* [bounds] keeps the cards.
|
||||||
|
*/
|
||||||
|
internal fun Modifier.dragToReorder(
|
||||||
|
enabled: Boolean,
|
||||||
|
bounds: CardBounds,
|
||||||
|
toRoot: (Offset) -> Offset,
|
||||||
|
carrier: CardCarrier,
|
||||||
|
): Modifier =
|
||||||
|
if (enabled) {
|
||||||
|
pointerInput(bounds) { awaitEachGesture { carry(bounds, toRoot, carrier) } }
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun AwaitPointerEventScope.carry(
|
||||||
|
bounds: CardBounds,
|
||||||
|
toRoot: (Offset) -> Offset,
|
||||||
|
carrier: CardCarrier,
|
||||||
|
) {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
val id = bounds.idAt(toRoot(down.position))
|
||||||
|
if (id == null || !holdsStill(down) || !movesAfterHold(down)) return
|
||||||
|
carrier.onStart(id, toRoot(down.position))
|
||||||
|
try {
|
||||||
|
followUntilUp(down.id) { carrier.onMove(toRoot(it)) }
|
||||||
|
} finally {
|
||||||
|
carrier.onEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the finger stayed down and inside the touch slop for a long press. */
|
||||||
|
private suspend fun AwaitPointerEventScope.holdsStill(down: PointerInputChange): Boolean {
|
||||||
|
val endedEarly =
|
||||||
|
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
||||||
|
var ended = false
|
||||||
|
while (!ended) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id }
|
||||||
|
ended = change == null || !change.pressed || change.movedPast(down, viewConfiguration.touchSlop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return endedEarly == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After the hold: true once the finger moves past the slop, false if it lifts first. */
|
||||||
|
private suspend fun AwaitPointerEventScope.movesAfterHold(down: PointerInputChange): Boolean {
|
||||||
|
var moved: Boolean? = null
|
||||||
|
while (moved == null) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id }
|
||||||
|
if (change == null || !change.pressed) {
|
||||||
|
moved = false
|
||||||
|
} else if (change.movedPast(down, viewConfiguration.touchSlop)) {
|
||||||
|
// Consumed so the grid's scrolling, which has not claimed this gesture
|
||||||
|
// yet, never does.
|
||||||
|
change.consume()
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moved == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report every position until the finger lifts, consuming each change. */
|
||||||
|
private suspend fun AwaitPointerEventScope.followUntilUp(
|
||||||
|
pointer: PointerId,
|
||||||
|
onMove: (Offset) -> Unit,
|
||||||
|
) {
|
||||||
|
var pressed = true
|
||||||
|
while (pressed) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == pointer }
|
||||||
|
pressed = change?.pressed == true
|
||||||
|
change?.consume()
|
||||||
|
if (change != null && pressed) onMove(change.position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PointerInputChange.movedPast(
|
||||||
|
down: PointerInputChange,
|
||||||
|
slop: Float,
|
||||||
|
): Boolean = (position - down.position).getDistance() > slop
|
||||||
|
|
||||||
|
/** The grid's own layout, so a position in it can be taken to root coordinates. */
|
||||||
|
internal class GridOrigin {
|
||||||
|
var coords: LayoutCoordinates? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [order] with the carried card moved to the place of the card under the finger, the
|
||||||
|
* way the web's drop splices it. Unchanged when there is no card there, or when it is
|
||||||
|
* on the other side of the pinned line: the store sorts pinned notes first, so a
|
||||||
|
* card dropped across the line would only snap back.
|
||||||
|
*/
|
||||||
|
internal fun moved(
|
||||||
|
order: List<String>?,
|
||||||
|
drag: CardDrag,
|
||||||
|
bounds: CardBounds,
|
||||||
|
notes: List<Note>,
|
||||||
|
): List<String>? {
|
||||||
|
val target = bounds.idAt(drag.at, except = drag.id)
|
||||||
|
val pinned = notes.associate { it.id to it.pinned }
|
||||||
|
val from = order?.indexOf(drag.id) ?: -1
|
||||||
|
val to = if (target != null && pinned[target] == pinned[drag.id]) order?.indexOf(target) ?: -1 else -1
|
||||||
|
return if (order == null || from < 0 || to < 0) {
|
||||||
|
order
|
||||||
|
} else {
|
||||||
|
order.toMutableList().apply {
|
||||||
|
removeAt(from)
|
||||||
|
add(to, drag.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import com.fabledsword.inkwell.core.NoteFacets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board's filters: the same three the web's Filters panel offers (FilterBar.vue,
|
||||||
|
* trimmed to these in #5180). Tags a note must ALL carry, has an attachment, and
|
||||||
|
* shared with me. Reminders and dates have their own lenses, here as on the web.
|
||||||
|
*
|
||||||
|
* Only the main board filters, as on the web. Opening Archive, Trash or a tag from
|
||||||
|
* the drawer starts that view unfiltered, and search runs over everything.
|
||||||
|
*/
|
||||||
|
data class BoardFilters(
|
||||||
|
val labelIds: Set<String> = emptySet(),
|
||||||
|
val hasAttachment: Boolean = false,
|
||||||
|
val sharedWithMe: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** How many are on, counted the way the web's badge counts them: each tag is one. */
|
||||||
|
val count: Int
|
||||||
|
get() = labelIds.size + (if (hasAttachment) 1 else 0) + (if (sharedWithMe) 1 else 0)
|
||||||
|
|
||||||
|
/** The core's facets for these, or null when none are on. */
|
||||||
|
fun facets(): NoteFacets? =
|
||||||
|
if (count == 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
NoteFacets(
|
||||||
|
q = null,
|
||||||
|
label = labelIds.toList().ifEmpty { null },
|
||||||
|
hasAttachment = if (hasAttachment) true else null,
|
||||||
|
createdAfter = null,
|
||||||
|
createdBefore = null,
|
||||||
|
shared = if (sharedWithMe) SHARED_WITH_ME else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleLabel(id: String) = copy(labelIds = if (id in labelIds) labelIds - id else labelIds + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The facet value the core reads as "only notes someone else shared with me". */
|
||||||
|
private const val SHARED_WITH_ME = "with_me"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row under the search bar: a Filters chip that opens the sheet, showing how many
|
||||||
|
* are on, and a Clear beside it once any are.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun FilterRow(
|
||||||
|
filters: BoardFilters,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = GUTTER),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.count > 0,
|
||||||
|
onClick = onOpen,
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
if (filters.count > 0) {
|
||||||
|
stringResource(R.string.filters_count, filters.count)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.filters)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (filters.count > 0) {
|
||||||
|
TextButton(onClick = onClear) { Text(stringResource(R.string.filters_clear)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the filters. Each tap applies at once, as on the web, so the board behind the
|
||||||
|
* sheet is already showing the answer when it is dismissed.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun FilterSheet(
|
||||||
|
filters: BoardFilters,
|
||||||
|
labels: List<Label>,
|
||||||
|
onChange: (BoardFilters) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
SheetTitle(R.string.filters)
|
||||||
|
|
||||||
|
FilterHeading(R.string.filters_show)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.hasAttachment,
|
||||||
|
onClick = { onChange(filters.copy(hasAttachment = !filters.hasAttachment)) },
|
||||||
|
label = { Text(stringResource(R.string.filters_has_attachment)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.sharedWithMe,
|
||||||
|
onClick = { onChange(filters.copy(sharedWithMe = !filters.sharedWithMe)) },
|
||||||
|
label = { Text(stringResource(R.string.filters_shared_with_me)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No heading and no empty row when there are no tags yet: a section that
|
||||||
|
// can only ever say "none" is noise in a sheet this small.
|
||||||
|
if (labels.isNotEmpty()) {
|
||||||
|
FilterHeading(R.string.filters_tags)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
labels.forEach { label ->
|
||||||
|
FilterChip(
|
||||||
|
selected = label.id in filters.labelIds,
|
||||||
|
onClick = { onChange(filters.toggleLabel(label.id)) },
|
||||||
|
label = { Text(label.name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.count > 0) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onChange(BoardFilters()) },
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.filters_clear))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterHeading(labelRes: Int) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(labelRes),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.pullToRefresh
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BoardScreen(
|
||||||
|
state: BoardState,
|
||||||
|
onOpen: (Destination) -> Unit,
|
||||||
|
onOpenNote: (Note) -> Unit,
|
||||||
|
sync: BoardSync,
|
||||||
|
onOpenSync: () -> Unit,
|
||||||
|
onManageTags: () -> Unit,
|
||||||
|
onSearch: (String) -> Unit,
|
||||||
|
onFilter: (BoardFilters) -> Unit,
|
||||||
|
onReorder: (List<String>) -> Unit,
|
||||||
|
onCompose: () -> Unit,
|
||||||
|
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||||
|
onNoteAction: (Note, EditorAction) -> Unit,
|
||||||
|
update: BoardUpdate?,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
) {
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbars = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
|
||||||
|
// the moment it scrolls out of view, which would take its dialog down with it —
|
||||||
|
// and the board can scroll under an open dialog.
|
||||||
|
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
|
||||||
|
var filtering by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Resolved in composition, not inside the coroutine: `stringResource` is a
|
||||||
|
// composable read and cannot be called from a suspend block.
|
||||||
|
val trashedMessage = stringResource(R.string.board_trashed)
|
||||||
|
val undoLabel = stringResource(R.string.board_undo)
|
||||||
|
|
||||||
|
// Trash gets an UNDO rather than a confirmation, and the two are not
|
||||||
|
// interchangeable. A long press is a gesture you can make by accident — resting a
|
||||||
|
// thumb while reading is enough — so the mistake worth designing for is the one
|
||||||
|
// nobody meant to make, and a dialog only helps someone who is paying attention
|
||||||
|
// in the moment they were not. Trash is already recoverable; the snackbar just
|
||||||
|
// says so where it happened, instead of leaving you to find the Trash view and
|
||||||
|
// work out which note went missing.
|
||||||
|
//
|
||||||
|
// Delete forever keeps its dialog. That one does not undo.
|
||||||
|
val onCardAction: (Note, EditorAction) -> Unit = { note, action ->
|
||||||
|
onNoteAction(note, action)
|
||||||
|
if (action == EditorAction.Trash) {
|
||||||
|
scope.launch {
|
||||||
|
val outcome =
|
||||||
|
snackbars.showSnackbar(
|
||||||
|
message = trashedMessage,
|
||||||
|
actionLabel = undoLabel,
|
||||||
|
duration = SnackbarDuration.Short,
|
||||||
|
)
|
||||||
|
// `note` is the pre-trash copy and deliberately so: Restore only needs
|
||||||
|
// its id, and the id is the one thing trashing does not change.
|
||||||
|
if (outcome == SnackbarResult.ActionPerformed) {
|
||||||
|
onNoteAction(note, EditorAction.Restore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
NavigationDrawer(
|
||||||
|
current = state.destination,
|
||||||
|
labels = state.labels,
|
||||||
|
syncSummary = sync.summary,
|
||||||
|
onOpen = {
|
||||||
|
onOpen(it)
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onOpenSync = {
|
||||||
|
onOpenSync()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
onManageTags = {
|
||||||
|
onManageTags()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
// The IME, added to what the Scaffold already insets for. `enableEdgeToEdge`
|
||||||
|
// makes the manifest's `adjustResize` a no-op on API 30+, so nothing resizes
|
||||||
|
// for the keyboard unless the app asks — and `ScaffoldDefaults.contentWindowInsets`
|
||||||
|
// is systemBars, which the IME is not part of. The Scaffold positions the FAB
|
||||||
|
// AND the snackbar host from this value, so without it both sit behind the
|
||||||
|
// keyboard whenever the search field has focus. That is not theoretical: the
|
||||||
|
// undo on a trashed search hit is exactly the control you cannot reach.
|
||||||
|
//
|
||||||
|
// `union` rather than `add` — the two are the same edge, not two stacked ones.
|
||||||
|
// Adding them would inset by the navigation bar a second time underneath a
|
||||||
|
// keyboard that already covers it.
|
||||||
|
//
|
||||||
|
// One owner for the edge, as with the search bar's missing statusBarsPadding:
|
||||||
|
// set here, the content Column gets it through `padding` and must not repeat it.
|
||||||
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
|
snackbarHost = { SnackbarHost(snackbars) },
|
||||||
|
floatingActionButton = {
|
||||||
|
// The + is the ONLY way in, by design: one obvious target rather
|
||||||
|
// than a capture bar and a button competing for the same job.
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onCompose,
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.compose_open))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
SearchBar(
|
||||||
|
query = state.query,
|
||||||
|
onQueryChange = onSearch,
|
||||||
|
onMenu = { scope.launch { drawerState.open() } },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.filterable) {
|
||||||
|
FilterRow(
|
||||||
|
filters = state.filters,
|
||||||
|
onOpen = { filtering = true },
|
||||||
|
onClear = { onFilter(BoardFilters()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let { message ->
|
||||||
|
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||||
|
}
|
||||||
|
// Stacked rather than one-or-the-other. A store failure and a sync
|
||||||
|
// failure are different facts about different halves of the app;
|
||||||
|
// hiding either behind the other would report the wrong problem.
|
||||||
|
sync.error?.let { message ->
|
||||||
|
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below the failures and above the notes: an update is worth saying,
|
||||||
|
// and never worth saying before a note failed to save.
|
||||||
|
update?.let {
|
||||||
|
UpdateBanner(
|
||||||
|
version = it.version,
|
||||||
|
busy = it.busy,
|
||||||
|
onInstall = it.onInstall,
|
||||||
|
onDismiss = it.onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only where someone is already thinking about reminders. On the
|
||||||
|
// main board it would nag people who have never set one.
|
||||||
|
if (state.destination == Destination.Reminders) ReminderNotice()
|
||||||
|
|
||||||
|
val pull = rememberPullToRefreshState()
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pullToRefresh(
|
||||||
|
isRefreshing = sync.refreshing,
|
||||||
|
state = pull,
|
||||||
|
// INERT on a device with no server, rather than
|
||||||
|
// spinning and finding nothing: there is no remote
|
||||||
|
// to fetch from, and a gesture that always comes
|
||||||
|
// back empty teaches people it is broken.
|
||||||
|
enabled = sync.canRefresh && !state.loading,
|
||||||
|
onRefresh = sync.onRefresh,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.loading -> LoadingBoard()
|
||||||
|
state.notes.isEmpty() -> EmptyBoard(state)
|
||||||
|
else ->
|
||||||
|
NoteBoard(
|
||||||
|
notes = state.notes,
|
||||||
|
onOpenNote = onOpenNote,
|
||||||
|
onToggleItem = onToggleItem,
|
||||||
|
onNoteAction = onCardAction,
|
||||||
|
onConfirmDelete = { confirmingDelete = it },
|
||||||
|
reorderable = state.reorderable,
|
||||||
|
onReorder = onReorder,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// `PullToRefreshBox` would be less code, but it takes no
|
||||||
|
// `enabled`, so the modifier and the indicator are wired by
|
||||||
|
// hand to keep the gate above.
|
||||||
|
PullToRefreshDefaults.Indicator(
|
||||||
|
state = pull,
|
||||||
|
isRefreshing = sync.refreshing,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtering) {
|
||||||
|
FilterSheet(
|
||||||
|
filters = state.filters,
|
||||||
|
labels = state.labels,
|
||||||
|
onChange = onFilter,
|
||||||
|
onDismiss = { filtering = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmingDelete?.let { note ->
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
onConfirm = {
|
||||||
|
confirmingDelete = null
|
||||||
|
onNoteAction(note, EditorAction.DeleteForever)
|
||||||
|
},
|
||||||
|
onDismiss = { confirmingDelete = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the board knows about sync, which is deliberately not much.
|
||||||
|
*
|
||||||
|
* A holder rather than six loose parameters, for the reason `EditorAction`
|
||||||
|
* exists: [summary] and [error] are both `String?` and both about sync, so as
|
||||||
|
* positional arguments they could be swapped with nothing to catch it. Named
|
||||||
|
* fields make that unsayable.
|
||||||
|
*
|
||||||
|
* Passed in rather than read from [BoardState]. Sync has its own view model, and
|
||||||
|
* giving the board a second copy of "is this device linked" would be two sources
|
||||||
|
* of truth for one fact.
|
||||||
|
*/
|
||||||
|
data class BoardSync(
|
||||||
|
/** One line for the drawer badge, or null when there is nothing worth saying. */
|
||||||
|
val summary: String?,
|
||||||
|
/** The last sync failure, still unacknowledged. */
|
||||||
|
val error: String?,
|
||||||
|
/** A sync is in flight — drives the indicator, whoever started it. */
|
||||||
|
val refreshing: Boolean,
|
||||||
|
/** Whether there is a server to refresh FROM. False means the gesture is off. */
|
||||||
|
val canRefresh: Boolean,
|
||||||
|
val onRefresh: () -> Unit,
|
||||||
|
val onDismissError: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The waiting app update, or null when there is nothing to say.
|
||||||
|
*
|
||||||
|
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
|
||||||
|
* `version` and a pair of booleans as positional arguments could be swapped with
|
||||||
|
* nothing to catch it.
|
||||||
|
*
|
||||||
|
* Null covers every reason there is nothing to show — unlinked, up to date, found but
|
||||||
|
* not yet downloaded, dismissed for this sitting — so the board never has to know
|
||||||
|
* which.
|
||||||
|
*/
|
||||||
|
data class BoardUpdate(
|
||||||
|
val version: String,
|
||||||
|
/** An install is in flight — the banner stays and reports it. */
|
||||||
|
val busy: Boolean,
|
||||||
|
val onInstall: () -> Unit,
|
||||||
|
val onDismiss: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A search field IS the top bar, following the phone convention rather than the
|
||||||
|
* desktop's title-plus-sidebar.
|
||||||
|
*
|
||||||
|
* On a phone, finding a note you already wrote is the most common thing after
|
||||||
|
* writing one, and burying it behind an icon costs a tap every time. The drawer
|
||||||
|
* lives inside it on the left, which is where every Android user reaches for
|
||||||
|
* navigation.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchBar(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onMenu: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
// No `statusBarsPadding()` here. The Scaffold this sits in already applies
|
||||||
|
// the system-bar insets to its content padding, so adding them again put
|
||||||
|
// the whole status bar's height of empty space above the search field —
|
||||||
|
// roughly a centimetre of nothing at the top of the first screen anyone
|
||||||
|
// sees. Insets get consumed once, by whichever component owns the edge.
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = GUTTER, vertical = 8.dp),
|
||||||
|
shape = RoundedCornerShape(SEARCH_RADIUS),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onMenu) {
|
||||||
|
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_open))
|
||||||
|
}
|
||||||
|
PlainTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
hint = R.string.search_hint,
|
||||||
|
singleLine = true,
|
||||||
|
// The search key is decorative here: results already land as you
|
||||||
|
// type, so pressing it should dismiss the keyboard and change
|
||||||
|
// nothing, which is what an empty handler does.
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = {}),
|
||||||
|
)
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_clear))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NavigationDrawer(
|
||||||
|
current: Destination,
|
||||||
|
labels: List<Label>,
|
||||||
|
syncSummary: String?,
|
||||||
|
onOpen: (Destination) -> Unit,
|
||||||
|
onOpenSync: () -> Unit,
|
||||||
|
onManageTags: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalDrawerSheet {
|
||||||
|
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.app_name),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
listOf(Destination.Notes, Destination.Reminders).forEach { destination ->
|
||||||
|
DrawerRow(destination, current, onOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header renders even with no tags, unlike the rows below it: the
|
||||||
|
// manage screen is where you go to MAKE the first one, and hiding the
|
||||||
|
// way in until one exists would be a door that appears only once you
|
||||||
|
// are already inside. It is an action ON the section rather than a row
|
||||||
|
// in it, so it cannot be mistaken for one more lens.
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 28.dp, end = 16.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.nav_labels),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
IconButton(onClick = onManageTags) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Edit,
|
||||||
|
contentDescription = stringResource(R.string.tags_manage),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels.forEach { label ->
|
||||||
|
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
|
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||||
|
DrawerRow(destination, current, onOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync sits below the divider with the destinations rather than behind
|
||||||
|
// a settings gear: it is not a preference, it is where you go to find
|
||||||
|
// out whether this phone and your desktop are actually in step. The
|
||||||
|
// subtitle answers that without opening it.
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(stringResource(R.string.sync_title)) },
|
||||||
|
badge = syncSummary?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
|
||||||
|
selected = false,
|
||||||
|
onClick = onOpenSync,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DrawerRow(
|
||||||
|
destination: Destination,
|
||||||
|
current: Destination,
|
||||||
|
onOpen: (Destination) -> Unit,
|
||||||
|
) {
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = { Text(destination.title) },
|
||||||
|
selected = destination == current,
|
||||||
|
onClick = { onOpen(destination) },
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board: a masonry of two to four columns, by window width, matching the web.
|
||||||
|
*
|
||||||
|
* Staggered rather than a uniform grid because notes are wildly different heights
|
||||||
|
* — a one-line thought beside a twelve-item checklist — and forcing them to a
|
||||||
|
* common height either clips the long ones or strands whitespace under the short
|
||||||
|
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NoteBoard(
|
||||||
|
notes: List<Note>,
|
||||||
|
onOpenNote: (Note) -> Unit,
|
||||||
|
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||||
|
onNoteAction: (Note, EditorAction) -> Unit,
|
||||||
|
onConfirmDelete: (Note) -> Unit,
|
||||||
|
/** Whether cards can be dragged into a new order: the plain main board only. */
|
||||||
|
reorderable: Boolean,
|
||||||
|
onReorder: (List<String>) -> Unit,
|
||||||
|
) {
|
||||||
|
// The web's breakpoints (NoteGrid.vue: three columns from `lg`, 1024px, four from
|
||||||
|
// `xl`, 1280px), on the window's width, so a tablet lays out like a browser of the
|
||||||
|
// same size. Below that it stays at two, where the web drops to one on a phone.
|
||||||
|
val windowWidth = LocalConfiguration.current.screenWidthDp
|
||||||
|
val columns =
|
||||||
|
when {
|
||||||
|
windowWidth >= 1280 -> 4
|
||||||
|
windowWidth >= 1024 -> 3
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// The drag (BoardDrag.kt). `order` is the board as the drag has rearranged it, and
|
||||||
|
// it stays until the store's next list arrives, so a drop does not flash back to
|
||||||
|
// the old order while the write is in flight.
|
||||||
|
val bounds = remember { CardBounds() }
|
||||||
|
val grid = remember { GridOrigin() }
|
||||||
|
var drag by remember { mutableStateOf<CardDrag?>(null) }
|
||||||
|
var order by remember { mutableStateOf<List<String>?>(null) }
|
||||||
|
val current by rememberUpdatedState(notes)
|
||||||
|
val reorder by rememberUpdatedState(onReorder)
|
||||||
|
LaunchedEffect(notes) { order = null }
|
||||||
|
// Which card is carried, read by every item; where it is, read only when its layer
|
||||||
|
// is drawn. A move then redraws one card instead of recomposing them all.
|
||||||
|
val carriedId by remember { derivedStateOf { drag?.id } }
|
||||||
|
|
||||||
|
val byId = notes.associateBy { it.id }
|
||||||
|
val shown = order?.mapNotNull { byId[it] } ?: notes
|
||||||
|
|
||||||
|
LazyVerticalStaggeredGrid(
|
||||||
|
columns = StaggeredGridCells.Fixed(columns),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.onGloballyPositioned { grid.coords = it }
|
||||||
|
.dragToReorder(
|
||||||
|
enabled = reorderable,
|
||||||
|
bounds = bounds,
|
||||||
|
toRoot = { grid.coords?.localToRoot(it) ?: it },
|
||||||
|
carrier =
|
||||||
|
CardCarrier(
|
||||||
|
onStart = { id, at ->
|
||||||
|
val corner = bounds[id]?.topLeft ?: at
|
||||||
|
drag = CardDrag(id = id, from = at, at = at, startTopLeft = corner)
|
||||||
|
order = current.map { it.id }
|
||||||
|
},
|
||||||
|
onMove = { at ->
|
||||||
|
drag = drag?.copy(at = at)
|
||||||
|
order = drag?.let { moved(order, it, bounds, current) } ?: order
|
||||||
|
},
|
||||||
|
onEnd = {
|
||||||
|
val final = order
|
||||||
|
drag = null
|
||||||
|
if (final != null && final != current.map { it.id }) reorder(final)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom padding clears the FAB, so the last note is never trapped under it.
|
||||||
|
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
|
||||||
|
verticalItemSpacing = 8.dp,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// Keyed by id so Compose reuses cards across a refresh rather than
|
||||||
|
// rebuilding them — and so a newly captured note slides in instead of
|
||||||
|
// making every card below it flicker.
|
||||||
|
items(items = shown, key = { it.id }) { note ->
|
||||||
|
val carried = carriedId == note.id
|
||||||
|
// Bounds recorded BEFORE the carried card's translation in the chain, so
|
||||||
|
// they are where the layout put it, not where the finger has taken it.
|
||||||
|
val follow =
|
||||||
|
if (carried) {
|
||||||
|
Modifier.zIndex(1f).graphicsLayer {
|
||||||
|
drag?.let { d ->
|
||||||
|
val laid = bounds[note.id]?.topLeft ?: d.startTopLeft
|
||||||
|
translationX = d.topLeft.x - laid.x
|
||||||
|
translationY = d.topLeft.y - laid.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier.animateItem()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onGloballyPositioned { bounds.record(note.id, it.boundsInRoot()) }
|
||||||
|
.then(follow),
|
||||||
|
) {
|
||||||
|
NoteCard(
|
||||||
|
note = note,
|
||||||
|
onOpen = { onOpenNote(note) },
|
||||||
|
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
|
||||||
|
onAction = { onNoteAction(note, it) },
|
||||||
|
onConfirmDelete = { onConfirmDelete(note) },
|
||||||
|
dragging = carried,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DisposableEffect(note.id) { onDispose { bounds.forget(note.id) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The empty state, which has to say something DIFFERENT per destination.
|
||||||
|
*
|
||||||
|
* "Nothing here yet" is encouraging on an empty board and wrong in Trash, where it
|
||||||
|
* should read as reassurance, and misleading after a search, where the notes exist
|
||||||
|
* but did not match.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EmptyBoard(state: BoardState) {
|
||||||
|
val (title, body) =
|
||||||
|
when {
|
||||||
|
state.searching ->
|
||||||
|
stringResource(R.string.empty_search_title) to
|
||||||
|
stringResource(R.string.empty_search_body, state.query)
|
||||||
|
state.destination == Destination.Notes && state.filters.count > 0 ->
|
||||||
|
stringResource(R.string.empty_filtered_title) to stringResource(R.string.empty_filtered_body)
|
||||||
|
state.destination == Destination.Trash ->
|
||||||
|
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
|
||||||
|
state.destination == Destination.Archive ->
|
||||||
|
stringResource(R.string.empty_archive_title) to stringResource(R.string.empty_archive_body)
|
||||||
|
state.destination == Destination.Reminders ->
|
||||||
|
stringResource(R.string.empty_reminders_title) to stringResource(R.string.empty_reminders_body)
|
||||||
|
else ->
|
||||||
|
stringResource(R.string.board_empty_title) to stringResource(R.string.board_empty_body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A LazyColumn holding one centred item, NOT a plain Column. Pull-to-refresh
|
||||||
|
// works through nested scroll, and a layout that never scrolls never dispatches
|
||||||
|
// any — so on a Column the gesture would be dead on exactly the screen where it
|
||||||
|
// matters most: linked, board empty, notes still on the server.
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingBoard() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the store could not be opened at all.
|
||||||
|
*
|
||||||
|
* No retry: whatever stopped SQLite opening will stop it again this launch. Saying
|
||||||
|
* so plainly beats a button that does nothing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun StoreUnavailableScreen(reason: String?) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.store_unavailable_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = reason ?: stringResource(R.string.store_unavailable_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not private: the reminder notice is board content and has to line up with the
|
||||||
|
// search bar and the cards, so it shares the board's gutter rather than guessing.
|
||||||
|
internal val GUTTER = 12.dp
|
||||||
|
private val SEARCH_RADIUS = 28.dp
|
||||||
@@ -0,0 +1,706 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import com.fabledsword.inkwell.core.NoteDraft
|
||||||
|
import com.fabledsword.inkwell.core.NoteEdit
|
||||||
|
import com.fabledsword.inkwell.core.NoteFacets
|
||||||
|
import com.fabledsword.inkwell.core.NoteQuery
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which pile of notes the board is showing. Mirrors the desktop sidebar.
|
||||||
|
*
|
||||||
|
* A sealed type rather than a string so the `when` that loads them is exhaustive —
|
||||||
|
* adding a destination becomes a compile error at the loader instead of a silently
|
||||||
|
* empty board.
|
||||||
|
*/
|
||||||
|
sealed interface Destination {
|
||||||
|
val title: String
|
||||||
|
|
||||||
|
data object Notes : Destination {
|
||||||
|
override val title = "Notes"
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Reminders : Destination {
|
||||||
|
override val title = "Reminders"
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Archive : Destination {
|
||||||
|
override val title = "Archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Trash : Destination {
|
||||||
|
override val title = "Trash"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WithLabel(
|
||||||
|
val id: String,
|
||||||
|
override val title: String,
|
||||||
|
) : Destination
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the board renders from, in one immutable snapshot. */
|
||||||
|
data class BoardState(
|
||||||
|
val destination: Destination = Destination.Notes,
|
||||||
|
val notes: List<Note> = emptyList(),
|
||||||
|
val labels: List<Label> = emptyList(),
|
||||||
|
val query: String = "",
|
||||||
|
/** The main board's filters. Only [Destination.Notes] applies them. */
|
||||||
|
val filters: BoardFilters = BoardFilters(),
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val saving: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
/**
|
||||||
|
* The note the editor is open on, or null for the board.
|
||||||
|
*
|
||||||
|
* The NOTE and not its id, so the editor always renders from the same object
|
||||||
|
* the store last returned. Every mutation hands back the reloaded note, so
|
||||||
|
* ticking a box or picking a colour updates this in place and the editor never
|
||||||
|
* has to re-query to see its own change.
|
||||||
|
*/
|
||||||
|
val editing: Note? = null,
|
||||||
|
/**
|
||||||
|
* Bumped each time the editor is opened on a DIFFERENT note, and deliberately
|
||||||
|
* not when the note it is already on changes.
|
||||||
|
*
|
||||||
|
* The editor keys its text field on this rather than on `editing.id`, because a
|
||||||
|
* draft's id changes the instant it is first saved — and re-keying on that would
|
||||||
|
* reset the field to whatever the store just returned, discarding anything typed
|
||||||
|
* during the write. That is a data-loss bug rather than a flicker.
|
||||||
|
*/
|
||||||
|
val editingSession: Long = 0,
|
||||||
|
) {
|
||||||
|
/** Search overrides the destination while there is a query to run. */
|
||||||
|
val searching: Boolean get() = query.isNotBlank()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main board only, and not while searching, as on the web: a search spans
|
||||||
|
* everything, so a filter over its hits would be a control that does nothing.
|
||||||
|
*/
|
||||||
|
val filterable: Boolean get() = destination == Destination.Notes && !searching
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole board in one order, as on the web: not a search, another view, or a
|
||||||
|
* filtered subset, whose order would be renumbered against notes it can't see.
|
||||||
|
*/
|
||||||
|
val reorderable: Boolean get() = filterable && filters.count == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the board off the shared Rust core.
|
||||||
|
*
|
||||||
|
* Every store call is a BLOCKING FFI call — synchronous SQLite behind a mutex — so
|
||||||
|
* they run on [Dispatchers.IO]. Doing otherwise would block the main thread on
|
||||||
|
* disk, which is the jank a native client exists to avoid.
|
||||||
|
*
|
||||||
|
* ONE view model for both screens, over detekt's objection. The obvious split —
|
||||||
|
* a second one for the editor — fails on the fact that every editor mutation has
|
||||||
|
* to reload the board behind it, so the editor's view model would need a
|
||||||
|
* reference back into this one and the two would share the note list anyway. What
|
||||||
|
* is left is a dozen small functions around a single coherent state machine,
|
||||||
|
* which is what the suppression says rather than hides.
|
||||||
|
*/
|
||||||
|
@Suppress("TooManyFunctions")
|
||||||
|
class BoardViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/**
|
||||||
|
* Called after any write that could have moved a reminder.
|
||||||
|
*
|
||||||
|
* The alarm is derived from the store, so anything that edits the store can
|
||||||
|
* invalidate it — setting a time, completing one, trashing the note it is on.
|
||||||
|
* Wired as a callback rather than reaching for a Context from a view model,
|
||||||
|
* which is how view models come to leak Activities. Same shape as the sync
|
||||||
|
* view model's `onStoreChanged`.
|
||||||
|
*/
|
||||||
|
private val onRemindersChanged: () -> Unit = {},
|
||||||
|
/**
|
||||||
|
* Read a picked or shared file. Blocking; only ever called on the IO dispatcher.
|
||||||
|
* A function rather than a Context for the same reason as the callback above.
|
||||||
|
*/
|
||||||
|
private val readFile: (Uri) -> PickedFile = { PickedFile.Refused(FALLBACK_ERROR) },
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(BoardState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-flight search. Held so each keystroke cancels the previous one:
|
||||||
|
* without it, a fast typist queues one full-text query per character and the
|
||||||
|
* results arrive out of order, so the board can settle on a stale answer.
|
||||||
|
*/
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
refreshLabels()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(destination: Destination) {
|
||||||
|
// Clearing the query is deliberate: picking Archive while a search is
|
||||||
|
// running should show the archive, not search results filtered by a box
|
||||||
|
// the user has visually moved on from.
|
||||||
|
searchJob?.cancel()
|
||||||
|
state = state.copy(destination = destination, query = "", filters = BoardFilters())
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the main board's filters. */
|
||||||
|
fun filter(filters: BoardFilters) {
|
||||||
|
state = state.copy(filters = filters)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(loading = true)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val destination = state.destination
|
||||||
|
val filters = state.filters
|
||||||
|
val notes = withContext(Dispatchers.IO) { load(destination, filters) }
|
||||||
|
state.copy(notes = notes, loading = false, error = null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Broad by intent: the board must render something for any
|
||||||
|
// failure, and the core reports problems as one error type
|
||||||
|
// carrying a message meant to be shown.
|
||||||
|
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(
|
||||||
|
destination: Destination,
|
||||||
|
filters: BoardFilters,
|
||||||
|
): List<Note> =
|
||||||
|
when (destination) {
|
||||||
|
Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets()))
|
||||||
|
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
|
||||||
|
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
|
||||||
|
// Not a board view: the core models reminders as its own query, since
|
||||||
|
// "has a reminder" cuts across archived and active alike.
|
||||||
|
Destination.Reminders -> core.reminderNotes()
|
||||||
|
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload the drawer's tags, and leave a lens whose tag no longer exists.
|
||||||
|
*
|
||||||
|
* Public because the Tags screen owns operations this board cannot see: a
|
||||||
|
* delete or a merge removes a tag, and the board may be LOOKING at that tag —
|
||||||
|
* `Destination.WithLabel` holds an id, and a query for a deleted one returns
|
||||||
|
* nothing forever. Without the fallback, tidying up tags could strand the board
|
||||||
|
* on a permanently empty lens whose only escape is the drawer.
|
||||||
|
*
|
||||||
|
* A rename needs no fallback: the id survives, and re-listing gives the drawer
|
||||||
|
* the new name. A rename that MERGED is a delete of one of the two, which this
|
||||||
|
* catches by id like any other.
|
||||||
|
*/
|
||||||
|
fun refreshLabels() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||||
|
.onSuccess { labels ->
|
||||||
|
state = state.copy(labels = labels)
|
||||||
|
// A deleted tag is dropped from the filters as it is from the lens
|
||||||
|
// below: a filter on an id nothing carries matches nothing forever.
|
||||||
|
val kept = state.filters.labelIds intersect labels.map { it.id }.toSet()
|
||||||
|
if (kept != state.filters.labelIds) filter(state.filters.copy(labelIds = kept))
|
||||||
|
val lens = state.destination
|
||||||
|
if (lens is Destination.WithLabel && labels.none { it.id == lens.id }) {
|
||||||
|
open(Destination.Notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A drawer that cannot list labels is a degraded drawer, not a
|
||||||
|
// broken board — the notes are still there. Failing quietly here
|
||||||
|
// beats an error banner over working content. The lens is left
|
||||||
|
// alone in this case on purpose: "I could not read the tags" is not
|
||||||
|
// evidence that this one is gone.
|
||||||
|
.onFailure { state = state.copy(labels = emptyList()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(text: String) {
|
||||||
|
state = state.copy(query = text)
|
||||||
|
searchJob?.cancel()
|
||||||
|
|
||||||
|
if (text.isBlank()) {
|
||||||
|
refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Let the typing settle before hitting the store. Short enough to
|
||||||
|
// feel live, long enough that a whole word is one query.
|
||||||
|
delay(SEARCH_DEBOUNCE_MS)
|
||||||
|
state = state.copy(loading = true)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val hits = withContext(Dispatchers.IO) { core.searchNotes(text) }
|
||||||
|
state.copy(notes = hits, loading = false, error = null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────── the editor ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a note by id, for a notification tap.
|
||||||
|
*
|
||||||
|
* Loads it fresh rather than searching the board's list: the board may be
|
||||||
|
* showing Trash, a label, or search results, and a reminder can fire for a note
|
||||||
|
* that is in none of them.
|
||||||
|
*/
|
||||||
|
fun openNoteById(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
|
||||||
|
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openNote(note: Note) {
|
||||||
|
state = state.copy(editing = note, editingSession = state.editingSession + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the editor on a note that does not exist yet.
|
||||||
|
*
|
||||||
|
* The + button used to raise a separate capture sheet, which meant a note being
|
||||||
|
* WRITTEN could not be given a colour, a reminder or a checklist — those live on
|
||||||
|
* the editor's toolbar, and the sheet had none. Writing and editing are now the
|
||||||
|
* same surface.
|
||||||
|
*
|
||||||
|
* The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the
|
||||||
|
* editor renders it without knowing that "not saved yet" is a state it can be
|
||||||
|
* in. It becomes a row on its first save; see [onDraftAction].
|
||||||
|
*/
|
||||||
|
fun compose() {
|
||||||
|
draftDismissed = false
|
||||||
|
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture text shared into the app from somewhere else, and open it.
|
||||||
|
*
|
||||||
|
* The note is CREATED here rather than opened as a pre-filled draft, and that
|
||||||
|
* is the whole design of this path. The editor only flushes when its text
|
||||||
|
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
|
||||||
|
* draft arriving already full of the shared text is a draft with nothing to
|
||||||
|
* save — share a link, press back without typing, and it would be gone. A
|
||||||
|
* share has already said "keep this"; making the row first is what honours it.
|
||||||
|
*
|
||||||
|
* Opening the editor afterwards is then free of that risk: the note exists,
|
||||||
|
* back leaves it alone, and adding a line of context is optional rather than
|
||||||
|
* load-bearing.
|
||||||
|
*/
|
||||||
|
fun captureShared(
|
||||||
|
text: String?,
|
||||||
|
files: List<Uri> = emptyList(),
|
||||||
|
) {
|
||||||
|
val content = text?.trim().orEmpty()
|
||||||
|
if (content.isEmpty() && files.isEmpty()) return
|
||||||
|
// A share is a new sitting even if the editor was already open on
|
||||||
|
// something, so the field must be re-keyed onto what arrives. `createFrom
|
||||||
|
// Draft` deliberately does not bump this — it is written for the autosave
|
||||||
|
// case, where re-keying mid-typing would be the bug.
|
||||||
|
draftDismissed = false
|
||||||
|
state = state.copy(editingSession = state.editingSession + 1)
|
||||||
|
// A shared photo arrives with no words, and the note it makes is still a
|
||||||
|
// note: the picture is its content.
|
||||||
|
createFromDraft(content, allowEmpty = files.isNotEmpty()) { created ->
|
||||||
|
if (files.isNotEmpty()) onEditorAction(created, EditorAction.Attach(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when a draft's editor closes, so a create still in flight does not reopen
|
||||||
|
* it. The editor flushes its text and then closes, and the flush is a coroutine —
|
||||||
|
* without this the note would be created, the screen would close, and the create
|
||||||
|
* would finish and put the screen back.
|
||||||
|
*/
|
||||||
|
private var draftDismissed = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor's actions, for a note that has no row yet.
|
||||||
|
*
|
||||||
|
* Everything a toolbar button does needs an id to act on, so the first action
|
||||||
|
* that needs one creates the note and replays itself against the real thing.
|
||||||
|
*/
|
||||||
|
private fun onDraftAction(
|
||||||
|
draft: Note,
|
||||||
|
action: EditorAction,
|
||||||
|
) {
|
||||||
|
when (action) {
|
||||||
|
// Nothing exists, so leaving leaves nothing behind — which is what makes
|
||||||
|
// tapping + and changing your mind free. Text typed before this point has
|
||||||
|
// already gone to createFromDraft via the editor's autosave or its flush.
|
||||||
|
EditorAction.Close, EditorAction.Trash -> {
|
||||||
|
draftDismissed = true
|
||||||
|
state = state.copy(editing = null)
|
||||||
|
}
|
||||||
|
EditorAction.DismissError -> dismissError()
|
||||||
|
is EditorAction.SaveText -> createFromDraft(action.body)
|
||||||
|
// Attaching to an empty draft is a note with a file and no words yet, so
|
||||||
|
// it is the one action that may create a note with no text.
|
||||||
|
is EditorAction.Attach ->
|
||||||
|
createFromDraft(draft.body, allowEmpty = true) { created -> onEditorAction(created, action) }
|
||||||
|
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
|
||||||
|
// a note. With autosave at a second, "typed something" is true by the time
|
||||||
|
// anyone reaches the toolbar; before that there is nothing to attribute.
|
||||||
|
else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a draft into a row, and keep the editor on it.
|
||||||
|
*
|
||||||
|
* Adopting the created note is what lets a session of autosaves stay one note:
|
||||||
|
* the second save sees a real id and updates rather than creating again.
|
||||||
|
*/
|
||||||
|
private fun createFromDraft(
|
||||||
|
content: String,
|
||||||
|
allowEmpty: Boolean = false,
|
||||||
|
then: (Note) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val cleanContent = content.trim()
|
||||||
|
// A blank draft is not a note. Ignored rather than rejected: tapping + and
|
||||||
|
// walking away is a slip, not a mistake worth interrupting someone over.
|
||||||
|
if (cleanContent.isEmpty() && !allowEmpty) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(saving = true)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
|
||||||
|
// Prepend rather than reload: the new note belongs at the top of
|
||||||
|
// the board, and a full re-query would cost a round trip to tell
|
||||||
|
// us what we already know. Skipped when the board is not showing
|
||||||
|
// plain notes — a note created while looking at Trash does not
|
||||||
|
// belong in that list.
|
||||||
|
val notes =
|
||||||
|
if (state.destination == Destination.Notes && !state.searching) {
|
||||||
|
listOf(created) + state.notes
|
||||||
|
} else {
|
||||||
|
state.notes
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||||
|
// editingSession is NOT bumped: this is the same sitting, and the
|
||||||
|
// editor's field must not be re-keyed underneath the typing.
|
||||||
|
state.copy(
|
||||||
|
notes = notes,
|
||||||
|
editing = if (draftDismissed) state.editing else created,
|
||||||
|
saving = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||||
|
}
|
||||||
|
if (!draftDismissed) state.editing?.let(then)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one editor action to the note the editor is open on.
|
||||||
|
*
|
||||||
|
* The `when` is exhaustive by construction, so adding a variant to
|
||||||
|
* [EditorAction] breaks THIS function until it is handled — which is the whole
|
||||||
|
* reason the editor speaks in actions rather than through a bundle of
|
||||||
|
* callbacks. The note is passed in rather than read from `state.editing` so a
|
||||||
|
* mutation that lands between a tap and its dispatch cannot redirect the
|
||||||
|
* action at a different note.
|
||||||
|
*
|
||||||
|
* Both suppressions have ONE cause: [EditorAction] has over twenty variants, so a
|
||||||
|
* total function over it is twenty branches and sixty-odd lines no matter how
|
||||||
|
* it is written. Splitting it into sub-dispatchers is the only way to shorten
|
||||||
|
* it, and each of those would need an `else` — which throws away precisely the
|
||||||
|
* exhaustiveness this shape exists for. Suppressed rather than worked around,
|
||||||
|
* because the rules are measuring the action type's size, not this function's.
|
||||||
|
*/
|
||||||
|
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||||
|
fun onEditorAction(
|
||||||
|
note: Note,
|
||||||
|
action: EditorAction,
|
||||||
|
) {
|
||||||
|
if (note.id == DRAFT_ID) {
|
||||||
|
onDraftAction(note, action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val id = note.id
|
||||||
|
when (action) {
|
||||||
|
EditorAction.Close -> state = state.copy(editing = null)
|
||||||
|
EditorAction.DismissError -> dismissError()
|
||||||
|
|
||||||
|
// Sent on an idle debounce while typing, and again on close. Writing
|
||||||
|
// this often is affordable because a body write no longer snapshots a
|
||||||
|
// revision — the core keeps one per editing session, not one per save.
|
||||||
|
is EditorAction.SaveText ->
|
||||||
|
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||||
|
|
||||||
|
// Pinning re-sorts the board rather than emptying it, and on a phone
|
||||||
|
// you often pin while still reading — so unlike the three below, it
|
||||||
|
// deliberately leaves the editor open.
|
||||||
|
is EditorAction.SetPinned -> edit(id, NoteEdit.Pinned(action.pinned))
|
||||||
|
|
||||||
|
// Archiving, trashing and restoring all take the note out of the list
|
||||||
|
// you were looking at, so the editor closes behind them: staying open
|
||||||
|
// on a note that has visibly left the board reads as a bug.
|
||||||
|
is EditorAction.SetArchived ->
|
||||||
|
mutate(closeEditor = true) {
|
||||||
|
it.updateNote(id, listOf(NoteEdit.Archived(action.archived)))
|
||||||
|
}
|
||||||
|
EditorAction.Trash -> mutate(closeEditor = true) { it.trashNote(id) }
|
||||||
|
EditorAction.Restore -> mutate(closeEditor = true) { it.restoreNote(id) }
|
||||||
|
EditorAction.DeleteForever ->
|
||||||
|
mutate(closeEditor = true) {
|
||||||
|
it.deleteNoteForever(id)
|
||||||
|
// Nothing to hand back — the row is gone. The board reload
|
||||||
|
// inside `mutate` is what makes it disappear.
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
|
||||||
|
|
||||||
|
is EditorAction.CreateLabel ->
|
||||||
|
action.name.trim().takeIf { it.isNotEmpty() }?.let { name ->
|
||||||
|
mutate {
|
||||||
|
val label = it.createLabel(name)
|
||||||
|
val manual = note.labels.filterNot { l -> l.viaTag }.map { l -> l.id }
|
||||||
|
it.setNoteLabels(id, (manual + label.id).distinct())
|
||||||
|
}
|
||||||
|
// The drawer lists labels with their note counts, and both
|
||||||
|
// just changed.
|
||||||
|
refreshLabels()
|
||||||
|
}
|
||||||
|
|
||||||
|
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
||||||
|
EditorAction.ClearReminder -> edit(id, NoteEdit.ClearRemindAt)
|
||||||
|
EditorAction.CompleteReminder -> mutate { it.completeReminder(id) }
|
||||||
|
is EditorAction.SnoozeReminder -> mutate { it.snoozeReminder(id, action.minutes) }
|
||||||
|
is EditorAction.SetRecurrence ->
|
||||||
|
edit(
|
||||||
|
id,
|
||||||
|
action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence,
|
||||||
|
)
|
||||||
|
|
||||||
|
is EditorAction.Attach -> attach(id, action.uris)
|
||||||
|
is EditorAction.RemoveAttachment -> mutate { it.deleteAttachment(id, action.attachmentId) }
|
||||||
|
is EditorAction.RemovePreview -> mutate { it.deletePreview(id, action.previewId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read each file and attach it, one at a time.
|
||||||
|
*
|
||||||
|
* A file that can't be read, or is too large, is skipped and named afterwards
|
||||||
|
* rather than failing the rest: sharing four photos where one is a video should
|
||||||
|
* still attach the three.
|
||||||
|
*/
|
||||||
|
private fun attach(
|
||||||
|
id: String,
|
||||||
|
uris: List<Uri>,
|
||||||
|
) {
|
||||||
|
val refused = mutableListOf<String>()
|
||||||
|
mutate(notice = { refused.takeIf { it.isNotEmpty() }?.joinToString("\n") }) { core ->
|
||||||
|
uris.fold(null as Note?) { latest, uri ->
|
||||||
|
when (val file = readFile(uri)) {
|
||||||
|
is PickedFile.Ready -> core.addAttachment(id, file.name, file.mime, file.bytes)
|
||||||
|
is PickedFile.Refused -> {
|
||||||
|
refused += file.reason
|
||||||
|
latest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The common case: one field-level edit to one note. */
|
||||||
|
private fun edit(
|
||||||
|
id: String,
|
||||||
|
change: NoteEdit,
|
||||||
|
) = mutate { it.updateNote(id, listOf(change)) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one path every store mutation takes.
|
||||||
|
*
|
||||||
|
* Each core mutation returns the reloaded note, which refreshes
|
||||||
|
* [BoardState.editing] so an OPEN editor shows its own change without a
|
||||||
|
* re-query — and does nothing at all when the editor is closed, because that
|
||||||
|
* field doubles as "which screen is up". The BOARD list is then reloaded rather than patched in place:
|
||||||
|
* pinning re-sorts it, archiving removes the note from it, and adding a label
|
||||||
|
* can move it in or out of a label view — a splice would have to reimplement
|
||||||
|
* the core's ordering and membership rules in Kotlin to get any of that right.
|
||||||
|
* The reload is a local SQLite query, so it costs less than the code that would
|
||||||
|
* avoid it.
|
||||||
|
*
|
||||||
|
* Quiet, deliberately: no spinner, because the board is already on screen with
|
||||||
|
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||||
|
* lie than showing it one frame stale.
|
||||||
|
*
|
||||||
|
* While a search is running the QUERY is re-run rather than the board's
|
||||||
|
* destination — running `load` here would replace the hits with the whole
|
||||||
|
* board, which is why this branch exists at all. It used to keep the existing
|
||||||
|
* list instead, and that was right for a note whose place in the pile changed
|
||||||
|
* and wrong for one that left it: trashing a hit left the card sitting there,
|
||||||
|
* with a snackbar saying it was gone, until the query happened to re-run
|
||||||
|
* (#3111). Re-asking is still the answer to the query, just a current one.
|
||||||
|
*/
|
||||||
|
private fun mutate(
|
||||||
|
closeEditor: Boolean = false,
|
||||||
|
// Something to say once the write has landed, shown where an error would be.
|
||||||
|
// Read after `block` has run, so the block can decide it.
|
||||||
|
notice: () -> String? = { null },
|
||||||
|
block: (Inkwell) -> Note?,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(saving = true)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||||
|
val notes =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (state.searching) {
|
||||||
|
core.searchNotes(state.query)
|
||||||
|
} else {
|
||||||
|
load(state.destination, state.filters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On IO, not here: re-deriving the alarm reads every note
|
||||||
|
// that carries a reminder, and this line runs on the main
|
||||||
|
// thread — the coroutine is back from its withContext by now.
|
||||||
|
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||||
|
state.copy(
|
||||||
|
notes = notes,
|
||||||
|
// Only REFRESHES an open editor; it must never open one.
|
||||||
|
// `editing != null` IS "the editor is on screen", so writing
|
||||||
|
// the reloaded note in unconditionally meant any mutation
|
||||||
|
// started from the BOARD threw the editor open on top of it —
|
||||||
|
// which is exactly what ticking a checkbox on a card did.
|
||||||
|
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
|
||||||
|
saving = false,
|
||||||
|
error = notice(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Broad by intent, as elsewhere: the core reports every failure
|
||||||
|
// as one error type carrying a message meant to be shown, and a
|
||||||
|
// half-applied edit must still leave a usable screen.
|
||||||
|
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tick or untick one item from the BOARD, without opening the note.
|
||||||
|
*
|
||||||
|
* The common gesture on a checklist, and the reason it goes through the store
|
||||||
|
* rather than the pure text helpers the editor uses: nothing here is holding a
|
||||||
|
* half-typed body, so the reloaded note is simply the truth.
|
||||||
|
*
|
||||||
|
* `index` is the item's ordinal, which is what its id is now (M304).
|
||||||
|
*/
|
||||||
|
fun toggleItem(
|
||||||
|
note: Note,
|
||||||
|
index: Int,
|
||||||
|
checked: Boolean,
|
||||||
|
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put the main board in this order, first on top: the end of a card drag.
|
||||||
|
*
|
||||||
|
* Through [mutate] so the board is re-read afterwards, which is what settles a
|
||||||
|
* drop the store did not take as shown. A card dragged across the pinned line
|
||||||
|
* snaps back to its own side, because the store sorts by pinned first.
|
||||||
|
*/
|
||||||
|
fun reorder(orderedIds: List<String>) =
|
||||||
|
mutate {
|
||||||
|
it.reorderNotes(orderedIds)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
state = state.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FALLBACK_ERROR = "Something went wrong."
|
||||||
|
private const val SEARCH_DEBOUNCE_MS = 180L
|
||||||
|
|
||||||
|
// The core's board vocabulary. "archived", not "archive" — it matches on
|
||||||
|
// the former and silently falls through to the default board otherwise.
|
||||||
|
private const val VIEW_NOTES = "notes"
|
||||||
|
private const val VIEW_ARCHIVE = "archived"
|
||||||
|
private const val VIEW_TRASH = "trash"
|
||||||
|
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
readFile: (Uri) -> PickedFile,
|
||||||
|
onRemindersChanged: () -> Unit,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
BoardViewModel(core, onRemindersChanged, readFile) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pure builders ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Neither of these reads or writes view-model state; they only shape a core input
|
||||||
|
// from arguments. Kept at file scope so the class above holds only things that
|
||||||
|
// actually depend on it — which is also what keeps its function count meaningful.
|
||||||
|
|
||||||
|
private fun query(
|
||||||
|
view: String,
|
||||||
|
labelId: String? = null,
|
||||||
|
facets: NoteFacets? = null,
|
||||||
|
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = facets)
|
||||||
|
|
||||||
|
private fun draft(content: String): NoteDraft =
|
||||||
|
// The core names the note from the body's first line, so a captured thought is
|
||||||
|
// findable without anyone being asked to name it. A checklist is added afterwards,
|
||||||
|
// in the editor — it is something a note HAS, not a different thing to capture.
|
||||||
|
NoteDraft(body = content, items = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id a note has before it has been saved.
|
||||||
|
*
|
||||||
|
* A real id is a uuid, so the empty string cannot collide with one. Using a sentinel
|
||||||
|
* rather than making the editor's note nullable keeps "not saved yet" out of a screen
|
||||||
|
* that reads eight fields off the note and should not have to null-check any of them.
|
||||||
|
*/
|
||||||
|
internal const val DRAFT_ID = ""
|
||||||
|
|
||||||
|
private fun blankDraft(): Note =
|
||||||
|
Note(
|
||||||
|
id = DRAFT_ID,
|
||||||
|
displayTitle = "",
|
||||||
|
body = "",
|
||||||
|
position = 0,
|
||||||
|
pinned = false,
|
||||||
|
archived = false,
|
||||||
|
trashed = false,
|
||||||
|
deletedAt = null,
|
||||||
|
remindAt = null,
|
||||||
|
recurrence = null,
|
||||||
|
labels = emptyList(),
|
||||||
|
items = emptyList(),
|
||||||
|
attachments = emptyList(),
|
||||||
|
previews = emptyList(),
|
||||||
|
createdAt = null,
|
||||||
|
updatedAt = null,
|
||||||
|
permission = "owner",
|
||||||
|
shared = false,
|
||||||
|
sharedBy = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
// The colour a LABEL wears when nobody picked one for it.
|
||||||
|
//
|
||||||
|
// Every `#tag` is born colourless, so without this a board of tags is a board of
|
||||||
|
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
|
||||||
|
// surface, costs no column and no migration, and a tag keeps its colour for life.
|
||||||
|
//
|
||||||
|
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
|
||||||
|
// one neutral and only its tags carry hue. The hash survived that removal because the
|
||||||
|
// job it still does — give a name a stable colour — was never the job that failed.
|
||||||
|
// What failed was asking a colour that means "which tag" to also mean nothing at all
|
||||||
|
// on an untagged note, at which point the board had two vocabularies and neither read.
|
||||||
|
//
|
||||||
|
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
|
||||||
|
// over the same key order, and the two must agree exactly or a tag is one colour on
|
||||||
|
// the phone and another in the browser. Same discipline as the checklist grammar's
|
||||||
|
// three implementations, and the same reason: a value that disagrees across surfaces
|
||||||
|
// is a bug you cannot unsee and cannot explain.
|
||||||
|
//
|
||||||
|
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
|
||||||
|
// pinned by a host-JVM test, and staying free of `androidx.compose` is what keeps
|
||||||
|
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. The
|
||||||
|
// web side has no test runner at all, so this test is the only mechanical guard the
|
||||||
|
// mirror gets — see the fixture comment in colors.ts.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
|
||||||
|
* the ABSENCE of a colour — a tag that derived it would be indistinguishable from one
|
||||||
|
* nobody has tagged. `gray` stays: as a chip it reads as a deliberate choice.
|
||||||
|
*
|
||||||
|
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering this
|
||||||
|
* list silently recolours every tag on one surface only.
|
||||||
|
*/
|
||||||
|
val DERIVED_TINT_KEYS: List<String> =
|
||||||
|
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
|
||||||
|
|
||||||
|
private const val FNV_OFFSET_BASIS = -0x7ee3623b // 0x811c9dc5 as a signed Int
|
||||||
|
private const val FNV_PRIME = 0x01000193
|
||||||
|
private const val BYTE_MASK = 0xFF
|
||||||
|
private const val UNSIGNED_MASK = 0xFFFFFFFFL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNV-1a over the id's bytes, 32-bit.
|
||||||
|
*
|
||||||
|
* Chosen because both languages compute it identically in ten lines with no library.
|
||||||
|
* Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has no equivalent,
|
||||||
|
* and reimplementing Java's from memory in TypeScript is exactly how a mirror drifts.
|
||||||
|
*
|
||||||
|
* `and BYTE_MASK` is a no-op for the ASCII of a UUID, and is kept because it states
|
||||||
|
* the intent — this hashes BYTES, so the TypeScript side reading `charCodeAt(i) &
|
||||||
|
* 0xff` is the same function rather than a coincidence.
|
||||||
|
*
|
||||||
|
* Overflow is the point: Kotlin's `Int` wraps on multiply, which is what the web's
|
||||||
|
* `Math.imul` exists to reproduce.
|
||||||
|
*/
|
||||||
|
fun tintHash(id: String): Int {
|
||||||
|
var hash = FNV_OFFSET_BASIS
|
||||||
|
for (ch in id) {
|
||||||
|
hash = hash xor (ch.code and BYTE_MASK)
|
||||||
|
hash *= FNV_PRIME
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The colour a name maps to, stable for as long as the name is. Called with a
|
||||||
|
* label's lowercased name; `id` is the parameter's history, not its meaning. */
|
||||||
|
fun derivedTint(id: String): String {
|
||||||
|
// Through Long to read the hash as unsigned. A signed remainder would be negative
|
||||||
|
// for half of all ids and index out of the list.
|
||||||
|
val index = (tintHash(id).toLong() and UNSIGNED_MASK) % DERIVED_TINT_KEYS.size
|
||||||
|
return DERIVED_TINT_KEYS[index.toInt()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour key for a LABEL — its chip, and its `#tag` where it sits in the prose.
|
||||||
|
*
|
||||||
|
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
|
||||||
|
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
|
||||||
|
* so without deriving, a board of tags would be a board of identical grey chips.
|
||||||
|
*
|
||||||
|
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
|
||||||
|
* a hashed colour written at each of the four places a label can be born — and named
|
||||||
|
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
|
||||||
|
* since most tags are born from typing `#grocery`, not from a management screen.
|
||||||
|
* Deriving has no mint points to miss and no backfill for the tags already out there.
|
||||||
|
* The cost is that renaming a tag recolours it, which is fair: the name IS the tag.
|
||||||
|
*
|
||||||
|
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
|
||||||
|
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
|
||||||
|
* `toLowerCase()` are both locale-independent, so the mirror holds.
|
||||||
|
*/
|
||||||
|
fun resolvedLabelColor(
|
||||||
|
name: String,
|
||||||
|
color: String,
|
||||||
|
known: Set<String>,
|
||||||
|
): String =
|
||||||
|
when {
|
||||||
|
color.isNotEmpty() && color != "default" && color in known -> color
|
||||||
|
name.isEmpty() -> "default"
|
||||||
|
else -> derivedTint(name.lowercase())
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the editor can ask for, as one type.
|
||||||
|
*
|
||||||
|
* The alternative was a bundle of twenty callbacks, and it was a bad one: twenty
|
||||||
|
* same-shaped `(String, String) -> Unit` parameters is a place for two of them to
|
||||||
|
* get swapped, with nothing to catch it. One `(EditorAction) -> Unit` costs a
|
||||||
|
* `when` at the far end and gets EXHAUSTIVENESS in exchange — adding a variant
|
||||||
|
* here breaks the dispatcher until it is handled, which is precisely the guarantee
|
||||||
|
* the callback bundle could not offer.
|
||||||
|
*
|
||||||
|
* No variant carries a note id. The editor is open on exactly one note and the
|
||||||
|
* dispatcher already has it, so threading it through every action would only
|
||||||
|
* create the possibility of the two disagreeing.
|
||||||
|
*/
|
||||||
|
sealed interface EditorAction {
|
||||||
|
/** Leave the editor. Text is saved separately, via [SaveText], before this. */
|
||||||
|
data object Close : EditorAction
|
||||||
|
|
||||||
|
/** Clear the error banner. Shared state — the board shows the same one. */
|
||||||
|
data object DismissError : EditorAction
|
||||||
|
|
||||||
|
data class SaveText(
|
||||||
|
val body: String,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
data class SetPinned(
|
||||||
|
val pinned: Boolean,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
data class SetArchived(
|
||||||
|
val archived: Boolean,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
data object Trash : EditorAction
|
||||||
|
|
||||||
|
data object Restore : EditorAction
|
||||||
|
|
||||||
|
data object DeleteForever : EditorAction
|
||||||
|
|
||||||
|
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
|
||||||
|
// body, so adding, renaming, ticking or deleting one is editing text — which the
|
||||||
|
// editor already does, through SaveText, with the same autosave and the same
|
||||||
|
// revision window as any other edit. Routing them through the store would have
|
||||||
|
// meant the store handing back a note whose body disagreed with the field the
|
||||||
|
// person was typing in.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's MANUAL labels, replacing whatever was there.
|
||||||
|
*
|
||||||
|
* `#tag` labels must never appear in this list. They are owned by the body
|
||||||
|
* text and the core re-derives them on every body edit — see
|
||||||
|
* `set_note_labels` in the FFI crate.
|
||||||
|
*/
|
||||||
|
data class SetLabels(
|
||||||
|
val labelIds: List<String>,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a label and attach it to this note in one gesture.
|
||||||
|
*
|
||||||
|
* Typing a new label in the picker and then having to tick it as well would
|
||||||
|
* be two steps for one intention. The core finds-or-creates, so typing the
|
||||||
|
* name of a label that already exists simply attaches that one.
|
||||||
|
*/
|
||||||
|
data class CreateLabel(
|
||||||
|
val name: String,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
/** `at` is an RFC3339 instant — see `Time.kt` for why the UI writes it. */
|
||||||
|
data class SetReminder(
|
||||||
|
val at: String,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
data object ClearReminder : EditorAction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the reminder dealt with.
|
||||||
|
*
|
||||||
|
* Distinct from [ClearReminder] even though the core does the same thing to
|
||||||
|
* the column today: this is where recurrence advancement lands when it is
|
||||||
|
* built, so a recurring reminder finished through the generic clear would
|
||||||
|
* silently stop recurring.
|
||||||
|
*/
|
||||||
|
data object CompleteReminder : EditorAction
|
||||||
|
|
||||||
|
data class SnoozeReminder(
|
||||||
|
val minutes: Long,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
/** null is "does not repeat". */
|
||||||
|
data class SetRecurrence(
|
||||||
|
val rule: String?,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach files picked on this phone or shared into the app.
|
||||||
|
*
|
||||||
|
* URIs rather than bytes: reading them is blocking I/O, and the view model does
|
||||||
|
* it on the IO dispatcher where a failure can still become the error banner.
|
||||||
|
*/
|
||||||
|
data class Attach(
|
||||||
|
val uris: List<Uri>,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
data class RemoveAttachment(
|
||||||
|
val attachmentId: String,
|
||||||
|
) : EditorAction
|
||||||
|
|
||||||
|
data class RemovePreview(
|
||||||
|
val previewId: String,
|
||||||
|
) : EditorAction
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import com.fabledsword.inkwell.core.checklistItems
|
||||||
|
import com.fabledsword.inkwell.core.checklistRender
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One piece of a note body, as the editor DRAWS it.
|
||||||
|
*
|
||||||
|
* The note is still one markdown string underneath (M304) — this is a rendering and
|
||||||
|
* input shape, and nothing below the editor can tell it exists. [joinBlocks] puts the
|
||||||
|
* string back together on every edit.
|
||||||
|
*
|
||||||
|
* A run of prose lines is ONE block rather than one per line. Typing a paragraph has
|
||||||
|
* to feel like typing a paragraph, and a separate field under every sentence would
|
||||||
|
* break the caret in the middle of writing. Only a checklist item earns a block of its
|
||||||
|
* own, because only a checklist item needs a widget.
|
||||||
|
*
|
||||||
|
* The block owns its [TextFieldValue], not just its text, so a caret survives an edit
|
||||||
|
* to some other block. And [id] is stable across edits: Compose keys fields by
|
||||||
|
* position unless told otherwise, so inserting an item above one would otherwise move
|
||||||
|
* everyone's caret up a row. Content cannot serve as that key — two empty items are
|
||||||
|
* identical and neither is the other.
|
||||||
|
*/
|
||||||
|
data class EditorBlock(
|
||||||
|
val id: Long,
|
||||||
|
val value: TextFieldValue,
|
||||||
|
/** null for prose; ticked-or-not for a checklist item. */
|
||||||
|
val checked: Boolean?,
|
||||||
|
) {
|
||||||
|
val isTask: Boolean get() = checked != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a body into blocks, numbering them from [firstId].
|
||||||
|
*
|
||||||
|
* Which lines are items comes from the core, not from a pattern here — the grammar is
|
||||||
|
* written three times already and Kotlin is not going to be the fourth.
|
||||||
|
*/
|
||||||
|
fun splitBlocks(
|
||||||
|
body: String,
|
||||||
|
firstId: Long = 0,
|
||||||
|
): List<EditorBlock> {
|
||||||
|
val itemAt = checklistItems(body).associateBy { it.line.toInt() }
|
||||||
|
val out = mutableListOf<EditorBlock>()
|
||||||
|
val prose = mutableListOf<String>()
|
||||||
|
var id = firstId
|
||||||
|
|
||||||
|
fun flushProse() {
|
||||||
|
if (prose.isNotEmpty()) {
|
||||||
|
out += EditorBlock(id++, TextFieldValue(prose.joinToString("\n")), null)
|
||||||
|
prose.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.split("\n").forEachIndexed { n, line ->
|
||||||
|
val item = itemAt[n]
|
||||||
|
if (item == null) {
|
||||||
|
prose += line
|
||||||
|
} else {
|
||||||
|
flushProse()
|
||||||
|
out += EditorBlock(id++, TextFieldValue(item.text), item.checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushProse()
|
||||||
|
|
||||||
|
// Never empty: an empty note still needs one field to type into.
|
||||||
|
return out.ifEmpty { listOf(EditorBlock(id, TextFieldValue(""), null)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body those blocks stand for — byte-identical to what [splitBlocks] was given,
|
||||||
|
* for a body already in canonical form. A non-canonical one (`- [X]`, an odd bullet)
|
||||||
|
* comes back canonical, which is the same rule every other rewriter in `derive`
|
||||||
|
* follows.
|
||||||
|
*/
|
||||||
|
fun joinBlocks(blocks: List<EditorBlock>): String =
|
||||||
|
blocks.joinToString("\n") { block ->
|
||||||
|
val checked = block.checked
|
||||||
|
if (checked == null) block.value.text else checklistRender(block.value.text, checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation carries the TEXT and re-derives the shape.
|
||||||
|
*
|
||||||
|
* Blocks are not parcelable and their ids are meaningless across a process death, so
|
||||||
|
* the body string is the honest thing to save — it is the real state, and everything
|
||||||
|
* else about a block is derived from it.
|
||||||
|
*/
|
||||||
|
val blocksSaver: Saver<List<EditorBlock>, String> =
|
||||||
|
Saver(save = { joinBlocks(it) }, restore = { splitBlocks(it) })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the return key does on a checklist item.
|
||||||
|
*
|
||||||
|
* `internal` rather than private because BlockBody.kt calls it. These three helpers
|
||||||
|
* are the block MODEL and the composables are the block UI — one file was doing both,
|
||||||
|
* which detekt noticed by counting functions before anybody noticed by reading.
|
||||||
|
*
|
||||||
|
* On one with words in it, a new empty item below. On an EMPTY one, the item becomes
|
||||||
|
* prose — which is how a list ENDS, and the same rule the plain text field used
|
||||||
|
* before this: without it a list is impossible to get out of.
|
||||||
|
*
|
||||||
|
* Deliberately appends rather than splitting at the caret. Splitting an item in two is
|
||||||
|
* a rarity, and the caret is at the end for every ordinary use of this key.
|
||||||
|
*/
|
||||||
|
internal fun afterEnter(
|
||||||
|
blocks: List<EditorBlock>,
|
||||||
|
index: Int,
|
||||||
|
newId: Long,
|
||||||
|
): List<EditorBlock> {
|
||||||
|
val block = blocks[index]
|
||||||
|
val out = blocks.toMutableList()
|
||||||
|
if (block.value.text.isBlank()) {
|
||||||
|
out[index] = block.copy(value = TextFieldValue(""), checked = null)
|
||||||
|
} else {
|
||||||
|
out.add(index + 1, EditorBlock(newId, TextFieldValue(""), false))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a block, leaving at least one field to type into. */
|
||||||
|
internal fun List<EditorBlock>.withoutIndex(index: Int): List<EditorBlock> {
|
||||||
|
val out = toMutableList().also { it.removeAt(index) }
|
||||||
|
return out.ifEmpty { listOf(EditorBlock(nextId(), TextFieldValue(""), null)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An id nothing else is using. Monotonic within a session, which is all it has to be. */
|
||||||
|
internal fun List<EditorBlock>.nextId(): Long = (maxOfOrNull { it.id } ?: -1L) + 1L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One more empty checklist item at the end, and the id to put the caret in.
|
||||||
|
*
|
||||||
|
* What the toolbar's checklist button does. It appends rather than inserting at the
|
||||||
|
* caret because a block editor has no single caret to insert at — the field that had
|
||||||
|
* focus may not even be the one being looked at by the time this runs.
|
||||||
|
*/
|
||||||
|
fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
|
||||||
|
val id = nextId()
|
||||||
|
return (this + EditorBlock(id, TextFieldValue(""), false)) to id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand, so a marker
|
||||||
|
* typed in the editor becomes an item without closing and reopening the note.
|
||||||
|
*
|
||||||
|
* **On blur, and only the block being left:** re-splitting on a keystroke would move
|
||||||
|
* the caret, and converting the moment `- [ ]` is complete would catch an item with
|
||||||
|
* no text yet.
|
||||||
|
*
|
||||||
|
* Returns THIS LIST when there was nothing to promote; the caller relies on that to
|
||||||
|
* leave the state alone, so a blur that changed nothing doesn't re-key every field.
|
||||||
|
*
|
||||||
|
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
|
||||||
|
* reopen.
|
||||||
|
*/
|
||||||
|
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
|
||||||
|
val block = getOrNull(index)
|
||||||
|
if (block == null || block.isTask) return this
|
||||||
|
val split = splitBlocks(block.value.text, nextId())
|
||||||
|
// A single prose block back means there was nothing to promote. `splitBlocks` never
|
||||||
|
// returns an empty list, so `first()` is safe.
|
||||||
|
val changed = split.size > 1 || split.first().isTask
|
||||||
|
return if (changed) take(index) + split + drop(index + 1) else this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put the caret at the end of the last block, for an editor that has just opened.
|
||||||
|
*
|
||||||
|
* Opening an existing note means continuing it, and a caret at offset zero would put
|
||||||
|
* the cursor before the first character of the wrong field.
|
||||||
|
*/
|
||||||
|
fun List<EditorBlock>.focusedAtEnd(): List<EditorBlock> {
|
||||||
|
if (isEmpty()) return this
|
||||||
|
val last = last()
|
||||||
|
return dropLast(1) + last.copy(value = last.value.copy(selection = TextRange(last.value.text.length)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor's action bar, along the top of the surface.
|
||||||
|
*
|
||||||
|
* It sits exactly where the capture sheet's drag handle used to. The handle cost
|
||||||
|
* this strip of screen and did nothing that a back gesture does not already do, so
|
||||||
|
* the strip carries the actions instead.
|
||||||
|
*
|
||||||
|
* Top rather than bottom, now that this one surface is used for WRITING as well as
|
||||||
|
* editing: the keyboard owns the bottom of the display for most of a note's life,
|
||||||
|
* so a bar down there spends its time riding on the IME. That is the right place
|
||||||
|
* for a send button and the wrong one for a colour picker, which is reached for
|
||||||
|
* between thoughts rather than at the end of them. The cost is honest — the top of
|
||||||
|
* a phone is further from a thumb than the bottom — and it buys a bar that does not
|
||||||
|
* move while you type.
|
||||||
|
*
|
||||||
|
* The three affordances with a permanent slot are the ones reached for while still
|
||||||
|
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
|
||||||
|
* archive, delete) is one tap further into the overflow, where it is spelled out
|
||||||
|
* in WORDS.
|
||||||
|
*
|
||||||
|
* That split is a deliberate trade against icon-guessing. `material-icons-core`
|
||||||
|
* carries no pin, archive or label glyph, and the two ways out were pulling in the
|
||||||
|
* ~1,000-vector extended set for four icons, or pressing unrelated ones into
|
||||||
|
* service — a star meaning "pin" is a star meaning "favourite" to everyone who has
|
||||||
|
* used another app. Text says exactly what it does and reads correctly aloud.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun EditorTopBar(
|
||||||
|
note: Note,
|
||||||
|
readOnly: Boolean,
|
||||||
|
access: NoteAccess,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onStartChecklist: () -> Unit,
|
||||||
|
onPicker: (Picker) -> Unit,
|
||||||
|
onAttach: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onConfirmDelete: () -> Unit,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
// Someone else's note (#5175): its text, at `edit`, and this account's own pin
|
||||||
|
// and archive (#5176) are all that may change here, so the bar keeps the
|
||||||
|
// checklist button and an overflow of those two, and nothing that is the owner's.
|
||||||
|
val owner = access == NoteAccess.OWNER
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
TopAppBar(
|
||||||
|
title = {},
|
||||||
|
navigationIcon = {
|
||||||
|
// The only way out, and the only thing that needed a "save" button
|
||||||
|
// before writes became continuous. Leaving IS saving now, which is what
|
||||||
|
// the line in the bottom corner is there to say out loud.
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.editor_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!readOnly && owner) {
|
||||||
|
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Notifications,
|
||||||
|
contentDescription = stringResource(R.string.editor_reminder),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!readOnly) {
|
||||||
|
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
||||||
|
// a checklist is text now (M304), so there is no section to be
|
||||||
|
// already-showing and no reason a second list cannot start further
|
||||||
|
// down the same note.
|
||||||
|
IconButton(onClick = onStartChecklist) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.List,
|
||||||
|
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On the bar rather than in the overflow: attaching a photo is something
|
||||||
|
// people look for, and a menu of words is where it would not be found.
|
||||||
|
if (!readOnly && owner) {
|
||||||
|
IconButton(onClick = onAttach) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_attach),
|
||||||
|
contentDescription = stringResource(R.string.editor_attach),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (owner || !note.trashed) {
|
||||||
|
OverflowMenu(
|
||||||
|
note = note,
|
||||||
|
owner = owner,
|
||||||
|
onPicker = onPicker,
|
||||||
|
onShare = onShare,
|
||||||
|
onConfirmDelete = onConfirmDelete,
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
|
||||||
|
// Material derives a bar's content colour from its container via
|
||||||
|
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
|
||||||
|
// returns Unspecified for anything else. The card surface is a plain constant
|
||||||
|
// and not a role, so the icons drew with no colour filter: black vectors on a
|
||||||
|
// near-black bar, a toolbar that rendered the whole time and was invisible in
|
||||||
|
// dark mode. STILL TRUE with one neutral surface — it is the same kind of
|
||||||
|
// value, so this stays exactly as it is.
|
||||||
|
//
|
||||||
|
// onSurface for the actions too, not the default onSurfaceVariant: the bar has
|
||||||
|
// to read against the card rather than the board, and the muted variant does
|
||||||
|
// not have the contrast to spare.
|
||||||
|
colors =
|
||||||
|
TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = noteCardSurface(dark),
|
||||||
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The footer: when the note was last written, and the way out.
|
||||||
|
*
|
||||||
|
* **Where the note stands.** There is no save button, and there should not be — a
|
||||||
|
* note is saved continuously, so a button offering to do what already happened is a
|
||||||
|
* lie with a tap attached. But that left nothing on screen saying the work is safe,
|
||||||
|
* and "closing this keeps it" is not a thing anyone should have to be told twice. So
|
||||||
|
* the state says it, as a fact rather than an instruction: Not saved yet → Saving… →
|
||||||
|
* Edited just now is the whole lifecycle, and someone who watches it once never has
|
||||||
|
* to wonder again.
|
||||||
|
*
|
||||||
|
* **The way out.** Down here because of where hands are. Moving the toolbar to the
|
||||||
|
* top took the back arrow with it, which left the only exit from a full-screen
|
||||||
|
* editor in the top-left corner — the furthest point on the display from a
|
||||||
|
* right-handed thumb, and reached over the whole note to get to. The operator hit
|
||||||
|
* that on the first device pass and was right to. So the exit lives in the bottom
|
||||||
|
* corner, which with the keyboard up sits directly above it.
|
||||||
|
*
|
||||||
|
* The top-left arrow stays as well. Two affordances for one action is usually
|
||||||
|
* clutter, but this is the case that earns it: the arrow is what habit, the system
|
||||||
|
* back gesture and TalkBack all expect of a full-screen surface, and removing it
|
||||||
|
* would strand the reflex to strike a duplicate that costs one icon slot.
|
||||||
|
*
|
||||||
|
* A checkmark, at the operator's ask. I had shipped the word "Done" here on the
|
||||||
|
* argument that a tick in a NOTES app reads as a checklist item; overruled, and the
|
||||||
|
* filled treatment is what settles it — a tonal button in the note's own colour is
|
||||||
|
* plainly a control, where a bare glyph beside a checklist would not be. It carries
|
||||||
|
* "Done" as its content description, so the reasoning survives where it actually
|
||||||
|
* mattered: read aloud.
|
||||||
|
*
|
||||||
|
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already
|
||||||
|
* knows the difference between minutes, hours and yesterday, and getting plurals
|
||||||
|
* right in every language is not this app's problem to solve twice.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EditorFooter(
|
||||||
|
updatedAt: String?,
|
||||||
|
saving: Boolean,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Rides above the keyboard, like the bar that used to be here. The
|
||||||
|
// content Column deliberately does not also inset for the IME:
|
||||||
|
// Scaffold measures this row at its lifted height and passes the
|
||||||
|
// inset down.
|
||||||
|
.imePadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
// The gap is what keeps the timestamp from reading as the button's label.
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = savedLabel(updatedAt, saving),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onClose,
|
||||||
|
// The BRAND, matching the board's compose FAB — the app's one existing
|
||||||
|
// statement of "this is the affirmative action here", now reused rather
|
||||||
|
// than a second one invented.
|
||||||
|
//
|
||||||
|
// This wore the note's own tint until M315, on the argument that it would
|
||||||
|
// otherwise be the one element on a tinted card ignoring the tint. There is
|
||||||
|
// no tint to ignore any more, and the alternative — Material's default
|
||||||
|
// secondaryContainer — is a baseline M3 colour this theme never sets, so
|
||||||
|
// taking the default would put an off-brand lilac in the corner of the
|
||||||
|
// editor.
|
||||||
|
colors =
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three things the footer can be saying, in the order it says them. */
|
||||||
|
@Composable
|
||||||
|
private fun savedLabel(
|
||||||
|
updatedAt: String?,
|
||||||
|
saving: Boolean,
|
||||||
|
): String {
|
||||||
|
// No timestamp means no row yet — a draft opened by + and not typed into.
|
||||||
|
val at = updatedAt?.let { epochMillis(it) }
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return when {
|
||||||
|
saving -> stringResource(R.string.editor_saving)
|
||||||
|
at == null -> stringResource(R.string.editor_unsaved)
|
||||||
|
// DateUtils rounds anything under its minimum resolution to "0 minutes
|
||||||
|
// ago" — which is both odd-looking and precisely the moment this line is
|
||||||
|
// on screen for, since it is the moment right after a save lands.
|
||||||
|
now - at < DateUtils.MINUTE_IN_MILLIS ->
|
||||||
|
stringResource(R.string.editor_edited, stringResource(R.string.editor_just_now))
|
||||||
|
else ->
|
||||||
|
stringResource(
|
||||||
|
R.string.editor_edited,
|
||||||
|
DateUtils.getRelativeTimeSpanString(at, now, DateUtils.MINUTE_IN_MILLIS).toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverflowMenu(
|
||||||
|
note: Note,
|
||||||
|
owner: Boolean,
|
||||||
|
onPicker: (Picker) -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onConfirmDelete: () -> Unit,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
val close = { open = false }
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { open = true }) {
|
||||||
|
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = close) {
|
||||||
|
if (note.trashed) {
|
||||||
|
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
|
||||||
|
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
|
||||||
|
} else {
|
||||||
|
MenuItem(
|
||||||
|
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||||
|
close,
|
||||||
|
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||||
|
if (owner) {
|
||||||
|
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
||||||
|
}
|
||||||
|
// Not on a draft: the server has nothing to share until it is saved.
|
||||||
|
if (owner && note.id != DRAFT_ID) {
|
||||||
|
MenuItem(R.string.editor_share, close, onShare)
|
||||||
|
}
|
||||||
|
MenuItem(
|
||||||
|
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||||
|
close,
|
||||||
|
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||||
|
if (owner) {
|
||||||
|
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's labels, each removable.
|
||||||
|
*
|
||||||
|
* `#tag` labels get no remove button: they are owned by the body text and the core
|
||||||
|
* re-derives them on the next edit, so a cross that undid itself a second later
|
||||||
|
* would look broken. The way to remove one is to delete the tag from the text,
|
||||||
|
* which is what the trailing note says.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EditorLabelRow(
|
||||||
|
note: Note,
|
||||||
|
readOnly: Boolean,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
note.labels.forEach { label ->
|
||||||
|
val tint = labelTintFor(label.name, label.color)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
// `#` on every chip, matching the card. This row still shows the
|
||||||
|
// tags the BODY owns as well — it is the control surface, and the
|
||||||
|
// "from tag" hint beside one is what says why it has no cross.
|
||||||
|
text = "#${label.name}",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = tint.tagInk(dark),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
if (label.viaTag) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_from_tag),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
} else if (!readOnly) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
// Only the MANUAL labels are sent: the core replaces
|
||||||
|
// exactly those, and including a tag label here would ask
|
||||||
|
// it to own something the body text already owns.
|
||||||
|
val kept =
|
||||||
|
note.labels
|
||||||
|
.filterNot { it.viaTag || it.id == label.id }
|
||||||
|
.map { it.id }
|
||||||
|
onAction(EditorAction.SetLabels(kept))
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.editor_remove_label),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set reminder, with the one-tap actions beside it.
|
||||||
|
*
|
||||||
|
* Done / 1h / 1d are the same three the web editor offers, for the same reason:
|
||||||
|
* when a reminder surfaces, the answer is almost always "handled" or "not yet",
|
||||||
|
* and making either of those cost a trip through the date picker is how a reminder
|
||||||
|
* ends up ignored instead of dealt with.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EditorReminderRow(
|
||||||
|
at: String,
|
||||||
|
recurrence: String?,
|
||||||
|
readOnly: Boolean,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = reminderLabel(at, recurrence),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color =
|
||||||
|
if (isPast(at)) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!readOnly) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
TextButton(onClick = { onAction(EditorAction.CompleteReminder) }) {
|
||||||
|
Text(stringResource(R.string.reminder_done))
|
||||||
|
}
|
||||||
|
TextButton(onClick = { onAction(EditorAction.SnoozeReminder(SNOOZE_HOUR)) }) {
|
||||||
|
Text(stringResource(R.string.reminder_snooze_hour))
|
||||||
|
}
|
||||||
|
TextButton(onClick = { onAction(EditorAction.SnoozeReminder(SNOOZE_DAY)) }) {
|
||||||
|
Text(stringResource(R.string.reminder_snooze_day))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SNOOZE_HOUR = 60L
|
||||||
|
private const val SNOOZE_DAY = 1440L
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.temporal.TemporalAdjusters
|
||||||
|
|
||||||
|
// The two things you pick rather than type: a set of labels, and a time.
|
||||||
|
//
|
||||||
|
// It was three. The colour sheet went with `note.color` in M315 — a card is one neutral
|
||||||
|
// surface now and colour lives on the tag, so the swatch grid was a control with nothing
|
||||||
|
// behind it.
|
||||||
|
//
|
||||||
|
// All bottom sheets rather than dialogs. A dialog takes the middle of the screen
|
||||||
|
// and asks to be dismissed; a sheet rises from the bottom, under the thumb, with
|
||||||
|
// the note still visible above it — which matters when the choice you are making
|
||||||
|
// is about the thing you are looking at.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every label, ticked where it is on the note.
|
||||||
|
*
|
||||||
|
* `#tag` labels appear ticked and disabled — they are true of the note, and they
|
||||||
|
* are owned by its text, so showing them unticked would be a lie and letting them
|
||||||
|
* be unticked would be a control that undoes itself.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LabelSheet(
|
||||||
|
note: Note,
|
||||||
|
labels: List<Label>,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var typed by remember { mutableStateOf("") }
|
||||||
|
val manual =
|
||||||
|
note.labels
|
||||||
|
.filterNot { it.viaTag }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
val viaTag =
|
||||||
|
note.labels
|
||||||
|
.filter { it.viaTag }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.imePadding()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
SheetTitle(R.string.label_picker_title)
|
||||||
|
|
||||||
|
PlainTextField(
|
||||||
|
value = typed,
|
||||||
|
onValueChange = { typed = it },
|
||||||
|
hint = R.string.label_new_hint,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(onDone = {
|
||||||
|
onAction(EditorAction.CreateLabel(typed))
|
||||||
|
typed = ""
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Capped rather than unbounded: a sheet that grows past the screen
|
||||||
|
// makes its own scroll fight the sheet's drag gesture.
|
||||||
|
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
|
||||||
|
items(items = labels, key = { it.id }) { label ->
|
||||||
|
val fromTag = label.id in viaTag
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(
|
||||||
|
checked = fromTag || label.id in manual,
|
||||||
|
enabled = !fromTag,
|
||||||
|
onCheckedChange = { on ->
|
||||||
|
val next = if (on) manual + label.id else manual - label.id
|
||||||
|
onAction(EditorAction.SetLabels(next.toList()))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
)
|
||||||
|
if (fromTag) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_from_tag),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labels.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_none_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When to be reminded.
|
||||||
|
*
|
||||||
|
* Presets first, and a full picker behind them. On a phone almost every reminder
|
||||||
|
* is "this evening", "tomorrow morning" or "next week" — the web's raw
|
||||||
|
* `datetime-local` field is the right control for a desktop and three taps too
|
||||||
|
* many for the common case here. The exact picker is still there, one tap down,
|
||||||
|
* because "Thursday at 3" is a real thing to want.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ReminderSheet(
|
||||||
|
note: Note,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var exact by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
) {
|
||||||
|
SheetTitle(R.string.reminder_title)
|
||||||
|
|
||||||
|
reminderPresets().forEach { (labelRes, at) ->
|
||||||
|
Text(
|
||||||
|
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onAction(EditorAction.SetReminder(rfc3339(at)))
|
||||||
|
onDismiss()
|
||||||
|
}.padding(vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_pick),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { exact = true }
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repeat only appears once there IS a reminder — a recurrence rule on
|
||||||
|
// a note with no time to recur from is a setting that does nothing.
|
||||||
|
if (note.remindAt != null) {
|
||||||
|
RecurrenceChips(
|
||||||
|
current = note.recurrence,
|
||||||
|
onPick = { onAction(EditorAction.SetRecurrence(it)) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reminder_clear),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onAction(EditorAction.ClearReminder)
|
||||||
|
onDismiss()
|
||||||
|
}.padding(vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exact) {
|
||||||
|
ExactReminderPicker(
|
||||||
|
initial = note.remindAt?.let { localTime(it) } ?: defaultPickerTime(),
|
||||||
|
onPick = {
|
||||||
|
onAction(EditorAction.SetReminder(rfc3339(it)))
|
||||||
|
exact = false
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
onDismiss = { exact = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date then time, as two dialogs.
|
||||||
|
*
|
||||||
|
* Material 3 ships a date picker and a time picker but nothing that does both, and
|
||||||
|
* a phone screen has no room for them side by side. Sequential also matches how
|
||||||
|
* the choice is actually made — you know the day before you know the hour.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ExactReminderPicker(
|
||||||
|
initial: LocalDateTime,
|
||||||
|
onPick: (LocalDateTime) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var date by remember { mutableStateOf<LocalDate?>(null) }
|
||||||
|
|
||||||
|
if (date == null) {
|
||||||
|
val state =
|
||||||
|
rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis =
|
||||||
|
initial
|
||||||
|
.toLocalDate()
|
||||||
|
.atStartOfDay(ZoneId.of("UTC"))
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli(),
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
// Nothing selected means nothing to confirm — the picker opens
|
||||||
|
// on a date, so this only guards a user who cleared it.
|
||||||
|
enabled = state.selectedDateMillis != null,
|
||||||
|
onClick = {
|
||||||
|
// The picker reports UTC midnight of the CALENDAR day that
|
||||||
|
// was tapped, so it has to be read back in UTC. Reading it
|
||||||
|
// in the device's zone shifts the date by one west of
|
||||||
|
// Greenwich — the classic off-by-a-day in this control.
|
||||||
|
date =
|
||||||
|
state.selectedDateMillis?.let {
|
||||||
|
Instant.ofEpochMilli(it).atZone(ZoneId.of("UTC")).toLocalDate()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.picker_next)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
DatePicker(state = state)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val state =
|
||||||
|
rememberTimePickerState(
|
||||||
|
initialHour = initial.hour,
|
||||||
|
initialMinute = initial.minute,
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.picker_time_title)) },
|
||||||
|
text = { TimePicker(state = state) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onPick(
|
||||||
|
LocalDateTime.of(
|
||||||
|
requireNotNull(date) { "the time step is only reachable with a date" },
|
||||||
|
LocalTime.of(state.hour, state.minute),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}) { Text(stringResource(R.string.picker_set)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecurrenceChips(
|
||||||
|
current: String?,
|
||||||
|
onPick: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
RECURRENCE_RULES.forEach { (rule, labelRes) ->
|
||||||
|
FilterChip(
|
||||||
|
selected = current.orEmpty() == rule.orEmpty(),
|
||||||
|
onClick = { onPick(rule) },
|
||||||
|
label = { Text(stringResource(labelRes)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SheetTitle(labelRes: Int) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(labelRes),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The presets, computed against the device clock at the moment the sheet opens.
|
||||||
|
*
|
||||||
|
* "Later today" disappears once the evening has passed rather than silently
|
||||||
|
* meaning tomorrow — an offer that quietly does something else is worse than one
|
||||||
|
* that isn't there.
|
||||||
|
*/
|
||||||
|
private fun reminderPresets(): List<Pair<Int, LocalDateTime>> {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
val presets = mutableListOf<Pair<Int, LocalDateTime>>()
|
||||||
|
val evening = now.toLocalDate().atTime(EVENING_HOUR, 0)
|
||||||
|
if (evening.isAfter(now)) {
|
||||||
|
presets += R.string.reminder_later_today to evening
|
||||||
|
}
|
||||||
|
presets += R.string.reminder_tomorrow to now.toLocalDate().plusDays(1).atTime(MORNING_HOUR, 0)
|
||||||
|
presets +=
|
||||||
|
R.string.reminder_next_week to
|
||||||
|
now
|
||||||
|
.toLocalDate()
|
||||||
|
.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
|
||||||
|
.atTime(MORNING_HOUR, 0)
|
||||||
|
return presets
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the exact picker opens when the note has no reminder yet. */
|
||||||
|
private fun defaultPickerTime(): LocalDateTime =
|
||||||
|
LocalDateTime
|
||||||
|
.now()
|
||||||
|
.toLocalDate()
|
||||||
|
.plusDays(1)
|
||||||
|
.atTime(MORNING_HOUR, 0)
|
||||||
|
|
||||||
|
/** The core's recurrence vocabulary; null is "does not repeat". */
|
||||||
|
private val RECURRENCE_RULES: List<Pair<String?, Int>> =
|
||||||
|
listOf(
|
||||||
|
null to R.string.recurrence_none,
|
||||||
|
"daily" to R.string.recurrence_daily,
|
||||||
|
"weekly" to R.string.recurrence_weekly,
|
||||||
|
"monthly" to R.string.recurrence_monthly,
|
||||||
|
"yearly" to R.string.recurrence_yearly,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val EVENING_HOUR = 18
|
||||||
|
private const val MORNING_HOUR = 8
|
||||||
|
private val LABEL_LIST_MAX_HEIGHT = 320.dp
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
|
||||||
|
// Shared by the board and the editor.
|
||||||
|
//
|
||||||
|
// A failed save is most likely to happen WHILE the editor is open — that is where
|
||||||
|
// the writes are — so a banner only the board could render meant the one screen
|
||||||
|
// that needed it was the one screen without it.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorBanner(
|
||||||
|
message: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint("red")
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(BANNER_RADIUS))
|
||||||
|
.background(tint.background(dark))
|
||||||
|
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
|
||||||
|
.padding(start = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val BANNER_RADIUS = 12.dp
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run [flush] when the app goes to the background.
|
||||||
|
*
|
||||||
|
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
|
||||||
|
* activity, which would save mid-sentence for no reason. The lambda goes through
|
||||||
|
* `rememberUpdatedState` so the observer — registered once — always calls the
|
||||||
|
* CURRENT one; captured directly it would hold the first composition's empty text
|
||||||
|
* forever and save that over a full note.
|
||||||
|
*
|
||||||
|
* Shared by the editor and the capture sheet. Both are places where text exists
|
||||||
|
* only in a composable until something writes it down, and the process can be
|
||||||
|
* killed while backgrounded without either of them being told again.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FlushOnStop(flush: () -> Unit) {
|
||||||
|
val current by rememberUpdatedState(flush)
|
||||||
|
val owner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(owner) {
|
||||||
|
val observer =
|
||||||
|
LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_STOP) current()
|
||||||
|
}
|
||||||
|
owner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls back when the app comes to the front and when it leaves.
|
||||||
|
*
|
||||||
|
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
|
||||||
|
* the editor's save-on-leave makes for the same reason: resume and pause fire for
|
||||||
|
* anything that merely covers the window — a permission dialog, the notification
|
||||||
|
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
|
||||||
|
*
|
||||||
|
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
|
||||||
|
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
|
||||||
|
* between them will read as leaving and returning.
|
||||||
|
*
|
||||||
|
* Two callers, wanting opposite halves of it: automatic sync uses the return to
|
||||||
|
* decide whether to fetch, and the reminder notice uses it to re-read a
|
||||||
|
* permission the person may have just changed in the system settings.
|
||||||
|
*
|
||||||
|
* Both callbacks go through [rememberUpdatedState]: the observer is registered
|
||||||
|
* once, and without it the lambda would keep reading the first composition's
|
||||||
|
* state forever — deciding whether to push unsent notes from a snapshot taken
|
||||||
|
* before any note existed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ForegroundTransitions(
|
||||||
|
onForeground: () -> Unit,
|
||||||
|
onBackground: () -> Unit,
|
||||||
|
) {
|
||||||
|
val forward by rememberUpdatedState(onForeground)
|
||||||
|
val away by rememberUpdatedState(onBackground)
|
||||||
|
val owner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(owner) {
|
||||||
|
val observer =
|
||||||
|
LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_START -> forward()
|
||||||
|
Lifecycle.Event.ON_STOP -> away()
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.core.LinkPreview
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
|
private val PREVIEW_RADIUS = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL that is the WHOLE body, whitespace either side allowed.
|
||||||
|
*
|
||||||
|
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
|
||||||
|
* agree on what counts as "this note is a link", or the same note reads as a card
|
||||||
|
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
|
||||||
|
* which is why the surrounding whitespace is tolerated rather than rejected.
|
||||||
|
*/
|
||||||
|
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview for a note that is nothing but a URL, or null.
|
||||||
|
*
|
||||||
|
* Null covers three different situations that all render the same way — the body
|
||||||
|
* is not a lone URL, the server has not unfurled it yet, or it never could. The
|
||||||
|
* card falls back to showing the URL as text in every one of them, so it is never
|
||||||
|
* blank and the link is never unreachable.
|
||||||
|
*
|
||||||
|
* A note written on the phone and not yet synced is permanently in the middle
|
||||||
|
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
|
||||||
|
* pull. That is the honest behaviour and it has to look deliberate, which showing
|
||||||
|
* the URL does.
|
||||||
|
*/
|
||||||
|
fun loneUrlPreview(note: Note): LinkPreview? {
|
||||||
|
if (!LONE_URL.matches(note.body)) return null
|
||||||
|
val url = note.body.trim()
|
||||||
|
return note.previews.firstOrNull { it.url == url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
|
||||||
|
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fetched link preview, in one of two sizes.
|
||||||
|
*
|
||||||
|
* [compact] is a single row — one line of title and the site — for a URL mentioned
|
||||||
|
* *inside* a note that has its own words. The note is the thing; the link is a
|
||||||
|
* footnote to it. Full size is for a note that IS a URL, where the link is the
|
||||||
|
* note and a compact strip would be a card with nothing on it.
|
||||||
|
*
|
||||||
|
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
|
||||||
|
* third-party address, so drawing it would have this app fetch from whatever host
|
||||||
|
* a link happens to point at — on a phone, on possibly metered data, and as the
|
||||||
|
* first image loading anywhere in this client. That is a decision about privacy
|
||||||
|
* and data use rather than a rendering detail, so the text card ships and the
|
||||||
|
* image is left to be asked for (Scribe #3307).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LinkPreviewCard(
|
||||||
|
preview: LinkPreview,
|
||||||
|
compact: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Every element of the Modifier chain stays on ONE line, which is why the shape
|
||||||
|
// and the two paddings are named first. `standard:chain-method-continuation`
|
||||||
|
// wants a `.` that follows a MULTILINE element glued to its closing paren —
|
||||||
|
// `).padding(…)` — which is unreadable, so the multiline element is avoided
|
||||||
|
// instead (Scribe #3110).
|
||||||
|
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
||||||
|
val padH = if (compact) 8.dp else 10.dp
|
||||||
|
val padV = if (compact) 6.dp else 8.dp
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||||
|
.padding(horizontal = padH, vertical = padV),
|
||||||
|
) {
|
||||||
|
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
|
||||||
|
Text(
|
||||||
|
text = site.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
// The URL stands in for a missing title so the row always says SOMETHING
|
||||||
|
// about where it goes.
|
||||||
|
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
|
||||||
|
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
// First thing to go when there is no room — the compact row is a footnote and
|
||||||
|
// a description would make it the loudest part of the card.
|
||||||
|
if (!compact) {
|
||||||
|
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this account holds a note (#5175), read from the core's `permission`.
|
||||||
|
*
|
||||||
|
* Someone else's note at [EDIT] may have its TEXT changed here; at [VIEW] it may
|
||||||
|
* not. Either way its pin and archive are this account's own (#5176). The core
|
||||||
|
* refuses the rest anyway — this only keeps the editor from offering what would be
|
||||||
|
* refused.
|
||||||
|
*/
|
||||||
|
enum class NoteAccess { OWNER, EDIT, VIEW }
|
||||||
|
|
||||||
|
val Note.access: NoteAccess
|
||||||
|
get() =
|
||||||
|
when (permission) {
|
||||||
|
"edit" -> NoteAccess.EDIT
|
||||||
|
"view" -> NoteAccess.VIEW
|
||||||
|
else -> NoteAccess.OWNER
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its content can't change here: it is in the trash, or shared with us to view. */
|
||||||
|
val Note.readOnlyHere: Boolean
|
||||||
|
get() = trashed || access == NoteAccess.VIEW
|
||||||
|
|
||||||
|
/** The owner's own controls apply: tags, reminder, files, previews, share, trash. */
|
||||||
|
val Note.ownerControlsHere: Boolean
|
||||||
|
get() = !readOnlyHere && access == NoteAccess.OWNER
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.BodyItem
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import com.fabledsword.inkwell.core.NoteLabel
|
||||||
|
import com.fabledsword.inkwell.core.bodyTags
|
||||||
|
import com.fabledsword.inkwell.core.checklistItems
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NoteCard(
|
||||||
|
note: Note,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
onConfirmDelete: () -> Unit,
|
||||||
|
/** The board is carrying this card (BoardDrag.kt). */
|
||||||
|
dragging: Boolean = false,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// A drag starts from the same long press that opens the menu, so the menu is
|
||||||
|
// already up when the card begins to move. It goes as the card does.
|
||||||
|
LaunchedEffect(dragging) { if (dragging) menuOpen = false }
|
||||||
|
|
||||||
|
// NAMED rather than written inline in the chain below, and not for taste: ktlint's
|
||||||
|
// chain-method-continuation wants the next `.` glued to the closing paren of a
|
||||||
|
// multiline element — `).background(…)` — which is worse to read than a modifier
|
||||||
|
// with a name. Every other multiline element in this codebase happens to be last
|
||||||
|
// in its chain, so this is the first place the rule bites.
|
||||||
|
val opening =
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClickLabel = stringResource(R.string.board_open_note),
|
||||||
|
onLongClickLabel = stringResource(R.string.board_note_actions),
|
||||||
|
onLongClick = {
|
||||||
|
// Fired HERE rather than when the menu appears. A long press is
|
||||||
|
// confirmed by the system before the popup has laid out, and the whole
|
||||||
|
// point of the buzz is to say "that registered" at the moment your
|
||||||
|
// finger has been still long enough — a menu that arrives with no tick
|
||||||
|
// under it reads as a phone that missed the gesture and then changed
|
||||||
|
// its mind.
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
menuOpen = true
|
||||||
|
},
|
||||||
|
onClick = onOpen,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no
|
||||||
|
// space, so the card's size is still the Column's.
|
||||||
|
Box {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Depth, not the boundary — the edge below is that. 1dp: enough to
|
||||||
|
// separate a white card from a #fafafa board, and the web's own
|
||||||
|
// `shadow-sm` is the value it is matching.
|
||||||
|
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
|
||||||
|
// Clipped BEFORE the click modifier, so the ripple is bounded by the
|
||||||
|
// card's rounded corners instead of a rectangle overhanging them —
|
||||||
|
// and BEFORE padding, so the padded edge is still a tap target.
|
||||||
|
.clip(RoundedCornerShape(CARD_RADIUS))
|
||||||
|
.then(opening)
|
||||||
|
// ONE surface and ONE edge on every card, both neutral, neither
|
||||||
|
// asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
|
||||||
|
.background(noteCardSurface(dark))
|
||||||
|
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
CardContents(note = note, onToggleItem = onToggleItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteMenu(
|
||||||
|
note = note,
|
||||||
|
expanded = menuOpen,
|
||||||
|
onDismiss = { menuOpen = false },
|
||||||
|
onAction = onAction,
|
||||||
|
onConfirmDelete = onConfirmDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a card shows, top to bottom: tags, body, links, attachments, reminder, sharing. */
|
||||||
|
@Composable
|
||||||
|
private fun CardContents(
|
||||||
|
note: Note,
|
||||||
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
// TAGS FIRST, on a row of their own above the body: a board is scanned, and
|
||||||
|
// what a note is about should be the first thing the eye lands on. Not beside
|
||||||
|
// the body, whose first line is the note's name.
|
||||||
|
//
|
||||||
|
// Only labels whose text is not still in the note (`via_tag` is false). A tag
|
||||||
|
// left in prose is tinted in place instead (see [tintTags]), so this row holds
|
||||||
|
// what the body cannot say: a tag lifted off its own line, or one added by hand.
|
||||||
|
val chips = note.labels.filterNot { it.viaTag }
|
||||||
|
if (chips.isNotEmpty()) {
|
||||||
|
LabelChips(labels = chips)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note that is NOTHING but a URL renders as its preview and nothing
|
||||||
|
// else — printing the raw address under a card that already says where it
|
||||||
|
// goes is saying the same thing twice, badly. Until the unfurl lands, or
|
||||||
|
// if it never does, `preview` is null and the body falls through to
|
||||||
|
// NoteBody, which shows the URL. Never a blank card.
|
||||||
|
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
|
||||||
|
|
||||||
|
// Body then checklist, in order — a note can carry both (M13 step 2). The
|
||||||
|
// first line of the body IS the note's name, at the same weight as the rest of
|
||||||
|
// it (M13 steps 3 and 4).
|
||||||
|
if (lonePreview != null) {
|
||||||
|
LinkPreviewCard(preview = lonePreview, compact = false)
|
||||||
|
} else if (note.body.isNotBlank()) {
|
||||||
|
// A note shared with us to view has inert boxes (#5175): ticking one is
|
||||||
|
// changing its text, which is not ours to do.
|
||||||
|
NoteBody(
|
||||||
|
note = note,
|
||||||
|
onToggleItem = if (note.access == NoteAccess.VIEW) INERT_TOGGLE else onToggleItem,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
|
||||||
|
// under the note's own words rather than stacked on top of them. Putting
|
||||||
|
// them above would set a stranger's headline where the note's first line
|
||||||
|
// should be — the web learned that in M13 and moved them down.
|
||||||
|
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
note.previews.forEach { preview ->
|
||||||
|
LinkPreviewCard(preview = preview, compact = true)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under the words and the links, like the web's card: a photo is often what a
|
||||||
|
// note is ABOUT, but its first line is still its name.
|
||||||
|
if (note.attachments.isNotEmpty()) {
|
||||||
|
CardAttachments(attachments = note.attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||||
|
// it reads as a rendering bug.
|
||||||
|
if (note.body.isBlank() && note.previews.isEmpty() && note.attachments.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.board_empty_note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
note.remindAt?.let { at ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedChip(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What you can do to a note without opening it, on a long press.
|
||||||
|
*
|
||||||
|
* The same items as the editor's overflow, in the same words from the same string
|
||||||
|
* resources, dispatched through [EditorAction] so `BoardViewModel.onEditorAction`
|
||||||
|
* stays the one place they are handled.
|
||||||
|
*
|
||||||
|
* Gated on the NOTE (`note.trashed`), not on the view, because Reminders and search
|
||||||
|
* mix piles: a note already in Trash must not offer "Move to trash".
|
||||||
|
*
|
||||||
|
* No Labels item: the picker it opens is editor state.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NoteMenu(
|
||||||
|
note: Note,
|
||||||
|
expanded: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
onConfirmDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Pin and archive are this account's own on someone else's note too (#5176);
|
||||||
|
// trash is the owner's. A note shared with us never reaches our Trash, so the
|
||||||
|
// trashed set is only ever the owner's.
|
||||||
|
val owner = note.access == NoteAccess.OWNER
|
||||||
|
DropdownMenu(expanded = expanded && (owner || !note.trashed), onDismissRequest = onDismiss) {
|
||||||
|
if (note.trashed) {
|
||||||
|
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
|
||||||
|
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
|
||||||
|
} else {
|
||||||
|
MenuItem(
|
||||||
|
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||||
|
onDismiss,
|
||||||
|
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||||
|
MenuItem(
|
||||||
|
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||||
|
onDismiss,
|
||||||
|
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||||
|
if (owner) {
|
||||||
|
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's body, with its checklist drawn where it actually sits.
|
||||||
|
*
|
||||||
|
* Rendered line by line rather than as one block of text, because an item is a line
|
||||||
|
* of the body now (M304) and a card that showed the prose and then the list would put
|
||||||
|
* every list in the wrong place — and, since the body already contains those lines,
|
||||||
|
* would show each one twice.
|
||||||
|
*
|
||||||
|
* Which lines are items is asked of the core rather than matched here. The grammar is
|
||||||
|
* already written three times; a fourth in Compose would be a fourth place for a
|
||||||
|
* checklist to change shape when it syncs.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NoteBody(
|
||||||
|
note: Note,
|
||||||
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val lines = remember(note.body) { note.body.split("\n") }
|
||||||
|
// Read from the BODY rather than from note.items, which is the same list by a
|
||||||
|
// longer route — and one that can lag the text by a save.
|
||||||
|
val itemAtLine =
|
||||||
|
remember(note.body) {
|
||||||
|
checklistItems(note.body)
|
||||||
|
.mapIndexed { index, item -> item.line.toInt() to (index to item) }
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
|
||||||
|
val found = itemAtLine[n]
|
||||||
|
when {
|
||||||
|
found != null ->
|
||||||
|
ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
|
||||||
|
// Kept as a gap rather than dropped: it is the paragraph break
|
||||||
|
// somebody typed, and the card reads as a wall without it.
|
||||||
|
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
||||||
|
else ->
|
||||||
|
Text(
|
||||||
|
text = tintTags(line, note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = MAX_WRAPPED_LINES,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.size > MAX_PREVIEW_LINES) {
|
||||||
|
Text(
|
||||||
|
text = "…",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One checklist row on a card, with a box you can actually tick.
|
||||||
|
*
|
||||||
|
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
|
||||||
|
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
|
||||||
|
* space a list out like a form. The tap target is the glyph's own padding, which is
|
||||||
|
* why it carries `clickable` rather than the row — clicking the TEXT should open the
|
||||||
|
* note, the way clicking anywhere else on the card does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistRow(
|
||||||
|
note: Note,
|
||||||
|
item: BodyItem,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Text(
|
||||||
|
text = if (item.checked) "☑" else "☐",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.padding(end = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = tintTags(item.text, note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||||
|
color =
|
||||||
|
if (item.checked) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
maxLines = MAX_WRAPPED_LINES,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
|
||||||
|
*
|
||||||
|
* This is what replaced the chip for a tag still living in the prose. The card used to
|
||||||
|
* print such a tag twice — once where it was typed and once in the row above — and the
|
||||||
|
* duplicate was the loud copy, which made a tagged note read as "tag, then some text
|
||||||
|
* that happens to start with the same word". Colouring it in place says the same thing
|
||||||
|
* with no furniture, and says it more honestly: the token you can see IS the text you
|
||||||
|
* would delete to remove the tag.
|
||||||
|
*
|
||||||
|
* WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which
|
||||||
|
* lines are checklist items. The grammar already exists three times (Rust, Python,
|
||||||
|
* TypeScript); a fourth in Compose would be a fourth thing to disagree — and this one
|
||||||
|
* would fail silently, as the wrong characters tinted rather than an error anywhere.
|
||||||
|
* The core's offsets are UTF-16 code units for this call site specifically, which is
|
||||||
|
* the only unit `addStyle` can take.
|
||||||
|
*
|
||||||
|
* Called per rendered STRING rather than once per body so a checklist item's text can
|
||||||
|
* be handled with no arithmetic: an item is a line minus a `- [ ] ` prefix of a length
|
||||||
|
* nothing carries, and shifting spans by a guessed prefix is the kind of off-by-one
|
||||||
|
* that shows up only on the one note that had a tag in a list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun tintTags(
|
||||||
|
text: String,
|
||||||
|
note: Note,
|
||||||
|
): AnnotatedString {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
return remember(text, note.labels, dark) {
|
||||||
|
val spans = bodyTags(text)
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
AnnotatedString(text)
|
||||||
|
} else {
|
||||||
|
// A tag the note does not carry as a label yet — just typed, not yet
|
||||||
|
// derived — still gets a colour: `labelTint` falls back to deriving one
|
||||||
|
// from the name, which is what the chip would have shown anyway.
|
||||||
|
val picked = note.labels.associate { it.name.lowercase() to it.color }
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
spans.forEach { tag ->
|
||||||
|
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
|
||||||
|
addStyle(
|
||||||
|
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
|
||||||
|
tag.start.toInt(),
|
||||||
|
tag.end.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LabelChips(labels: List<NoteLabel>) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
// A plain row that clips rather than wraps: a card with eight labels should
|
||||||
|
// not grow taller than its content. The editor shows the full set.
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
||||||
|
val tint = labelTintFor(label.name, label.color)
|
||||||
|
Text(
|
||||||
|
// The `#` is carried on every chip, because everything that reaches
|
||||||
|
// this row is a tag — a tag lifted off its own line, or one attached
|
||||||
|
// through the picker — and the hash is how you would type either. It
|
||||||
|
// also keeps a lifted chip reading as the `#todo` somebody wrote.
|
||||||
|
text = "#${label.name}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tint.tagInk(dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.border(1.dp, tint.chipBorder(dark), RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reminder, red once it has passed.
|
||||||
|
*
|
||||||
|
* Red for overdue and neutral otherwise, matching the web card exactly — the same
|
||||||
|
* red-100/red-700 and black/5 pairs, resolved through the shared tint table. It
|
||||||
|
* used to be blue for every reminder here, which made "you missed this" and
|
||||||
|
* "coming up on Friday" look identical on a board full of both.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ReminderChip(
|
||||||
|
instant: String,
|
||||||
|
recurrence: String?,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint(if (isPast(instant)) "red" else "default")
|
||||||
|
Text(
|
||||||
|
text = reminderLabel(instant, recurrence),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tint.chipForeground(dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whose note this is, when it is not only ours (#5175): "From Robin" on a note someone
|
||||||
|
* shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SharedChip(note: Note) {
|
||||||
|
val text =
|
||||||
|
when {
|
||||||
|
note.access != NoteAccess.OWNER ->
|
||||||
|
stringResource(
|
||||||
|
R.string.share_chip_by,
|
||||||
|
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone),
|
||||||
|
)
|
||||||
|
note.shared -> stringResource(R.string.share_chip_shared)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint("default")
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tint.chipForeground(dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A box that does nothing when tapped: someone else's note, shared to view. */
|
||||||
|
private val INERT_TOGGLE: (Int, Boolean) -> Unit = { _, _ -> }
|
||||||
|
|
||||||
|
private const val MAX_PREVIEW_LINES = 8
|
||||||
|
|
||||||
|
/** How far one long line of a card may wrap before it is cut. */
|
||||||
|
private const val MAX_WRAPPED_LINES = 2
|
||||||
|
private const val MAX_LABEL_CHIPS = 3
|
||||||
|
private val CARD_RADIUS = 12.dp
|
||||||
|
private val CARD_ELEVATION = 1.dp
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WHAT A CARD IS: one surface and one edge, neither of which asks the note anything.
|
||||||
|
//
|
||||||
|
// Both are constants HERE rather than columns in NoteTint precisely so the palette
|
||||||
|
// CANNOT vary them; uniformity is the feature. The editor reads the surface from here
|
||||||
|
// too, so a note opened is the same object as the note on the board.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE CARD SURFACE — one neutral per theme (M315).
|
||||||
|
*
|
||||||
|
* This used to be a function of the note: a palette fill for a tagged one, a colour
|
||||||
|
* generated from the id for the rest. Both are gone. The operator's verdict after four
|
||||||
|
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
|
||||||
|
* to make the color work and somehow it never seems to land" — and the diagnosis under
|
||||||
|
* it is that a card's fill was being asked to carry meaning it could not carry. Nine
|
||||||
|
* keys is too few to identify anything on a board of any size, and a generated fill
|
||||||
|
* identifies nothing by construction, so a coloured board taught the eye to read hue
|
||||||
|
* as significant and then handed it noise. Colour lives on the TAG now, where the
|
||||||
|
* thing it names is right beside it.
|
||||||
|
*
|
||||||
|
* The values are `neutral-900` on dark and white on light — exactly what the palette's
|
||||||
|
* `default` always was, and exactly what the web card and both editors already use, so
|
||||||
|
* this is a collapse onto a surface every surface already had rather than a new colour
|
||||||
|
* anybody has to like. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
|
||||||
|
* (`bg-white dark:bg-neutral-900`).
|
||||||
|
*
|
||||||
|
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
|
||||||
|
* and Material's `surfaceContainer` roles are unset here so they would resolve to
|
||||||
|
* baseline M3 greys rather than to the web's neutrals. Two hexes matching the web beats
|
||||||
|
* a role that nearly does.
|
||||||
|
*
|
||||||
|
* Measured, against the operator's "not the same color as their background but close
|
||||||
|
* to it" — the card fill is deliberately the WEAKEST number on the card:
|
||||||
|
*
|
||||||
|
* card vs board light #FFFFFF on #FAFAFA 1.04
|
||||||
|
* dark #171717 on #0A0A0A 1.10
|
||||||
|
* edge vs card light #B8B8B8 on #FFFFFF 1.98
|
||||||
|
* dark #404040 on #171717 1.73
|
||||||
|
* body vs card light #171717 on #FFFFFF 17.93 (needs 4.5)
|
||||||
|
* dark #FAFAFA on #171717 17.17
|
||||||
|
* muted vs card light #404040 on #FFFFFF 10.37
|
||||||
|
* dark #E5E5E5 on #171717 14.23
|
||||||
|
*
|
||||||
|
* A card is not separated from the board by its fill and never was — the edge and the
|
||||||
|
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
|
||||||
|
* that separated on its own would be a panel, and a board of panels is the wall this
|
||||||
|
* whole line of work started from.
|
||||||
|
*/
|
||||||
|
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
|
||||||
|
|
||||||
|
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
|
||||||
|
private val CARD_SURFACE_DARK = Color(0xFF171717)
|
||||||
|
|
||||||
|
// THE CARD'S EDGE — one neutral grey, every card, both themes. The fill barely differs
|
||||||
|
// from the board, so this line is what makes a card a card. Neutral on purpose: a
|
||||||
|
// coloured edge would carry information and turn a board into a grid of outlines.
|
||||||
|
//
|
||||||
|
// Opaque rather than a translucent black/white, which would composite differently over
|
||||||
|
// whatever is under it. NoteCard.vue writes the same two hex values, which is how the
|
||||||
|
// surfaces stay the same card. Contrast: #B8B8B8 on white 1.98, #404040 on #171717 1.73.
|
||||||
|
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
|
||||||
|
private val CARD_EDGE_DARK = Color(0xFF404040)
|
||||||
|
private val CHIP_RADIUS = 6.dp
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one writing surface: a new note and an existing one are the same screen.
|
||||||
|
*
|
||||||
|
* Shaped like the capture sheet it replaced — a rounded card that begins below the
|
||||||
|
* status bar — so opening a note still reads as something rising over the board
|
||||||
|
* rather than a place you navigated to. It is full height rather than a real
|
||||||
|
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
|
||||||
|
* negotiating with the IME for the bottom half of the display, and the swipe-down it
|
||||||
|
* buys is a gesture back already does. The shape is what was worth keeping.
|
||||||
|
*
|
||||||
|
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
|
||||||
|
* a note reads as the same object growing to fill the display — the more literally
|
||||||
|
* true since M315, where the board and the editor became the same one neutral.
|
||||||
|
*
|
||||||
|
* No save button, deliberately. Writes are continuous, so a button offering to do
|
||||||
|
* what already happened would be a lie with a tap attached; [EditorFooter] in the
|
||||||
|
* bottom corner says the same thing as a fact instead, beside the Done that
|
||||||
|
* leaves.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun NoteEditorScreen(
|
||||||
|
note: Note,
|
||||||
|
sessionKey: Long,
|
||||||
|
labels: List<Label>,
|
||||||
|
saving: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onShare: () -> Unit,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
||||||
|
// needs a key — but a draft's id changes the moment it is first saved, and
|
||||||
|
// re-keying on that would reset this state to whatever the store just returned,
|
||||||
|
// throwing away every character typed during the write.
|
||||||
|
//
|
||||||
|
// BLOCKS rather than one string, because a checklist item is drawn as a real
|
||||||
|
// checkbox now and a widget cannot live inside a text field. The note is still one
|
||||||
|
// markdown body underneath — see EditorBlock.kt — and `bodyText` is what is saved.
|
||||||
|
//
|
||||||
|
// Saveable, because a new note has nothing to fall back on if the phone rotates
|
||||||
|
// mid-capture. The saver carries the TEXT and re-derives the shape, since a block's
|
||||||
|
// id means nothing across a process death.
|
||||||
|
var blocks by
|
||||||
|
rememberSaveable(sessionKey, stateSaver = blocksSaver) {
|
||||||
|
mutableStateOf(splitBlocks(note.body).focusedAtEnd())
|
||||||
|
}
|
||||||
|
// Which field the caret is wanted in, or null. Held HERE rather than inside
|
||||||
|
// BlockBody because the toolbar's checklist button also asks for one.
|
||||||
|
var focus by remember(sessionKey) { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
|
val bodyText = remember(blocks) { joinBlocks(blocks) }
|
||||||
|
|
||||||
|
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
|
||||||
|
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// A note in the trash is a record, not a document: editing one would silently
|
||||||
|
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||||
|
// Restore and Delete forever as the only things to do with it.
|
||||||
|
//
|
||||||
|
// A note shared with us to view is read-only for the same reason it is on the
|
||||||
|
// web: nothing about it is ours to change (#5175). At edit, the text is, and the
|
||||||
|
// rows that are the owner's (tags, reminder, files, previews) stay inert.
|
||||||
|
val access = note.access
|
||||||
|
val readOnly = note.readOnlyHere
|
||||||
|
val ownerControls = note.ownerControlsHere
|
||||||
|
|
||||||
|
// Persist the text, if it changed. The baseline check is what makes "open a
|
||||||
|
// note, read it, back out" write nothing at all — without it every glance
|
||||||
|
// would bump `updated_at`, mark the note dirty for sync, and snapshot a
|
||||||
|
// revision identical to the one before it.
|
||||||
|
val flush = {
|
||||||
|
if (!readOnly && bodyText != note.body) {
|
||||||
|
onAction(EditorAction.SaveText(bodyText))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val leave = {
|
||||||
|
flush()
|
||||||
|
onAction(EditorAction.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening an existing note means continuing it. Without this the note arrives
|
||||||
|
// unfocused, and carrying on costs a tap into the last field.
|
||||||
|
//
|
||||||
|
// Not for a trashed note: it renders read-only, and a keyboard over a record you
|
||||||
|
// cannot edit is noise.
|
||||||
|
LaunchedEffect(sessionKey) {
|
||||||
|
if (!readOnly) focus = blocks.lastOrNull()?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
|
||||||
|
// keystroke, so the delay only ever elapses once typing stops.
|
||||||
|
//
|
||||||
|
// Saving this often is affordable because a body write no longer costs a
|
||||||
|
// revision: history snapshots once per editing session rather than once per
|
||||||
|
// save. Before that, writing was expensive enough that this editor hoarded
|
||||||
|
// text until it closed — and an app kill mid-session lost the lot.
|
||||||
|
//
|
||||||
|
// For a note that does not exist yet this is also what CREATES it, which is why
|
||||||
|
// every toolbar button works moments after the first keystroke rather than
|
||||||
|
// needing the note to be saved by hand first.
|
||||||
|
LaunchedEffect(bodyText, sessionKey) {
|
||||||
|
if (readOnly || bodyText == note.body) return@LaunchedEffect
|
||||||
|
delay(AUTOSAVE_IDLE_MS)
|
||||||
|
onAction(EditorAction.SaveText(bodyText))
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(onBack = leave)
|
||||||
|
|
||||||
|
// The system picker, for any type: a note can carry a PDF as readily as a photo.
|
||||||
|
// Leaving for it stops this screen, so FlushOnStop has already saved the text by
|
||||||
|
// the time the files come back.
|
||||||
|
val pickFiles =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||||
|
if (uris.isNotEmpty()) onAction(EditorAction.Attach(uris))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving the APP is not closing the editor, so the text has to be saved
|
||||||
|
// without the screen being torn down. Losing a paragraph to an incoming call
|
||||||
|
// is exactly the failure that makes someone stop trusting a notes app.
|
||||||
|
FlushOnStop(flush)
|
||||||
|
|
||||||
|
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
|
||||||
|
// status bar AND consumes that inset, so the bar inside adds no second gap of
|
||||||
|
// its own — the strip above the rounded corner is what makes this read as a card
|
||||||
|
// over the board rather than a screen that replaced it.
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
|
||||||
|
color = noteCardSurface(dark),
|
||||||
|
// Both content colours are spelled out for the reason the toolbar had to
|
||||||
|
// be: Surface and Scaffold each default theirs to contentColorFor(their
|
||||||
|
// container), which returns Unspecified for anything that is not a
|
||||||
|
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
|
||||||
|
// Unspecified as LocalContentColor and everything inside that does not
|
||||||
|
// set its own colour draws black — which is how the last toolbar became
|
||||||
|
// invisible in dark mode.
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
containerColor = noteCardSurface(dark),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
topBar = {
|
||||||
|
EditorTopBar(
|
||||||
|
note = note,
|
||||||
|
readOnly = readOnly,
|
||||||
|
access = access,
|
||||||
|
onClose = leave,
|
||||||
|
onStartChecklist = {
|
||||||
|
val (next, id) = blocks.plusTask()
|
||||||
|
blocks = next
|
||||||
|
focus = id
|
||||||
|
},
|
||||||
|
onPicker = { picker = it },
|
||||||
|
onAttach = { pickFiles.launch(ANY_TYPE) },
|
||||||
|
onShare = {
|
||||||
|
flush()
|
||||||
|
onShare()
|
||||||
|
},
|
||||||
|
onConfirmDelete = { confirmingDelete = true },
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
// Where the action bar used to be, carrying the two things that
|
||||||
|
// belong within reach of a thumb: whether the note is safe, and the
|
||||||
|
// way out. See [EditorFooter] for why the exit is down here and not
|
||||||
|
// only in the top-left corner.
|
||||||
|
bottomBar = {
|
||||||
|
EditorFooter(
|
||||||
|
updatedAt = note.updatedAt,
|
||||||
|
saving = saving,
|
||||||
|
onClose = leave,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// No imePadding here: EditorFooter carries it, so
|
||||||
|
// Scaffold measures that row at its keyboard-lifted
|
||||||
|
// height and the inset already reaches this Column
|
||||||
|
// through `padding`. Adding it again would inset for the
|
||||||
|
// keyboard twice.
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
// A failed save has to be visible HERE. The board renders the
|
||||||
|
// same banner, but a write that fails while the editor is open
|
||||||
|
// would otherwise report itself only after the user had already
|
||||||
|
// left.
|
||||||
|
error?.let { message ->
|
||||||
|
ErrorBanner(
|
||||||
|
message = message,
|
||||||
|
onDismiss = { onAction(EditorAction.DismissError) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whose note this is, and what may be done with it here.
|
||||||
|
SharedByLine(note)
|
||||||
|
|
||||||
|
// A note is its body; its NAME is that body's first line, so there
|
||||||
|
// is nothing separate to type into and nothing rendered bolder than
|
||||||
|
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
||||||
|
// how the body is DRAWN — checklist items as boxes rather than as
|
||||||
|
// the markup for boxes.
|
||||||
|
BlockBody(
|
||||||
|
blocks = blocks,
|
||||||
|
readOnly = readOnly,
|
||||||
|
focus = focus,
|
||||||
|
onChange = { blocks = it },
|
||||||
|
onFocus = { focus = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
// No checklist section. The items ARE lines of the field above
|
||||||
|
// (M304) — rendering them again down here is what would put every
|
||||||
|
// list on screen twice.
|
||||||
|
|
||||||
|
if (note.labels.isNotEmpty()) {
|
||||||
|
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
note.remindAt?.let { at ->
|
||||||
|
EditorReminderRow(
|
||||||
|
at = at,
|
||||||
|
recurrence = note.recurrence,
|
||||||
|
readOnly = !ownerControls,
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.attachments.isNotEmpty()) {
|
||||||
|
EditorAttachments(
|
||||||
|
attachments = note.attachments,
|
||||||
|
readOnly = !ownerControls,
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.previews.isNotEmpty()) {
|
||||||
|
EditorPreviews(previews = note.previews, readOnly = !ownerControls, onAction = onAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorOverlays(
|
||||||
|
note = note,
|
||||||
|
labels = labels,
|
||||||
|
picker = picker,
|
||||||
|
onPicker = { picker = it },
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (confirmingDelete) {
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
onConfirm = {
|
||||||
|
confirmingDelete = false
|
||||||
|
onAction(EditorAction.DeleteForever)
|
||||||
|
},
|
||||||
|
onDismiss = { confirmingDelete = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
|
||||||
|
enum class Picker { NONE, LABELS, REMINDER }
|
||||||
|
|
||||||
|
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
|
||||||
|
@Composable
|
||||||
|
private fun EditorOverlays(
|
||||||
|
note: Note,
|
||||||
|
labels: List<Label>,
|
||||||
|
picker: Picker,
|
||||||
|
onPicker: (Picker) -> Unit,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
val dismiss = { onPicker(Picker.NONE) }
|
||||||
|
when (picker) {
|
||||||
|
Picker.NONE -> Unit
|
||||||
|
Picker.LABELS ->
|
||||||
|
LabelSheet(
|
||||||
|
note = note,
|
||||||
|
labels = labels,
|
||||||
|
onAction = onAction,
|
||||||
|
onDismiss = dismiss,
|
||||||
|
)
|
||||||
|
Picker.REMINDER ->
|
||||||
|
ReminderSheet(
|
||||||
|
note = note,
|
||||||
|
onAction = onAction,
|
||||||
|
onDismiss = dismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long typing has to stop before the note is written.
|
||||||
|
*
|
||||||
|
* Long enough that a normal sentence is one write, short enough that nothing
|
||||||
|
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
|
||||||
|
* cover the window between the last keystroke and this elapsing.
|
||||||
|
*/
|
||||||
|
private const val AUTOSAVE_IDLE_MS = 1_000L
|
||||||
|
|
||||||
|
/** What the file picker offers: everything. The server takes any type. */
|
||||||
|
private const val ANY_TYPE = "*/*"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's top corner radius — Material's extra-large, which is what a bottom
|
||||||
|
* sheet uses. Same shape as the capture surface this replaced, on purpose.
|
||||||
|
*/
|
||||||
|
private val SHEET_CORNER = 28.dp
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
|
||||||
|
*
|
||||||
|
* A colour is stored by the core as a key ("red", "teal", …) and every surface
|
||||||
|
* resolves it to its own tints. The web app resolves through Tailwind classes; this
|
||||||
|
* table is those same Tailwind colours as literals, so a tag that is amber on the
|
||||||
|
* desktop is the same amber on the phone rather than a near-miss. Generated from
|
||||||
|
* tailwindcss 3.4's palette rather than transcribed by eye.
|
||||||
|
*
|
||||||
|
* Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
|
||||||
|
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
|
||||||
|
* background the same way in both.
|
||||||
|
*
|
||||||
|
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
|
||||||
|
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside the
|
||||||
|
* edge, where the palette cannot reach either of them. What is left here is the chip,
|
||||||
|
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
|
||||||
|
* are.
|
||||||
|
*
|
||||||
|
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
|
||||||
|
* acid against the neutral surfaces.
|
||||||
|
*/
|
||||||
|
data class NoteTint(
|
||||||
|
val label: String,
|
||||||
|
val lightBackground: Color,
|
||||||
|
val lightBorder: Color,
|
||||||
|
val darkBackground: Color,
|
||||||
|
val darkBorder: Color,
|
||||||
|
val lightChipBackground: Color,
|
||||||
|
val darkChipBackground: Color,
|
||||||
|
/**
|
||||||
|
* The REMINDER pill's ink, and nothing else's — see [chipForeground].
|
||||||
|
*
|
||||||
|
* Only two of these ten are ever read (`red` when a reminder has passed, `default`
|
||||||
|
* otherwise). They stay a per-hue column because they are transcribed from the
|
||||||
|
* web's literals rather than derived from anything here.
|
||||||
|
*/
|
||||||
|
val lightChipForeground: Color,
|
||||||
|
val darkChipForeground: Color,
|
||||||
|
/**
|
||||||
|
* THE INK A TAG IS DRAWN IN — inline in the prose and as a chip's text — see
|
||||||
|
* [tagInk]. One value serves both: `-800` light / `-300` dark clears body-text
|
||||||
|
* contrast on the card and on a chip's own fill.
|
||||||
|
*/
|
||||||
|
val lightTagInk: Color,
|
||||||
|
val darkTagInk: Color,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
|
||||||
|
* places that borrow a hue to say what they are. Not a note's: since M315 a card
|
||||||
|
* has one neutral surface and does not come through this table at all.
|
||||||
|
*/
|
||||||
|
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
|
||||||
|
|
||||||
|
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
|
||||||
|
|
||||||
|
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The REMINDER pill's ink. NOT a tag's — a tag takes [tagInk] wherever it is drawn.
|
||||||
|
*
|
||||||
|
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
|
||||||
|
* chip's shape and its `red-100`/`black-5` fills, and its `red-700`/`neutral-600`
|
||||||
|
* text is transcribed from NoteCard.vue's literal classes. The two happened to be
|
||||||
|
* one value; making the tag ink one step deeper (M315) is where they parted, and
|
||||||
|
* moving the reminder with it would have silently broken that mirror instead.
|
||||||
|
*/
|
||||||
|
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
|
||||||
|
*
|
||||||
|
* A tag whose text is in the body is no longer repeated as a chip (the card was
|
||||||
|
* printing every tag twice — once where it was typed, once at the top). It is
|
||||||
|
* tinted in place instead, which is both less furniture and a more honest card:
|
||||||
|
* the thing you see IS the thing you would delete to remove the tag.
|
||||||
|
*/
|
||||||
|
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hairline edge for a chip, in its own ink at low alpha.
|
||||||
|
*
|
||||||
|
* THE EDGE IS THE PILL. Against the one card surface a chip's fill measures
|
||||||
|
* 1.02-1.26 in light and 1.02-1.73 in dark — very nearly nothing, and dark red at
|
||||||
|
* 1.02 is literally invisible. Without this the tag name would read as loose text.
|
||||||
|
* The fill only tints a shape the edge is drawing.
|
||||||
|
*
|
||||||
|
* An edge rather than a heavier fill, because a fill loud enough to hold its own
|
||||||
|
* shape would be the loudest thing on a board whose whole point is now that the tag
|
||||||
|
* is the one coloured thing on it.
|
||||||
|
*/
|
||||||
|
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// How strongly a chip's edge is drawn, as a fraction of its own ink.
|
||||||
|
//
|
||||||
|
// SOLVED FOR, NOT GUESSED — and re-solved once the answer became solvable. 0.60 was
|
||||||
|
// picked against the worst case of the time: a chip on a card of its OWN colour, back
|
||||||
|
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
|
||||||
|
// 1.4.11, and the comment here reasoned its way out of that on the grounds that a
|
||||||
|
// chip's information is its text.
|
||||||
|
//
|
||||||
|
// M315 removed that worst case. The edge is now the ink at alpha over a KNOWN fill, so
|
||||||
|
// the smallest alpha clearing 3:1 for all ten hues is arithmetic rather than judgment:
|
||||||
|
// 0.60 gives 2.75-3.82 in light and misses for six of the ten, 0.65 gives 3.03-4.36 and
|
||||||
|
// misses for none. Dark runs 4.52-5.76. The old comment named 0.80 as the fallback if
|
||||||
|
// the judgment were ever overruled; it is not needed, and it draws a hard outline where
|
||||||
|
// a hairline does the job.
|
||||||
|
//
|
||||||
|
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
|
||||||
|
private const val CHIP_EDGE_ALPHA = 0.65f
|
||||||
|
|
||||||
|
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
|
||||||
|
val NOTE_TINTS: Map<String, NoteTint> =
|
||||||
|
mapOf(
|
||||||
|
"default" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Default",
|
||||||
|
lightBackground = Color(0xFFFFFFFF),
|
||||||
|
lightBorder = Color(0xFFE5E5E5),
|
||||||
|
darkBackground = Color(0xFF171717),
|
||||||
|
darkBorder = Color(0xFF404040),
|
||||||
|
lightChipBackground = Color(0x0D000000),
|
||||||
|
lightChipForeground = Color(0xFF525252),
|
||||||
|
darkChipBackground = Color(0x1AFFFFFF),
|
||||||
|
darkChipForeground = Color(0xFFD4D4D4),
|
||||||
|
lightTagInk = Color(0xFF404040),
|
||||||
|
darkTagInk = Color(0xFFD4D4D4),
|
||||||
|
),
|
||||||
|
"red" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Red",
|
||||||
|
lightBackground = Color(0xFFFEF2F2),
|
||||||
|
lightBorder = Color(0xFFFECACA),
|
||||||
|
darkBackground = Color(0x66450A0A),
|
||||||
|
darkBorder = Color(0xFF7F1D1D),
|
||||||
|
lightChipBackground = Color(0xFFFEE2E2),
|
||||||
|
lightChipForeground = Color(0xFFB91C1C),
|
||||||
|
darkChipBackground = Color(0x80450A0A),
|
||||||
|
darkChipForeground = Color(0xFFFCA5A5),
|
||||||
|
lightTagInk = Color(0xFF991B1B),
|
||||||
|
darkTagInk = Color(0xFFFCA5A5),
|
||||||
|
),
|
||||||
|
"orange" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Orange",
|
||||||
|
lightBackground = Color(0xFFFFF7ED),
|
||||||
|
lightBorder = Color(0xFFFED7AA),
|
||||||
|
darkBackground = Color(0x66431407),
|
||||||
|
darkBorder = Color(0xFF7C2D12),
|
||||||
|
lightChipBackground = Color(0xFFFFEDD5),
|
||||||
|
lightChipForeground = Color(0xFFC2410C),
|
||||||
|
darkChipBackground = Color(0x80431407),
|
||||||
|
darkChipForeground = Color(0xFFFDBA74),
|
||||||
|
lightTagInk = Color(0xFF9A3412),
|
||||||
|
darkTagInk = Color(0xFFFDBA74),
|
||||||
|
),
|
||||||
|
"yellow" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Yellow",
|
||||||
|
lightBackground = Color(0xFFFFFBEB),
|
||||||
|
lightBorder = Color(0xFFFDE68A),
|
||||||
|
darkBackground = Color(0x66451A03),
|
||||||
|
darkBorder = Color(0xFF78350F),
|
||||||
|
lightChipBackground = Color(0xFFFEF3C7),
|
||||||
|
lightChipForeground = Color(0xFF92400E),
|
||||||
|
darkChipBackground = Color(0x80451A03),
|
||||||
|
darkChipForeground = Color(0xFFFCD34D),
|
||||||
|
lightTagInk = Color(0xFF92400E),
|
||||||
|
darkTagInk = Color(0xFFFCD34D),
|
||||||
|
),
|
||||||
|
"green" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Green",
|
||||||
|
lightBackground = Color(0xFFF0FDF4),
|
||||||
|
lightBorder = Color(0xFFBBF7D0),
|
||||||
|
darkBackground = Color(0x66052E16),
|
||||||
|
darkBorder = Color(0xFF14532D),
|
||||||
|
lightChipBackground = Color(0xFFDCFCE7),
|
||||||
|
lightChipForeground = Color(0xFF15803D),
|
||||||
|
darkChipBackground = Color(0x80052E16),
|
||||||
|
darkChipForeground = Color(0xFF86EFAC),
|
||||||
|
lightTagInk = Color(0xFF166534),
|
||||||
|
darkTagInk = Color(0xFF86EFAC),
|
||||||
|
),
|
||||||
|
"teal" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Teal",
|
||||||
|
lightBackground = Color(0xFFF0FDFA),
|
||||||
|
lightBorder = Color(0xFF99F6E4),
|
||||||
|
darkBackground = Color(0x66042F2E),
|
||||||
|
darkBorder = Color(0xFF134E4A),
|
||||||
|
lightChipBackground = Color(0xFFCCFBF1),
|
||||||
|
lightChipForeground = Color(0xFF0F766E),
|
||||||
|
darkChipBackground = Color(0x80042F2E),
|
||||||
|
darkChipForeground = Color(0xFF5EEAD4),
|
||||||
|
lightTagInk = Color(0xFF115E59),
|
||||||
|
darkTagInk = Color(0xFF5EEAD4),
|
||||||
|
),
|
||||||
|
"blue" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Blue",
|
||||||
|
lightBackground = Color(0xFFEFF6FF),
|
||||||
|
lightBorder = Color(0xFFBFDBFE),
|
||||||
|
darkBackground = Color(0x66172554),
|
||||||
|
darkBorder = Color(0xFF1E3A8A),
|
||||||
|
lightChipBackground = Color(0xFFDBEAFE),
|
||||||
|
lightChipForeground = Color(0xFF1D4ED8),
|
||||||
|
darkChipBackground = Color(0x80172554),
|
||||||
|
darkChipForeground = Color(0xFF93C5FD),
|
||||||
|
lightTagInk = Color(0xFF1E40AF),
|
||||||
|
darkTagInk = Color(0xFF93C5FD),
|
||||||
|
),
|
||||||
|
"purple" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Purple",
|
||||||
|
lightBackground = Color(0xFFFAF5FF),
|
||||||
|
lightBorder = Color(0xFFE9D5FF),
|
||||||
|
darkBackground = Color(0x663B0764),
|
||||||
|
darkBorder = Color(0xFF581C87),
|
||||||
|
lightChipBackground = Color(0xFFF3E8FF),
|
||||||
|
lightChipForeground = Color(0xFF7E22CE),
|
||||||
|
darkChipBackground = Color(0x803B0764),
|
||||||
|
darkChipForeground = Color(0xFFD8B4FE),
|
||||||
|
lightTagInk = Color(0xFF6B21A8),
|
||||||
|
darkTagInk = Color(0xFFD8B4FE),
|
||||||
|
),
|
||||||
|
"pink" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Pink",
|
||||||
|
lightBackground = Color(0xFFFDF2F8),
|
||||||
|
lightBorder = Color(0xFFFBCFE8),
|
||||||
|
darkBackground = Color(0x66500724),
|
||||||
|
darkBorder = Color(0xFF831843),
|
||||||
|
lightChipBackground = Color(0xFFFCE7F3),
|
||||||
|
lightChipForeground = Color(0xFFBE185D),
|
||||||
|
darkChipBackground = Color(0x80500724),
|
||||||
|
darkChipForeground = Color(0xFFF9A8D4),
|
||||||
|
lightTagInk = Color(0xFF9D174D),
|
||||||
|
darkTagInk = Color(0xFFF9A8D4),
|
||||||
|
),
|
||||||
|
"gray" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Gray",
|
||||||
|
lightBackground = Color(0xFFF5F5F5),
|
||||||
|
lightBorder = Color(0xFFD4D4D4),
|
||||||
|
darkBackground = Color(0xFF262626),
|
||||||
|
darkBorder = Color(0xFF404040),
|
||||||
|
lightChipBackground = Color(0xFFE5E5E5),
|
||||||
|
lightChipForeground = Color(0xFF404040),
|
||||||
|
darkChipBackground = Color(0xFF404040),
|
||||||
|
darkChipForeground = Color(0xFFE5E5E5),
|
||||||
|
lightTagInk = Color(0xFF262626),
|
||||||
|
darkTagInk = Color(0xFFE5E5E5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a stored colour key.
|
||||||
|
*
|
||||||
|
* An unknown key falls back to `default` rather than throwing: colours are data
|
||||||
|
* that arrives from a server which may be newer than this client, and a note
|
||||||
|
* whose tint we don't recognise should still be readable.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tint for a LABEL, derived from its name when nobody has picked one.
|
||||||
|
*
|
||||||
|
* Every `#tag` is born colourless, so without this a board of tags is a board of
|
||||||
|
* identical grey chips. See `DerivedTint.kt` for why this derives rather than
|
||||||
|
* persisting a colour when the tag is minted.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
fun labelTintFor(
|
||||||
|
name: String,
|
||||||
|
color: String,
|
||||||
|
): NoteTint = labelTint(name, color)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [labelTintFor] with no composable context, for a caller building its value inside
|
||||||
|
* `remember` — where a `@Composable` call is not allowed. The card's inline tag
|
||||||
|
* colours are computed there, once per body rather than once per recomposition.
|
||||||
|
*
|
||||||
|
* One implementation, two entry points: the composable one delegates here rather than
|
||||||
|
* repeating the lookup, so the chip and the inline token cannot resolve differently.
|
||||||
|
*/
|
||||||
|
fun labelTint(
|
||||||
|
name: String,
|
||||||
|
color: String,
|
||||||
|
): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default")
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bordered block, tinted from the same table the notes use.
|
||||||
|
*
|
||||||
|
* Reusing the note palette rather than Material's `errorContainer` keeps the whole
|
||||||
|
* app one visual language: a warning here is the same yellow a note can be, which
|
||||||
|
* is also what the web app does with its Tailwind amber.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Panel(
|
||||||
|
tone: Tone = Tone.NEUTRAL,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint(tone.tintKey())
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(PANEL_RADIUS))
|
||||||
|
.background(tint.background(dark))
|
||||||
|
.border(1.dp, tint.border(dark), RoundedCornerShape(PANEL_RADIUS))
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Notice(
|
||||||
|
tone: Tone,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
onDismiss: (() -> Unit)? = null,
|
||||||
|
/**
|
||||||
|
* A way to FIX what the notice describes, when there is one.
|
||||||
|
*
|
||||||
|
* Separate from [onDismiss] because they are opposites: dismissing accepts the
|
||||||
|
* situation, acting changes it. A notice about a permission has an action and
|
||||||
|
* no dismiss — acknowledging a reminder that cannot ring does not make it ring.
|
||||||
|
*/
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onAction: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Panel(tone = tone) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (actionLabel != null && onAction != null) {
|
||||||
|
TextButton(onClick = onAction) { Text(actionLabel) }
|
||||||
|
}
|
||||||
|
onDismiss?.let {
|
||||||
|
TextButton(onClick = it) { Text(stringResource(R.string.error_dismiss)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of a dropdown menu, closing the menu before it acts.
|
||||||
|
*
|
||||||
|
* Lives here rather than beside either menu because there are two now — the
|
||||||
|
* editor's overflow and the board's long-press menu — and they offer the same
|
||||||
|
* actions in the same words. A second copy of this would be a second place for the
|
||||||
|
* closing order to be got wrong.
|
||||||
|
*
|
||||||
|
* Closing FIRST is the whole point: an action that raises a sheet or a dialog would
|
||||||
|
* otherwise do it underneath a menu still hanging over the screen. Doing it in here
|
||||||
|
* means no call site can forget.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MenuItem(
|
||||||
|
@StringRes labelRes: Int,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(labelRes)) },
|
||||||
|
onClick = {
|
||||||
|
onClose()
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one confirmation in the app.
|
||||||
|
*
|
||||||
|
* Delete-forever is the only irreversible thing a note can be asked to do —
|
||||||
|
* archive, trash, even unlinking a server all undo — so it is the only one that
|
||||||
|
* interrupts. Both surfaces that offer it raise THIS dialog: the editor's overflow
|
||||||
|
* and the board's long-press menu are two ways to the same act, and two dialogs
|
||||||
|
* would be two chances to word the consequences differently.
|
||||||
|
*
|
||||||
|
* Nothing about a note is passed in. The caller already knows which note it is
|
||||||
|
* asking about and holds it while this is on screen; taking one here would only let
|
||||||
|
* the dialog and the action that follows it disagree.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConfirmDeleteDialog(
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
|
||||||
|
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.editor_delete_forever_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three tones a panel or notice can take, mapped onto the note palette. */
|
||||||
|
enum class Tone { NEUTRAL, WARN, ERROR }
|
||||||
|
|
||||||
|
private fun Tone.tintKey(): String =
|
||||||
|
when (this) {
|
||||||
|
Tone.NEUTRAL -> "default"
|
||||||
|
Tone.WARN -> "yellow"
|
||||||
|
Tone.ERROR -> "red"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PANEL_RADIUS = 12.dp
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldColors
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text field with no box around it.
|
||||||
|
*
|
||||||
|
* The search box, the label picker, the sync-pairing form: fields that sit on a
|
||||||
|
* surface which already has its own edges and its own colour, where Material's filled
|
||||||
|
* field would draw a second, differently coloured box inside the first. Stripping the
|
||||||
|
* container and the indicator at each site independently is how they drift apart, so
|
||||||
|
* it happens once, here.
|
||||||
|
*
|
||||||
|
* The note EDITOR no longer comes through this. It dropped to `BasicTextField`
|
||||||
|
* (see `EditorBlock.kt`) for density: Material's field puts 16dp above and below its
|
||||||
|
* text, which is right for a form and is the whole row height on a checklist. Nothing
|
||||||
|
* about "no box" was lost there — BasicTextField never had one.
|
||||||
|
*
|
||||||
|
* The disabled colours are stripped too: a trashed note is shown through this
|
||||||
|
* field read-only, and Material's disabled treatment would grey out text the user
|
||||||
|
* is meant to be reading.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PlainTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
@StringRes hint: Int? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
singleLine: Boolean = false,
|
||||||
|
minLines: Int = 1,
|
||||||
|
textStyle: TextStyle = LocalTextStyle.current,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
enabled = enabled,
|
||||||
|
placeholder = hint?.let { { Text(stringResource(it)) } },
|
||||||
|
singleLine = singleLine,
|
||||||
|
minLines = minLines,
|
||||||
|
textStyle = textStyle,
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
|
colors = plainFieldColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One definition of "no box". Two copies of this is exactly the drift this file
|
||||||
|
* exists to prevent.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun plainFieldColors(): TextFieldColors =
|
||||||
|
TextFieldDefaults.colors(
|
||||||
|
// Full-strength, not Material's 38%-alpha disabled treatment: a trashed
|
||||||
|
// note is rendered read-only through this field and its text is meant to
|
||||||
|
// be READ, not visually retired.
|
||||||
|
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent,
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.Reminders
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Says so when a reminder would not actually reach anyone.
|
||||||
|
*
|
||||||
|
* Both conditions here are ones Android can put the app into at any time and
|
||||||
|
* never tells it about: notifications switched off in system settings, and exact
|
||||||
|
* alarms refused. Either one turns reminders into something that silently does
|
||||||
|
* nothing, and a feature that silently does nothing is worse than one that is
|
||||||
|
* plainly absent — the person keeps setting reminders and keeps not getting them.
|
||||||
|
*
|
||||||
|
* Shown only on the Reminders view, which is where somebody is already thinking
|
||||||
|
* about this. Putting it on the main board would nag people who have never set a
|
||||||
|
* reminder at all.
|
||||||
|
*
|
||||||
|
* Re-read on every return to the app, because the fix happens in a system screen
|
||||||
|
* this app cannot observe: without that, someone would grant the permission, come
|
||||||
|
* back, and still be looking at a warning telling them they had not.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderNotice() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var canNotify by remember { mutableStateOf(notificationsAllowed(context)) }
|
||||||
|
var canBeExact by remember { mutableStateOf(exactAllowed(context)) }
|
||||||
|
|
||||||
|
ForegroundTransitions(
|
||||||
|
onForeground = {
|
||||||
|
canNotify = notificationsAllowed(context)
|
||||||
|
canBeExact = exactAllowed(context)
|
||||||
|
},
|
||||||
|
onBackground = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (canNotify && canBeExact) return
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(horizontal = GUTTER, vertical = 4.dp)) {
|
||||||
|
if (!canNotify) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.reminder_notifications_blocked_title),
|
||||||
|
body = stringResource(R.string.reminder_notifications_blocked_body),
|
||||||
|
actionLabel = stringResource(R.string.reminder_open_settings),
|
||||||
|
onAction = { context.startActivity(appNotificationSettings(context)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Only worth raising once notifications work at all: told both at once, the
|
||||||
|
// second is noise about the punctuality of something that is not arriving.
|
||||||
|
if (canNotify && !canBeExact) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.reminder_inexact_title),
|
||||||
|
body = stringResource(R.string.reminder_inexact_body),
|
||||||
|
actionLabel = stringResource(R.string.reminder_allow_exact),
|
||||||
|
onAction = { context.startActivity(exactAlarmSettings(context)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notificationsAllowed(context: Context): Boolean =
|
||||||
|
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
|
||||||
|
private fun exactAllowed(context: Context): Boolean {
|
||||||
|
val alarms = context.getSystemService(AlarmManager::class.java) ?: return true
|
||||||
|
return Reminders.canBeExact(alarms)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appNotificationSettings(context: Context): Intent =
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
private fun exactAlarmSettings(context: Context): Intent =
|
||||||
|
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
|
||||||
|
.setData(Uri.fromParts("package", context.packageName, null))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Member
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import com.fabledsword.inkwell.core.NoteShare
|
||||||
|
import com.fabledsword.inkwell.core.ShareGroup
|
||||||
|
import com.fabledsword.inkwell.core.ShareTarget
|
||||||
|
|
||||||
|
/** "Shared by Robin · view only" over someone else's note; nothing over our own. */
|
||||||
|
@Composable
|
||||||
|
fun SharedByLine(note: Note) {
|
||||||
|
if (note.access == NoteAccess.OWNER) return
|
||||||
|
val who = note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
|
||||||
|
val line =
|
||||||
|
if (note.access == NoteAccess.EDIT) {
|
||||||
|
stringResource(R.string.share_by_can_edit, who)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.share_by_view_only, who)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val VIEW = "view"
|
||||||
|
private const val EDIT = "edit"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Share sheet: who the note is shared with, and adding someone or a group.
|
||||||
|
*
|
||||||
|
* A peer of the web's `ShareDialog.vue`. Each share's permission is a pair of chips
|
||||||
|
* rather than a menu, because there are exactly two and both fit.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ShareSheet(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||||
|
onUnshare: (shareId: String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
SheetTitle(R.string.share_title)
|
||||||
|
when {
|
||||||
|
state.loading -> Muted(stringResource(R.string.share_loading))
|
||||||
|
state.needsServer -> Muted(stringResource(R.string.share_needs_server))
|
||||||
|
state.loadError != null -> ErrorText(state.loadError)
|
||||||
|
else -> ShareBody(state, onShare, onUnshare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareBody(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||||
|
onUnshare: (shareId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (state.shares.isEmpty()) {
|
||||||
|
Muted(stringResource(R.string.share_none))
|
||||||
|
}
|
||||||
|
state.shares.forEach { share ->
|
||||||
|
ShareRow(
|
||||||
|
share = share,
|
||||||
|
busy = state.busy,
|
||||||
|
onPermission = { onShare(share.target, it) },
|
||||||
|
onRemove = { onUnshare(share.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.available.isEmpty()) {
|
||||||
|
Muted(stringResource(R.string.share_everyone))
|
||||||
|
} else {
|
||||||
|
AddPerson(state = state, onShare = onShare)
|
||||||
|
}
|
||||||
|
state.error?.let { ErrorText(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareRow(
|
||||||
|
share: NoteShare,
|
||||||
|
busy: Boolean,
|
||||||
|
onPermission: (String) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TargetName(share.member, share.group, Modifier.weight(1f))
|
||||||
|
IconButton(onClick = onRemove, enabled = !busy) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionChips(selected = share.permission, enabled = !busy, onSelect = onPermission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddPerson(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||||
|
) {
|
||||||
|
var pick by remember(state.noteId) { mutableStateOf<ShareCandidate?>(null) }
|
||||||
|
var permission by remember(state.noteId) { mutableStateOf(VIEW) }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.share_add),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
// Capped, like the tag picker: a sheet that grows past the screen makes its own
|
||||||
|
// scroll fight the sheet's drag.
|
||||||
|
LazyColumn(modifier = Modifier.heightIn(max = MEMBER_LIST_MAX_HEIGHT)) {
|
||||||
|
items(items = state.available, key = { it.key }) { candidate ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { pick = candidate },
|
||||||
|
) {
|
||||||
|
RadioButton(selected = pick?.key == candidate.key, onClick = { pick = candidate })
|
||||||
|
TargetName(candidate.member, candidate.group, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PermissionChips(selected = permission, enabled = !state.busy, onSelect = { permission = it })
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
enabled = pick != null && !state.busy,
|
||||||
|
onClick = {
|
||||||
|
pick?.let { onShare(it.target, permission) }
|
||||||
|
pick = null
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.share_action)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionChips(
|
||||||
|
selected: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == VIEW,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { onSelect(VIEW) },
|
||||||
|
label = { Text(stringResource(R.string.share_can_view)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == EDIT,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { onSelect(EDIT) },
|
||||||
|
label = { Text(stringResource(R.string.share_can_edit)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A person (name over email) or a group (name over how many are in it). */
|
||||||
|
@Composable
|
||||||
|
private fun TargetName(
|
||||||
|
member: Member?,
|
||||||
|
group: ShareGroup?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val title = group?.name ?: member?.let { it.displayName.ifBlank { it.email } }.orEmpty()
|
||||||
|
val detail =
|
||||||
|
when {
|
||||||
|
group != null ->
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.share_group_members,
|
||||||
|
group.memberCount.toInt(),
|
||||||
|
group.memberCount.toInt(),
|
||||||
|
)
|
||||||
|
member != null && member.displayName.isNotBlank() -> member.email
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
detail?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Muted(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorText(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MEMBER_LIST_MAX_HEIGHT = 240.dp
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.inkwell.core.CoreException
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.core.Member
|
||||||
|
import com.fabledsword.inkwell.core.NoteShare
|
||||||
|
import com.fabledsword.inkwell.core.ShareGroup
|
||||||
|
import com.fabledsword.inkwell.core.ShareTarget
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Everything the Share sheet renders from. [noteId] null means the sheet is closed. */
|
||||||
|
data class ShareState(
|
||||||
|
val noteId: String? = null,
|
||||||
|
val loading: Boolean = false,
|
||||||
|
val members: List<Member> = emptyList(),
|
||||||
|
/** The groups the admin made (#5177); a note shared with one reaches its members. */
|
||||||
|
val groups: List<ShareGroup> = emptyList(),
|
||||||
|
val shares: List<NoteShare> = emptyList(),
|
||||||
|
/** This device has no server, and sharing is between people on one. */
|
||||||
|
val needsServer: Boolean = false,
|
||||||
|
/** Why the sheet couldn't load, when it has a server and still failed. */
|
||||||
|
val loadError: String? = null,
|
||||||
|
/** An in-flight write, for disabling the controls that would race it. */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
/** The people and groups it isn't shared with yet, people first. */
|
||||||
|
val available: List<ShareCandidate>
|
||||||
|
get() {
|
||||||
|
val people = shares.mapNotNull { it.member?.id }.toSet()
|
||||||
|
val grouped = shares.mapNotNull { it.group?.id }.toSet()
|
||||||
|
return members.filterNot { it.id in people }.map { ShareCandidate(member = it) } +
|
||||||
|
groups.filterNot { it.id in grouped }.map { ShareCandidate(group = it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Someone, or a group, the sheet offers to share with. Exactly one is set. */
|
||||||
|
data class ShareCandidate(
|
||||||
|
val member: Member? = null,
|
||||||
|
val group: ShareGroup? = null,
|
||||||
|
) {
|
||||||
|
val key: String
|
||||||
|
get() = group?.let { "g:${it.id}" } ?: "u:${member?.id}"
|
||||||
|
val target: ShareTarget
|
||||||
|
get() = targetOf(member, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who an existing share goes to, to change its permission. */
|
||||||
|
val NoteShare.target: ShareTarget
|
||||||
|
get() = targetOf(member, group)
|
||||||
|
|
||||||
|
private fun targetOf(
|
||||||
|
member: Member?,
|
||||||
|
group: ShareGroup?,
|
||||||
|
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sharing a note with other people on the linked server (#5175).
|
||||||
|
*
|
||||||
|
* A peer of the web's `ShareDialog.vue`. Shares belong to the server, so every call
|
||||||
|
* here is a network round-trip through the core — `suspend` functions on uniffi's
|
||||||
|
* tokio runtime, not blocking store calls, so no IO dispatcher is needed. The one
|
||||||
|
* thing the core keeps locally is the note's `shared` flag, which is why a write
|
||||||
|
* that landed tells the board to reload: its card chip reads that flag.
|
||||||
|
*/
|
||||||
|
class ShareViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/** Called after a share was granted, changed or removed. */
|
||||||
|
private val onStoreChanged: () -> Unit,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(ShareState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun open(noteId: String) {
|
||||||
|
state = ShareState(noteId = noteId, loading = true)
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Unlinked is not a failure: it is the phone working as intended, so the
|
||||||
|
// sheet says what sharing needs rather than showing an error.
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
if (!withContext(Dispatchers.IO) { core.syncStatus().linked }) {
|
||||||
|
state.copy(loading = false, needsServer = true)
|
||||||
|
} else {
|
||||||
|
val directory = core.shareDirectory()
|
||||||
|
val shares = core.noteShares(noteId)
|
||||||
|
state.copy(
|
||||||
|
loading = false,
|
||||||
|
members = directory.members,
|
||||||
|
groups = directory.groups,
|
||||||
|
shares = shares,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: CoreException) {
|
||||||
|
state.copy(loading = false, loadError = e.describeShareFailure())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
state = ShareState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share with someone or a group, or change what they may do. */
|
||||||
|
fun share(
|
||||||
|
target: ShareTarget,
|
||||||
|
permission: String,
|
||||||
|
) = write { noteId -> core.shareNote(noteId, target, permission) }
|
||||||
|
|
||||||
|
fun unshare(shareId: String) = write { noteId -> core.unshareNote(noteId, shareId) }
|
||||||
|
|
||||||
|
private fun write(call: suspend (String) -> List<NoteShare>) {
|
||||||
|
val noteId = state.noteId ?: return
|
||||||
|
state = state.copy(busy = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val shares = call(noteId)
|
||||||
|
onStoreChanged()
|
||||||
|
state.copy(busy = false, shares = shares)
|
||||||
|
} catch (e: CoreException) {
|
||||||
|
state.copy(busy = false, error = e.describeShareFailure())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
onStoreChanged: () -> Unit,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = ShareViewModel(core, onStoreChanged) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The core's message is written to be shown ("The server doesn't have this note yet…"). */
|
||||||
|
private fun Throwable.describeShareFailure(): String = message ?: "Something went wrong."
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Compatibility
|
||||||
|
import com.fabledsword.inkwell.core.RevokeOutcome
|
||||||
|
|
||||||
|
// Becoming linked: the probe-then-sign-in flow, and the notices around it.
|
||||||
|
//
|
||||||
|
// Split from SyncScreen.kt because it is a different job. That file renders the
|
||||||
|
// state of an existing connection; this one is the several-step negotiation that
|
||||||
|
// creates one, and it is the half that has to be careful — it is where a password
|
||||||
|
// gets typed.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UnlinkedPanel(
|
||||||
|
state: SyncState,
|
||||||
|
onProbe: (String) -> Unit,
|
||||||
|
onClearProbe: () -> Unit,
|
||||||
|
onLink: (String, Credentials) -> Unit,
|
||||||
|
onDismissRevokeNotice: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Saveable for the things it would be annoying to retype after a rotation —
|
||||||
|
// and deliberately NOT for the password or the token. `rememberSaveable`
|
||||||
|
// persists into the instance-state bundle, and a secret has no business being
|
||||||
|
// written there to save someone four seconds of typing.
|
||||||
|
var url by rememberSaveable { mutableStateOf("") }
|
||||||
|
var mode by rememberSaveable { mutableStateOf(LinkMode.PASSWORD) }
|
||||||
|
var email by rememberSaveable { mutableStateOf("") }
|
||||||
|
var deviceName by rememberSaveable { mutableStateOf(defaultDeviceName()) }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var token by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
state.lastRevoke?.let { RevokeNotice(revoke = it, onDismiss = onDismissRevokeNotice) }
|
||||||
|
|
||||||
|
Panel {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_offline_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_offline_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AddressSection(
|
||||||
|
url = url,
|
||||||
|
onUrlChange = {
|
||||||
|
url = it
|
||||||
|
// A probe describes ONE address. The moment it is edited the answer on
|
||||||
|
// screen is about a server the user is no longer asking about.
|
||||||
|
if (state.probe != null || state.probeError != null) onClearProbe()
|
||||||
|
},
|
||||||
|
busy = state.busy,
|
||||||
|
onProbe = { onProbe(url) },
|
||||||
|
)
|
||||||
|
|
||||||
|
ProbeSection(state)
|
||||||
|
|
||||||
|
if (state.probeUsable) {
|
||||||
|
SignInFields(
|
||||||
|
mode = mode,
|
||||||
|
onMode = { mode = it },
|
||||||
|
email = email,
|
||||||
|
onEmail = { email = it },
|
||||||
|
password = password,
|
||||||
|
onPassword = { password = it },
|
||||||
|
token = token,
|
||||||
|
onToken = { token = it },
|
||||||
|
deviceName = deviceName,
|
||||||
|
onDeviceName = { deviceName = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
state.linkError?.let {
|
||||||
|
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_link_failed), body = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.linking || state.syncing) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
val credentials =
|
||||||
|
when (mode) {
|
||||||
|
LinkMode.PASSWORD ->
|
||||||
|
Credentials
|
||||||
|
.Password(email, password, deviceName)
|
||||||
|
.takeIf { email.isNotBlank() && password.isNotEmpty() }
|
||||||
|
LinkMode.TOKEN -> Credentials.Token(token).takeIf { token.isNotBlank() }
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { credentials?.let { onLink(url, it) } },
|
||||||
|
enabled = credentials != null && !state.busy,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sync_connect))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where app updates come from, said here rather than left as a gap. This
|
||||||
|
// device has no update path at all until it is linked, and a Check button
|
||||||
|
// that always found nothing would be worse than the sentence.
|
||||||
|
UnlinkedUpdateNote()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this device proves who it is.
|
||||||
|
*
|
||||||
|
* Two modes, because two situations: an email and password is what most people
|
||||||
|
* have, and a pasted device token is for anyone who would rather not type a
|
||||||
|
* password into an app — or whose account is behind SSO and has no password to
|
||||||
|
* type. Both are verified before anything is stored, so a slip fails here rather
|
||||||
|
* than at the next sync.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SignInFields(
|
||||||
|
mode: LinkMode,
|
||||||
|
onMode: (LinkMode) -> Unit,
|
||||||
|
email: String,
|
||||||
|
onEmail: (String) -> Unit,
|
||||||
|
password: String,
|
||||||
|
onPassword: (String) -> Unit,
|
||||||
|
token: String,
|
||||||
|
onToken: (String) -> Unit,
|
||||||
|
deviceName: String,
|
||||||
|
onDeviceName: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_signin),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = mode == LinkMode.PASSWORD,
|
||||||
|
onClick = { onMode(LinkMode.PASSWORD) },
|
||||||
|
label = { Text(stringResource(R.string.sync_mode_password)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = mode == LinkMode.TOKEN,
|
||||||
|
onClick = { onMode(LinkMode.TOKEN) },
|
||||||
|
label = { Text(stringResource(R.string.sync_mode_token)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == LinkMode.PASSWORD) {
|
||||||
|
PlainTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = onEmail,
|
||||||
|
hint = R.string.sync_email,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
PlainTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = onPassword,
|
||||||
|
hint = R.string.sync_password,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
)
|
||||||
|
// Only on this path: a device token was already minted against a named
|
||||||
|
// device in the web app, so `link_with_token` takes no name and offering
|
||||||
|
// the field there would collect something with nowhere to go.
|
||||||
|
PlainTextField(
|
||||||
|
value = deviceName,
|
||||||
|
onValueChange = onDeviceName,
|
||||||
|
hint = R.string.sync_device_name,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_device_name_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PlainTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = onToken,
|
||||||
|
hint = R.string.sync_token,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_token_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The address field and its Check button — a probe, never a link. */
|
||||||
|
@Composable
|
||||||
|
private fun AddressSection(
|
||||||
|
url: String,
|
||||||
|
onUrlChange: (String) -> Unit,
|
||||||
|
busy: Boolean,
|
||||||
|
onProbe: () -> Unit,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_address_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PlainTextField(
|
||||||
|
value = url,
|
||||||
|
onValueChange = onUrlChange,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
hint = R.string.sync_address_hint,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
// No autocapitalise, and a URI keyboard so the IME stops
|
||||||
|
// autocorrecting. A phone "helpfully" capitalising a hostname
|
||||||
|
// is the difference between connecting and a baffling failure.
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Go,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { onProbe() }),
|
||||||
|
)
|
||||||
|
TextButton(onClick = onProbe, enabled = url.isNotBlank() && !busy) {
|
||||||
|
Text(stringResource(R.string.sync_check))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_address_help),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the probe produced: progress, failure, what answered, and the risk. */
|
||||||
|
@Composable
|
||||||
|
private fun ProbeSection(state: SyncState) {
|
||||||
|
if (state.probing) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
state.probeError?.let {
|
||||||
|
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_probe_failed), body = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.probe?.let { probe ->
|
||||||
|
ProbeCard(probe.siteName, probe.version, probe.compatibility)
|
||||||
|
|
||||||
|
// Cleartext is permitted app-wide so a self-hosted server on a LAN works at
|
||||||
|
// all (the core explicitly supports `http://192.168.1.10:8000`). Permitting
|
||||||
|
// it silently would be the wrong half of that trade — this warning is what
|
||||||
|
// turns a platform default into an informed choice, and it appears BEFORE
|
||||||
|
// the credential fields rather than after.
|
||||||
|
if (probe.baseUrl.startsWith("http://")) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.sync_insecure_title),
|
||||||
|
body = stringResource(R.string.sync_insecure_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What answered, shown BEFORE any credential is offered to it. */
|
||||||
|
@Composable
|
||||||
|
private fun ProbeCard(
|
||||||
|
siteName: String?,
|
||||||
|
version: String?,
|
||||||
|
compatibility: Compatibility,
|
||||||
|
) {
|
||||||
|
val incompatible = compatibility is Compatibility.Incompatible
|
||||||
|
Panel(tone = if (incompatible) Tone.ERROR else Tone.NEUTRAL) {
|
||||||
|
Text(
|
||||||
|
text = siteName ?: stringResource(R.string.sync_server_generic),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
version?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_server_version, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = describeCompatibility(compatibility),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color =
|
||||||
|
if (incompatible) {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when unlinking could not retire the token server-side.
|
||||||
|
*
|
||||||
|
* Not a transient message. Someone who disconnected in order to retire a phone
|
||||||
|
* needs to know a live credential is still out there, and needs it to still be
|
||||||
|
* there when they come back to check.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RevokeNotice(
|
||||||
|
revoke: RevokeOutcome,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Revoked and Skipped are the fine cases and say nothing — a notice for "it
|
||||||
|
// worked" is noise on a screen someone is leaving.
|
||||||
|
val body =
|
||||||
|
when (revoke) {
|
||||||
|
is RevokeOutcome.Unsupported -> stringResource(R.string.sync_revoke_unsupported)
|
||||||
|
is RevokeOutcome.Failed -> stringResource(R.string.sync_revoke_failed, revoke.reason)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.sync_revoke_title),
|
||||||
|
body = body,
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phone's own name, so the server's device list reads usefully by default. */
|
||||||
|
private fun defaultDeviceName(): String =
|
||||||
|
listOfNotNull(Build.MANUFACTURER?.replaceFirstChar(Char::titlecase), Build.MODEL)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString(" ")
|
||||||
|
.ifBlank { "Android phone" }
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.UpdateOutcome
|
||||||
|
import com.fabledsword.inkwell.installedVersionName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in server pairing.
|
||||||
|
*
|
||||||
|
* The whole screen is written around one idea: **being unlinked is not a
|
||||||
|
* problem.** Inkwell is local-first and completely usable having never opened
|
||||||
|
* this screen, so the unlinked state leads with "Working offline on this device"
|
||||||
|
* and explains what connecting would ADD, rather than presenting an empty form as
|
||||||
|
* unfinished setup.
|
||||||
|
*
|
||||||
|
* Structurally a port of the desktop's `SyncView.vue` — same probe-then-link
|
||||||
|
* order, same copy where the copy was already right — because the two surfaces
|
||||||
|
* pair with the same servers and a difference in wording here would read as a
|
||||||
|
* difference in behaviour.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SyncScreen(
|
||||||
|
state: SyncState,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onProbe: (String) -> Unit,
|
||||||
|
onClearProbe: () -> Unit,
|
||||||
|
onLink: (String, Credentials) -> Unit,
|
||||||
|
onSyncNow: () -> Unit,
|
||||||
|
onUnlink: () -> Unit,
|
||||||
|
onDismissRevokeNotice: () -> Unit,
|
||||||
|
automatic: Boolean,
|
||||||
|
onAutomaticChange: (Boolean) -> Unit,
|
||||||
|
update: UpdateState,
|
||||||
|
onCheckUpdate: () -> Unit,
|
||||||
|
onInstallUpdate: () -> Unit,
|
||||||
|
onDismissUpdateError: () -> Unit,
|
||||||
|
onInstallOutcome: (UpdateOutcome.Result) -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.sync_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.editor_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.imePadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.loading -> CircularProgressIndicator(modifier = Modifier.padding(32.dp))
|
||||||
|
state.linked ->
|
||||||
|
LinkedPanel(
|
||||||
|
state = state,
|
||||||
|
onSyncNow = onSyncNow,
|
||||||
|
onUnlink = onUnlink,
|
||||||
|
automatic = automatic,
|
||||||
|
onAutomaticChange = onAutomaticChange,
|
||||||
|
update = update,
|
||||||
|
onCheckUpdate = onCheckUpdate,
|
||||||
|
onInstallUpdate = onInstallUpdate,
|
||||||
|
onDismissUpdateError = onDismissUpdateError,
|
||||||
|
onInstallOutcome = onInstallOutcome,
|
||||||
|
)
|
||||||
|
else ->
|
||||||
|
UnlinkedPanel(
|
||||||
|
state = state,
|
||||||
|
onProbe = onProbe,
|
||||||
|
onClearProbe = onClearProbe,
|
||||||
|
onLink = onLink,
|
||||||
|
onDismissRevokeNotice = onDismissRevokeNotice,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildLine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
|
||||||
|
* bottom of its rail (#3181).
|
||||||
|
*
|
||||||
|
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
|
||||||
|
* self-report is the only answer to "which build is this?" — so it renders
|
||||||
|
* "unknown" rather than nothing when the name is absent, because a blank line looks
|
||||||
|
* like a layout bug and a plausible default cannot be caught by anything.
|
||||||
|
*
|
||||||
|
* The read itself is `installedVersionName()`, shared with the client header the
|
||||||
|
* app sends its server: one answer to "which build is on this phone", so the line
|
||||||
|
* a person quotes in a bug report and the line in the server's log cannot disagree.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BuildLine() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val unknown = stringResource(R.string.build_unknown)
|
||||||
|
val version = remember(context) { context.installedVersionName() ?: unknown }
|
||||||
|
Text(
|
||||||
|
text = version,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── linked ─────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LinkedPanel(
|
||||||
|
state: SyncState,
|
||||||
|
onSyncNow: () -> Unit,
|
||||||
|
onUnlink: () -> Unit,
|
||||||
|
automatic: Boolean,
|
||||||
|
onAutomaticChange: (Boolean) -> Unit,
|
||||||
|
update: UpdateState,
|
||||||
|
onCheckUpdate: () -> Unit,
|
||||||
|
onInstallUpdate: () -> Unit,
|
||||||
|
onDismissUpdateError: () -> Unit,
|
||||||
|
onInstallOutcome: (UpdateOutcome.Result) -> Unit,
|
||||||
|
) {
|
||||||
|
var confirmingUnlink by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Panel {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_connected_to),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.status?.serverUrl.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
state.linkedAs?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_linked_as, it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.sync_last_synced,
|
||||||
|
state.status?.lastSyncAt?.let { formatInstant(it) }
|
||||||
|
?: stringResource(R.string.sync_never),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (state.pending) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_unsent),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AutomaticRow(automatic = automatic, enabled = !state.busy, onChange = onAutomaticChange)
|
||||||
|
|
||||||
|
if (state.syncing) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onSyncNow, enabled = !state.busy) {
|
||||||
|
Text(stringResource(R.string.sync_now))
|
||||||
|
}
|
||||||
|
TextButton(onClick = { confirmingUnlink = true }, enabled = !state.busy) {
|
||||||
|
Text(stringResource(R.string.sync_disconnect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastOutcome?.let { outcome ->
|
||||||
|
if (state.syncError == null) {
|
||||||
|
Text(
|
||||||
|
text = syncSummary(outcome),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Rejections are the server refusing a SPECIFIC change. Surfaced, never
|
||||||
|
// swallowed, because only a person can resolve them.
|
||||||
|
if (outcome.push.rejected > 0uL) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.sync_rejected_title),
|
||||||
|
body =
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.sync_rejected_body,
|
||||||
|
outcome.push.rejected.toInt(),
|
||||||
|
outcome.push.rejected.toInt(),
|
||||||
|
outcome.push.errors.joinToString("; "),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.degraded.isNotEmpty()) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.sync_degraded_title),
|
||||||
|
body = stringResource(R.string.sync_degraded_body, state.degraded.joinToString(", ")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.syncError?.let {
|
||||||
|
Notice(tone = Tone.ERROR, title = stringResource(R.string.sync_failed_title), body = it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The app itself comes from this server too, not just the notes.
|
||||||
|
UpdateCard(
|
||||||
|
state = update,
|
||||||
|
onCheck = onCheckUpdate,
|
||||||
|
onInstall = onInstallUpdate,
|
||||||
|
onDismissError = onDismissUpdateError,
|
||||||
|
onOutcome = onInstallOutcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_footer),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (confirmingUnlink) {
|
||||||
|
// Confirmed because it is not obvious what disconnecting does to the notes.
|
||||||
|
// The copy answers that first — they stay — since the fear it raises is
|
||||||
|
// "will this delete something?", not "am I sure?".
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmingUnlink = false },
|
||||||
|
title = { Text(stringResource(R.string.sync_disconnect_title)) },
|
||||||
|
text = { Text(stringResource(R.string.sync_disconnect_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
confirmingUnlink = false
|
||||||
|
onUnlink()
|
||||||
|
}) { Text(stringResource(R.string.sync_disconnect)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmingUnlink = false }) {
|
||||||
|
Text(stringResource(R.string.editor_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one setting this screen has.
|
||||||
|
*
|
||||||
|
* Reads as a statement of what the phone does rather than a feature name, and
|
||||||
|
* says what "automatically" means in minutes — an interval a person cannot see is
|
||||||
|
* one they cannot trust, and "syncs automatically" covers everything from every
|
||||||
|
* keystroke to once a day.
|
||||||
|
*
|
||||||
|
* Turning it off is not turning sync off. The copy says so, because a switch next
|
||||||
|
* to a Disconnect button invites exactly that reading.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AutomaticRow(
|
||||||
|
automatic: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sync_automatic),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
if (automatic) {
|
||||||
|
R.string.sync_automatic_on
|
||||||
|
} else {
|
||||||
|
R.string.sync_automatic_off
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(checked = automatic, onCheckedChange = onChange, enabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Compatibility
|
||||||
|
import com.fabledsword.inkwell.core.SyncOutcome
|
||||||
|
|
||||||
|
// Turning sync results into sentences.
|
||||||
|
//
|
||||||
|
// Composables rather than plain functions, because every word here comes from
|
||||||
|
// strings.xml and `stringResource` needs a composition. The view model keeps the
|
||||||
|
// raw `SyncOutcome`, which is the same split `Time.kt` draws: the core decides
|
||||||
|
// what happened, the UI decides how a person reads it.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a sync did, in one line.
|
||||||
|
*
|
||||||
|
* Counts what MOVED rather than everything the protocol reports. `batches`,
|
||||||
|
* `pages`, `noop` and `cursor` are all real numbers and none of them answer the
|
||||||
|
* question the person is actually asking, which is whether their notes are in
|
||||||
|
* step. A cycle that moved nothing says so plainly instead of listing zeroes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun syncSummary(outcome: SyncOutcome): String {
|
||||||
|
val sent = (outcome.push.created + outcome.push.applied).toInt()
|
||||||
|
val received = (outcome.pull.notesApplied + outcome.pull.notesDeleted).toInt()
|
||||||
|
val blobs = outcome.pull.blobsDownloaded.toInt()
|
||||||
|
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (sent > 0) parts += stringResource(R.string.sync_summary_sent, sent)
|
||||||
|
if (received > 0) parts += stringResource(R.string.sync_summary_received, received)
|
||||||
|
if (blobs > 0) parts += pluralStringResource(R.plurals.sync_summary_attachments, blobs, blobs)
|
||||||
|
val uploaded = outcome.push.uploaded.toInt()
|
||||||
|
if (uploaded > 0) parts += pluralStringResource(R.plurals.sync_summary_uploaded, uploaded, uploaded)
|
||||||
|
|
||||||
|
val line =
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
stringResource(R.string.sync_summary_uptodate)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.sync_summary, parts.joinToString(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments that didn't arrive retry on the next cycle, so this is a note
|
||||||
|
// rather than an error — but saying nothing would leave a missing image
|
||||||
|
// looking like data loss.
|
||||||
|
val failed = outcome.pull.blobsFailed.toInt()
|
||||||
|
val notUploaded = outcome.push.uploadFailed.toInt()
|
||||||
|
val notes = mutableListOf(line)
|
||||||
|
if (failed > 0) notes += pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
|
||||||
|
// A refused upload is recorded on its attachment and shown in the editor; this
|
||||||
|
// line is what sends someone looking.
|
||||||
|
if (notUploaded > 0) notes += pluralStringResource(R.plurals.sync_summary_upload_failed, notUploaded, notUploaded)
|
||||||
|
return notes.joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a probed server's compatibility means for the person reading it.
|
||||||
|
*
|
||||||
|
* `Incompatible` carries the core's own reason and is shown verbatim: the core
|
||||||
|
* knows which protocol version is missing and phrases it for a human, and
|
||||||
|
* substituting a generic "not compatible" here would throw that away.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun describeCompatibility(compatibility: Compatibility): String =
|
||||||
|
when (compatibility) {
|
||||||
|
is Compatibility.Ok -> stringResource(R.string.sync_compat_ok)
|
||||||
|
is Compatibility.Degraded ->
|
||||||
|
stringResource(
|
||||||
|
R.string.sync_compat_degraded,
|
||||||
|
compatibility.unavailable.joinToString(", "),
|
||||||
|
)
|
||||||
|
is Compatibility.Incompatible -> compatibility.reason
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.inkwell.core.Compatibility
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.core.ProbeResult
|
||||||
|
import com.fabledsword.inkwell.core.RevokeOutcome
|
||||||
|
import com.fabledsword.inkwell.core.SyncOutcome
|
||||||
|
import com.fabledsword.inkwell.core.SyncStatus
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** How the device proves who it is when pairing. */
|
||||||
|
enum class LinkMode { PASSWORD, TOKEN }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What gets sent to pair this device, by mode.
|
||||||
|
*
|
||||||
|
* A sealed type rather than six loose strings, and not only for the parameter
|
||||||
|
* count: the two modes genuinely carry different things. `link_with_token` takes
|
||||||
|
* NO device name — the token was already minted against a named device in the web
|
||||||
|
* app — so a flat argument list meant collecting one in token mode and silently
|
||||||
|
* dropping it. Modelling it this way made that impossible to express.
|
||||||
|
*/
|
||||||
|
sealed interface Credentials {
|
||||||
|
data class Password(
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val deviceName: String,
|
||||||
|
) : Credentials
|
||||||
|
|
||||||
|
data class Token(
|
||||||
|
val token: String,
|
||||||
|
) : Credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the sync screen renders from.
|
||||||
|
*
|
||||||
|
* Deliberately holds NO credentials. Email, password, token, address and device
|
||||||
|
* name live in the screen's own state and are handed to [SyncViewModel.link] at
|
||||||
|
* submit time, so a secret never outlives the composable that collected it — and
|
||||||
|
* never lands in a view model that survives the screen being closed.
|
||||||
|
*
|
||||||
|
* Results are kept RAW ([lastOutcome], [lastRevoke]) rather than as prose. Turning
|
||||||
|
* a sync result into a sentence is localisation, which belongs where
|
||||||
|
* `stringResource` is in scope; the same split `Time.kt` draws for timestamps.
|
||||||
|
*/
|
||||||
|
data class SyncState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val status: SyncStatus? = null,
|
||||||
|
val pending: Boolean = false,
|
||||||
|
val probing: Boolean = false,
|
||||||
|
val probe: ProbeResult? = null,
|
||||||
|
val probeError: String? = null,
|
||||||
|
val linking: Boolean = false,
|
||||||
|
val linkError: String? = null,
|
||||||
|
/** The account this device paired as, for the duration of the session. */
|
||||||
|
val linkedAs: String? = null,
|
||||||
|
/** Features this server doesn't have. Not an error — everything else syncs. */
|
||||||
|
val degraded: List<String> = emptyList(),
|
||||||
|
val syncing: Boolean = false,
|
||||||
|
val syncError: String? = null,
|
||||||
|
val lastOutcome: SyncOutcome? = null,
|
||||||
|
/** Set only when unlinking left the token alive server-side. */
|
||||||
|
val lastRevoke: RevokeOutcome? = null,
|
||||||
|
) {
|
||||||
|
val linked: Boolean get() = status?.linked == true
|
||||||
|
|
||||||
|
/** Any in-flight network call, for disabling the controls that would race it. */
|
||||||
|
val busy: Boolean get() = probing || linking || syncing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the server is usable at all.
|
||||||
|
*
|
||||||
|
* An incompatible server is the one probe result that must not lead to a
|
||||||
|
* credential prompt — the core refuses the link anyway, and offering the form
|
||||||
|
* would collect a password only to throw it away.
|
||||||
|
*/
|
||||||
|
val probeUsable: Boolean
|
||||||
|
get() = probe != null && probe.compatibility !is Compatibility.Incompatible
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in server pairing and sync.
|
||||||
|
*
|
||||||
|
* Being UNLINKED is the resting state, not an incomplete setup: the app is
|
||||||
|
* local-first and entirely usable having never opened this screen. Nothing here
|
||||||
|
* may frame it as a problem to be fixed.
|
||||||
|
*
|
||||||
|
* ## Threading
|
||||||
|
*
|
||||||
|
* The two shapes are genuinely different and are called differently. `probe`,
|
||||||
|
* `linkWithPassword`, `linkWithToken`, `unlink` and `syncNow` are Rust `async`
|
||||||
|
* exported through uniffi, so Kotlin sees `suspend` functions already driven by a
|
||||||
|
* tokio runtime — they are awaited directly, and wrapping them in
|
||||||
|
* [Dispatchers.IO] would park a thread to wait on something that never blocks one.
|
||||||
|
* `syncStatus` and `hasPending` are ordinary blocking FFI into SQLite and do need
|
||||||
|
* the IO dispatcher, exactly like the board's calls.
|
||||||
|
*
|
||||||
|
* ## Cancellation
|
||||||
|
*
|
||||||
|
* Leaving the screen cancels [viewModelScope], which drops the Rust future
|
||||||
|
* mid-sync. That is safe by construction rather than by luck: no async path in the
|
||||||
|
* core holds the store lock across an await, and `last_sync_at` is stamped only
|
||||||
|
* after both halves of a cycle succeed, so an interrupted sync resumes from the
|
||||||
|
* stored cursor next time (Scribe #2736).
|
||||||
|
*/
|
||||||
|
class SyncViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/**
|
||||||
|
* Called after a sync that changed the store.
|
||||||
|
*
|
||||||
|
* The board is a separate view model holding its own snapshot of the notes,
|
||||||
|
* and a pull can have rewritten every one of them underneath it. Wiring the
|
||||||
|
* two together explicitly is less magic than a shared event bus and makes the
|
||||||
|
* dependency visible at the construction site.
|
||||||
|
*/
|
||||||
|
private val onStoreChanged: () -> Unit,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(SyncState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the stored link. Cheap and local — no network. */
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val status = withContext(Dispatchers.IO) { core.syncStatus() }
|
||||||
|
val pending = withContext(Dispatchers.IO) { core.hasPending() }
|
||||||
|
state.copy(status = status, pending = pending, loading = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// A store that won't answer is a real fault, but the screen
|
||||||
|
// still has to render — showing the unlinked state is honest,
|
||||||
|
// since without a readable link there is effectively none.
|
||||||
|
state.copy(loading = false, status = null, syncError = e.describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask a server who it is, committing to nothing.
|
||||||
|
*
|
||||||
|
* Separated from linking on purpose: it is what lets someone see what answered
|
||||||
|
* BEFORE handing over a password. A typo that reaches a stranger's server
|
||||||
|
* should cost a round trip, not a credential.
|
||||||
|
*/
|
||||||
|
fun probe(url: String) {
|
||||||
|
if (url.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(probing = true, probeError = null, probe = null, linkError = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
state.copy(probe = core.probe(url), probing = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(probing = false, probeError = e.describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard the probe, so editing the address doesn't leave a stale answer up. */
|
||||||
|
fun clearProbe() {
|
||||||
|
state = state.copy(probe = null, probeError = null, linkError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair with the probed server, then immediately sync.
|
||||||
|
*
|
||||||
|
* The sync is part of the action, not a separate step the user has to think
|
||||||
|
* of: connecting an account and then facing an empty board would read as the
|
||||||
|
* link having failed.
|
||||||
|
*
|
||||||
|
* `url` comes from the PROBE's normalised `base_url` where there is one, so
|
||||||
|
* the address that was inspected is the address that gets paired — not a
|
||||||
|
* re-parse of whatever is currently in the text field.
|
||||||
|
*/
|
||||||
|
fun link(
|
||||||
|
url: String,
|
||||||
|
credentials: Credentials,
|
||||||
|
) {
|
||||||
|
val target = state.probe?.baseUrl ?: url
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(linking = true, linkError = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val identity =
|
||||||
|
when (credentials) {
|
||||||
|
is Credentials.Password ->
|
||||||
|
core.linkWithPassword(
|
||||||
|
target,
|
||||||
|
credentials.email.trim(),
|
||||||
|
credentials.password,
|
||||||
|
credentials.deviceName.trim(),
|
||||||
|
)
|
||||||
|
is Credentials.Token ->
|
||||||
|
core.linkWithToken(target, credentials.token.trim())
|
||||||
|
}
|
||||||
|
val compatibility = state.probe?.compatibility
|
||||||
|
state.copy(
|
||||||
|
linking = false,
|
||||||
|
linkedAs = identity.email,
|
||||||
|
degraded =
|
||||||
|
(compatibility as? Compatibility.Degraded)?.unavailable ?: emptyList(),
|
||||||
|
// The probe has done its job; leaving it up would keep the
|
||||||
|
// connect form on screen next to a live connection.
|
||||||
|
probe = null,
|
||||||
|
status = withContext(Dispatchers.IO) { core.syncStatus() },
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(linking = false, linkError = e.describe())
|
||||||
|
}
|
||||||
|
if (state.linked) syncNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sync the person asked for. Failures are reported. */
|
||||||
|
fun syncNow() = sync(announce = true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sync nothing asked for — app resume, or the periodic worker.
|
||||||
|
*
|
||||||
|
* The difference is entirely in how FAILURE is treated. Someone who pulled
|
||||||
|
* the board down is owed an answer; someone who merely opened the app did not
|
||||||
|
* ask a question, and answering it with a red banner about a server being
|
||||||
|
* unreachable makes their own notes look broken when nothing of theirs is.
|
||||||
|
* The quiet channel for a persistent problem is the drawer badge, which reads
|
||||||
|
* `has_pending` and does not care how the attempt was made.
|
||||||
|
*
|
||||||
|
* It does NOT clear an existing error either: a failure the person was already
|
||||||
|
* shown stays shown until they dismiss it or a real sync succeeds.
|
||||||
|
*/
|
||||||
|
fun syncQuietly() = sync(announce = false)
|
||||||
|
|
||||||
|
private fun sync(announce: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(syncing = true, syncError = if (announce) null else state.syncError)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val outcome = core.syncNow()
|
||||||
|
state.copy(
|
||||||
|
syncing = false,
|
||||||
|
// A success clears the error whoever started it: the
|
||||||
|
// condition it described is demonstrably over.
|
||||||
|
syncError = null,
|
||||||
|
lastOutcome = outcome,
|
||||||
|
status = outcome.status,
|
||||||
|
pending = withContext(Dispatchers.IO) { core.hasPending() },
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(
|
||||||
|
syncing = false,
|
||||||
|
syncError = if (announce) e.describe() else state.syncError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Only when something actually arrived: a no-op sync must not make the
|
||||||
|
// board flash its loading state for nothing.
|
||||||
|
if (state.lastOutcome?.changedTheStore() == true) onStoreChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop syncing, retiring this device's token on the server.
|
||||||
|
*
|
||||||
|
* The local half is unconditional in the core — someone unlinking because the
|
||||||
|
* phone is being sold must not be held to it by a server that is offline. The
|
||||||
|
* revoke outcome comes back so the screen can say plainly when the token is
|
||||||
|
* still live, which is the one thing about this flow worth interrupting for.
|
||||||
|
*/
|
||||||
|
fun unlink() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(syncing = true, syncError = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val revoked = core.unlink()
|
||||||
|
state.copy(
|
||||||
|
syncing = false,
|
||||||
|
lastRevoke = revoked,
|
||||||
|
status = withContext(Dispatchers.IO) { core.syncStatus() },
|
||||||
|
// Everything below described the connection that just ended.
|
||||||
|
linkedAs = null,
|
||||||
|
degraded = emptyList(),
|
||||||
|
lastOutcome = null,
|
||||||
|
pending = false,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(syncing = false, syncError = e.describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRevokeNotice() {
|
||||||
|
state = state.copy(lastRevoke = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledge a sync failure.
|
||||||
|
*
|
||||||
|
* Exists because the BOARD reports these too, and a banner the person cannot
|
||||||
|
* get rid of is worse than the failure it describes. Clearing is honest here:
|
||||||
|
* an unsynced note is still pending, `hasPending` still says so, and the next
|
||||||
|
* cycle will report the same fault if it is still there.
|
||||||
|
*/
|
||||||
|
fun dismissSyncError() {
|
||||||
|
state = state.copy(syncError = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
onStoreChanged: () -> Unit,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = SyncViewModel(core, onStoreChanged) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a sync actually moved anything, so the board is only reloaded when its
|
||||||
|
* contents can have changed.
|
||||||
|
*
|
||||||
|
* Attachments count: a note whose image finally downloaded renders differently
|
||||||
|
* even though the note row itself is untouched.
|
||||||
|
*/
|
||||||
|
private fun SyncOutcome.changedTheStore(): Boolean =
|
||||||
|
pull.notesApplied > 0uL ||
|
||||||
|
pull.notesDeleted > 0uL ||
|
||||||
|
pull.labelsApplied > 0uL ||
|
||||||
|
pull.labelsDeleted > 0uL ||
|
||||||
|
pull.blobsDownloaded > 0uL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message to show for a failure.
|
||||||
|
*
|
||||||
|
* The core writes these for people to read — "notes.example.com responded, but not
|
||||||
|
* with Inkwell's configuration" — so they are shown as-is rather than
|
||||||
|
* replaced with a generic string that would throw away the only useful part.
|
||||||
|
*/
|
||||||
|
private fun Exception.describe(): String = message ?: "Something went wrong."
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
|
||||||
|
/** The swatch shown beside a tag, and tapped to change its colour. */
|
||||||
|
private val SWATCH = 22.dp
|
||||||
|
|
||||||
|
/** What the row's overflow menu is currently asking about. */
|
||||||
|
private sealed interface TagDialog {
|
||||||
|
data class Rename(
|
||||||
|
val tag: Label,
|
||||||
|
) : TagDialog
|
||||||
|
|
||||||
|
/** A rename whose new name another tag already holds — see [RenameDialog]. */
|
||||||
|
data class ConfirmMerge(
|
||||||
|
val tag: Label,
|
||||||
|
val into: Label,
|
||||||
|
val name: String,
|
||||||
|
) : TagDialog
|
||||||
|
|
||||||
|
data class Merge(
|
||||||
|
val tag: Label,
|
||||||
|
) : TagDialog
|
||||||
|
|
||||||
|
data class Delete(
|
||||||
|
val tag: Label,
|
||||||
|
) : TagDialog
|
||||||
|
|
||||||
|
data class Colour(
|
||||||
|
val tag: Label,
|
||||||
|
) : TagDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag management: list, create, rename, recolour, delete, merge.
|
||||||
|
*
|
||||||
|
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
|
||||||
|
* because a desktop has room to float one over the board; on a phone this is a
|
||||||
|
* place you visit to tidy up, and a full screen is what that is.
|
||||||
|
*
|
||||||
|
* It is also, since the per-note colour picker was removed, the ONLY colour
|
||||||
|
* control in the product. That is why the swatch is a first-class tap target on
|
||||||
|
* every row rather than something behind the overflow menu.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TagsScreen(
|
||||||
|
state: TagsState,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onCreate: (String) -> Unit,
|
||||||
|
onRename: (String, String) -> Unit,
|
||||||
|
onColour: (String, String) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
onMerge: (String, String) -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
) {
|
||||||
|
var dialog by remember { mutableStateOf<TagDialog?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.tags_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.tags_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
// An indeterminate bar rather than blocking the list: a tag write is a
|
||||||
|
// local SQLite call and usually finishes before this is seen at all.
|
||||||
|
if (state.busy) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same banner the board and the editor use. A third way of saying
|
||||||
|
// "that did not work" would be a third thing to keep consistent.
|
||||||
|
state.error?.let { message ->
|
||||||
|
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||||
|
}
|
||||||
|
|
||||||
|
NewTagField(
|
||||||
|
enabled = !state.busy,
|
||||||
|
onCreate = onCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!state.loading && state.tags.isEmpty()) {
|
||||||
|
EmptyTags()
|
||||||
|
}
|
||||||
|
|
||||||
|
// weight, NOT fillMaxSize: this has siblings above it, and filling the
|
||||||
|
// whole height would measure the list against space the field and the
|
||||||
|
// banner have already taken — pushing the end of the list off-screen.
|
||||||
|
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||||
|
items(state.tags, key = { it.id }) { tag ->
|
||||||
|
TagRow(
|
||||||
|
tag = tag,
|
||||||
|
enabled = !state.busy,
|
||||||
|
onColour = { dialog = TagDialog.Colour(tag) },
|
||||||
|
onRename = { dialog = TagDialog.Rename(tag) },
|
||||||
|
onMerge = { dialog = TagDialog.Merge(tag) },
|
||||||
|
onDelete = { dialog = TagDialog.Delete(tag) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val open = dialog) {
|
||||||
|
null -> Unit
|
||||||
|
|
||||||
|
is TagDialog.Rename ->
|
||||||
|
RenameDialog(
|
||||||
|
tag = open.tag,
|
||||||
|
others = state.tags,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onRename = { name ->
|
||||||
|
dialog = null
|
||||||
|
onRename(open.tag.id, name)
|
||||||
|
},
|
||||||
|
// Renaming onto a name another tag holds MERGES the two, and that
|
||||||
|
// cannot be undone by repeating it, so the confirmation replaces
|
||||||
|
// this dialog rather than the rename just happening.
|
||||||
|
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
|
||||||
|
)
|
||||||
|
|
||||||
|
is TagDialog.ConfirmMerge ->
|
||||||
|
ConfirmDialog(
|
||||||
|
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
|
||||||
|
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
|
||||||
|
confirm = stringResource(R.string.tags_rename_merges_confirm),
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onConfirm = {
|
||||||
|
dialog = null
|
||||||
|
onRename(open.tag.id, open.name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
is TagDialog.Merge ->
|
||||||
|
MergeDialog(
|
||||||
|
tag = open.tag,
|
||||||
|
others = state.tags.filter { it.id != open.tag.id },
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onMerge = { target ->
|
||||||
|
dialog = null
|
||||||
|
onMerge(open.tag.id, target.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
is TagDialog.Delete ->
|
||||||
|
ConfirmDialog(
|
||||||
|
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
|
||||||
|
// The count is the part that makes the consequence real — "it is on
|
||||||
|
// 40 notes" is a different decision from "delete this tag?". It comes
|
||||||
|
// from the LIST, the only call the core populates a count on.
|
||||||
|
body =
|
||||||
|
open.tag.count
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { stringResource(R.string.tags_delete_body_counted, it) }
|
||||||
|
?: stringResource(R.string.tags_delete_body),
|
||||||
|
footnote = stringResource(R.string.tags_delete_from_text),
|
||||||
|
confirm = stringResource(R.string.tags_delete_confirm),
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onConfirm = {
|
||||||
|
dialog = null
|
||||||
|
onDelete(open.tag.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
is TagDialog.Colour ->
|
||||||
|
ColourDialog(
|
||||||
|
tag = open.tag,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onPick = { key ->
|
||||||
|
dialog = null
|
||||||
|
onColour(open.tag.id, key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `#` on the name, everywhere it is spoken about.
|
||||||
|
*
|
||||||
|
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
|
||||||
|
* reason these are called tags at all — a dialog that said "Delete grocery?" would
|
||||||
|
* be talking about something else.
|
||||||
|
*/
|
||||||
|
private fun hash(name: String): String = "#$name"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NewTagField(
|
||||||
|
enabled: Boolean,
|
||||||
|
onCreate: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
val submit = {
|
||||||
|
if (text.isNotBlank()) {
|
||||||
|
onCreate(text)
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
PlainTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
hint = R.string.tags_new_hint,
|
||||||
|
enabled = enabled,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||||
|
)
|
||||||
|
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
|
||||||
|
Text(stringResource(R.string.tags_create))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Said out loud rather than left as a blank screen — and it names the `#` route,
|
||||||
|
* because the operator did not know `#tag` extraction existed at all (Scribe
|
||||||
|
* #2949) and this is the natural place to say so.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EmptyTags() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tags_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tags_empty_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TagRow(
|
||||||
|
tag: Label,
|
||||||
|
enabled: Boolean,
|
||||||
|
onColour: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onMerge: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = labelTintFor(tag.name, tag.color)
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(SWATCH)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||||
|
.clickable(enabled = enabled, onClick = onColour),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = hash(tag.name),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = tint.tagInk(dark),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = countLabel(tag.count),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.tags_actions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||||
|
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
|
||||||
|
// these raises a dialog, and it closes the menu BEFORE acting so the
|
||||||
|
// dialog cannot open underneath a menu still hanging over it.
|
||||||
|
val close = { menuOpen = false }
|
||||||
|
MenuItem(R.string.tags_rename, close, onRename)
|
||||||
|
MenuItem(R.string.tags_merge, close, onMerge)
|
||||||
|
MenuItem(R.string.tags_delete, close, onDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero is its own sentence, not "0 notes".
|
||||||
|
*
|
||||||
|
* A count of null means the core did not populate one — only `list_labels` does —
|
||||||
|
* which is a different thing from a tag with no notes, so it reads as unknown
|
||||||
|
* rather than as empty.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun countLabel(count: Long?): String =
|
||||||
|
when {
|
||||||
|
count == null -> ""
|
||||||
|
count <= 0L -> stringResource(R.string.tags_count_none)
|
||||||
|
count == 1L -> stringResource(R.string.tags_count_one)
|
||||||
|
else -> stringResource(R.string.tags_count, count.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename, with the merge caught before it happens.
|
||||||
|
*
|
||||||
|
* The collision is detected HERE, against the list, rather than from what the
|
||||||
|
* core returns: the merge survivor is whichever tag is older, so it may well be
|
||||||
|
* the one being renamed, and an unchanged id afterwards would prove nothing.
|
||||||
|
* Matching is case-insensitive because the core's is.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RenameDialog(
|
||||||
|
tag: Label,
|
||||||
|
others: List<Label>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRename: (String) -> Unit,
|
||||||
|
onWouldMerge: (Label, String) -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember(tag.id) { mutableStateOf(tag.name) }
|
||||||
|
val trimmed = text.trim()
|
||||||
|
val clash =
|
||||||
|
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
|
||||||
|
val submit = {
|
||||||
|
when {
|
||||||
|
trimmed.isEmpty() -> Unit
|
||||||
|
clash != null -> onWouldMerge(clash, trimmed)
|
||||||
|
else -> onRename(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
|
||||||
|
text = {
|
||||||
|
PlainTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
hint = R.string.tags_new_hint,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
|
||||||
|
Text(stringResource(R.string.tags_rename_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge, with the direction stated and the survivor named.
|
||||||
|
*
|
||||||
|
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
|
||||||
|
* depend on which way round it was typed — this one is deliberate, so the
|
||||||
|
* direction the person chooses IS the intent and is honoured. The price of that
|
||||||
|
* is that the direction has to be unmissable, which is why the body names the tag
|
||||||
|
* that stops existing and every row here is the one that survives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MergeDialog(
|
||||||
|
tag: Label,
|
||||||
|
others: List<Label>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onMerge: (Label) -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
if (others.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tags_merge_none),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
others.forEach { other ->
|
||||||
|
val tint = labelTintFor(other.name, other.color)
|
||||||
|
Text(
|
||||||
|
text = hash(other.name),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = tint.tagInk(dark),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onMerge(other) }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette, and since the per-note picker was removed this is the only place in
|
||||||
|
* the product a colour is chosen.
|
||||||
|
*
|
||||||
|
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
|
||||||
|
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
|
||||||
|
* means "let it pick" rather than "grey", and taking it away would leave no way
|
||||||
|
* back to that.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ColourDialog(
|
||||||
|
tag: Label,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onPick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
NOTE_TINTS.forEach { (key, tint) ->
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onPick(key) }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(SWATCH)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.border(1.dp, tint.chipBorder(dark), CircleShape),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = tint.label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color =
|
||||||
|
if (key == tag.color) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A destructive confirmation: what it is, what it costs, and one way out. */
|
||||||
|
@Composable
|
||||||
|
private fun ConfirmDialog(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
confirm: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
footnote: String? = null,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
footnote?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) { Text(confirm) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the Tags screen renders from.
|
||||||
|
*
|
||||||
|
* [tags] comes from `list_labels`, which is the only call that populates a
|
||||||
|
* `count` — the single-tag returns leave it null by design. So the counts a
|
||||||
|
* confirmation dialog quotes are always the LIST's, never an operation's result.
|
||||||
|
*/
|
||||||
|
data class TagsState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val tags: List<Label> = emptyList(),
|
||||||
|
/** An in-flight write, for disabling the controls that would race it. */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create, rename, recolour, delete and merge tags.
|
||||||
|
*
|
||||||
|
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
|
||||||
|
* operations over the same core the desktop uses.
|
||||||
|
*
|
||||||
|
* ## Why every write re-lists
|
||||||
|
*
|
||||||
|
* A tag operation changes more than the row it names. A merge deletes one tag and
|
||||||
|
* moves its notes; a delete changes nothing else's count but removes a drawer
|
||||||
|
* lens; a rename can MERGE (see below) and so can make a different row vanish.
|
||||||
|
* Re-listing after each write costs one cheap local SQLite read and removes a
|
||||||
|
* whole class of "the screen thinks there are still two" bugs. Patching the list
|
||||||
|
* in place would mean re-deriving, in Kotlin, rules the core already owns.
|
||||||
|
*
|
||||||
|
* ## Renaming can merge
|
||||||
|
*
|
||||||
|
* `rename_label` folds two tags together when the new name is one another tag
|
||||||
|
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
|
||||||
|
* tag whose id is not the one passed in, and it can make another tag stop
|
||||||
|
* existing. The screen asks first; this view model does not, because a
|
||||||
|
* confirmation belongs to the surface with a person in front of it.
|
||||||
|
*
|
||||||
|
* ## Threading
|
||||||
|
*
|
||||||
|
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
|
||||||
|
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
|
||||||
|
* the core marks the rows dirty and the next sync carries them.
|
||||||
|
*/
|
||||||
|
class TagsViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/**
|
||||||
|
* Called after any write that landed.
|
||||||
|
*
|
||||||
|
* The board holds its own snapshot of the tag list for the drawer, and its
|
||||||
|
* current destination may BE one of these tags — deleting or merging that one
|
||||||
|
* leaves it looking at a lens that no longer exists. Wiring the two together
|
||||||
|
* explicitly is less magic than a shared event bus and makes the dependency
|
||||||
|
* visible at the construction site, the same way [SyncViewModel] does it.
|
||||||
|
*/
|
||||||
|
private val onStoreChanged: () -> Unit,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(TagsState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||||
|
.fold(
|
||||||
|
onSuccess = { state.copy(tags = it, loading = false, error = null) },
|
||||||
|
// Unlike the drawer, this screen cannot fail quietly: it is
|
||||||
|
// the only thing on the display, and an empty list here
|
||||||
|
// would read as "you have no tags" rather than "I couldn't
|
||||||
|
// look".
|
||||||
|
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(name: String) {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
// Find-or-create in the core: typing a name that exists in another case
|
||||||
|
// attaches the existing tag rather than minting a near-duplicate.
|
||||||
|
write { it.createLabel(trimmed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rename(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
write { it.renameLabel(id, trimmed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setColour(
|
||||||
|
id: String,
|
||||||
|
colour: String,
|
||||||
|
) = write { it.setLabelColor(id, colour) }
|
||||||
|
|
||||||
|
fun remove(id: String) = write { it.removeLabel(id) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold [sourceId] into [targetId]. The source stops existing.
|
||||||
|
*
|
||||||
|
* Directional and not undone by repeating it — the caller has to have said
|
||||||
|
* which one survives before this runs, because afterwards there is nothing
|
||||||
|
* left to read the direction from.
|
||||||
|
*/
|
||||||
|
fun merge(
|
||||||
|
sourceId: String,
|
||||||
|
targetId: String,
|
||||||
|
) {
|
||||||
|
if (sourceId == targetId) return
|
||||||
|
write { it.mergeLabels(sourceId, targetId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
state = state.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one store write, then re-list and tell the board.
|
||||||
|
*
|
||||||
|
* `busy` is cleared in the same assignment that stores the result, so no path
|
||||||
|
* out of here can leave the screen stuck with its controls disabled.
|
||||||
|
*/
|
||||||
|
private fun write(block: (Inkwell) -> Unit) {
|
||||||
|
if (state.busy) return
|
||||||
|
state = state.copy(busy = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
val failure =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { block(core) } }
|
||||||
|
.exceptionOrNull()
|
||||||
|
val tags =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||||
|
.getOrDefault(state.tags)
|
||||||
|
state =
|
||||||
|
state.copy(
|
||||||
|
tags = tags,
|
||||||
|
busy = false,
|
||||||
|
error = failure?.describeTagFailure(),
|
||||||
|
)
|
||||||
|
// Even a FAILED write can have changed the store — a merge that threw
|
||||||
|
// partway still moved rows — so the board is told either way.
|
||||||
|
onStoreChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
onStoreChanged: () -> Unit,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core reports problems as one error type carrying a message meant to be
|
||||||
|
* shown, so the message is used when there is one.
|
||||||
|
*/
|
||||||
|
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// The brand colour: the same #F5C518 the web app's manifest, its <meta
|
||||||
|
// name="theme-color"> and the adaptive launcher icon all use.
|
||||||
|
private val Brand = Color(0xFFF5C518)
|
||||||
|
|
||||||
|
// Neutral surfaces lifted from the web app's palette so the three clients share a
|
||||||
|
// ground, not just an accent. style.css paints neutral-50 in light and neutral-950
|
||||||
|
// in dark, which is also what the desktop window is painted before the webview
|
||||||
|
// draws its first frame.
|
||||||
|
private val Neutral50 = Color(0xFFFAFAFA)
|
||||||
|
private val Neutral200 = Color(0xFFE5E5E5)
|
||||||
|
private val Neutral500 = Color(0xFF737373)
|
||||||
|
private val Neutral700 = Color(0xFF404040)
|
||||||
|
private val Neutral800 = Color(0xFF262626)
|
||||||
|
private val Neutral900 = Color(0xFF171717)
|
||||||
|
private val Neutral950 = Color(0xFF0A0A0A)
|
||||||
|
private val Ink = Color(0xFF1A1A1A)
|
||||||
|
|
||||||
|
private val LightColors =
|
||||||
|
lightColorScheme(
|
||||||
|
primary = Brand,
|
||||||
|
// Black on gold, never white: the brand colour is bright enough that white
|
||||||
|
// text on it fails contrast outright.
|
||||||
|
onPrimary = Ink,
|
||||||
|
primaryContainer = Brand,
|
||||||
|
onPrimaryContainer = Ink,
|
||||||
|
background = Neutral50,
|
||||||
|
onBackground = Neutral900,
|
||||||
|
surface = Neutral50,
|
||||||
|
onSurface = Neutral900,
|
||||||
|
surfaceVariant = Neutral200,
|
||||||
|
onSurfaceVariant = Neutral700,
|
||||||
|
outline = Neutral500,
|
||||||
|
outlineVariant = Neutral200,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors =
|
||||||
|
darkColorScheme(
|
||||||
|
primary = Brand,
|
||||||
|
onPrimary = Ink,
|
||||||
|
primaryContainer = Brand,
|
||||||
|
onPrimaryContainer = Ink,
|
||||||
|
background = Neutral950,
|
||||||
|
onBackground = Neutral50,
|
||||||
|
surface = Neutral950,
|
||||||
|
onSurface = Neutral50,
|
||||||
|
surfaceVariant = Neutral800,
|
||||||
|
onSurfaceVariant = Neutral200,
|
||||||
|
outline = Neutral500,
|
||||||
|
outlineVariant = Neutral700,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material 3 in Inkwell's own colours, following the system light/dark setting.
|
||||||
|
*
|
||||||
|
* DELIBERATELY NOT Material You dynamic colour, which this used until the operator
|
||||||
|
* saw the first build. Dynamic colour is the more Android-native choice and it
|
||||||
|
* makes the app look like a different product on the phone than on the desktop and
|
||||||
|
* the web — on a stock device with no wallpaper it renders as undifferentiated
|
||||||
|
* grey. The three surfaces are peers held to one quality bar, so they share one
|
||||||
|
* identity; taking the wallpaper's palette instead would throw that away for
|
||||||
|
* platform convention.
|
||||||
|
*
|
||||||
|
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InkwellTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
// The timestamp seam between the core and the phone.
|
||||||
|
//
|
||||||
|
// The core stores and syncs RFC3339 in UTC, because that is what SQLite holds and
|
||||||
|
// what the server speaks. Deciding how a human should READ an instant is the UI's
|
||||||
|
// job and the answer differs per device, so the conversion lives here — once,
|
||||||
|
// rather than in the card and the editor separately, where the two would
|
||||||
|
// eventually format the same reminder differently.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly the shape the core writes: UTC, milliseconds, `Z`.
|
||||||
|
*
|
||||||
|
* `Instant.toString()` would also be valid RFC3339, but it varies its precision
|
||||||
|
* with the value — it drops the fractional part on a whole second. Matching the
|
||||||
|
* core's `to_rfc3339_opts(Millis, true)` byte for byte means a reminder set on the
|
||||||
|
* phone is indistinguishable from one set on the desktop, including to anything
|
||||||
|
* downstream that compares the strings rather than parsing them.
|
||||||
|
*/
|
||||||
|
private val RFC3339_UTC: DateTimeFormatter =
|
||||||
|
DateTimeFormatter
|
||||||
|
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
|
||||||
|
.withZone(ZoneId.of("UTC"))
|
||||||
|
|
||||||
|
/** A local wall-clock time, as the instant the core will store. */
|
||||||
|
fun rfc3339(local: LocalDateTime): String = RFC3339_UTC.format(local.atZone(ZoneId.systemDefault()).toInstant())
|
||||||
|
|
||||||
|
/** An instant from the core, as this device's local wall-clock time. */
|
||||||
|
fun localTime(raw: String): LocalDateTime? =
|
||||||
|
runCatching {
|
||||||
|
OffsetDateTime.parse(raw).atZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stored instant in the device's own locale and zone.
|
||||||
|
*
|
||||||
|
* Used for reminders and for "last synced" — anywhere the core hands the UI an
|
||||||
|
* RFC3339 string and a person has to read it.
|
||||||
|
*
|
||||||
|
* A string we cannot parse is shown verbatim rather than swallowed: a visibly odd
|
||||||
|
* reminder beats a silently missing one, and the raw value is what someone would
|
||||||
|
* need in order to report it.
|
||||||
|
*/
|
||||||
|
fun formatInstant(raw: String): String =
|
||||||
|
localTime(raw)
|
||||||
|
?.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))
|
||||||
|
?: raw
|
||||||
|
|
||||||
|
/** The reminder as one line, with its repeat rule if it has one. */
|
||||||
|
fun reminderLabel(
|
||||||
|
raw: String,
|
||||||
|
recurrence: String?,
|
||||||
|
): String =
|
||||||
|
buildString {
|
||||||
|
append("⏰ ")
|
||||||
|
append(formatInstant(raw))
|
||||||
|
if (!recurrence.isNullOrBlank()) {
|
||||||
|
append(" · ↻ ")
|
||||||
|
append(recurrence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stored instant as epoch milliseconds, or null if it will not parse. */
|
||||||
|
fun epochMillis(raw: String): Long? = runCatching { OffsetDateTime.parse(raw).toInstant().toEpochMilli() }.getOrNull()
|
||||||
|
|
||||||
|
/** Whether a stored reminder has already passed, for showing it as overdue. */
|
||||||
|
fun isPast(raw: String): Boolean {
|
||||||
|
val at = epochMillis(raw) ?: return false
|
||||||
|
return at < System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a timestamp is older than [minutes] ago — or absent entirely.
|
||||||
|
*
|
||||||
|
* Null reads as stale, which is the answer that matters at the one call site:
|
||||||
|
* a device that has never completed a sync has the most to gain from one.
|
||||||
|
* Unparseable reads as stale too, for the same reason — guessing "recent" from a
|
||||||
|
* value we could not understand would suppress the sync that might fix it.
|
||||||
|
*/
|
||||||
|
fun olderThan(
|
||||||
|
raw: String?,
|
||||||
|
minutes: Long,
|
||||||
|
): Boolean {
|
||||||
|
val at = raw?.let { epochMillis(it) }
|
||||||
|
return at == null || at < System.currentTimeMillis() - minutes * MILLIS_PER_MINUTE
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000L
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.AppUpdate
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.UpdateOutcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updating the app from the server it is linked to.
|
||||||
|
*
|
||||||
|
* Lives on the sync screen because that is what it IS — the server hands out the
|
||||||
|
* client as well as the notes. Putting it in a settings screen of its own would
|
||||||
|
* separate two halves of one relationship.
|
||||||
|
*
|
||||||
|
* Nothing here appears on an unlinked device; [UnlinkedUpdateNote] says why in one
|
||||||
|
* line instead, so the absence reads as a consequence of not being linked rather
|
||||||
|
* than as a missing feature.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateCard(
|
||||||
|
state: UpdateState,
|
||||||
|
onCheck: () -> Unit,
|
||||||
|
onInstall: () -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
onOutcome: (UpdateOutcome.Result) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// The system answers an install through a BroadcastReceiver, which has no way
|
||||||
|
// back into a view model. This is the seam.
|
||||||
|
UpdateOutcome.latest?.let { result ->
|
||||||
|
LaunchedEffect(result) { onOutcome(result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.update_installed_version, state.installedVersion),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
val available = state.available
|
||||||
|
if (available != null) {
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.update_available,
|
||||||
|
available.version,
|
||||||
|
available.size / BYTES_PER_MB,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
} else if (state.upToDate) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.update_current),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.working) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (available == null) {
|
||||||
|
TextButton(onClick = onCheck, enabled = !state.busy) {
|
||||||
|
Text(stringResource(R.string.update_check))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(onClick = onInstall, enabled = !state.busy) {
|
||||||
|
Text(stringResource(R.string.update_install))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android's "install unknown apps" grant is separate from anything in the
|
||||||
|
// manifest and only the person can give it. Said BEFORE a download rather
|
||||||
|
// than after, so nobody spends 55 MiB to be told no.
|
||||||
|
if (available != null && !AppUpdate.canInstall(context)) {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.WARN,
|
||||||
|
title = stringResource(R.string.update_permission_title),
|
||||||
|
body = stringResource(R.string.update_permission_body),
|
||||||
|
actionLabel = stringResource(R.string.update_permission_action),
|
||||||
|
onAction = { context.startActivity(AppUpdate.installPermissionSettings(context)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let {
|
||||||
|
Notice(
|
||||||
|
tone = Tone.ERROR,
|
||||||
|
title = stringResource(R.string.update_failed_title),
|
||||||
|
body = it,
|
||||||
|
onDismiss = onDismissError,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nag: an update is waiting, said where someone will actually see it.
|
||||||
|
*
|
||||||
|
* Until this existed the only way to learn about a new build was to open the sync
|
||||||
|
* screen and press Check — so the updates that got installed were the ones somebody
|
||||||
|
* went looking for, and the rest were simply never found.
|
||||||
|
*
|
||||||
|
* Only ever shown once the build is DOWNLOADED, so the offer is a single tap rather
|
||||||
|
* than the start of a wait — and so nothing is said at all until the app has been on
|
||||||
|
* wifi, which is where the fetch happens.
|
||||||
|
*
|
||||||
|
* Dismissible, but not permanently. "Later" clears it for this sitting; the next time
|
||||||
|
* the app comes forward it says so again. That is the difference between a reminder
|
||||||
|
* and a notice you can lose.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateBanner(
|
||||||
|
version: String,
|
||||||
|
busy: Boolean,
|
||||||
|
onInstall: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint("blue")
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(BANNER_RADIUS))
|
||||||
|
.background(tint.background(dark))
|
||||||
|
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
|
||||||
|
.padding(start = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.update_banner_ready, version),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (busy) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(BANNER_SPINNER), strokeWidth = 2.dp)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.update_later)) }
|
||||||
|
TextButton(onClick = onInstall) { Text(stringResource(R.string.update_install)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val BANNER_RADIUS = 12.dp
|
||||||
|
private val BANNER_SPINNER = 18.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line an unlinked device gets.
|
||||||
|
*
|
||||||
|
* Updates arrive from a linked server, so there is genuinely nothing to offer
|
||||||
|
* here — and a Check button that always found nothing would be worse than saying
|
||||||
|
* so.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UnlinkedUpdateNote() {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.update_needs_server),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
// Carries the bottom breathing room the connect button used to provide,
|
||||||
|
// now that it is the last thing on the unlinked screen.
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BYTES_PER_MB = 1024 * 1024
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.inkwell.AppUpdate
|
||||||
|
import com.fabledsword.inkwell.UpdateOutcome
|
||||||
|
import com.fabledsword.inkwell.core.ClientUpdate
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Everything the update card renders from. */
|
||||||
|
data class UpdateState(
|
||||||
|
/** What is running now. Shown even when there is nothing to update to. */
|
||||||
|
val installedVersion: Long = 0,
|
||||||
|
val checking: Boolean = false,
|
||||||
|
/** Only ever set to something NEWER — the core does that comparison. */
|
||||||
|
val available: ClientUpdate? = null,
|
||||||
|
/** A check completed and found nothing. Distinct from "not checked yet". */
|
||||||
|
val upToDate: Boolean = false,
|
||||||
|
/** The available build has been fetched and is sitting in the cache. */
|
||||||
|
val ready: Boolean = false,
|
||||||
|
val downloading: Boolean = false,
|
||||||
|
val working: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
/** The banner has been waved away — until the app next comes forward. */
|
||||||
|
val nagDismissed: Boolean = false,
|
||||||
|
) {
|
||||||
|
val busy: Boolean get() = checking || downloading || working
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worth interrupting the board for.
|
||||||
|
*
|
||||||
|
* Gated on [ready], so the banner never appears until the bytes are on disk. An
|
||||||
|
* update that has been FOUND is not news anyone can act on quickly — offering it
|
||||||
|
* off wifi would turn one tap into a download somebody did not plan.
|
||||||
|
*
|
||||||
|
* Deliberately still true while [working]: the install is the one moment the
|
||||||
|
* banner has something to report, and hiding it there would look like the tap
|
||||||
|
* did nothing.
|
||||||
|
*/
|
||||||
|
val nagging: Boolean get() = ready && available != null && !nagDismissed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updating the app from the server it syncs with.
|
||||||
|
*
|
||||||
|
* **Linked-only, and said out loud.** The app is local-first and completely usable
|
||||||
|
* having never touched a server, so an unlinked install has no update path at all.
|
||||||
|
* The card says that rather than offering a Check button that silently finds
|
||||||
|
* nothing — the same lesson as the desktop's unlink copy (issue 2110).
|
||||||
|
*
|
||||||
|
* The core does the network work, not this class: the device token lives in the
|
||||||
|
* Rust store and pulling it into Kotlin to make an HTTP call would spread the one
|
||||||
|
* secret this app holds across two languages for no gain.
|
||||||
|
*/
|
||||||
|
class UpdateViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/**
|
||||||
|
* MUST be the application context — it outlives this view model, and holding an
|
||||||
|
* Activity here is the textbook way to leak a window.
|
||||||
|
*/
|
||||||
|
private val context: Context,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** When the last check ran, so coming back to the app twice in a minute is one. */
|
||||||
|
private var lastCheckAt = 0L
|
||||||
|
|
||||||
|
/** Ask the linked server what it has. The Check button on the sync screen. */
|
||||||
|
fun check() = runCheck(fetch = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The automatic path: look, fetch, then nag.
|
||||||
|
*
|
||||||
|
* Called when the app comes forward. Until this existed an update was only ever
|
||||||
|
* found by someone opening the sync screen and pressing a button — so the ones
|
||||||
|
* that mattered were the ones nobody went looking for.
|
||||||
|
*
|
||||||
|
* Skipped when a check is already in flight, when a build is already waiting, and
|
||||||
|
* when one ran recently: flicking between two apps is not a request to re-check.
|
||||||
|
*/
|
||||||
|
fun checkInBackground() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
when {
|
||||||
|
state.busy -> Unit
|
||||||
|
|
||||||
|
// Already fetched and waved away — say so again. "Later" is for that
|
||||||
|
// sitting, not forever, and without this branch a single dismissal would
|
||||||
|
// silence the update permanently. Which is precisely the "lost" this whole
|
||||||
|
// path exists to prevent.
|
||||||
|
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
|
||||||
|
|
||||||
|
// Found one and never fetched it — almost always because the last look
|
||||||
|
// happened on mobile data. Retry the FETCH rather than the check, and
|
||||||
|
// ignore the interval: this is what makes an update found on the train
|
||||||
|
// arrive when the person gets home instead of waiting out six hours.
|
||||||
|
state.available != null ->
|
||||||
|
if (AppUpdate.onWifi(context)) viewModelScope.launch { download() }
|
||||||
|
|
||||||
|
// Flicking between two apps is not a request to re-check.
|
||||||
|
now - lastCheckAt < CHECK_INTERVAL_MS -> Unit
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
lastCheckAt = now
|
||||||
|
runCheck(fetch = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runCheck(fetch: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(checking = true, error = null, upToDate = false)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val found = core.clientUpdate(state.installedVersion)
|
||||||
|
state.copy(
|
||||||
|
checking = false,
|
||||||
|
available = found,
|
||||||
|
upToDate = found == null,
|
||||||
|
// A build that is still there is worth mentioning again. The
|
||||||
|
// dismissal was for that sitting, not for this version.
|
||||||
|
nagDismissed = if (found == null) state.nagDismissed else false,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Broad by intent, as everywhere the core is called: it reports
|
||||||
|
// every failure as one error type carrying a message written to
|
||||||
|
// be read, and a failed check must not take the screen down.
|
||||||
|
state.copy(checking = false, error = e.message ?: FALLBACK)
|
||||||
|
}
|
||||||
|
// Fetched before anything is said, so the banner is a one-tap install
|
||||||
|
// rather than the start of a wait. Off wifi this simply does not happen
|
||||||
|
// and the app stays quiet — the next foreground on wifi picks it up.
|
||||||
|
if (fetch && state.available != null && AppUpdate.onWifi(context)) {
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the waiting build into the cache, leaving it for [install]. */
|
||||||
|
private suspend fun download() {
|
||||||
|
state = state.copy(downloading = true, error = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
|
||||||
|
state.copy(downloading = false, ready = true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop nagging for this sitting. The next trip to the foreground says it again. */
|
||||||
|
fun dismissNag() {
|
||||||
|
state = state.copy(nagDismissed = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the update to the system installer, downloading first if it is not already
|
||||||
|
* in the cache.
|
||||||
|
*
|
||||||
|
* Still one action from the outside. A downloaded APK is not a state anyone wants
|
||||||
|
* to think about, so whether the fetch already happened in the background is this
|
||||||
|
* class's problem rather than the person's.
|
||||||
|
*/
|
||||||
|
fun downloadAndInstall() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(working = true, error = null)
|
||||||
|
UpdateOutcome.clear()
|
||||||
|
val failure =
|
||||||
|
try {
|
||||||
|
val target = AppUpdate.downloadTarget(context)
|
||||||
|
if (!state.ready) core.downloadClientUpdate(target.absolutePath)
|
||||||
|
// Off the main thread: this streams ~55 MiB into the session.
|
||||||
|
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.message ?: FALLBACK
|
||||||
|
}
|
||||||
|
// `working` stays TRUE on success: the install is still in flight, and
|
||||||
|
// on a silent update this process is about to be replaced. Clearing it
|
||||||
|
// here would flash "ready" a moment before the app disappears.
|
||||||
|
state =
|
||||||
|
if (failure == null) state else state.copy(working = false, error = failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take whatever the system finally said about the install.
|
||||||
|
*
|
||||||
|
* Called from the composition, because the answer arrives at a BroadcastReceiver
|
||||||
|
* the system owns and there is no other way back into this class.
|
||||||
|
*/
|
||||||
|
fun consumeInstallOutcome(result: UpdateOutcome.Result) {
|
||||||
|
UpdateOutcome.clear()
|
||||||
|
state = state.copy(working = false, error = result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
state = state.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
context: Context,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
UpdateViewModel(core, context.applicationContext) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FALLBACK = "The update couldn't be checked."
|
||||||
|
private const val FALLBACK_DOWNLOAD = "The update couldn't be downloaded."
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a background check stays good for.
|
||||||
|
*
|
||||||
|
* Long enough that switching to another app and back is not a re-check; short enough
|
||||||
|
* that a build published this morning is offered today. The same reasoning as sync's
|
||||||
|
* STALE_MINUTES, at a slower cadence — an app update is not urgent, it is just
|
||||||
|
* something that must not get lost.
|
||||||
|
*/
|
||||||
|
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The Material paperclip ("attach_file"), for the editor's Attach button and the
|
||||||
|
file rows under a note.
|
||||||
|
|
||||||
|
A drawable rather than an Icons.* constant because material-icons-core does not
|
||||||
|
carry it, and the extended set is a multi-megabyte dependency for one glyph.
|
||||||
|
Tinted by whatever draws it, like every other icon in the toolbar.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The status-bar icon for a reminder.
|
||||||
|
|
||||||
|
A flat white silhouette on transparency, because that is the only thing Android
|
||||||
|
renders here — a status-bar icon is used as a MASK, so the launcher icon (which
|
||||||
|
is a full-colour adaptive asset) would come out as a solid white blob. This is
|
||||||
|
the Material bell, matching the icon the editor's reminder button already uses,
|
||||||
|
so the same idea wears the same shape in both places.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
||||||
|
no legacy raster fallback is needed.
|
||||||
|
|
||||||
|
The foreground is the inkwell mark alone on transparency, inside the 66dp safe
|
||||||
|
circle; the yellow is the background colour. It is rendered by
|
||||||
|
packaging/icons.py from the same drawing as the web and desktop icons, so all
|
||||||
|
three wear one face. Transparent rather than a full-bleed tile because the
|
||||||
|
monochrome layer below reuses it: a themed icon draws its alpha as a silhouette,
|
||||||
|
and an opaque foreground would come out as a solid square.
|
||||||
|
-->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
||||||
|
no legacy raster fallback is needed.
|
||||||
|
|
||||||
|
The foreground is the shared maskable asset the web app already ships
|
||||||
|
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
|
||||||
|
padding adaptive icons require. Reusing it means the phone, the web app and the
|
||||||
|
desktop all wear the same face rather than three near-misses.
|
||||||
|
-->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- The product's brand colour, same value the web app's manifest and
|
||||||
|
<meta name="theme-color"> already use. One source of truth for "what
|
||||||
|
colour is Inkwell" across the three surfaces. -->
|
||||||
|
<color name="ic_launcher_background">#F5C518</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Inkwell</string>
|
||||||
|
|
||||||
|
<!-- Search bar -->
|
||||||
|
<string name="search_hint">Search your notes</string>
|
||||||
|
<string name="search_clear">Clear search</string>
|
||||||
|
<string name="nav_open">Open navigation</string>
|
||||||
|
<string name="nav_labels">Tags</string>
|
||||||
|
|
||||||
|
<!-- Compose sheet -->
|
||||||
|
<string name="compose_open">New note</string>
|
||||||
|
|
||||||
|
<!-- Board -->
|
||||||
|
<string name="board_empty_note">Empty note</string>
|
||||||
|
|
||||||
|
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
|
||||||
|
note has one vocabulary of things you can do to it, and a board that said
|
||||||
|
"Delete" where the editor says "Move to trash" would be describing two
|
||||||
|
different apps. Only the wrapper and the undo need words of their own. -->
|
||||||
|
<string name="board_note_actions">Note actions</string>
|
||||||
|
<string name="board_trashed">Moved to trash</string>
|
||||||
|
<string name="board_undo">Undo</string>
|
||||||
|
|
||||||
|
<!-- Empty states. Each destination says something true of ITSELF; a single
|
||||||
|
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
||||||
|
<string name="board_empty_title">Nothing here yet</string>
|
||||||
|
<string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string>
|
||||||
|
<string name="empty_search_title">No matches</string>
|
||||||
|
<string name="empty_search_body">Nothing matched “%1$s”.</string>
|
||||||
|
<string name="empty_filtered_title">No matches</string>
|
||||||
|
<string name="empty_filtered_body">No notes match these filters.</string>
|
||||||
|
<string name="filters">Filters</string>
|
||||||
|
<string name="filters_count">Filters · %1$d</string>
|
||||||
|
<string name="filters_clear">Clear</string>
|
||||||
|
<string name="filters_show">Show only</string>
|
||||||
|
<string name="filters_has_attachment">Has attachment</string>
|
||||||
|
<string name="filters_shared_with_me">Shared with me</string>
|
||||||
|
<string name="filters_tags">Tagged with all of</string>
|
||||||
|
<string name="empty_trash_title">Trash is empty</string>
|
||||||
|
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string>
|
||||||
|
<string name="empty_archive_title">Nothing archived</string>
|
||||||
|
<string name="empty_archive_body">Archived notes leave the board but stay searchable.</string>
|
||||||
|
<string name="empty_reminders_title">No reminders</string>
|
||||||
|
<string name="empty_reminders_body">Notes with a reminder set will appear here.</string>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<string name="board_open_note">Open note</string>
|
||||||
|
<string name="editor_back">Back to notes</string>
|
||||||
|
<string name="editor_add_checklist">Add a checklist</string>
|
||||||
|
<string name="editor_body_hint">Take a note…</string>
|
||||||
|
<string name="editor_remove_item">Remove item</string>
|
||||||
|
<string name="editor_remove_label">Remove tag</string>
|
||||||
|
<string name="editor_reminder">Set a reminder</string>
|
||||||
|
<string name="editor_more">More actions</string>
|
||||||
|
<string name="editor_saving">Saving…</string>
|
||||||
|
<string name="editor_unsaved">Not saved yet</string>
|
||||||
|
<string name="editor_edited">Edited %1$s</string>
|
||||||
|
<string name="editor_just_now">just now</string>
|
||||||
|
<string name="editor_done">Done</string>
|
||||||
|
<string name="editor_pin">Pin</string>
|
||||||
|
<string name="editor_unpin">Unpin</string>
|
||||||
|
<string name="editor_labels">Tags…</string>
|
||||||
|
<string name="editor_archive">Archive</string>
|
||||||
|
<string name="editor_unarchive">Unarchive</string>
|
||||||
|
<string name="editor_trash">Move to trash</string>
|
||||||
|
<string name="editor_restore">Restore</string>
|
||||||
|
<string name="editor_cancel">Cancel</string>
|
||||||
|
<string name="editor_share">Share…</string>
|
||||||
|
|
||||||
|
<!-- Sharing (#5175). Shares live on the server, so the sheet says so when this
|
||||||
|
phone isn't linked to one. -->
|
||||||
|
<string name="share_title">Share</string>
|
||||||
|
<string name="share_loading">Loading…</string>
|
||||||
|
<string name="share_needs_server">Sharing is between people on a server. Link this phone in Sync to share notes.</string>
|
||||||
|
<string name="share_none">Not shared with anyone yet.</string>
|
||||||
|
<string name="share_everyone">Shared with everyone on this server.</string>
|
||||||
|
<string name="share_add">Add someone or a group</string>
|
||||||
|
<string name="share_action">Share</string>
|
||||||
|
<string name="share_remove">Stop sharing</string>
|
||||||
|
<string name="share_can_view">Can view</string>
|
||||||
|
<string name="share_can_edit">Can edit</string>
|
||||||
|
<string name="share_someone">someone</string>
|
||||||
|
<string name="share_by_view_only">Shared by %1$s · view only</string>
|
||||||
|
<string name="share_by_can_edit">Shared by %1$s · you can edit the text</string>
|
||||||
|
<string name="share_chip_shared">Shared</string>
|
||||||
|
<string name="share_chip_by">From %1$s</string>
|
||||||
|
<plurals name="share_group_members">
|
||||||
|
<item quantity="one">Group · %d person</item>
|
||||||
|
<item quantity="other">Group · %d people</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<!-- Attachments. A file is stored on the phone at once and uploaded on a later
|
||||||
|
sync, so nothing here talks about the network. -->
|
||||||
|
<string name="editor_attach">Attach a file</string>
|
||||||
|
<string name="attach_remove">Remove attachment</string>
|
||||||
|
<string name="attach_unnamed">Unnamed file</string>
|
||||||
|
<string name="attach_more">+%1$d more</string>
|
||||||
|
<string name="attach_refused">Not uploaded: %1$s</string>
|
||||||
|
<string name="attach_too_large">%1$s is over %2$d MB, too large to attach from the phone.</string>
|
||||||
|
<string name="attach_unreadable">Couldn\'t read %1$s.</string>
|
||||||
|
<string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string>
|
||||||
|
<string name="attach_no_app">No app on this phone opens this kind of file.</string>
|
||||||
|
<string name="attach_open_failed">Couldn\'t open the file: %1$s</string>
|
||||||
|
<string name="preview_remove">Remove link preview</string>
|
||||||
|
|
||||||
|
<!-- Deleting for good is the only thing in the app that cannot be undone, so
|
||||||
|
the copy says exactly that rather than asking "Are you sure?". -->
|
||||||
|
<string name="editor_delete_forever">Delete forever</string>
|
||||||
|
<string name="editor_delete_forever_title">Delete this note?</string>
|
||||||
|
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
|
||||||
|
<string name="editor_delete_forever_confirm">Delete</string>
|
||||||
|
|
||||||
|
<!-- Quick capture from outside the app: the share sheet and the text-selection
|
||||||
|
toolbar. "New note" says what happens; the activity's own label would say
|
||||||
|
who it happens in. -->
|
||||||
|
<string name="capture_process_text">New note</string>
|
||||||
|
|
||||||
|
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
|
||||||
|
schema still says Label, and no string here needs to know that. -->
|
||||||
|
<string name="tags_manage">Manage tags</string>
|
||||||
|
<string name="tags_title">Tags</string>
|
||||||
|
<string name="tags_back">Back</string>
|
||||||
|
<string name="tags_new_hint">New tag</string>
|
||||||
|
<string name="tags_create">Create</string>
|
||||||
|
<string name="tags_count">%1$d notes</string>
|
||||||
|
<string name="tags_count_one">1 note</string>
|
||||||
|
<string name="tags_count_none">No notes yet</string>
|
||||||
|
<string name="tags_empty_title">No tags yet</string>
|
||||||
|
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
|
||||||
|
<string name="tags_actions">More actions</string>
|
||||||
|
<string name="tags_colour">Colour</string>
|
||||||
|
<string name="tags_colour_of">Colour for %1$s</string>
|
||||||
|
|
||||||
|
<string name="tags_rename">Rename</string>
|
||||||
|
<string name="tags_rename_title">Rename %1$s</string>
|
||||||
|
<string name="tags_rename_confirm">Rename</string>
|
||||||
|
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
|
||||||
|
#3324). A merge cannot be undone by repeating it and is reachable here by
|
||||||
|
a typo, so it says so before it happens — same reasoning as #2116. -->
|
||||||
|
<string name="tags_rename_merges_title">Merge with %1$s?</string>
|
||||||
|
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
|
||||||
|
<string name="tags_rename_merges_confirm">Merge</string>
|
||||||
|
|
||||||
|
<string name="tags_merge">Merge into…</string>
|
||||||
|
<string name="tags_merge_title">Merge %1$s into…</string>
|
||||||
|
<!-- The survivor is named in the button, not just the title: this is the one
|
||||||
|
operation here that repeating does not undo. -->
|
||||||
|
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
|
||||||
|
<string name="tags_merge_none">There is no other tag to merge into.</string>
|
||||||
|
|
||||||
|
<string name="tags_delete">Delete</string>
|
||||||
|
<string name="tags_delete_title">Delete %1$s?</string>
|
||||||
|
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
|
||||||
|
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
|
||||||
|
<string name="tags_delete_confirm">Delete</string>
|
||||||
|
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
|
||||||
|
row cannot un-write the word, so it comes back on that note's next edit —
|
||||||
|
said here rather than left as a surprise. -->
|
||||||
|
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
|
||||||
|
|
||||||
|
<string name="tags_cancel">Cancel</string>
|
||||||
|
|
||||||
|
<!-- Pickers -->
|
||||||
|
<string name="label_picker_title">Tags</string>
|
||||||
|
<string name="label_new_hint">Type a tag and press enter</string>
|
||||||
|
<string name="label_from_tag">from the text</string>
|
||||||
|
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||||
|
<string name="picker_next">Next</string>
|
||||||
|
<string name="picker_set">Set</string>
|
||||||
|
<string name="picker_time_title">Pick a time</string>
|
||||||
|
|
||||||
|
<!-- Reminders -->
|
||||||
|
<string name="reminder_title">Remind me</string>
|
||||||
|
<string name="reminder_later_today">Later today</string>
|
||||||
|
<string name="reminder_tomorrow">Tomorrow</string>
|
||||||
|
<string name="reminder_next_week">Next week</string>
|
||||||
|
<string name="reminder_pick">Pick a date & time</string>
|
||||||
|
<string name="reminder_clear">Remove reminder</string>
|
||||||
|
<string name="reminder_done">Done</string>
|
||||||
|
<string name="reminder_snooze_hour">Snooze 1h</string>
|
||||||
|
<string name="reminder_snooze_day">Snooze 1d</string>
|
||||||
|
<string name="recurrence_none">Once</string>
|
||||||
|
<string name="recurrence_daily">Daily</string>
|
||||||
|
<string name="recurrence_weekly">Weekly</string>
|
||||||
|
<string name="recurrence_monthly">Monthly</string>
|
||||||
|
<string name="recurrence_yearly">Yearly</string>
|
||||||
|
|
||||||
|
<!-- Store failure -->
|
||||||
|
<string name="store_unavailable_title">Your notes couldn\'t be opened</string>
|
||||||
|
<string name="store_unavailable_body">The note store on this device could not be read. Reinstalling will start a fresh one, but anything not synced to a server would be lost.</string>
|
||||||
|
|
||||||
|
<!-- Sync. Opt-in, and the copy has to carry that: being unlinked is the
|
||||||
|
normal resting state of a local-first app, not unfinished setup. -->
|
||||||
|
<string name="sync_title">Sync</string>
|
||||||
|
<string name="sync_badge_on">On</string>
|
||||||
|
<string name="sync_badge_unsent">Unsent</string>
|
||||||
|
|
||||||
|
<!-- Linked -->
|
||||||
|
<string name="sync_connected_to">Connected to</string>
|
||||||
|
<string name="sync_linked_as">as %1$s</string>
|
||||||
|
<string name="sync_last_synced">Last synced %1$s</string>
|
||||||
|
<string name="sync_never">never</string>
|
||||||
|
<string name="sync_unsent">This device has changes that haven\'t been sent yet.</string>
|
||||||
|
<string name="sync_now">Sync now</string>
|
||||||
|
<string name="sync_disconnect">Disconnect</string>
|
||||||
|
<string name="sync_disconnect_title">Stop syncing with this server?</string>
|
||||||
|
<string name="sync_disconnect_body">Your notes stay on this device, and the copy on the server is left alone. This device\'s access token is revoked, so it can\'t be used to reach the server again.</string>
|
||||||
|
<string name="reminder_channel">Reminders</string>
|
||||||
|
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
|
||||||
|
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
|
||||||
|
<string name="reminder_notifications_blocked_body">Notifications are turned off for Inkwell, so reminders will only show here on the board.</string>
|
||||||
|
<string name="reminder_open_settings">Open settings</string>
|
||||||
|
<string name="reminder_inexact_title">Reminders may arrive late</string>
|
||||||
|
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
|
||||||
|
<string name="reminder_allow_exact">Allow exact timing</string>
|
||||||
|
<string name="sync_automatic">Sync automatically</string>
|
||||||
|
<string name="sync_automatic_on">Checks about every 15 minutes, and whenever you open the app.</string>
|
||||||
|
<string name="sync_automatic_off">Only when you pull the board down or tap Sync now.</string>
|
||||||
|
<string name="update_installed_version">This app is build %1$d.</string>
|
||||||
|
<string name="update_available">Build %1$s is available (%2$d MB).</string>
|
||||||
|
<string name="update_current">You\'re on the newest build this server has.</string>
|
||||||
|
<string name="update_check">Check for an update</string>
|
||||||
|
<string name="update_install">Update</string>
|
||||||
|
<string name="update_banner_ready">Build %1$s is downloaded and ready.</string>
|
||||||
|
<string name="update_later">Later</string>
|
||||||
|
<string name="update_failed_title">The update didn\'t install</string>
|
||||||
|
<string name="update_permission_title">Android needs your permission</string>
|
||||||
|
<string name="update_permission_body">Inkwell has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
||||||
|
<string name="update_permission_action">Allow installing</string>
|
||||||
|
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
|
||||||
|
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
|
||||||
|
<string name="sync_failed_title">Sync failed</string>
|
||||||
|
<string name="sync_rejected_title">The server wouldn\'t accept some changes</string>
|
||||||
|
<plurals name="sync_rejected_body">
|
||||||
|
<item quantity="one">%1$d change was rejected: %2$s</item>
|
||||||
|
<item quantity="other">%1$d changes were rejected: %2$s</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="sync_degraded_title">Some features aren\'t available here</string>
|
||||||
|
<string name="sync_degraded_body">This server doesn\'t support: %1$s. Everything else syncs normally.</string>
|
||||||
|
|
||||||
|
<!-- Unlinked -->
|
||||||
|
<string name="sync_offline_title">Working offline on this device</string>
|
||||||
|
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect an Inkwell server if you want them to reach your other devices.</string>
|
||||||
|
<string name="sync_address_label">Server address</string>
|
||||||
|
<string name="sync_address_hint">notes.example.com</string>
|
||||||
|
<string name="sync_address_help">Uses https unless you type http:// yourself.</string>
|
||||||
|
<string name="sync_check">Check</string>
|
||||||
|
<string name="sync_probe_failed">Couldn\'t reach that server</string>
|
||||||
|
<string name="sync_link_failed">Couldn\'t connect</string>
|
||||||
|
<string name="sync_server_generic">Inkwell server</string>
|
||||||
|
<string name="sync_server_version">v%1$s</string>
|
||||||
|
<string name="sync_compat_ok">Fully compatible.</string>
|
||||||
|
<string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
|
||||||
|
|
||||||
|
<!-- Shown before any credential field, whenever the probed address is http://.
|
||||||
|
Android blocks cleartext by default and this app allows it so that a
|
||||||
|
self-hosted server on a LAN works at all; this is the other half of
|
||||||
|
that trade. -->
|
||||||
|
<string name="sync_insecure_title">This connection isn\'t encrypted</string>
|
||||||
|
<string name="sync_insecure_body">You\'re about to sign in over plain http. Anyone on the same network can read your password and your notes. Use https unless this is a server you control, on a network you trust.</string>
|
||||||
|
|
||||||
|
<string name="sync_signin">Sign in</string>
|
||||||
|
<string name="sync_mode_password">Email and password</string>
|
||||||
|
<string name="sync_mode_token">Device token</string>
|
||||||
|
<string name="sync_email">Email</string>
|
||||||
|
<string name="sync_password">Password</string>
|
||||||
|
<string name="sync_token">Paste a device token</string>
|
||||||
|
<string name="sync_token_help">Create one in the web app under Account → Linked devices.</string>
|
||||||
|
<string name="sync_device_name">Name for this device</string>
|
||||||
|
<string name="sync_device_name_help">Shown in your account\'s list of linked devices.</string>
|
||||||
|
<string name="sync_connect">Connect and sync</string>
|
||||||
|
|
||||||
|
<!-- An unlink whose server-side revoke didn\'t land leaves a live credential.
|
||||||
|
Never a transient message: someone disconnecting to retire a phone has to
|
||||||
|
still find this when they come back to check. -->
|
||||||
|
<string name="sync_revoke_title">This device\'s token is still valid on the server</string>
|
||||||
|
<string name="sync_revoke_unsupported">This server is older than in-app sign-out, so this device\'s token had to be left in place. Revoke it in the web app under Account → Linked devices.</string>
|
||||||
|
<string name="sync_revoke_failed">%1$s Until it\'s revoked, this device\'s token still works — you can revoke it in the web app under Account → Linked devices.</string>
|
||||||
|
|
||||||
|
<!-- What a sync did. Counts what MOVED; batches, pages and cursors are real
|
||||||
|
numbers that answer nobody\'s question. -->
|
||||||
|
<string name="sync_summary">Synced — %1$s.</string>
|
||||||
|
<string name="sync_summary_sent">sent %1$d</string>
|
||||||
|
<string name="sync_summary_received">received %1$d</string>
|
||||||
|
<string name="sync_summary_uptodate">Already up to date.</string>
|
||||||
|
<plurals name="sync_summary_attachments">
|
||||||
|
<item quantity="one">%d attachment</item>
|
||||||
|
<item quantity="other">%d attachments</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="sync_summary_attachments_failed">
|
||||||
|
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item>
|
||||||
|
<item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="sync_summary_uploaded">
|
||||||
|
<item quantity="one">uploaded %d file</item>
|
||||||
|
<item quantity="other">uploaded %d files</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="sync_summary_upload_failed">
|
||||||
|
<item quantity="one">%d file didn\'t upload. If the server refused it, its note says why; otherwise it\'ll retry.</item>
|
||||||
|
<item quantity="other">%d files didn\'t upload. Any the server refused say why on their note; the rest will retry.</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<!-- Errors -->
|
||||||
|
<string name="error_dismiss">Dismiss</string>
|
||||||
|
|
||||||
|
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
|
||||||
|
a real state (a bare `gradlew assembleDebug` with no override) and saying
|
||||||
|
so is better than an empty line that reads as a layout bug. -->
|
||||||
|
<string name="build_unknown">unknown</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!--
|
||||||
|
A bare Material3 parent. The real palette is applied in Compose
|
||||||
|
(ui/Theme.kt) so light/dark follows the system without a second source of
|
||||||
|
truth in XML — the same reason the desktop reads its live theme rather
|
||||||
|
than hardcoding a window colour.
|
||||||
|
-->
|
||||||
|
<style name="Theme.Inkwell" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
What the FileProvider may hand to another app: ONLY the copies made to open an
|
||||||
|
attachment (AttachmentFiles.open), never the store or the blob directory itself.
|
||||||
|
A viewer gets read access to the one file it was asked to show.
|
||||||
|
-->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="open" path="open/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AttachmentRulesTest {
|
||||||
|
@Test
|
||||||
|
fun `raster images draw inline and SVG never does`() {
|
||||||
|
assertTrue(rendersInline("image/png"))
|
||||||
|
assertTrue(rendersInline("image/jpeg"))
|
||||||
|
assertTrue(rendersInline("IMAGE/WEBP; charset=binary"))
|
||||||
|
assertFalse(rendersInline("image/svg+xml"))
|
||||||
|
assertFalse(rendersInline("Image/SVG+XML"))
|
||||||
|
assertFalse(rendersInline("application/pdf"))
|
||||||
|
assertFalse(rendersInline(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a decode is scaled down but never below the size it is drawn at`() {
|
||||||
|
assertEquals(1, sampleSize(width = 600, height = 400, maxPx = 640))
|
||||||
|
assertEquals(1, sampleSize(width = 1279, height = 900, maxPx = 640))
|
||||||
|
assertEquals(2, sampleSize(width = 1280, height = 900, maxPx = 640))
|
||||||
|
// A 12-megapixel portrait photo for a card: the longer side decides.
|
||||||
|
assertEquals(4, sampleSize(width = 3024, height = 4032, maxPx = 640))
|
||||||
|
assertTrue(4032 / sampleSize(3024, 4032, 640) >= 640)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cache copy's name cannot leave its directory and is never empty`() {
|
||||||
|
assertEquals("receipt.pdf", safeName("receipt.pdf"))
|
||||||
|
assertEquals("passwd", safeName("../../etc/passwd"))
|
||||||
|
assertEquals("evil.txt", safeName("C:\\temp\\evil.txt"))
|
||||||
|
assertEquals("file", safeName(".."))
|
||||||
|
assertEquals("file", safeName(" "))
|
||||||
|
assertEquals("file", safeName(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sizes print like the web prints them`() {
|
||||||
|
assertEquals("512 B", sizeLabel(512))
|
||||||
|
assertEquals("2 KB", sizeLabel(1536))
|
||||||
|
assertEquals("3.5 MB", sizeLabel(3_670_016))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BoardFiltersTest {
|
||||||
|
@Test
|
||||||
|
fun `no filters asks the core for no facets at all`() {
|
||||||
|
assertNull(BoardFilters().facets())
|
||||||
|
assertEquals(0, BoardFilters().count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `each tag counts as one, as on the web's badge`() {
|
||||||
|
val filters = BoardFilters(labelIds = setOf("a", "b"), hasAttachment = true, sharedWithMe = true)
|
||||||
|
assertEquals(4, filters.count)
|
||||||
|
|
||||||
|
val facets = requireNotNull(filters.facets())
|
||||||
|
assertEquals(setOf("a", "b"), facets.label?.toSet())
|
||||||
|
assertEquals(true, facets.hasAttachment)
|
||||||
|
assertEquals("with_me", facets.shared)
|
||||||
|
// Dates are Timeline's and text is the search bar's; the filters never set them.
|
||||||
|
assertNull(facets.q)
|
||||||
|
assertNull(facets.createdAfter)
|
||||||
|
assertNull(facets.createdBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a switch that is off is left out rather than sent as false`() {
|
||||||
|
val facets = requireNotNull(BoardFilters(labelIds = setOf("a")).facets())
|
||||||
|
assertNull(facets.hasAttachment)
|
||||||
|
assertNull(facets.shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tapping a tag twice takes it back off`() {
|
||||||
|
val once = BoardFilters().toggleLabel("a")
|
||||||
|
assertEquals(setOf("a"), once.labelIds)
|
||||||
|
assertEquals(BoardFilters(), once.toggleLabel("a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
|
||||||
|
*
|
||||||
|
* These are not tests of Kotlin — they pin the pair. The web runs the same values
|
||||||
|
* from `core/testdata/grammar.json` (`tint`) in `notes/grammar.test.ts`; this file
|
||||||
|
* writes them out by hand, so changing the hash or the key order means changing the
|
||||||
|
* fixture and this file together, or one of the two suites goes red.
|
||||||
|
*
|
||||||
|
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
|
||||||
|
* the resolution order that chose between a picked colour, a tag's and a generated one
|
||||||
|
* — went with the code it guarded when the card became one neutral. The four UUID
|
||||||
|
* hashes stay because they are what the hash ITSELF is pinned by; nothing derives a
|
||||||
|
* colour from an id any more, only from a tag's name.
|
||||||
|
*/
|
||||||
|
class DerivedTintTest {
|
||||||
|
@Test
|
||||||
|
fun `hashes match the fixture shared with the web`() {
|
||||||
|
// Kotlin's Int is signed, so the two hashes above 0x7FFFFFFF are written as
|
||||||
|
// their negative literal. The unsigned value in the comment is what colors.ts
|
||||||
|
// records and what an implementation of FNV-1a will actually produce.
|
||||||
|
assertEquals(-0x41B8712F, tintHash("00000000-0000-0000-0000-000000000000")) // 0xbe478ed1
|
||||||
|
assertEquals(0x3D75CC01, tintHash("11111111-1111-1111-1111-111111111111"))
|
||||||
|
assertEquals(-0x0EF71AD0, tintHash("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) // 0xf108e530
|
||||||
|
assertEquals(0x5B651540, tintHash("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `colours match the fixture shared with the web`() {
|
||||||
|
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
|
||||||
|
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
|
||||||
|
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
|
||||||
|
assertEquals("orange", derivedTint("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
|
||||||
|
* index out of the list for those. The bug this catches is a crash, not a wrong
|
||||||
|
* colour, so it is worth more than one name's worth of coverage. */
|
||||||
|
@Test
|
||||||
|
fun `every derived colour is a real palette key, over many names`() {
|
||||||
|
for (n in 0 until 2000) {
|
||||||
|
assertEquals(true, derivedTint("tag-$n") in DERIVED_TINT_KEYS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the derived palette excludes default`() {
|
||||||
|
assertEquals(false, "default" in DERIVED_TINT_KEYS)
|
||||||
|
assertEquals(9, DERIVED_TINT_KEYS.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The order IS the mapping — reordering silently recolours every tag on one
|
||||||
|
* surface only. Written out longhand so a reorder fails here loudly. */
|
||||||
|
@Test
|
||||||
|
fun `key order matches colors ts`() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"),
|
||||||
|
DERIVED_TINT_KEYS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tag with no colour of its own derives one from its NAME, which is what makes
|
||||||
|
* every `#todo` chip the same colour rather than nine different ones. */
|
||||||
|
@Test
|
||||||
|
fun `a label with no colour derives one from its name`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
val todo = resolvedLabelColor("todo", "default", known)
|
||||||
|
assertEquals(derivedTint("todo"), todo)
|
||||||
|
assertNotEquals("default", todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags dedupe case-insensitively, so `#Todo` and `#todo` are one tag and must not
|
||||||
|
* be two colours. This is the whole reason the name is lowercased first. */
|
||||||
|
@Test
|
||||||
|
fun `label colour ignores case`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
assertEquals(
|
||||||
|
resolvedLabelColor("todo", "default", known),
|
||||||
|
resolvedLabelColor("ToDo", "default", known),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `teal` deliberately, NOT the colour "todo" derives to (pink) — asserting the
|
||||||
|
* derived value here would pass even with the explicit branch deleted. */
|
||||||
|
@Test
|
||||||
|
fun `an explicitly picked label colour still wins`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
assertNotEquals("teal", derivedTint("todo"))
|
||||||
|
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unreadable key is not a choice — it is data from a server newer than this
|
||||||
|
* client, and the tag should still be drawn as something. */
|
||||||
|
@Test
|
||||||
|
fun `an unknown colour key falls back to the derived colour`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
assertEquals(derivedTint("todo"), resolvedLabelColor("todo", "chartreuse", known))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A label with no name at all has nothing to hash. Neutral, not a random hue. */
|
||||||
|
@Test
|
||||||
|
fun `a nameless label stays default`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
assertEquals("default", resolvedLabelColor("", "", known))
|
||||||
|
assertEquals("default", resolvedLabelColor("", "default", known))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spread is real, and collisions are real too.
|
||||||
|
*
|
||||||
|
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
|
||||||
|
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
|
||||||
|
* both green. Colour is a hint that two chips are distinct, never a claim that two
|
||||||
|
* of one colour are the same tag; the chip's TEXT is what says which tag it is.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `different tag names spread across the palette`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
val names = listOf("todo", "grocery", "work", "home", "ideas", "reading", "urgent")
|
||||||
|
val colours = names.map { resolvedLabelColor(it, "default", known) }
|
||||||
|
assertEquals(true, colours.toSet().size >= 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reason the feature exists: two tags on one board should not look identical. */
|
||||||
|
@Test
|
||||||
|
fun `the derived colour spreads across the palette`() {
|
||||||
|
val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
|
||||||
|
assertEquals(DERIVED_TINT_KEYS.size, seen.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "inkwell-uniffi-bindgen"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Generates the Kotlin bindings for inkwell-ffi"
|
||||||
|
authors = ["bvandeusen"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# A crate whose ONLY dependency is uniffi itself.
|
||||||
|
#
|
||||||
|
# This started life as a `[[bin]]` inside inkwell-ffi, which failed: building
|
||||||
|
# it compiled that crate and therefore the core, reqwest, native-tls and
|
||||||
|
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
|
||||||
|
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
|
||||||
|
# OpenSSL that ci-rust-android has no reason to carry, and the generator died
|
||||||
|
# with "failed to run custom build command for openssl-sys".
|
||||||
|
#
|
||||||
|
# Adding libssl-dev to the image would have worked and been wrong: a code
|
||||||
|
# generator should not link the app's TLS stack to emit Kotlin. Splitting it out
|
||||||
|
# means the generator compiles ~15 small crates and nothing else.
|
||||||
|
#
|
||||||
|
# Still a WORKSPACE MEMBER, deliberately. That is what keeps `uniffi` here and
|
||||||
|
# `uniffi` linked into the .so on one version from one lockfile — they are two
|
||||||
|
# halves of one ABI, and a separate lockfile is exactly how they would drift.
|
||||||
|
[dependencies]
|
||||||
|
uniffi = { version = "0.32", features = ["cli"] }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//! The Kotlin generator.
|
||||||
|
//!
|
||||||
|
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --locked -p inkwell-uniffi-bindgen -- \
|
||||||
|
//! generate --library <path/to/libinkwell_ffi.so> \
|
||||||
|
//! --language kotlin --out-dir <build/generated/uniffi>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `--library` mode reads uniffi's metadata straight out of the compiled artifact,
|
||||||
|
//! so the generated bindings can never describe a different version of the Rust
|
||||||
|
//! than the one being packaged.
|
||||||
|
fn main() {
|
||||||
|
uniffi::uniffi_bindgen_main()
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
// kotlin-android is NOT registered: AGP 9 enables built-in Kotlin, and the
|
||||||
|
// older plugin can't cast AGP 9's ApplicationExtension to the removed
|
||||||
|
// BaseExtension. Same conclusion Minstrel reached on this toolchain pair.
|
||||||
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
|
// ktlint/detekt are run from the CI image's pinned CLIs, not as Gradle
|
||||||
|
// plugins — see the note in gradle/libs.versions.toml.
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Per-rule overrides layered on top of detekt's defaults
|
||||||
|
# (`--build-upon-default-config` on the CLI invocation in the Android lane).
|
||||||
|
#
|
||||||
|
# The pre-2.0 `build:` top-level was removed; failure is controlled by the CLI's
|
||||||
|
# exit code instead.
|
||||||
|
|
||||||
|
naming:
|
||||||
|
# Composables conventionally use PascalCase function names. Matches every
|
||||||
|
# mainstream Compose codebase, and mirrors the ktlint exemption in
|
||||||
|
# android/.editorconfig — the two tools have to agree or one of them is always
|
||||||
|
# wrong.
|
||||||
|
FunctionNaming:
|
||||||
|
ignoreAnnotated:
|
||||||
|
- "Composable"
|
||||||
|
|
||||||
|
style:
|
||||||
|
MagicNumber:
|
||||||
|
ignoreAnnotated:
|
||||||
|
- "Composable"
|
||||||
|
# Colour literals and dp constants are declared as named properties, which is
|
||||||
|
# exactly the "define it as a well-named constant" the rule asks for — the
|
||||||
|
# number simply appears in the declaration itself. Flagging
|
||||||
|
# `private val Brand = Color(0xFFF5C518)` would demand a constant holding the
|
||||||
|
# constant.
|
||||||
|
ignorePropertyDeclaration: true
|
||||||
|
|
||||||
|
complexity:
|
||||||
|
# Compose breaks the PREMISE of both rules below, not just their thresholds.
|
||||||
|
#
|
||||||
|
# * LongParameterList assumes a long list means an over-general function. A
|
||||||
|
# composable's parameters ARE its UI contract — Material's own TextField
|
||||||
|
# takes twenty — and collapsing them into a parameter object makes the call
|
||||||
|
# site worse, not better, because named arguments are what keep a Compose
|
||||||
|
# tree readable.
|
||||||
|
# * LongMethod assumes length tracks branching. A composable's length tracks
|
||||||
|
# how many ELEMENTS are on the screen; a full-screen editor with a title, a
|
||||||
|
# body, a checklist, labels and a reminder row is long because it renders
|
||||||
|
# five things, and cutting it into five one-call wrappers would add
|
||||||
|
# indirection without removing a single decision.
|
||||||
|
#
|
||||||
|
# Scoped to @Composable rather than disabled: on ordinary functions both rules
|
||||||
|
# are right, and one of them still fires below (see BoardViewModel).
|
||||||
|
LongParameterList:
|
||||||
|
ignoreAnnotated:
|
||||||
|
- "Composable"
|
||||||
|
LongMethod:
|
||||||
|
ignoreAnnotated:
|
||||||
|
- "Composable"
|
||||||
|
|
||||||
|
exceptions:
|
||||||
|
TooGenericExceptionCaught:
|
||||||
|
# Catching broadly is DELIBERATE in these two places, and each site says so.
|
||||||
|
#
|
||||||
|
# * the ViewModel — a note that fails to save must become a visible error
|
||||||
|
# banner, never a crash. Narrowing this would mean an unanticipated
|
||||||
|
# failure takes the app down instead of being reported, which is strictly
|
||||||
|
# worse for the user.
|
||||||
|
# * the Application — the store failing to open is the one thing that must
|
||||||
|
# still let the app start, so it can explain itself.
|
||||||
|
# * the background Worker — it runs with nobody present, so an escaping
|
||||||
|
# exception is a crash report for a job the person never asked for. Every
|
||||||
|
# realistic failure there (no route, server down, token rotating) has the
|
||||||
|
# same right answer, which is Result.retry().
|
||||||
|
# * the reminder BroadcastReceiver — same argument, one step worse: it can be
|
||||||
|
# woken at 3am by an alarm or by BOOT_COMPLETED, and every path inside it
|
||||||
|
# has already logged its own failure by the time this catches anything.
|
||||||
|
# * the self-updater — the install path throws IOException from three
|
||||||
|
# different calls and SecurityException when the "install unknown apps"
|
||||||
|
# grant has been revoked since it was checked. All of them mean one thing
|
||||||
|
# to the person ("it did not install"), and none should take the app down
|
||||||
|
# while it is holding their notes.
|
||||||
|
#
|
||||||
|
# Scoped to those paths rather than disabled globally: elsewhere the rule is
|
||||||
|
# right and still applies.
|
||||||
|
excludes:
|
||||||
|
- "**/ui/**"
|
||||||
|
- "**/InkwellApplication.kt"
|
||||||
|
- "**/SyncWorker.kt"
|
||||||
|
- "**/ReminderReceiver.kt"
|
||||||
|
- "**/AppUpdate.kt"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "inkwell-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "uniffi bindings exposing inkwell-core to the native Android client"
|
||||||
|
authors = ["bvandeusen"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# cdylib is the `.so` Android's System.loadLibrary opens. `lib` alongside it so the
|
||||||
|
# bindgen binary below — and this crate's own tests — can use the crate normally;
|
||||||
|
# a cdylib-only crate is unusable from Rust.
|
||||||
|
crate-type = ["cdylib", "lib"]
|
||||||
|
name = "inkwell_ffi"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
inkwell-core = { path = "../../core" }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
|
||||||
|
# tokio lets an exported `async fn` be driven by a tokio runtime, which the sync
|
||||||
|
# engine needs: it is reqwest all the way down.
|
||||||
|
uniffi = { version = "0.32", features = ["tokio"] }
|
||||||
|
|
||||||
|
# reqwest requires a reactor; uniffi's `async_runtime = "tokio"` needs one to exist.
|
||||||
|
# rt-multi-thread rather than current_thread: a sync cycle is network-bound and a
|
||||||
|
# Compose UI may have more than one call in flight.
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
|
||||||
|
# Display + Error impls for the error enum uniffi turns into a Kotlin exception.
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
|||||||
|
//! The types that cross into Kotlin.
|
||||||
|
//!
|
||||||
|
//! These MIRROR `inkwell_core::local::models` rather than reusing it. The core's
|
||||||
|
//! shapes are serde structs whose field names and optionality are contracted with the
|
||||||
|
//! shared Vue frontend; hanging uniffi derives on them would couple two very
|
||||||
|
//! different consumers to one definition and put a `serde_json::Value` (which has no
|
||||||
|
//! uniffi representation) in the middle of it.
|
||||||
|
//!
|
||||||
|
//! The cost of mirroring is drift — an Android client quietly missing a field the
|
||||||
|
//! desktop gained. Every conversion below therefore DESTRUCTURES the core struct
|
||||||
|
//! exhaustively instead of reading fields it cares about. Add a field to
|
||||||
|
//! `core::local::models::Note` and this file stops compiling until Android is told
|
||||||
|
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
|
||||||
|
//! style here; please keep it.
|
||||||
|
|
||||||
|
use inkwell_core::local::models as core_models;
|
||||||
|
use inkwell_core::sync::client as core_client;
|
||||||
|
use inkwell_core::sync::compat as core_compat;
|
||||||
|
use inkwell_core::sync::engine as core_engine;
|
||||||
|
use inkwell_core::sync::pull as core_pull;
|
||||||
|
use inkwell_core::sync::push as core_push;
|
||||||
|
use inkwell_core::sync::state as core_state;
|
||||||
|
|
||||||
|
/// A note, with everything needed to render a card or open the editor.
|
||||||
|
///
|
||||||
|
/// Timestamps are RFC3339 strings, not a date type: that is what SQLite holds and
|
||||||
|
/// what the server speaks, and converting here would mean this layer picking a
|
||||||
|
/// calendar/timezone policy that belongs to the UI.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Note {
|
||||||
|
pub id: String,
|
||||||
|
/// The note's NAME: its first non-blank body line, else its first checklist item.
|
||||||
|
/// Always present. Derived by the core, never stored.
|
||||||
|
pub display_title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub position: i64,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub archived: bool,
|
||||||
|
pub trashed: bool,
|
||||||
|
pub deleted_at: Option<String>,
|
||||||
|
pub remind_at: Option<String>,
|
||||||
|
pub recurrence: Option<String>,
|
||||||
|
pub labels: Vec<NoteLabel>,
|
||||||
|
pub items: Vec<ChecklistItem>,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
pub previews: Vec<LinkPreview>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
/// How this account holds the note (#5175): "owner", or "edit"/"view" for one
|
||||||
|
/// someone shared with it.
|
||||||
|
pub permission: String,
|
||||||
|
/// Whether the owner has shared it with anyone.
|
||||||
|
pub shared: bool,
|
||||||
|
/// Who shared it with us; absent on our own notes.
|
||||||
|
pub shared_by: Option<SharedBy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner of a note shared with this account.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SharedBy {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A checklist item as it sits in a note's body.
|
||||||
|
///
|
||||||
|
/// Mirrors `derive::DerivedItem`. Carries the LINE because every renderer that walks
|
||||||
|
/// a body line by line needs the text, the state and the position together — the card
|
||||||
|
/// to draw a box in the right place, the block editor to know where one block ends.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct BodyItem {
|
||||||
|
pub line: u32,
|
||||||
|
pub text: String,
|
||||||
|
pub checked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<inkwell_core::local::derive::DerivedItem> for BodyItem {
|
||||||
|
fn from(i: inkwell_core::local::derive::DerivedItem) -> Self {
|
||||||
|
let inkwell_core::local::derive::DerivedItem {
|
||||||
|
text,
|
||||||
|
checked,
|
||||||
|
line,
|
||||||
|
} = i;
|
||||||
|
BodyItem {
|
||||||
|
line,
|
||||||
|
text,
|
||||||
|
checked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `#tag` and where it sits in a note's body.
|
||||||
|
///
|
||||||
|
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
|
||||||
|
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
|
||||||
|
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct BodyTag {
|
||||||
|
pub line: u32,
|
||||||
|
pub start: u32,
|
||||||
|
pub end: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<inkwell_core::local::derive::DerivedTag> for BodyTag {
|
||||||
|
fn from(t: inkwell_core::local::derive::DerivedTag) -> Self {
|
||||||
|
let inkwell_core::local::derive::DerivedTag {
|
||||||
|
line,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
name,
|
||||||
|
} = t;
|
||||||
|
BodyTag {
|
||||||
|
line,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An Android build the linked server is offering, already judged to be newer.
|
||||||
|
///
|
||||||
|
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
||||||
|
/// reason every other record here is one: the core's shapes are contracted with
|
||||||
|
/// other consumers, and `url` in particular is an implementation detail of how
|
||||||
|
/// the download is fetched — the app never needs it, because it asks the core to
|
||||||
|
/// do the downloading.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ClientUpdate {
|
||||||
|
/// For people to read.
|
||||||
|
pub version: String,
|
||||||
|
/// For machines to compare.
|
||||||
|
pub version_code: i64,
|
||||||
|
pub size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<inkwell_core::sync::client::ClientRelease> for ClientUpdate {
|
||||||
|
fn from(r: inkwell_core::sync::client::ClientRelease) -> Self {
|
||||||
|
// Destructured exhaustively, like every other conversion in this file: a
|
||||||
|
// field added upstream stops this compiling until Android is told what to
|
||||||
|
// do with it, which turns silent drift into a build error.
|
||||||
|
let inkwell_core::sync::client::ClientRelease {
|
||||||
|
version,
|
||||||
|
version_code,
|
||||||
|
size,
|
||||||
|
sha256: _,
|
||||||
|
url: _,
|
||||||
|
} = r;
|
||||||
|
ClientUpdate {
|
||||||
|
version,
|
||||||
|
version_code,
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteLabel {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub color: String,
|
||||||
|
/// True when attached because of a `#tag` in the body, so the UI can show it is
|
||||||
|
/// owned by the text and not independently removable.
|
||||||
|
pub via_tag: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ChecklistItem {
|
||||||
|
pub id: String,
|
||||||
|
pub text: String,
|
||||||
|
pub checked: bool,
|
||||||
|
pub position: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub mime: String,
|
||||||
|
pub size: Option<i64>,
|
||||||
|
pub sha256: Option<String>,
|
||||||
|
/// Why the server refused a file attached on this device. None otherwise.
|
||||||
|
pub upload_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct LinkPreview {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::Note> for Note {
|
||||||
|
fn from(value: core_models::Note) -> Self {
|
||||||
|
// Exhaustive on purpose — see the module header.
|
||||||
|
let core_models::Note {
|
||||||
|
id,
|
||||||
|
display_title,
|
||||||
|
body,
|
||||||
|
position,
|
||||||
|
pinned,
|
||||||
|
archived,
|
||||||
|
trashed,
|
||||||
|
deleted_at,
|
||||||
|
remind_at,
|
||||||
|
recurrence,
|
||||||
|
labels,
|
||||||
|
items,
|
||||||
|
attachments,
|
||||||
|
previews,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
permission,
|
||||||
|
shared,
|
||||||
|
shared_by,
|
||||||
|
} = value;
|
||||||
|
Note {
|
||||||
|
id,
|
||||||
|
display_title,
|
||||||
|
body,
|
||||||
|
position,
|
||||||
|
pinned,
|
||||||
|
archived,
|
||||||
|
trashed,
|
||||||
|
deleted_at,
|
||||||
|
remind_at,
|
||||||
|
recurrence,
|
||||||
|
labels: labels.into_iter().map(NoteLabel::from).collect(),
|
||||||
|
items: items.into_iter().map(ChecklistItem::from).collect(),
|
||||||
|
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
||||||
|
previews: previews.into_iter().map(LinkPreview::from).collect(),
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
permission,
|
||||||
|
shared,
|
||||||
|
shared_by: shared_by.map(|by| SharedBy {
|
||||||
|
id: by.id,
|
||||||
|
display_name: by.display_name,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::NoteLabel> for NoteLabel {
|
||||||
|
fn from(value: core_models::NoteLabel) -> Self {
|
||||||
|
let core_models::NoteLabel {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
via_tag,
|
||||||
|
} = value;
|
||||||
|
NoteLabel {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
via_tag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::ChecklistItem> for ChecklistItem {
|
||||||
|
fn from(value: core_models::ChecklistItem) -> Self {
|
||||||
|
let core_models::ChecklistItem {
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
checked,
|
||||||
|
position,
|
||||||
|
} = value;
|
||||||
|
ChecklistItem {
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
checked,
|
||||||
|
position,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::Attachment> for Attachment {
|
||||||
|
fn from(value: core_models::Attachment) -> Self {
|
||||||
|
let core_models::Attachment {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
filename,
|
||||||
|
mime,
|
||||||
|
size,
|
||||||
|
sha256,
|
||||||
|
upload_error,
|
||||||
|
} = value;
|
||||||
|
Attachment {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
filename,
|
||||||
|
mime,
|
||||||
|
size,
|
||||||
|
sha256,
|
||||||
|
upload_error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::LinkPreview> for LinkPreview {
|
||||||
|
fn from(value: core_models::LinkPreview) -> Self {
|
||||||
|
let core_models::LinkPreview {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image_url,
|
||||||
|
site_name,
|
||||||
|
} = value;
|
||||||
|
LinkPreview {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image_url,
|
||||||
|
site_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A label, as the sidebar lists them.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Label {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// Same colour vocabulary as notes, so one palette serves both.
|
||||||
|
pub color: String,
|
||||||
|
/// How many notes carry it. Only populated in listings — `None` elsewhere,
|
||||||
|
/// matching the REST single-label responses.
|
||||||
|
pub count: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_models::Label> for Label {
|
||||||
|
fn from(value: core_models::Label) -> Self {
|
||||||
|
let core_models::Label {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
count,
|
||||||
|
} = value;
|
||||||
|
Label {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── queries and edits ─────────────────────────────
|
||||||
|
|
||||||
|
/// What the board is asking for. Mirrors the core's `ListQuery`.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteQuery {
|
||||||
|
/// "notes" | "archive" | "trash" | "reminders" | "labels" — the core validates.
|
||||||
|
pub view: String,
|
||||||
|
pub label_id: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub facets: Option<NoteFacets>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteFacets {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub label: Option<Vec<String>>,
|
||||||
|
pub has_attachment: Option<bool>,
|
||||||
|
pub created_after: Option<String>,
|
||||||
|
pub created_before: Option<String>,
|
||||||
|
/// "with_me": only notes someone else shared with this account.
|
||||||
|
pub shared: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NoteQuery> for core_models::ListQuery {
|
||||||
|
fn from(value: NoteQuery) -> Self {
|
||||||
|
let NoteQuery {
|
||||||
|
view,
|
||||||
|
label_id,
|
||||||
|
sort,
|
||||||
|
facets,
|
||||||
|
} = value;
|
||||||
|
core_models::ListQuery {
|
||||||
|
view,
|
||||||
|
label_id,
|
||||||
|
sort,
|
||||||
|
facets: facets.map(core_models::Facets::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NoteFacets> for core_models::Facets {
|
||||||
|
fn from(value: NoteFacets) -> Self {
|
||||||
|
let NoteFacets {
|
||||||
|
q,
|
||||||
|
label,
|
||||||
|
has_attachment,
|
||||||
|
created_after,
|
||||||
|
created_before,
|
||||||
|
shared,
|
||||||
|
} = value;
|
||||||
|
core_models::Facets {
|
||||||
|
q,
|
||||||
|
label,
|
||||||
|
has_attachment,
|
||||||
|
created_after,
|
||||||
|
created_before,
|
||||||
|
shared,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new note.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteDraft {
|
||||||
|
pub body: String,
|
||||||
|
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
|
||||||
|
/// is not an alternative to `body` — it is an addition to it.
|
||||||
|
pub items: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||||
|
fn from(value: NoteDraft) -> Self {
|
||||||
|
let NoteDraft { body, items } = value;
|
||||||
|
core_models::NoteCreateInput { body, items }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One field-level change to a note.
|
||||||
|
///
|
||||||
|
/// A LIST of these rather than a struct of optional fields, because the core's patch
|
||||||
|
/// semantics distinguish three states — leave alone, set to a value, and clear to
|
||||||
|
/// null — and Kotlin has no way to express the third with a nullable field.
|
||||||
|
/// `remindAt: null` in a data class is indistinguishable from `remindAt` unset, so
|
||||||
|
/// the editor could never clear a reminder. Explicit `Clear*` variants say it out
|
||||||
|
/// loud, and Kotlin gets a sealed class it can `when` over exhaustively.
|
||||||
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
|
pub enum NoteEdit {
|
||||||
|
Body { value: String },
|
||||||
|
Pinned { value: bool },
|
||||||
|
Archived { value: bool },
|
||||||
|
RemindAt { value: String },
|
||||||
|
ClearRemindAt,
|
||||||
|
Recurrence { value: String },
|
||||||
|
ClearRecurrence,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoteEdit {
|
||||||
|
/// The (key, value) pair this edit contributes to the core's JSON patch.
|
||||||
|
///
|
||||||
|
/// The core reads a patch object where a present key means "change this" and a
|
||||||
|
/// null value means "clear it" — the shape the REST API and the Tauri commands
|
||||||
|
/// both already speak. Translating here keeps that one patch format in one
|
||||||
|
/// place instead of teaching a second dialect to the store.
|
||||||
|
fn entry(self) -> (&'static str, serde_json::Value) {
|
||||||
|
use serde_json::Value;
|
||||||
|
match self {
|
||||||
|
NoteEdit::Body { value } => ("body", Value::String(value)),
|
||||||
|
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||||
|
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
||||||
|
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
||||||
|
NoteEdit::ClearRemindAt => ("remind_at", Value::Null),
|
||||||
|
NoteEdit::Recurrence { value } => ("recurrence", Value::String(value)),
|
||||||
|
NoteEdit::ClearRecurrence => ("recurrence", Value::Null),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold a list of edits into the single patch object the store applies.
|
||||||
|
///
|
||||||
|
/// Later edits win on a repeated key, which is what a caller batching "set a
|
||||||
|
/// reminder, then clear it" would expect.
|
||||||
|
pub fn patch_from(edits: Vec<NoteEdit>) -> serde_json::Value {
|
||||||
|
let mut map = serde_json::Map::new();
|
||||||
|
for edit in edits {
|
||||||
|
let (key, value) = edit.entry();
|
||||||
|
map.insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── sync ─────────────────────────────────
|
||||||
|
|
||||||
|
/// What the UI may know about the link. Carries no device token, deliberately —
|
||||||
|
/// the core withholds it from `Status` for the same reason, and a bearer token has
|
||||||
|
/// no business in UI state.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SyncStatus {
|
||||||
|
pub linked: bool,
|
||||||
|
pub server_url: Option<String>,
|
||||||
|
pub last_cursor: i64,
|
||||||
|
pub last_sync_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_state::Status> for SyncStatus {
|
||||||
|
fn from(value: core_state::Status) -> Self {
|
||||||
|
let core_state::Status {
|
||||||
|
linked,
|
||||||
|
server_url,
|
||||||
|
last_cursor,
|
||||||
|
last_sync_at,
|
||||||
|
} = value;
|
||||||
|
SyncStatus {
|
||||||
|
linked,
|
||||||
|
server_url,
|
||||||
|
last_cursor,
|
||||||
|
last_sync_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a server said about itself, before committing to anything.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ProbeResult {
|
||||||
|
/// Normalised by the core — this, not what the user typed, is what gets stored.
|
||||||
|
pub base_url: String,
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub trash_retention_days: Option<u32>,
|
||||||
|
pub compatibility: Compatibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this client and that server can sync at all.
|
||||||
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
|
pub enum Compatibility {
|
||||||
|
Ok,
|
||||||
|
/// Safe to sync, but these named capabilities are missing. The UI should say so
|
||||||
|
/// rather than let a feature silently do nothing.
|
||||||
|
Degraded {
|
||||||
|
unavailable: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Do not sync. `client_must_update` says which side can fix it, so the message
|
||||||
|
/// can be actionable.
|
||||||
|
Incompatible {
|
||||||
|
reason: String,
|
||||||
|
client_must_update: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_compat::Compatibility> for Compatibility {
|
||||||
|
fn from(value: core_compat::Compatibility) -> Self {
|
||||||
|
match value {
|
||||||
|
core_compat::Compatibility::Ok => Compatibility::Ok,
|
||||||
|
core_compat::Compatibility::Degraded { unavailable } => {
|
||||||
|
Compatibility::Degraded { unavailable }
|
||||||
|
}
|
||||||
|
core_compat::Compatibility::Incompatible {
|
||||||
|
reason,
|
||||||
|
client_must_update,
|
||||||
|
} => Compatibility::Incompatible {
|
||||||
|
reason,
|
||||||
|
client_must_update,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::ProbeResult> for ProbeResult {
|
||||||
|
fn from(value: core_client::ProbeResult) -> Self {
|
||||||
|
let core_client::ProbeResult {
|
||||||
|
base_url,
|
||||||
|
server,
|
||||||
|
compatibility,
|
||||||
|
} = value;
|
||||||
|
let core_compat::ServerInfo {
|
||||||
|
site_name,
|
||||||
|
version,
|
||||||
|
// Protocol numbers are the raw material of the compatibility verdict,
|
||||||
|
// which is already carried above in a form the UI can act on. Sending
|
||||||
|
// them too would invite a second, worse judgement being made in Kotlin.
|
||||||
|
sync_protocol_version: _,
|
||||||
|
min_client_protocol_version: _,
|
||||||
|
sync_features: _,
|
||||||
|
trash_retention_days,
|
||||||
|
} = server;
|
||||||
|
ProbeResult {
|
||||||
|
base_url,
|
||||||
|
site_name,
|
||||||
|
version,
|
||||||
|
trash_retention_days,
|
||||||
|
compatibility: compatibility.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who the server thinks this device belongs to.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Identity {
|
||||||
|
pub id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::Identity> for Identity {
|
||||||
|
fn from(value: core_client::Identity) -> Self {
|
||||||
|
let core_client::Identity {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
} = value;
|
||||||
|
Identity {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
display_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Someone on the instance a note can be shared with (#5175).
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Member {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::Member> for Member {
|
||||||
|
fn from(value: core_client::Member) -> Self {
|
||||||
|
let core_client::Member {
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
email,
|
||||||
|
} = value;
|
||||||
|
Member {
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group the admin made, which a note can be shared with (#5177).
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ShareGroup {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub member_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::ShareGroup> for ShareGroup {
|
||||||
|
fn from(value: core_client::ShareGroup) -> Self {
|
||||||
|
let core_client::ShareGroup {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
member_count,
|
||||||
|
} = value;
|
||||||
|
ShareGroup {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
member_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everyone a note can be shared with: the other people, and every group.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Directory {
|
||||||
|
pub members: Vec<Member>,
|
||||||
|
pub groups: Vec<ShareGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::Directory> for Directory {
|
||||||
|
fn from(value: core_client::Directory) -> Self {
|
||||||
|
let core_client::Directory { members, groups } = value;
|
||||||
|
Directory {
|
||||||
|
members: members.into_iter().map(Member::from).collect(),
|
||||||
|
groups: groups.into_iter().map(ShareGroup::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who a new share goes to: one person, or a group.
|
||||||
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
|
pub enum ShareTarget {
|
||||||
|
Member { id: String },
|
||||||
|
Group { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ShareTarget> for core_client::ShareTarget {
|
||||||
|
fn from(value: ShareTarget) -> Self {
|
||||||
|
match value {
|
||||||
|
ShareTarget::Member { id } => core_client::ShareTarget::Member(id),
|
||||||
|
ShareTarget::Group { id } => core_client::ShareTarget::Group(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One share of a note, at "view" or "edit": to a person or to a group, never both.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteShare {
|
||||||
|
pub id: String,
|
||||||
|
pub member: Option<Member>,
|
||||||
|
pub group: Option<ShareGroup>,
|
||||||
|
pub permission: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::NoteShare> for NoteShare {
|
||||||
|
fn from(value: core_client::NoteShare) -> Self {
|
||||||
|
// `created_at` stays behind: nothing on the phone orders or shows by it.
|
||||||
|
let core_client::NoteShare {
|
||||||
|
id,
|
||||||
|
member,
|
||||||
|
group,
|
||||||
|
permission,
|
||||||
|
created_at: _,
|
||||||
|
} = value;
|
||||||
|
NoteShare {
|
||||||
|
id,
|
||||||
|
member: member.map(Member::from),
|
||||||
|
group: group.map(ShareGroup::from),
|
||||||
|
permission,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What became of this device's token on the server during an unlink.
|
||||||
|
///
|
||||||
|
/// Separate from the local result because the local half always succeeds and the
|
||||||
|
/// remote half may not — someone unlinking a machine they are selling deserves to be
|
||||||
|
/// told plainly that the token is still live.
|
||||||
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
|
pub enum RevokeOutcome {
|
||||||
|
Revoked,
|
||||||
|
/// This server predates the self-revoke route. Only the web app can retire it.
|
||||||
|
Unsupported,
|
||||||
|
Failed {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// Nothing to revoke; the app wasn't linked.
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::RevokeOutcome> for RevokeOutcome {
|
||||||
|
fn from(value: core_client::RevokeOutcome) -> Self {
|
||||||
|
match value {
|
||||||
|
core_client::RevokeOutcome::Revoked => RevokeOutcome::Revoked,
|
||||||
|
core_client::RevokeOutcome::Unsupported => RevokeOutcome::Unsupported,
|
||||||
|
core_client::RevokeOutcome::Failed { reason } => RevokeOutcome::Failed { reason },
|
||||||
|
core_client::RevokeOutcome::Skipped => RevokeOutcome::Skipped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of one full push-then-pull cycle.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SyncOutcome {
|
||||||
|
pub push: PushSummary,
|
||||||
|
pub pull: PullSummary,
|
||||||
|
/// The state after the cycle, so the UI refreshes from one call rather than
|
||||||
|
/// following every sync with a status query.
|
||||||
|
pub status: SyncStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts are `u64` because the core uses `usize`, which has no uniffi
|
||||||
|
/// representation. Widening is lossless on every target we build for; narrowing to
|
||||||
|
/// u32 would be a silent truncation waiting for a very large sync.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct PushSummary {
|
||||||
|
pub batches: u64,
|
||||||
|
pub sent: u64,
|
||||||
|
pub created: u64,
|
||||||
|
pub applied: u64,
|
||||||
|
/// The server had a newer edit and kept it. Not a failure — the local row stops
|
||||||
|
/// being dirty and the following pull adopts the server's version.
|
||||||
|
pub kept: u64,
|
||||||
|
pub noop: u64,
|
||||||
|
/// Still dirty, and surfaced: these need a human (a duplicate label name is the
|
||||||
|
/// realistic case). Silently retrying forever would be the wrong shape.
|
||||||
|
pub rejected: u64,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
/// Files attached on this device that reached the server this cycle.
|
||||||
|
pub uploaded: u64,
|
||||||
|
/// Files that didn't; their reasons are in `errors`.
|
||||||
|
pub upload_failed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct PullSummary {
|
||||||
|
pub pages: u64,
|
||||||
|
pub notes_applied: u64,
|
||||||
|
pub notes_deleted: u64,
|
||||||
|
pub labels_applied: u64,
|
||||||
|
pub labels_deleted: u64,
|
||||||
|
pub cursor: i64,
|
||||||
|
/// Rows that still held unpushed local edits when the server's version landed on
|
||||||
|
/// top. Should be 0 in a normal cycle, because push runs first; anything higher
|
||||||
|
/// means local work was overwritten, which is worth saying out loud.
|
||||||
|
pub clobbered_dirty: u64,
|
||||||
|
pub blobs_downloaded: u64,
|
||||||
|
/// Attachments whose bytes couldn't be fetched or failed verification. Counted
|
||||||
|
/// rather than fatal.
|
||||||
|
pub blobs_failed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_push::PushSummary> for PushSummary {
|
||||||
|
fn from(value: core_push::PushSummary) -> Self {
|
||||||
|
let core_push::PushSummary {
|
||||||
|
batches,
|
||||||
|
sent,
|
||||||
|
created,
|
||||||
|
applied,
|
||||||
|
kept,
|
||||||
|
noop,
|
||||||
|
rejected,
|
||||||
|
errors,
|
||||||
|
uploaded,
|
||||||
|
upload_failed,
|
||||||
|
} = value;
|
||||||
|
PushSummary {
|
||||||
|
batches: batches as u64,
|
||||||
|
sent: sent as u64,
|
||||||
|
created: created as u64,
|
||||||
|
applied: applied as u64,
|
||||||
|
kept: kept as u64,
|
||||||
|
noop: noop as u64,
|
||||||
|
rejected: rejected as u64,
|
||||||
|
errors,
|
||||||
|
uploaded: uploaded as u64,
|
||||||
|
upload_failed: upload_failed as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_pull::PullSummary> for PullSummary {
|
||||||
|
fn from(value: core_pull::PullSummary) -> Self {
|
||||||
|
let core_pull::PullSummary {
|
||||||
|
pages,
|
||||||
|
notes_applied,
|
||||||
|
notes_deleted,
|
||||||
|
labels_applied,
|
||||||
|
labels_deleted,
|
||||||
|
cursor,
|
||||||
|
clobbered_dirty,
|
||||||
|
blobs_downloaded,
|
||||||
|
blobs_failed,
|
||||||
|
} = value;
|
||||||
|
PullSummary {
|
||||||
|
pages: pages as u64,
|
||||||
|
notes_applied: notes_applied as u64,
|
||||||
|
notes_deleted: notes_deleted as u64,
|
||||||
|
labels_applied: labels_applied as u64,
|
||||||
|
labels_deleted: labels_deleted as u64,
|
||||||
|
cursor,
|
||||||
|
clobbered_dirty: clobbered_dirty as u64,
|
||||||
|
blobs_downloaded: blobs_downloaded as u64,
|
||||||
|
blobs_failed: blobs_failed as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_engine::SyncOutcome> for SyncOutcome {
|
||||||
|
fn from(value: core_engine::SyncOutcome) -> Self {
|
||||||
|
let core_engine::SyncOutcome { push, pull, status } = value;
|
||||||
|
SyncOutcome {
|
||||||
|
push: push.into(),
|
||||||
|
pull: pull.into(),
|
||||||
|
status: status.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_set_and_a_clear_are_different_patch_entries() {
|
||||||
|
let set = patch_from(vec![NoteEdit::RemindAt {
|
||||||
|
value: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
}]);
|
||||||
|
assert_eq!(set["remind_at"], serde_json::json!("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
let cleared = patch_from(vec![NoteEdit::ClearRemindAt]);
|
||||||
|
assert!(
|
||||||
|
cleared["remind_at"].is_null(),
|
||||||
|
"a clear must reach the store as JSON null — an absent key means \
|
||||||
|
'leave alone', which is a different instruction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_edit_list_is_an_empty_patch() {
|
||||||
|
// Not merely tidy: the core rejects a non-object patch, and a UI that
|
||||||
|
// batches edits may well end up sending none.
|
||||||
|
assert_eq!(patch_from(vec![]), serde_json::json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn later_edits_win_on_a_repeated_field() {
|
||||||
|
let patch = patch_from(vec![
|
||||||
|
NoteEdit::RemindAt {
|
||||||
|
value: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
NoteEdit::ClearRemindAt,
|
||||||
|
]);
|
||||||
|
assert!(patch["remind_at"].is_null());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Where the generated Kotlin lands. Matches the app's package so the bindings are
|
||||||
|
# `com.fabledsword.inkwell.core.*` rather than something the app has to alias.
|
||||||
|
[bindings.kotlin]
|
||||||
|
package_name = "com.fabledsword.inkwell.core"
|
||||||
|
cdylib_name = "inkwell_ffi"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user