AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
+38
-34
@@ -1,4 +1,4 @@
|
||||
# ThoughtSync production settings. Copy to `.env` and edit:
|
||||
# Inkwell production settings. Copy to `.env` and edit:
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
@@ -9,56 +9,60 @@
|
||||
|
||||
# --- required ---------------------------------------------------------------
|
||||
|
||||
# Generate one and keep it: changing it later means also changing it inside the
|
||||
# database, or Postgres will reject the app's connection.
|
||||
# Generate one and keep it: changing it later means changing it inside the database
|
||||
# too. Letters and digits only — it goes into a URL, where `@ / : #` break it.
|
||||
#
|
||||
# openssl rand -base64 24 | tr -d '/+=' | head -c 32
|
||||
#
|
||||
# Stick to letters and digits. This value goes into a connection URL, so a `@`,
|
||||
# `/`, `:` or `#` in it will be misparsed as URL structure rather than password.
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# --- optional ---------------------------------------------------------------
|
||||
|
||||
# Which build to run.
|
||||
#
|
||||
# latest tracks the `main` branch — the production line (default)
|
||||
# dev tracks the `dev` branch — newer, less settled
|
||||
# <commit sha> pins one exact build; every push publishes one, and this is
|
||||
# the rollback lever when an upgrade misbehaves
|
||||
#
|
||||
# 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.
|
||||
#THOUGHTSYNC_TAG=latest
|
||||
# Which build: `latest` (the `main` branch, default), `dev` (newer, less settled),
|
||||
# or a commit sha on `main` to pin one exact build — the rollback lever.
|
||||
#INKWELL_TAG=latest
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
#THOUGHTSYNC_BIND=0.0.0.0
|
||||
# Which interface to bind. Behind a reverse proxy, set 127.0.0.1 so only the proxy
|
||||
# can reach the app.
|
||||
#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 many proxies sit in front is a setting, not an env var: Settings → Security →
|
||||
# "Trusted proxy hops" (default 1).
|
||||
|
||||
# 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`.
|
||||
#THOUGHTSYNC_LOG_LEVEL=INFO
|
||||
# How much the app logs. Credential events (sign-ins, failures, throttles, device
|
||||
# tokens issued) log at INFO; read them with `docker compose logs app`.
|
||||
#INKWELL_LOG_LEVEL=INFO
|
||||
|
||||
# Database identity. Changing these AFTER the first start does not rename anything
|
||||
# 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_DB=thoughtsync
|
||||
#
|
||||
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_*; the old names
|
||||
# aren't read.
|
||||
|
||||
# --- a note on HTTPS --------------------------------------------------------
|
||||
#
|
||||
# The app marks its session cookie Secure automatically when a request arrives over
|
||||
# HTTPS, directly or via a proxy setting X-Forwarded-Proto — no setting needed.
|
||||
# The session cookie is marked Secure automatically when a request arrives over
|
||||
# HTTPS, directly or via a proxy setting X-Forwarded-Proto.
|
||||
#
|
||||
# Worth knowing if you use the desktop app: typing a bare hostname there defaults to
|
||||
# https://, deliberately, so a device token never crosses the wire in cleartext by
|
||||
# accident. Serving over plain HTTP means typing the `http://` yourself.
|
||||
# The desktop and Android apps read a bare hostname as https://, so a server on
|
||||
# plain HTTP has to be typed with its `http://`.
|
||||
|
||||
+113
-69
@@ -1,11 +1,7 @@
|
||||
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 `thoughtsync-core` cross-compiled by cargo-ndk and loaded through
|
||||
# uniffi.
|
||||
# The native Kotlin/Compose client over the shared Rust core. 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
|
||||
@@ -15,12 +11,12 @@ name: Android
|
||||
# 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).
|
||||
# with it.
|
||||
|
||||
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.
|
||||
# See desktop.yml for why.
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -32,17 +28,17 @@ 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).
|
||||
# gradle.properties is not enough on its own.
|
||||
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`.
|
||||
# See the equivalent job in desktop.yml — same reasoning, the same one file set in
|
||||
# `packaging/version.sh`.
|
||||
#
|
||||
# The guard runs here so it covers the skip path too (§6.3).
|
||||
# The guard runs here so it covers the skip path too.
|
||||
#
|
||||
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches
|
||||
# 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.
|
||||
@@ -72,22 +68,44 @@ jobs:
|
||||
sh packaging/guard-forward.sh android "$channel"
|
||||
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
|
||||
|
||||
build:
|
||||
name: Kotlin + Rust (APK)
|
||||
# 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.
|
||||
#
|
||||
# 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, so `openssl-sys` fails
|
||||
# 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 is only a scheduling label (Label Model B). flutter-ci is the
|
||||
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. 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
|
||||
# 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
|
||||
# For the dispatch at the end: this lane starts the server image build.
|
||||
actions: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -97,7 +115,7 @@ jobs:
|
||||
- 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).
|
||||
# desktop.yml. Depth-1 is silently wrong here, not loudly broken.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cache Gradle and Cargo
|
||||
@@ -121,7 +139,7 @@ jobs:
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
|
||||
# TWO CLOCKS, ON PURPOSE. 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
|
||||
@@ -134,16 +152,14 @@ jobs:
|
||||
echo "code=$code" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks
|
||||
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/thoughtsync-release.jks" >> $GITHUB_OUTPUT
|
||||
# An optimised .so for the APK people install. The cargoNdk task keeps
|
||||
# the symbols uniffi reads its metadata from, which the release
|
||||
# profile would strip.
|
||||
echo "profile=release" >> $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
|
||||
@@ -167,10 +183,10 @@ jobs:
|
||||
# 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 -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
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
|
||||
# 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.
|
||||
#
|
||||
@@ -185,15 +201,15 @@ jobs:
|
||||
- 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 covered by the workspace
|
||||
# tests in the desktop lane.
|
||||
# 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
|
||||
# not exist. 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 -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }}
|
||||
|
||||
- name: Assemble the APK
|
||||
env:
|
||||
@@ -203,20 +219,39 @@ jobs:
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
./gradlew assemble${{ steps.build.outputs.variant }} \
|
||||
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
||||
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \
|
||||
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }}
|
||||
-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
|
||||
#
|
||||
# And FAILS unless there is exactly one signer and it is the release key.
|
||||
# Printing alone is not a check: a build signed with any other key would go on
|
||||
# to be staged and published, and every phone would refuse it. The steps that
|
||||
# publish come after this one in the same job, so a failure here stops them.
|
||||
- name: Check the signing certificate
|
||||
if: steps.build.outputs.keystore != ''
|
||||
env:
|
||||
# The release certificate's SHA-256, as apksigner prints it for a signed
|
||||
# APK (CN=Bryan Van Deusen, O=fabledsword). Every
|
||||
# installed copy carries this certificate. It only changes if the key
|
||||
# does, and then every install has to be replaced by hand anyway.
|
||||
RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa
|
||||
run: |
|
||||
apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)"
|
||||
"$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk"
|
||||
certs="$("$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk")"
|
||||
printf '%s\n' "$certs"
|
||||
signers="$(printf '%s\n' "$certs" | grep -c '^Signer #[0-9]* certificate SHA-256 digest:' || true)"
|
||||
digest="$(printf '%s\n' "$certs" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')"
|
||||
if [ "$signers" != 1 ] || [ "$digest" != "$RELEASE_CERT_SHA256" ]; then
|
||||
echo "::error::The APK is not signed by the release key alone ($signers signer(s), first $digest; expected $RELEASE_CERT_SHA256)."
|
||||
exit 1
|
||||
fi
|
||||
echo "Signed by the release key."
|
||||
|
||||
# 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
|
||||
@@ -226,10 +261,10 @@ jobs:
|
||||
if: steps.build.outputs.keystore != ''
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk
|
||||
size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')"
|
||||
sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)"
|
||||
cat > dist/thoughtsync-android.json <<JSON
|
||||
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 }},
|
||||
@@ -237,7 +272,7 @@ jobs:
|
||||
"sha256": "$sha"
|
||||
}
|
||||
JSON
|
||||
cat dist/thoughtsync-android.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
|
||||
@@ -245,10 +280,9 @@ jobs:
|
||||
# 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.
|
||||
# Publishing `stable` from main 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`.
|
||||
- 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: .
|
||||
@@ -256,43 +290,53 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in
|
||||
main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
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` would shadow 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
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
||||
# it reports success while Gitea serves artifacts back only through the
|
||||
# v4 API, so the upload is stored and invisible. Pinned by SHA because
|
||||
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
|
||||
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
# Stock action: it works on this forge because gitea/runner 3.x edits
|
||||
# upload-artifact's client-side GHES refusal out of the action bundle. Never
|
||||
# @v3 — it reports success while Gitea serves artifacts back only through the
|
||||
# v4 API, so the upload is stored and invisible.
|
||||
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: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
||||
# The APK's variant, NOT the Cargo profile. They are the same word for
|
||||
# different things, and an unsigned debug APK still differs from a
|
||||
# signed release one in more than its Rust.
|
||||
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: 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 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.
|
||||
# No `always()`: a red core check or APK build means no image for this
|
||||
# commit. Building regardless would publish a `:<sha>` holding a client that
|
||||
# isn't from that commit. The previous `:dev` stays where it is; fix forward.
|
||||
#
|
||||
# Not `if: success()` and not skipped on tags either: every ref that builds an
|
||||
# image needs the call, or nothing builds one at all.
|
||||
# Not skipped on tags: every ref that builds an image needs the call, since
|
||||
# ci.yml stands down on these pushes and nothing else builds one.
|
||||
- name: Build the server image now the client is published
|
||||
if: always() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||
working-directory: .
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
|
||||
+87
-96
@@ -3,22 +3,21 @@
|
||||
# Push to dev: typecheck + lint + test + build :dev
|
||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||
#
|
||||
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
|
||||
# nothing pins one — verified by looking for a consumer, not for whether one is
|
||||
# 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.
|
||||
# THAT IS THE COMPLETE TAG SET. No version-shaped image tag in any lane: nothing
|
||||
# pins one, and the git release tag is a different object in a different system.
|
||||
# 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.
|
||||
# and a tag rebuilding that same source would re-push :<sha>, which a published tag
|
||||
# never allows.
|
||||
#
|
||||
# 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.
|
||||
# main is the production line, so a merge moves :latest to its tip — 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 pulls, accumulating forever, for
|
||||
# a channel whose entire contract is that it moves.
|
||||
#
|
||||
# Required secret (repo -> Settings -> Secrets -> Actions):
|
||||
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
||||
# REGISTRY_TOKEN -- forge PAT with write:packages scope
|
||||
# The registry username is derived from github.repository_owner (public — it's in
|
||||
# the image path), so no REGISTRY_USER secret is needed.
|
||||
name: CI & Build
|
||||
@@ -28,13 +27,12 @@ on:
|
||||
# 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.
|
||||
# * Every push to `main` publishes a `:<sha>`, so any production commit is
|
||||
# addressable. A path filter would break that 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.
|
||||
# picks those updates up.
|
||||
#
|
||||
# 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.
|
||||
@@ -54,7 +52,7 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: git.fabledsword.com
|
||||
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
|
||||
IMAGE: git.fabledsword.com/bvandeusen/inkwell
|
||||
|
||||
jobs:
|
||||
# Should this push build an image now, or is the Android lane about to publish a
|
||||
@@ -62,17 +60,17 @@ jobs:
|
||||
#
|
||||
# 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)
|
||||
# would be no second build — `:<sha>` is the immutable rollback unit
|
||||
# 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.
|
||||
# The path list below MUST match android's file set in packaging/version.sh. Two
|
||||
# places holding one decision drift apart; it is here because this gate needs the
|
||||
# answer before the Android lane has decided, 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'
|
||||
@@ -125,11 +123,10 @@ jobs:
|
||||
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.
|
||||
# MUST match android's file set in packaging/version.sh. A path missing here
|
||||
# lets a push touching only it rebuild and dispatch from the Android lane
|
||||
# while this gate ALSO lets the image build: two images for one commit and,
|
||||
# on main, a second push of the same :<sha> with different bytes.
|
||||
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"
|
||||
@@ -143,8 +140,12 @@ jobs:
|
||||
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. Among other things they guard the web copy of the note grammar, which is
|
||||
# mirrored in Rust and Python.
|
||||
typecheck:
|
||||
name: TypeScript typecheck
|
||||
name: Web typecheck and unit tests
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
@@ -156,8 +157,14 @@ jobs:
|
||||
run: npm ci
|
||||
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
|
||||
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
|
||||
|
||||
lint:
|
||||
@@ -193,17 +200,16 @@ jobs:
|
||||
# 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.
|
||||
# Real-Postgres lane: `alembic upgrade head` runs here before any deployment runs
|
||||
# it, and the schema the migrations build is 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
|
||||
# Job key stays separator-free ("integration") with no `name:`. 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.
|
||||
# on this runner, 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
|
||||
@@ -215,11 +221,11 @@ jobs:
|
||||
# Postgres it will actually meet.
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: thoughtsync
|
||||
POSTGRES_USER: inkwell
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: thoughtsync_test
|
||||
POSTGRES_DB: inkwell_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U thoughtsync"
|
||||
--health-cmd "pg_isready -U inkwell"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
@@ -239,12 +245,22 @@ jobs:
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for the name filter) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1)
|
||||
test -n "$PG"
|
||||
# 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, and would take 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 THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test"
|
||||
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
|
||||
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
|
||||
# Wait for Postgres to accept connections. `run:` is busybox sh —
|
||||
# no bash /dev/tcp — so use the Python that is always present here.
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
@@ -257,7 +273,7 @@ jobs:
|
||||
else:
|
||||
sys.exit("postgres did not become reachable")
|
||||
PY
|
||||
# Real migrations build the schema, never metadata.create_all (rule 82) —
|
||||
# Real migrations build the schema, never metadata.create_all —
|
||||
# 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
|
||||
@@ -265,15 +281,9 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
# Every lane gates the build. This once stopped at lint + typecheck, on the
|
||||
# reasoning that DB-backed testing happened manually against the dev image
|
||||
# rather than on every push — true until 6f21db8 added the integration lane,
|
||||
# and false since.
|
||||
#
|
||||
# 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.
|
||||
# Every lane gates the build. An image tag is the rollback substrate, and 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
|
||||
@@ -286,22 +296,20 @@ jobs:
|
||||
- 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).
|
||||
# and produces a too-low value silently, with the lane green.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
||||
# run: steps execute under busybox sh, so use POSIX `case`,
|
||||
# NOT bash `[[ ]]`.
|
||||
run: |
|
||||
# The image's version is DERIVED from its own shipped files — including the
|
||||
# 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.
|
||||
# it. One value and no ordering key: nothing compares a server image.
|
||||
#
|
||||
# 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.
|
||||
# Date-shaped, because "how old is this instance?" is 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
|
||||
refs/heads/dev)
|
||||
@@ -323,53 +331,36 @@ jobs:
|
||||
docker system prune -af || true
|
||||
docker builder prune --keep-storage 5g -f || true
|
||||
|
||||
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
||||
# :<version> all carry one and a `docker compose pull` delivers a new client
|
||||
# along with the new server.
|
||||
# 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, with no trip to a
|
||||
# release page on the forge.
|
||||
#
|
||||
# Always the rolling `dev` release — the newest build there is. A versioned
|
||||
# image therefore carries the newest client rather than one pinned to that
|
||||
# version; the two negotiate a sync protocol version before linking, so
|
||||
# "newest" is safe in a way "matching" would not buy anything over.
|
||||
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
||||
# the price of the product being complete, 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 release is private, and a
|
||||
# token used inside a build lands in the context or a layer.
|
||||
# Fetched by the JOB, not by the Dockerfile, so the token it may use never lands
|
||||
# in the build context or a layer.
|
||||
#
|
||||
# NEVER fails the build. An image with no Android client advertises none and
|
||||
# hides the download — a supported state, and the only one available before
|
||||
# the first Android build has ever published.
|
||||
- name: Fetch the Android client to bake in
|
||||
# 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: |
|
||||
mkdir -p client
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
|
||||
# client; :latest serves the stable one. 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, fixed here because this
|
||||
# is the step that gave `stable` an APK to point at.
|
||||
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||
# :latest serves stable ones. The channel is chosen here rather than inside
|
||||
# the script because the caller is what knows which image it is building.
|
||||
case "${{ github.ref_name }}" in
|
||||
main) channel=stable ;;
|
||||
*) channel=dev ;;
|
||||
esac
|
||||
echo "Baking in the $channel client."
|
||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
|
||||
ok=1
|
||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
||||
done
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo "Baking in:"
|
||||
cat client/thoughtsync-android.json
|
||||
ls -l client/thoughtsync.apk
|
||||
else
|
||||
# Both or neither. Half a pair is worse than none: the server would
|
||||
# read a sidecar describing an APK that isn't there, or an APK it
|
||||
# cannot state a version for.
|
||||
echo "::warning::No Android client on the dev release — this image ships without one."
|
||||
rm -f client/thoughtsync.apk client/thoughtsync-android.json
|
||||
fi
|
||||
sh packaging/fetch-clients.sh "$channel" client
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
+157
-104
@@ -4,22 +4,20 @@
|
||||
# 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.
|
||||
# are peer surfaces held to one quality bar, not a primary and its fallbacks. A
|
||||
# build takes 4-5 minutes, so there is no cost worth narrowing that for.
|
||||
#
|
||||
# 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, not a per-purpose
|
||||
# runner. The frontend is built here because tauri's generate_context! embeds it.
|
||||
name: Desktop (Tauri)
|
||||
|
||||
on:
|
||||
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).
|
||||
# NO `paths:` FILTER. It would be a second, hand-kept statement of this artifact's
|
||||
# file set beside the one in `packaging/version.sh`, and the two drift. The
|
||||
# `decide` job below reads the real set and skips in seconds when nothing moved —
|
||||
# one definition, one reader.
|
||||
#
|
||||
# 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
|
||||
@@ -40,13 +38,9 @@ permissions:
|
||||
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.
|
||||
# ONE reader of ONE definition: the file sets in `packaging/version.sh`.
|
||||
#
|
||||
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
|
||||
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one.
|
||||
# Skipping because "the channel already serves this version" is indistinguishable
|
||||
# from "we derived a stale value that happens to match" unless something checks.
|
||||
decide:
|
||||
@@ -60,7 +54,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Derives a version — depth-1 is silently wrong (§6.1).
|
||||
# Derives a version — depth-1 is silently wrong.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Decide
|
||||
@@ -75,9 +69,77 @@ jobs:
|
||||
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: nothing publishes on red.
|
||||
#
|
||||
# As steps inside `build` they would gate the Linux publish and nothing else, and
|
||||
# the Windows installer would publish beside a failed clippy. 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]
|
||||
timeout-minutes: 20
|
||||
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.
|
||||
- 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. 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. Inside the
|
||||
# desktop crate, cargo scopes to the desktop PACKAGE: the core's tests would not
|
||||
# run and nothing would lint the Android uniffi shim, while both still COMPILE
|
||||
# as dependencies — so the run stays green and the gap stays invisible.
|
||||
- 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:
|
||||
name: Tauri desktop (Linux)
|
||||
needs: [decide]
|
||||
needs: [decide, verify]
|
||||
# A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step
|
||||
# runs to the runner's own 30-minute cap, as a rust-lld link whose threads have
|
||||
# all deadlocked will. Kept under that cap so this one is the limit
|
||||
# that fires, whatever the runner is configured with.
|
||||
timeout-minutes: 20
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
@@ -92,57 +154,24 @@ jobs:
|
||||
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
|
||||
# the lane green — 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
|
||||
# frontend must exist before any cargo compile (clippy/test/build), not just
|
||||
# at bundle time.
|
||||
# frontend must exist before cargo compiles anything, not just at bundle time.
|
||||
- name: Build the shared frontend
|
||||
run: npm ci && npm run build
|
||||
working-directory: frontend
|
||||
|
||||
# --locked on the FIRST cargo invocation of the job is the lockfile gate: it
|
||||
# fails the run if Cargo.toml and the committed Cargo.lock disagree, instead
|
||||
# of silently re-resolving. Everything after it in this job then compiles the
|
||||
# exact versions recorded in the lockfile, so the flag isn't repeated on the
|
||||
# bundle build (issue 2102).
|
||||
#
|
||||
# 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 the ~20-40 min
|
||||
# bundle build, so a fmt failure doesn't burn that.
|
||||
- name: Rust format check
|
||||
run: cargo fmt --all --check
|
||||
|
||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||
#
|
||||
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
|
||||
# tauri FAILS the build if it's asked to produce updater artifacts with no key,
|
||||
# so making it conditional is what lets the pipeline stay green before the
|
||||
# operator has added the secret. With the key present, each bundle gets a
|
||||
# createUpdaterArtifacts is applied only when a signing key exists: tauri
|
||||
# FAILS the build if it's asked to produce updater artifacts with no key, so
|
||||
# making it conditional is what lets the pipeline stay green without the
|
||||
# secret. With the key present, each bundle gets a
|
||||
# `.sig` beside it — the file the updater actually verifies against.
|
||||
- name: Tauri build (deb + AppImage)
|
||||
env:
|
||||
@@ -159,9 +188,17 @@ jobs:
|
||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||
# 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).
|
||||
# separate value, exported just below.
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
# The DISPLAY version, baked into the binary by `option_env!`.
|
||||
# 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 \
|
||||
--config '{"build":{"beforeBuildCommand":""}}' \
|
||||
--config "{\"version\":\"$version\"}" \
|
||||
@@ -170,7 +207,7 @@ jobs:
|
||||
|
||||
# Tauri's AppImage bundles the build host's graphics/display libs
|
||||
# (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU
|
||||
# drivers and abort to a black window (EGL_BAD_PARAMETER, issue 2021).
|
||||
# drivers and abort to a black window (EGL_BAD_PARAMETER).
|
||||
# Strip that host-coupled stack so the app uses the running system's
|
||||
# graphics libs; webkit/gtk stay bundled. Runs from the repo root (the
|
||||
# script resolves its own paths), overwriting the AppImage in place.
|
||||
@@ -204,9 +241,9 @@ jobs:
|
||||
# control file and cross-checks it against what the ELF actually needs
|
||||
# (dpkg-shlibdeps).
|
||||
#
|
||||
# We deliberately do NOT set bundle.linux.deb.depends: run 2872 showed
|
||||
# tauri already infers exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so
|
||||
# declaring them again only produced a control file listing each twice.
|
||||
# We deliberately do NOT set bundle.linux.deb.depends: tauri already infers
|
||||
# exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring them again only
|
||||
# produces a control file listing each twice.
|
||||
# This step is the guard instead — if tauri's inference ever stops
|
||||
# covering what the binary links, the build fails here.
|
||||
- name: Verify the .deb
|
||||
@@ -222,18 +259,16 @@ jobs:
|
||||
run: bash desktop/packaging/arch/package-prebuilt.sh
|
||||
|
||||
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
||||
# Mirrored action, never actions/upload-artifact: @v4+ throws
|
||||
# GHESNotSupportedError on the hostname before it connects, and @v3 uploads
|
||||
# something Gitea stores but will never serve back (it returns artifacts only
|
||||
# through the v4 API, which filters on content_encoding='application/zip').
|
||||
# Pinned by SHA — the mirror auto-syncs, so a moved upstream tag would
|
||||
# silently change what runs. See Scribe issues 2255 / 2270.
|
||||
# No continue-on-error: a swallowed upload failure is exactly how 110
|
||||
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
|
||||
# Stock action: it works on this forge because gitea/runner 3.x edits
|
||||
# upload-artifact's client-side GHES refusal out of the action bundle. Never
|
||||
# @v3 — it uploads 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 lets unreachable artifacts pile up unnoticed. Fail loudly instead.
|
||||
- name: Upload bundles
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: thoughtsync-linux
|
||||
name: inkwell-linux
|
||||
path: |
|
||||
target/release/bundle/appimage/*.AppImage
|
||||
target/release/bundle/deb/*.deb
|
||||
@@ -244,14 +279,11 @@ jobs:
|
||||
|
||||
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
||||
# are releases whose tag never moves, so the updater has a permanent URL to
|
||||
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
|
||||
# read — the forge has no /releases/latest/download/<asset> route, so "newest"
|
||||
# cannot be named in a URL.
|
||||
#
|
||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
||||
# M314 step 3 this job built on main and published nothing, so the stable
|
||||
# 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 build.
|
||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
|
||||
# reaches stable users with no tag cut.
|
||||
#
|
||||
# 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
|
||||
@@ -267,13 +299,16 @@ jobs:
|
||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||
exit 0
|
||||
fi
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
|
||||
# `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) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
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` would shadow 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
|
||||
@@ -293,7 +328,8 @@ jobs:
|
||||
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
||||
windows:
|
||||
name: Windows installer (cross-compiled)
|
||||
needs: [decide]
|
||||
needs: [decide, verify]
|
||||
timeout-minutes: 20
|
||||
if: needs.decide.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
@@ -303,7 +339,7 @@ jobs:
|
||||
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
|
||||
# the lane green — 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.
|
||||
@@ -324,8 +360,8 @@ jobs:
|
||||
run: cargo tauri icon app-icon.png
|
||||
working-directory: desktop/src-tauri
|
||||
|
||||
# This lane's lockfile gate (the Linux job gets it from `cargo clippy
|
||||
# --locked`). It has to be its own step here because the build is this job's
|
||||
# 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.
|
||||
@@ -344,9 +380,17 @@ jobs:
|
||||
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||
# 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).
|
||||
# separate value, exported just below.
|
||||
version="$(sh ../../packaging/version.sh key desktop)"
|
||||
echo "Building desktop ordering key $version"
|
||||
# The DISPLAY version, baked into the binary by `option_env!`.
|
||||
# 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='{}'
|
||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
||||
@@ -360,26 +404,21 @@ jobs:
|
||||
--config "$updater"
|
||||
working-directory: desktop/src-tauri
|
||||
|
||||
# Mirrored action, never actions/upload-artifact — see the Linux job's
|
||||
# Upload bundles step for the full reasoning. Pinned by SHA because the
|
||||
# mirror auto-syncs.
|
||||
# Stock action — see the Linux job's Upload bundles step for why.
|
||||
- name: Upload installer
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: thoughtsync-windows
|
||||
name: inkwell-windows
|
||||
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
||||
# are releases whose tag never moves, so the updater has a permanent URL to
|
||||
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
|
||||
# read — the forge has no /releases/latest/download/<asset> route, so "newest"
|
||||
# cannot be named in a URL.
|
||||
#
|
||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
||||
# M314 step 3 this job built on main and published nothing, so the stable
|
||||
# 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 build.
|
||||
# MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main
|
||||
# reaches stable users with no tag cut.
|
||||
#
|
||||
# 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
|
||||
@@ -395,13 +434,16 @@ jobs:
|
||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||
exit 0
|
||||
fi
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh.
|
||||
# `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) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;;
|
||||
*) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;;
|
||||
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` would shadow 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
|
||||
@@ -417,6 +459,7 @@ jobs:
|
||||
manifest:
|
||||
name: Update manifest
|
||||
needs: [build, windows]
|
||||
timeout-minutes: 5
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
@@ -426,7 +469,7 @@ jobs:
|
||||
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
|
||||
# the lane green — 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.
|
||||
@@ -447,17 +490,27 @@ jobs:
|
||||
# match the binary it points at is an updater that never settles. It must
|
||||
# be `key`: this value is matched against bundle filenames.
|
||||
version="$(sh packaging/version.sh key desktop)"
|
||||
# The version a PERSON reads, published beside the manifest as
|
||||
# `inkwell-desktop.json`. The image build reads it to describe the
|
||||
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
|
||||
# anything from its own checkout — which would be a different commit
|
||||
# whenever the desktop did not rebuild.
|
||||
display="$(sh packaging/version.sh display desktop)"
|
||||
# Both channels are rolling: the manifest lands on the same release that
|
||||
# holds the bundles, and the previous build's bundles are dropped once it
|
||||
# points at this one. Nothing can reach them, and they are ~100 MB a push.
|
||||
#
|
||||
# No tag arm any more. A `v*` tag does not reach this workflow at all — it
|
||||
# No tag arm: a `v*` tag does not reach this workflow at all — it
|
||||
# triggers release.yml, which writes a changelog and builds nothing.
|
||||
case "${GITHUB_REF_NAME}" in
|
||||
main) export RELEASE_TAG=stable
|
||||
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
|
||||
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
|
||||
*) export RELEASE_TAG=dev
|
||||
*) RELEASE_TAG="$(sh packaging/channel-tag.sh 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" bash desktop/packaging/write-manifest.sh
|
||||
APP_VERSION="$version" DISPLAY_VERSION="$display" \
|
||||
bash desktop/packaging/write-manifest.sh
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
name: Release
|
||||
|
||||
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
|
||||
# A RELEASE BUILDS NOTHING. That is the whole point of this lane.
|
||||
#
|
||||
# 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.
|
||||
# `:<sha>`, which a published tag never allows.
|
||||
#
|
||||
# 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
|
||||
# what was in it. 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.
|
||||
# This lane never cuts a tag; it 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
|
||||
# Same string as the artifact version (`vYYYY.MM.DD.HHMM`), different
|
||||
# system.
|
||||
|
||||
on:
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
# 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
|
||||
# wrote, 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
|
||||
|
||||
+2
-2
@@ -209,5 +209,5 @@ android/local.properties
|
||||
# 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/thoughtsync.apk
|
||||
client/thoughtsync-android.json
|
||||
client/inkwell.apk
|
||||
client/inkwell-android.json
|
||||
|
||||
Generated
+681
-59
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
||||
# 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).
|
||||
# module inside the desktop app.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"]
|
||||
|
||||
+34
-12
@@ -20,22 +20,33 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
# 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),
|
||||
# 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/ alembic/
|
||||
|
||||
# The Android client this server hands out. CI fetches the newest published build
|
||||
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
||||
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
||||
# delivers a new one with no file copying by hand.
|
||||
# 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.
|
||||
#
|
||||
# Fetched by the JOB rather than here on purpose: the release is private, and a
|
||||
# ~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 APK is a supported state — the server
|
||||
# advertises nothing and the web UI hides the download (client_dist.py).
|
||||
COPY client/ src/thoughtsync/client/
|
||||
# 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/
|
||||
|
||||
# The desktop installer, served at /install.sh with this server's own address
|
||||
# written into it (installer.py). The SAME script the forge serves raw, copied rather
|
||||
# than moved so the forge's raw URL keeps working.
|
||||
COPY desktop/packaging/install.sh src/inkwell/install.sh
|
||||
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
@@ -44,6 +55,17 @@ ENV APP_VERSION=$BUILD_VERSION
|
||||
|
||||
EXPOSE 5000
|
||||
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
||||
# slow/unready database from crash-looping the container. Family convention
|
||||
# (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"]
|
||||
# slow or unready database from crash-looping the container. The schema is built by
|
||||
# real migrations, never metadata.create_all.
|
||||
#
|
||||
# Timeouts, as hypercorn 0.18 implements them:
|
||||
# - `--keep-alive 120` is the idle timeout. It is also the header timeout:
|
||||
# hypercorn marks a connection busy only once a whole request has arrived, so a
|
||||
# client dribbling headers is still "idle" and is cut off at this. 120 stays
|
||||
# above Traefik's 90s idle timeout for backend connections, so the proxy never
|
||||
# reuses one this server has just closed (a 502).
|
||||
# - No `--read-timeout`, on purpose. It bounds every socket read, including the
|
||||
# whole time a download is streaming and the client sends nothing, so it would
|
||||
# cut off an APK fetched slowly over mobile data. Bodies are capped instead
|
||||
# (MAX_CONTENT_LENGTH in app.py).
|
||||
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 120"]
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -1,62 +1,21 @@
|
||||
# ThoughtSync
|
||||
<p align="center">
|
||||
<img src="frontend/public/icon.svg" width="96" alt="Inkwell logo">
|
||||
</p>
|
||||
|
||||
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
||||
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
||||
under a second, designed to grow into a lightweight second brain (labels, search,
|
||||
`[[wiki-links]]`, graph).
|
||||
<h1 align="center">Inkwell</h1>
|
||||
|
||||
- **Stack:** Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI.
|
||||
- **Auth:** native email + password (signed-cookie sessions). Multi-user with the
|
||||
family owner + direct-share + group-share ACL.
|
||||
- **Web-first.** An Android companion is a later, conditional milestone.
|
||||
<p align="center">Capture a thought in under a second, and find it again later.</p>
|
||||
|
||||
## Layout
|
||||
Inkwell is a self-hosted notes app built around a Keep-style board of cards. Type
|
||||
and it's saved; tag with `#words`, tick checklists, set reminders, attach files,
|
||||
and search or browse by date when you need something back. It runs in the browser,
|
||||
as a desktop app for Linux and Windows that works offline, and as an Android app,
|
||||
all syncing with your own server.
|
||||
|
||||
```
|
||||
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
|
||||
alembic/ async migrations (schema built via `alembic upgrade head`)
|
||||
tests/ DB-free unit tests (pytest)
|
||||
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
|
||||
Dockerfile 2-stage build (Vue -> python:3.12-slim runtime)
|
||||
docker-compose.yml local app + Postgres stack
|
||||
```
|
||||
## Quick start
|
||||
|
||||
## Development
|
||||
|
||||
Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
|
||||
|
||||
```sh
|
||||
pip install -e ".[dev]"
|
||||
alembic upgrade head
|
||||
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
|
||||
```
|
||||
|
||||
Frontend (proxies `/api` to `:5000`):
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Or run it in Docker. **Hot-reload dev stack** (edit code, changes reload live) —
|
||||
Postgres + backend + Vite dev server, app at http://localhost:5173:
|
||||
|
||||
```sh
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
Or the **full production image** (SPA baked in) at http://localhost:5000:
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Deploy (self-host)
|
||||
|
||||
A complete two-container stack (app + Postgres) you can copy and run. Save it as
|
||||
`docker-compose.yml`, change the two `CHANGE_ME` passwords (they must match), then
|
||||
`docker compose up -d`:
|
||||
Save this as `docker-compose.yml`, replace both `CHANGE_ME`s with the same password,
|
||||
and run `docker compose up -d`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -64,65 +23,142 @@ services:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: thoughtsync
|
||||
POSTGRES_PASSWORD: CHANGE_ME # change this
|
||||
POSTGRES_DB: thoughtsync
|
||||
POSTGRES_USER: inkwell
|
||||
POSTGRES_PASSWORD: CHANGE_ME
|
||||
POSTGRES_DB: inkwell
|
||||
volumes:
|
||||
- thoughtsync-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
- inkwell-db:/var/lib/postgresql/data
|
||||
|
||||
app:
|
||||
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
|
||||
image: git.fabledsword.com/bvandeusen/inkwell:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
- db
|
||||
environment:
|
||||
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
|
||||
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
|
||||
volumes:
|
||||
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
|
||||
- inkwell-data:/var/inkwell
|
||||
ports:
|
||||
- "5000:5000"
|
||||
|
||||
volumes:
|
||||
thoughtsync-db:
|
||||
thoughtsync-data:
|
||||
inkwell-db:
|
||||
inkwell-data:
|
||||
```
|
||||
|
||||
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
||||
Open `http://<host>:5000` and register. **The first account becomes the admin.**
|
||||
|
||||
- **Only `THOUGHTSYNC_DATABASE_URL` is required.** `THOUGHTSYNC_SECRET_KEY` is optional; if
|
||||
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`.
|
||||
- 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) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
|
||||
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):** ThoughtSync is installable ("Add to Home Screen" / the
|
||||
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
|
||||
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
||||
The repository's own [docker-compose.yml](docker-compose.yml) is the same stack read
|
||||
from a `.env` file; copy [.env.example](.env.example) to `.env` to use it.
|
||||
|
||||
## Milestones
|
||||
## Configuration
|
||||
|
||||
- **M0 — Foundation & Identity** ✅: Quart+Vue+Postgres skeleton, native auth,
|
||||
sharing-ACL spine, Fabled-Git CI.
|
||||
- **M1 — Capture Core** ✅: note model + masonry board (quick-add, colors, pin,
|
||||
edit-in-place, archive, trash+restore).
|
||||
- **M1.5 — Roles & DB-backed Settings** ✅: first user is admin, admin Settings UI,
|
||||
`DATABASE_URL` is the only required env.
|
||||
- M2 — Organize: labels/tags, search, checklists, attachments.
|
||||
- M3 — Second-brain seeds: `[[wiki-links]]`, backlinks, graph view, reminders.
|
||||
- M4 — Launch hardening: responsive/PWA, settings UI, polish.
|
||||
| Variable | Required | What it is |
|
||||
|---|---|---|
|
||||
| `INKWELL_DATABASE_URL` | yes | The Postgres connection URL. |
|
||||
| `INKWELL_SECRET_KEY` | no | Signs sessions. Unset, one is generated and kept in the database. |
|
||||
| `INKWELL_LOG_LEVEL` | no | `INFO` by default. Sign-ins and other credential events log at INFO. |
|
||||
|
||||
Work happens on `dev`; `main` is protected (PR-only).
|
||||
Everything else — site name, sign-ups, email, attachment limits, trash retention,
|
||||
link previews, backups, proxy hops — is set by the admin in **Settings**.
|
||||
|
||||
## Before you put it on the internet
|
||||
|
||||
Close registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing
|
||||
the app port directly, and back up the attachment volume as well as the database.
|
||||
[docs/public-hosting.md](docs/public-hosting.md) walks through each one, and lists
|
||||
what the app already hardens on its own.
|
||||
|
||||
The browser only offers to install Inkwell as an app (PWA) over HTTPS or `localhost`.
|
||||
|
||||
## Updating and backups
|
||||
|
||||
- **Update:** `docker compose pull && docker compose up -d`. Migrations run on start.
|
||||
- **Roll back:** pin `image: …/inkwell:<commit-sha>`. Every build on `main` publishes one.
|
||||
- **Back up:** turn on scheduled backups in **Settings → Backups** (a folder, an
|
||||
S3-compatible bucket, or Fabled Archives). See [docs/backups.md](docs/backups.md).
|
||||
Otherwise back up both volumes: `inkwell-db` and `inkwell-data`.
|
||||
|
||||
## Features
|
||||
|
||||
**Capturing**
|
||||
- A masonry board of cards. Start typing anywhere on it to write a new note.
|
||||
- Notes save as you type; there is no save button.
|
||||
- Checklists inside a note, ticked straight from the card.
|
||||
- Images and files attached to a note, shown on its card; click an image to see it
|
||||
full size or copy it back out.
|
||||
- Paste a link and it gets a preview card.
|
||||
- On the desktop, a global hotkey opens a small capture window over whatever you're doing.
|
||||
- On Android, share text or images into Inkwell from any app.
|
||||
|
||||
**Organising**
|
||||
- `#tags` written in the text become tags; a line of tags on its own is tidied away.
|
||||
- Each tag gets a colour, and tags can be renamed, recoloured, merged and deleted.
|
||||
- Pin, archive, drag to reorder.
|
||||
- Trash with undo, emptied after a retention period the admin sets.
|
||||
|
||||
**Finding**
|
||||
- Full-text search, combined with filters for tags, attachments and notes shared with you.
|
||||
- A timeline that groups notes by when you wrote them.
|
||||
- A command palette that jumps to any note by name.
|
||||
- Version history: every editing session keeps the note as it was before.
|
||||
|
||||
**Remembering**
|
||||
- Reminders, one-off or repeating, with Done and snooze from the card or the
|
||||
notification.
|
||||
- The desktop and Android apps notify you when the app isn't in front.
|
||||
|
||||
**Sharing and accounts**
|
||||
- Share a note with a person or a group, to view or to edit.
|
||||
- Invites, groups, per-account storage limits and an activity log for the admin.
|
||||
- Password reset by email, or by a link the admin makes.
|
||||
- Export everything as a zip, and import an Inkwell export or a Google Keep Takeout.
|
||||
|
||||
**Every device**
|
||||
- Web app, installable as a PWA.
|
||||
- Desktop app for Linux (`.deb`, Arch package, AppImage) and Windows. It keeps its
|
||||
own local store, works with no server at all, and syncs once you link one. Install
|
||||
it from your server's Account page, or with `curl -fsSL https://<your-server>/install.sh | sh`.
|
||||
- Android app, downloaded from your server and updated by it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| What you see | Why | Fix |
|
||||
|---|---|---|
|
||||
| The app container restarts with a database error at startup | Postgres isn't up yet, or the two passwords differ | Wait a minute; check both `CHANGE_ME`s match |
|
||||
| No "Install app" option in the browser | The page isn't on HTTPS | Serve it over HTTPS, or open it on `localhost` |
|
||||
| The desktop or Android app can't reach a plain-HTTP server | A bare hostname is read as `https://` | Type the address with `http://` |
|
||||
| A deployment upgraded from ThoughtSync shows no notes | It's pointed at new, empty volumes | Set `INKWELL_DB_VOLUME` and `INKWELL_DATA_VOLUME`; see [.env.example](.env.example) |
|
||||
|
||||
## Development
|
||||
|
||||
Backend (needs Postgres at `INKWELL_DATABASE_URL`):
|
||||
|
||||
```sh
|
||||
pip install -e ".[dev]"
|
||||
alembic upgrade head
|
||||
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
|
||||
```
|
||||
|
||||
Frontend, proxying `/api` to `:5000`: `cd frontend && npm install && npm run dev`
|
||||
(http://localhost:5173). Or run everything with hot reload:
|
||||
`docker compose -f docker-compose.dev.yml up`.
|
||||
|
||||
| Path | What's there |
|
||||
|---|---|
|
||||
| `src/inkwell/` | The Quart server |
|
||||
| `alembic/` | Database migrations |
|
||||
| `frontend/` | The Vue web app, also the desktop app's UI |
|
||||
| `core/` | The Rust store and sync engine the desktop and Android apps share |
|
||||
| `desktop/` | The Tauri desktop app and its packaging |
|
||||
| `android/` | The Kotlin/Compose Android app |
|
||||
|
||||
How devices sync is in [docs/sync.md](docs/sync.md); how the Android app reaches a
|
||||
server is in [docs/android-distribution.md](docs/android-distribution.md). Work
|
||||
happens on `dev`; `main` is the stable line.
|
||||
|
||||
## Licence
|
||||
|
||||
Inkwell is free software under the [GNU Affero General Public License v3.0 only](LICENSE)
|
||||
(`AGPL-3.0-only`). If you run a modified version for other people over a network, the
|
||||
licence requires you to offer them its source.
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
# Alembic single-database async configuration for ThoughtSync.
|
||||
# Alembic single-database async configuration for Inkwell.
|
||||
|
||||
[alembic]
|
||||
script_location = %(here)s/alembic
|
||||
prepend_sys_path = . src
|
||||
path_separator = os
|
||||
|
||||
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py).
|
||||
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync
|
||||
# Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
|
||||
sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
|
||||
|
||||
|
||||
[loggers]
|
||||
|
||||
+3
-3
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from thoughtsync.models import Base
|
||||
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata
|
||||
from inkwell.models import Base
|
||||
import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
|
||||
|
||||
config.set_main_option(
|
||||
"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
|
||||
|
||||
@@ -4,8 +4,8 @@ Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-07-19
|
||||
|
||||
The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the
|
||||
shares table is polymorphic so notes reuse it without a schema retrofit.
|
||||
Identity and the sharing ACL. The shares table is polymorphic, so any shareable
|
||||
entity uses it.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -4,7 +4,7 @@ Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-07-19
|
||||
|
||||
The M1 capture core: the notes table the masonry board renders.
|
||||
The notes table the board renders.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -5,7 +5,7 @@ Revises: 0002
|
||||
Create Date: 2026-07-19
|
||||
|
||||
Adds the admin role bit and the DB-backed settings table. No rows are seeded —
|
||||
defaults live in the code registry (thoughtsync.settings), so an empty table
|
||||
defaults live in the code registry (inkwell.settings), so an empty table
|
||||
means every setting is at its default.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
@@ -16,10 +16,8 @@ depends_on = None
|
||||
def upgrade() -> None:
|
||||
# The note's display NAME: explicit title if set, else the first body line.
|
||||
op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default=""))
|
||||
# Best-effort backfill (kept intentionally simple + unambiguous — CI has no
|
||||
# Postgres lane, so this only runs on a real deploy). The app recomputes the
|
||||
# precise value via derive_display_title on the next save (which additionally
|
||||
# skips leading blank lines). Capped at 200 chars.
|
||||
# Best-effort backfill, capped at 200 chars. The app recomputes the exact value
|
||||
# (derive_display_title, which also skips leading blank lines) on the next save.
|
||||
op.execute(
|
||||
r"""
|
||||
UPDATE notes SET display_title = LEFT(btrim(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""sync revision + tombstone infrastructure (M8 sync hub, step 1)
|
||||
"""sync revision + tombstone infrastructure
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
@@ -11,9 +11,6 @@ forget to bump it. Child-row changes (items / attachments / label membership) bu
|
||||
their parent note's revision, since a note syncs as a whole. `purged_at` is the
|
||||
hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an
|
||||
offline client learns a row is gone instead of resurrecting it.
|
||||
|
||||
No API behavior changes here — this is pure infrastructure. Triggers/behavior are
|
||||
operator-verified on deploy (no Postgres CI lane).
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""device_tokens (M8 sync hub, step 2)
|
||||
"""device_tokens
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""labels.updated_at (M8 sync hub, step 4 — LWW field for the label catalog)
|
||||
"""labels.updated_at, the last-write-wins field for the label catalog
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""note_attachments.sha256 (M8 sync hub, step 5 — attachment blob sync)
|
||||
"""note_attachments.sha256, for attachment blob sync
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""note_attachments.filename (any-file attachments — M6 1900)
|
||||
"""note_attachments.filename, for attachments of any file type
|
||||
|
||||
Revision ID: 0019
|
||||
Revises: 0018
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""note_link_previews (URL unfurl — M6 1901)
|
||||
"""note_link_previews, cached URL unfurls
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""saved_filters (richer search + saved views — M6 1902)
|
||||
"""saved_filters, saved search views
|
||||
|
||||
Revision ID: 0021
|
||||
Revises: 0020
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""notes.recurrence (recurring reminders — M6 1908)
|
||||
"""notes.recurrence, for recurring reminders
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
|
||||
"""note_links.target_id — resolve [[links]] to a note, not to a string
|
||||
|
||||
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).
|
||||
A wiki-link stored only as normalized text makes a note's name the edge, so renaming
|
||||
the note breaks every inbound link. Resolving to an id survives a rename.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
"""drop note_links — [[wiki-links]] are removed (note 2897)
|
||||
"""drop note_links — [[wiki-links]] are removed
|
||||
|
||||
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 stays in the chain: removing an applied revision would strand a database's
|
||||
alembic_version pointer.
|
||||
|
||||
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.
|
||||
note_links was derived from note bodies, so nothing typed is lost; the `[[text]]` is
|
||||
still in every body.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
"""drop notes.kind — a checklist is something a note HAS (M13 step 2)
|
||||
"""drop notes.kind — a checklist is something a note has
|
||||
|
||||
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.
|
||||
A note is a body plus optional checkable items. Items are rows in their own table,
|
||||
so a `kind = 'list'` note keeps every one.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
@@ -29,7 +21,6 @@ def upgrade() -> None:
|
||||
|
||||
|
||||
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.
|
||||
# server_default so existing rows get a value. Every note comes back as 'text':
|
||||
# nothing records which were lists.
|
||||
op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text"))
|
||||
|
||||
@@ -4,33 +4,14 @@ 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.
|
||||
A note's name is its first non-empty body line, carried by `display_title`.
|
||||
|
||||
## The search vector has to be rebuilt, not just left alone
|
||||
`notes.search_vector` is a stored generated column over `title` (0005, weight A), and
|
||||
Postgres won't drop a column a generated one depends on. So it is recreated over
|
||||
`display_title`, keeping the name ranked above the body. That recomputes every row
|
||||
and rebuilds the GIN index, which takes a while on a large table.
|
||||
|
||||
`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`).
|
||||
`display_title` was already derived from the title, so every note keeps its name.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -4,64 +4,31 @@ 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.
|
||||
A checklist item becomes a `- [ ] milk` line of `notes.body`, so a list can sit
|
||||
between two paragraphs, and `note_items` goes.
|
||||
|
||||
## 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.
|
||||
* Rows are read before the table is dropped, in this one transaction.
|
||||
* The existing body is only appended to.
|
||||
* The layout (a blank line between prose and the list, nothing between items) is
|
||||
what the export writes and what `derive::append_item` produces, so the clients
|
||||
migrate their own stores independently and still agree with the server.
|
||||
|
||||
Careful was still the right call. It cost little, and the same care is what the rule
|
||||
demands the day someone does import something:
|
||||
The fold is inlined rather than imported from `notes/checklist.py`: a migration must
|
||||
keep producing what it produced the day it ran.
|
||||
|
||||
* 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.
|
||||
## `updated_at` is left alone
|
||||
|
||||
## The fold is inlined on purpose
|
||||
Raw SQL, so `onupdate` never fires, and a client holding an unpushed edit keeps the
|
||||
newer `updated_at` and wins last-write-wins. The `sync_revision` trigger does fire,
|
||||
so every migrated note is pulled once and clients converge.
|
||||
|
||||
`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.
|
||||
## The downgrade is not a true inverse
|
||||
|
||||
## `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.
|
||||
It recreates an empty `note_items` and leaves the bodies alone: nothing tells a line
|
||||
this wrote from one somebody typed. The real rollback is a database restore. The
|
||||
table must exist because 0015's downgrade drops a trigger on it.
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -100,9 +67,8 @@ def upgrade():
|
||||
|
||||
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.
|
||||
# An item whose note is gone has nothing to fold into. The foreign key should
|
||||
# prevent it; skipping avoids a half-migrated database.
|
||||
if body is None:
|
||||
continue
|
||||
for text, checked in items:
|
||||
@@ -113,9 +79,8 @@ def upgrade():
|
||||
|
||||
|
||||
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.
|
||||
# Exactly as 0006 created it, index name included: 0015's downgrade names both
|
||||
# the table and its trigger.
|
||||
op.create_table(
|
||||
"note_items",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
|
||||
@@ -4,73 +4,27 @@ 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.
|
||||
A tag standing on its own line shows as a chip, so its text is removed, by the same
|
||||
rule as `notes/tags.py:split_body_tags`:
|
||||
|
||||
## This migration rewrites note bodies
|
||||
* A line containing nothing but tags and whitespace is removed.
|
||||
* Every other line is left exactly as written, so `remember to call #mom` stays.
|
||||
* A line inside a ``` fence is never touched.
|
||||
* A note that is nothing but tags keeps its text, rather than becoming a blank card.
|
||||
|
||||
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
|
||||
applies from here on:
|
||||
The label rows flip to `via_tag = false` in the same transaction: the text backing
|
||||
them is gone, and `_lift_and_reconcile_tags` would otherwise detach them on the next
|
||||
save.
|
||||
|
||||
* A line containing nothing but tags and whitespace is REMOVED.
|
||||
* Every other line is left exactly as written.
|
||||
The transform and `_display_title` are inlined: a migration must keep producing what
|
||||
it produced the day it ran. The title is recomputed only where the body moved.
|
||||
|
||||
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.
|
||||
Raw SQL, so `onupdate` never fires and a client's unpushed edit still wins
|
||||
last-write-wins. The `sync_revision` trigger fires, so clients pull the server's text.
|
||||
|
||||
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.
|
||||
The downgrade does nothing. Restoring the lines would be guessing, and flipping the
|
||||
rows back to `via_tag = true` would detach every label on the next save. The real
|
||||
rollback is a database restore.
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -152,17 +106,11 @@ def upgrade():
|
||||
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.
|
||||
# The flip is driven by the tag list, not by whether the text changed: an
|
||||
# earlier pass may already have removed the line.
|
||||
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.
|
||||
"""
|
||||
"""Deliberately empty; see the module docstring."""
|
||||
|
||||
@@ -4,50 +4,18 @@ 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.
|
||||
Cards are one neutral surface per theme; the only colour on a board is a tag's.
|
||||
`labels.color` is untouched. A colour a note was given is dropped with the column.
|
||||
|
||||
`labels.color` is untouched. That is the colour that survived, and the one the whole
|
||||
milestone was about keeping.
|
||||
`saved_filters.params` may carry a `"color"` facet, so stored views are swept too. In
|
||||
Python rather than `params::jsonb - 'color'`, because Postgres has no try-cast and one
|
||||
malformed blob would abort the migration; a row that doesn't parse is left as it is.
|
||||
|
||||
## 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.
|
||||
The search vector never named `color`, so it needs no rebuild.
|
||||
|
||||
## 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.
|
||||
Restores the column, empty, at its old default. The values are not recoverable.
|
||||
"""
|
||||
import json
|
||||
|
||||
@@ -71,9 +39,8 @@ def upgrade() -> None:
|
||||
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.
|
||||
# A blob that doesn't parse can't be edited safely; the app couldn't read
|
||||
# it either.
|
||||
continue
|
||||
if not isinstance(parsed, dict) or "color" not in parsed:
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""a stored site_name of the old default follows the rename to Inkwell
|
||||
|
||||
Revision ID: 0030
|
||||
Revises: 0029
|
||||
Create Date: 2026-10-06
|
||||
|
||||
The app was renamed from ThoughtSync to Inkwell. The Settings page saves every key,
|
||||
so most servers have a stored `site_name` of the old default without anyone choosing
|
||||
it. The row is rewritten only when it holds exactly that; a typed name is left alone.
|
||||
|
||||
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,31 @@
|
||||
"""a link preview's insert, update or delete bumps its note's sync revision
|
||||
|
||||
Revision ID: 0031
|
||||
Revises: 0030
|
||||
Create Date: 2026-10-07
|
||||
|
||||
A note syncs as a whole, so every child table bumps its note's `sync_revision`. With
|
||||
this, a preview fetched after a save, or dismissed on the web, reaches every device.
|
||||
The trigger is the 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,41 @@
|
||||
"""invites: single-use, expiring registration links an admin issues
|
||||
|
||||
Revision ID: 0032
|
||||
Revises: 0031
|
||||
Create Date: 2026-10-07
|
||||
|
||||
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,49 @@
|
||||
"""password resets: an admin-issued, one-hour link; sessions an account can outlive
|
||||
|
||||
Revision ID: 0033
|
||||
Revises: 0032
|
||||
Create Date: 2026-10-07
|
||||
|
||||
An admin makes a reset link for the account and hands it over. 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 without one is read as, so the upgrade signs
|
||||
nobody out.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Drops the table and the column. Outstanding reset links stop working; sessions keep
|
||||
working, since nothing checks an epoch.
|
||||
"""
|
||||
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,49 @@
|
||||
"""shares: one grant per note and person, and only the permissions the app knows
|
||||
|
||||
Revision ID: 0034
|
||||
Revises: 0033
|
||||
Create Date: 2026-10-07
|
||||
|
||||
For the Share dialog:
|
||||
- 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).
|
||||
- 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. 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,53 @@
|
||||
"""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. The owner's are columns on `notes`; this
|
||||
table holds everyone else's, one row per (note, person it is shared with) who has
|
||||
pinned, archived or moved it. 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,34 @@
|
||||
"""users: drop email_verified and avatar_path, which nothing used
|
||||
|
||||
Revision ID: 0037
|
||||
Revises: 0036
|
||||
Create Date: 2026-10-07
|
||||
|
||||
Nothing set `email_verified` or read `avatar_path`, and a field whose value means
|
||||
nothing would be trusted by a later feature.
|
||||
|
||||
## 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,39 @@
|
||||
"""saved_filters: drop the table, saved views are gone
|
||||
|
||||
Revision ID: 0038
|
||||
Revises: 0037
|
||||
Create Date: 2026-10-08
|
||||
|
||||
Saved views are removed; tags in the drawer 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,40 @@
|
||||
"""audit_events: the audit log
|
||||
|
||||
Revision ID: 0039
|
||||
Revises: 0038
|
||||
Create Date: 2026-10-08
|
||||
|
||||
Keeps credential events in the database for `audit_retention_days`, for admins to
|
||||
read in Settings → Activity; the app log isn't queryable and rotates away.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Drops the table and every event in it.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "0039"
|
||||
down_revision = "0038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"audit_events",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("email", sa.Text(), nullable=True),
|
||||
sa.Column("address", sa.Text(), nullable=True),
|
||||
sa.Column("detail", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_audit_events_at", "audit_events", ["at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_audit_events_at", table_name="audit_events")
|
||||
op.drop_table("audit_events")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""labels: a tag's name is unique among LIVE tags only
|
||||
|
||||
Revision ID: 0040
|
||||
Revises: 0039
|
||||
Create Date: 2026-10-08
|
||||
|
||||
Deleting a tag leaves a tombstone row (`purged_at` set) so the change feed tells
|
||||
linked devices. A tombstone must not hold its name, so `(owner_id, name)` is unique
|
||||
only where `purged_at IS NULL`.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Restores the plain unique constraint. Tombstones are deleted first, since one may
|
||||
share its name with a live tag; devices that have not pulled since keep the tag.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0040"
|
||||
down_revision = "0039"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("uq_labels_owner_name", "labels", type_="unique")
|
||||
op.create_index(
|
||||
"uq_labels_owner_name_live",
|
||||
"labels",
|
||||
["owner_id", "name"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("purged_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_labels_owner_name_live", table_name="labels")
|
||||
op.execute("DELETE FROM labels WHERE purged_at IS NOT NULL")
|
||||
op.create_unique_constraint("uq_labels_owner_name", "labels", ["owner_id", "name"])
|
||||
@@ -10,17 +10,16 @@ plugins {
|
||||
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).
|
||||
// covers older 32-bit hardware; the two x86 targets are what emulators run, so the
|
||||
// app stays testable on a desktop emulator.
|
||||
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
|
||||
/**
|
||||
* Cross-compile `thoughtsync-ffi` for each Android ABI and drop the resulting
|
||||
* 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
|
||||
* `ExecOperations` injected rather than `project.exec`: Gradle 9 has no
|
||||
* `project.exec`, and reaching for `project` at execution time breaks the
|
||||
* configuration cache this build has enabled.
|
||||
*/
|
||||
abstract class CargoNdkBuild : DefaultTask() {
|
||||
@@ -49,15 +48,31 @@ abstract class CargoNdkBuild : DefaultTask() {
|
||||
args += "-t"
|
||||
args += abi
|
||||
}
|
||||
args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi")
|
||||
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"
|
||||
val release = cargoProfile.get() == "release"
|
||||
if (release) args += "--release"
|
||||
|
||||
execOps.exec {
|
||||
commandLine(listOf("cargo") + args)
|
||||
workingDir = workspaceDir.get().asFile
|
||||
if (release) {
|
||||
// The workspace's release profile with two settings put back, for this
|
||||
// build only. Environment overrides rather than a profile of our own,
|
||||
// because cargo-ndk copies its `-o` output from the `release`
|
||||
// directory; the desktop's binaries are untouched either way.
|
||||
//
|
||||
// Keep the symbol table: `strip = true` removes the symbols uniffi's
|
||||
// `--library` mode reads the interface out of, and bindgen then fails
|
||||
// with "No UniFFI metadata found". A release build has no debug info to
|
||||
// strip, so this keeps symbols and nothing else.
|
||||
environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo")
|
||||
// Unwind, so a panic in the core reaches Kotlin as an exception the
|
||||
// board can show rather than ending the app. See the ffi crate's docs.
|
||||
environment("CARGO_PROFILE_RELEASE_PANIC", "unwind")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +109,7 @@ abstract class UniffiBindgen : DefaultTask() {
|
||||
"run",
|
||||
"--locked",
|
||||
"-p",
|
||||
"thoughtsync-uniffi-bindgen",
|
||||
"inkwell-uniffi-bindgen",
|
||||
"--",
|
||||
"generate",
|
||||
"--library",
|
||||
@@ -124,24 +139,16 @@ val rustInputs =
|
||||
/**
|
||||
* 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.
|
||||
* A property rather than a debug/release task pair: this runner has no working
|
||||
* Gradle or Cargo cache, so a cold cross-compile of four ABIs costs about four
|
||||
* minutes, and a lane that type-checks and packages with different profiles would
|
||||
* pay it twice. `android.yml` picks one profile 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.
|
||||
* CI passes `release` for a signed APK, so a phone runs an optimised store and sync
|
||||
* engine; `CargoNdkBuild` keeps the symbols bindgen needs.
|
||||
*/
|
||||
val rustProfile =
|
||||
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||
?: "debug"
|
||||
|
||||
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
||||
@@ -149,7 +156,7 @@ val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
||||
|
||||
val cargoNdk =
|
||||
tasks.register<CargoNdkBuild>("cargoNdk") {
|
||||
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
|
||||
description = "Cross-compile inkwell-ffi for the Android ABIs."
|
||||
rustSources.from(rustInputs)
|
||||
abis.set(androidAbis)
|
||||
cargoProfile.set(rustProfile)
|
||||
@@ -163,27 +170,26 @@ val generateBindings =
|
||||
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/libthoughtsync_ffi.so") })
|
||||
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libinkwell_ffi.so") })
|
||||
workspaceDir.set(workspaceRoot)
|
||||
outputDir.set(bindingsOut)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.fabledsword.thoughtsync"
|
||||
namespace = "com.fabledsword.inkwell"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.fabledsword.thoughtsync"
|
||||
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
|
||||
// room to spare.
|
||||
applicationId = "com.fabledsword.inkwell"
|
||||
// 26 (Android 8, 2017) 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("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
(project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||
val codeOverride =
|
||||
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull()
|
||||
(project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull()
|
||||
versionCode = codeOverride ?: 1
|
||||
versionName = nameOverride ?: "dev"
|
||||
|
||||
@@ -199,11 +205,10 @@ android {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path. It
|
||||
// is never in the repo. 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() }
|
||||
|
||||
@@ -212,10 +217,11 @@ android {
|
||||
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.
|
||||
// Hardcoded, and NOT a secret: the alias is fixed for the life of this
|
||||
// app and written into the certificate every install carries. It says
|
||||
// `thoughtsync`, the app's earlier name, because it names the key
|
||||
// inside the existing keystore; renaming it would stop the key being
|
||||
// found.
|
||||
keyAlias = "thoughtsync"
|
||||
// PKCS12 cannot hold a key password distinct from the store
|
||||
// password — keytool refuses to set one — so this is the same
|
||||
@@ -288,5 +294,9 @@ dependencies {
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// A link preview's image (LinkPreviewRow.kt).
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -4,4 +4,4 @@
|
||||
# is the worst possible time to learn it.
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-keepclassmembers class * extends com.sun.jna.** { public *; }
|
||||
-keep class com.fabledsword.thoughtsync.core.** { *; }
|
||||
-keep class com.fabledsword.inkwell.core.** { *; }
|
||||
|
||||
@@ -25,9 +25,8 @@
|
||||
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.
|
||||
Listed because all four appear in the app's permission list and nothing
|
||||
else in this file would explain where they came from.
|
||||
-->
|
||||
|
||||
<!--
|
||||
@@ -64,24 +63,22 @@
|
||||
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
|
||||
declared here too because ReminderReceiver 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).
|
||||
Updating this app from the server it syncs with.
|
||||
|
||||
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.
|
||||
all. It is NOT what makes an install look suspicious to on-device heuristics;
|
||||
the legacy ACTION_VIEW install intent is, and this app uses a PackageInstaller
|
||||
session instead (AppUpdate.kt). The person must also 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.
|
||||
UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+) removes the install confirmation
|
||||
on the UPDATE path only: Android will not let an app silently put a NEW package
|
||||
on a device. It applies only when the new build is signed with the same key as
|
||||
the installed one.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
@@ -90,24 +87,78 @@
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!--
|
||||
allowBackup stays true. Inkwell is local-first, so an unlinked phone's notes
|
||||
exist only on the phone, and Android's backup is their one other copy. The
|
||||
device token is stored sealed under a Keystore key that never leaves the phone
|
||||
(KeystoreTokenSeal.kt), so a backup carries the notes and a token nothing can
|
||||
open, and a restored app asks to sign in again.
|
||||
-->
|
||||
<application
|
||||
android:name=".ThoughtSyncApplication"
|
||||
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.ThoughtSync"
|
||||
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, but 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 on the activity, every share while the app is running
|
||||
would stack a second MainActivity: a second view model, a second board,
|
||||
and a back press that lands on a stale copy of the app.
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.ThoughtSync">
|
||||
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.
|
||||
|
||||
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>
|
||||
|
||||
<!--
|
||||
@@ -124,8 +175,24 @@
|
||||
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).
|
||||
from someone declining the dialog.
|
||||
-->
|
||||
<!--
|
||||
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" />
|
||||
|
||||
+31
-27
@@ -1,8 +1,9 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
@@ -17,24 +18,20 @@ import java.io.File
|
||||
*
|
||||
* ## 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.
|
||||
* 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 produces a "bypassing Android security"
|
||||
* warning. 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.
|
||||
* action is required, which, paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION`,
|
||||
* removes the confirmation dialog on the UPDATE path. Not on a first install: the
|
||||
* OS will not let an app quietly put a NEW package on a device.
|
||||
*
|
||||
* 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.
|
||||
* Neither `setRequestUpdateOwnership` nor `REQUEST_INSTALL_PACKAGES` is what avoids
|
||||
* the warning; the session is.
|
||||
*
|
||||
* ## The outcome comes back
|
||||
*
|
||||
@@ -42,7 +39,7 @@ import java.io.File
|
||||
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
|
||||
*/
|
||||
object AppUpdate {
|
||||
private const val TAG = "ThoughtSyncUpdate"
|
||||
private const val TAG = LogTag.UPDATE
|
||||
|
||||
/** This build's versionCode — what the server's is compared against. */
|
||||
fun installedVersionCode(context: Context): Long =
|
||||
@@ -50,6 +47,17 @@ object AppUpdate {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
|
||||
}.getOrDefault(0L)
|
||||
|
||||
/**
|
||||
* Whether this build can update itself at all.
|
||||
*
|
||||
* A debuggable build is signed with a debug key (CI's unsigned path, or a local
|
||||
* build), and Android refuses the release-signed APK over it with
|
||||
* INSTALL_FAILED_UPDATE_INCOMPATIBLE. Offering that update would be a download
|
||||
* that can only fail, so a debug build says so instead.
|
||||
*/
|
||||
fun selfUpdates(context: Context): Boolean =
|
||||
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0
|
||||
|
||||
/**
|
||||
* Whether this app may install packages at all.
|
||||
*
|
||||
@@ -112,10 +120,9 @@ object AppUpdate {
|
||||
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.
|
||||
// Only honoured on an UPDATE of an app signed with the same key, which
|
||||
// is this case. 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)
|
||||
}
|
||||
|
||||
@@ -137,11 +144,8 @@ object AppUpdate {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Stream the APK into the session. Its own function so a stream inside a session
|
||||
* inside a try isn't three nested things to hold at once.
|
||||
*/
|
||||
private fun writeApk(
|
||||
session: PackageInstaller.Session,
|
||||
@@ -173,5 +177,5 @@ object AppUpdate {
|
||||
.intentSender
|
||||
}
|
||||
|
||||
private const val WRITE_NAME = "thoughtsync-update"
|
||||
private const val WRITE_NAME = "inkwell-update"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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. 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, never as a plausible default.
|
||||
*/
|
||||
fun Context.installedVersionName(): String? =
|
||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
|
||||
+13
-6
@@ -1,8 +1,9 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.setClientAgent
|
||||
|
||||
/**
|
||||
* Opens the shared Rust core once, for the process lifetime.
|
||||
@@ -15,14 +16,14 @@ import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
* 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 ThoughtSyncApplication : Application() {
|
||||
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: ThoughtSync? = null
|
||||
var core: Inkwell? = null
|
||||
private set
|
||||
|
||||
var openFailure: String? = null
|
||||
@@ -30,8 +31,14 @@ class ThoughtSyncApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Introduce this app to any server it links to, before anything can sync. The
|
||||
// core can't name its host app, since the same crate is compiled into the
|
||||
// desktop app. "unknown" rather than a guess when the package manager won't say.
|
||||
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
|
||||
|
||||
try {
|
||||
val handle = ThoughtSync(filesDir.absolutePath)
|
||||
val handle = Inkwell(filesDir.absolutePath, KeystoreTokenSeal())
|
||||
core = handle
|
||||
Log.i(TAG, "local store ready — ${handle.summary()}")
|
||||
} catch (e: Exception) {
|
||||
@@ -44,6 +51,6 @@ class ThoughtSyncApplication : Application() {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ThoughtSync"
|
||||
const val TAG = LogTag.APP
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Log
|
||||
import com.fabledsword.inkwell.core.TokenSeal
|
||||
import java.security.KeyStore
|
||||
import java.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/**
|
||||
* Keeps the device token sealed under a key in the Android Keystore. The core
|
||||
* stores what this returns, never the token itself.
|
||||
*
|
||||
* The key never leaves this phone, so a copy of the app's files yields a token
|
||||
* nothing can open. That includes Android's backup, which stays on: an unlinked
|
||||
* phone's notes exist only here, and a backup is their one other copy. Restored
|
||||
* onto another phone, the notes come back and the app asks to sign in again.
|
||||
*
|
||||
* Both methods answer null rather than throw, because an exception crossing into
|
||||
* the core would be a panic there. The core reads null from [sealToken] as "store
|
||||
* it as is for now" and from [openToken] as "sign in again".
|
||||
*/
|
||||
class KeystoreTokenSeal : TokenSeal {
|
||||
override fun sealToken(token: String): String? =
|
||||
runCatching { SealedBox.seal(key(), token) }
|
||||
.onFailure { Log.w(TAG, "couldn't seal the device token", it) }
|
||||
.getOrNull()
|
||||
|
||||
override fun openToken(sealed: String): String? =
|
||||
runCatching { SealedBox.open(key(), sealed) }
|
||||
.onFailure { Log.w(TAG, "the device token won't open on this phone", it) }
|
||||
.getOrNull()
|
||||
|
||||
private fun key(): SecretKey {
|
||||
val store = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
(store.getKey(ALIAS, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||
generator.init(
|
||||
KeyGenParameterSpec
|
||||
.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(KEY_BITS)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = LogTag.APP
|
||||
const val KEYSTORE = "AndroidKeyStore"
|
||||
const val ALIAS = "inkwell-device-token"
|
||||
const val KEY_BITS = 256
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-GCM with a fresh IV per seal, the IV stored in front of the ciphertext. Split
|
||||
* from the Keystore so it can be tested on the JVM with an ordinary key.
|
||||
*/
|
||||
internal object SealedBox {
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val TAG_BITS = 128
|
||||
private const val IV_BYTES = 12
|
||||
|
||||
// Binds a sealed value to what it is, so it can't be passed off as another secret.
|
||||
private val AAD = "inkwell-device-token-v1".toByteArray(Charsets.UTF_8)
|
||||
|
||||
fun seal(
|
||||
key: SecretKey,
|
||||
plaintext: String,
|
||||
): String {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
// The provider picks a random IV; the Keystore refuses a caller-chosen one.
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
cipher.updateAAD(AAD)
|
||||
val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
return Base64.getEncoder().encodeToString(sealed)
|
||||
}
|
||||
|
||||
fun open(
|
||||
key: SecretKey,
|
||||
sealed: String,
|
||||
): String {
|
||||
val bytes = Base64.getDecoder().decode(sealed)
|
||||
require(bytes.size > IV_BYTES) { "sealed value too short" }
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES))
|
||||
cipher.updateAAD(AAD)
|
||||
return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
/**
|
||||
* The tags the app logs under, one per concern, so `adb logcat -s` on one of them
|
||||
* finds every line about it whichever file wrote it.
|
||||
*/
|
||||
internal object LogTag {
|
||||
const val APP = "Inkwell"
|
||||
const val UPDATE = "InkwellUpdate"
|
||||
const val REMINDERS = "InkwellReminders"
|
||||
const val WORKER = "InkwellWorker"
|
||||
}
|
||||
+192
-61
@@ -1,7 +1,8 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
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
|
||||
@@ -11,6 +12,7 @@ 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
|
||||
@@ -21,21 +23,29 @@ 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.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardUpdate
|
||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncScreen
|
||||
import com.fabledsword.thoughtsync.ui.SyncState
|
||||
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
||||
import com.fabledsword.thoughtsync.ui.UpdateViewModel
|
||||
import com.fabledsword.thoughtsync.ui.olderThan
|
||||
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.OnEachForeground
|
||||
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
|
||||
@@ -51,15 +61,27 @@ class MainActivity : ComponentActivity() {
|
||||
*/
|
||||
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 ThoughtSyncApplication
|
||||
val app = application as InkwellApplication
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
shared.value = takeShared(intent)
|
||||
|
||||
setContent {
|
||||
ThoughtSyncTheme {
|
||||
InkwellTheme {
|
||||
val core = app.core
|
||||
if (core == null) {
|
||||
// The store never opened. There is no board to show and no
|
||||
@@ -67,7 +89,12 @@ class MainActivity : ComponentActivity() {
|
||||
// than render an empty board that looks like data loss.
|
||||
StoreUnavailableScreen(reason = app.openFailure)
|
||||
} else {
|
||||
App(core, requestedNote)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +104,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
requestedNote.value = takeRequestedNote(intent)
|
||||
shared.value = takeShared(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,10 +120,83 @@ class MainActivity : ComponentActivity() {
|
||||
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, 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 }
|
||||
private enum class Screen { BOARD, EDITOR, SYNC, TAGS }
|
||||
|
||||
/**
|
||||
* The whole app, once the store is open.
|
||||
@@ -104,21 +205,23 @@ private enum class Screen { BOARD, EDITOR, SYNC }
|
||||
* 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. Three destinations, each entered from exactly one
|
||||
* 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: ThoughtSync,
|
||||
core: Inkwell,
|
||||
files: AttachmentFiles,
|
||||
requestedNote: MutableState<String?>,
|
||||
shared: MutableState<SharedIn?>,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val board: BoardViewModel =
|
||||
viewModel(
|
||||
factory =
|
||||
BoardViewModel.factory(core) {
|
||||
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.
|
||||
@@ -135,6 +238,15 @@ private fun App(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -145,10 +257,21 @@ private fun App(
|
||||
// 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.
|
||||
// There is no separate capture sheet: the + button opens the editor on an unsaved
|
||||
// draft, so writing a note and editing one are the same surface.
|
||||
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) }
|
||||
@@ -161,6 +284,9 @@ private fun App(
|
||||
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
|
||||
}
|
||||
@@ -188,6 +314,18 @@ private fun App(
|
||||
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.
|
||||
@@ -196,6 +334,7 @@ private fun App(
|
||||
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) },
|
||||
@@ -218,7 +357,10 @@ private fun App(
|
||||
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
|
||||
@@ -244,9 +386,20 @@ private fun App(
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,7 +416,7 @@ private fun App(
|
||||
* notifications this app would send — is spending it on nothing.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReminderAlarms(core: ThoughtSync) {
|
||||
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
|
||||
@@ -277,7 +430,7 @@ private fun ReminderAlarms(core: ThoughtSync) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
|
||||
ForegroundTransitions(onForeground = { refresh() })
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
|
||||
@@ -292,33 +445,21 @@ private fun ReminderAlarms(core: ThoughtSync) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Looking for an app update without being asked, so a new build is found without
|
||||
* anyone going to look for one.
|
||||
*
|
||||
* 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.
|
||||
* Unlinked devices are skipped, since updates come from a linked server.
|
||||
*/
|
||||
@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()
|
||||
}
|
||||
// Waits for a link rather than dropping the trip: linking a device while the app is
|
||||
// in front checks as soon as it is linked.
|
||||
OnEachForeground(ready = linked, act = onCheck)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,28 +496,18 @@ private fun AutomaticSync(
|
||||
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
|
||||
}
|
||||
|
||||
var wanted by remember { mutableStateOf(false) }
|
||||
ForegroundTransitions(
|
||||
onForeground = { wanted = true },
|
||||
// Ready once `loading` is done, 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.
|
||||
OnEachForeground(
|
||||
ready = !state.loading,
|
||||
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()
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -8,7 +8,7 @@ import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
|
||||
/**
|
||||
* What a due reminder looks like in the shade.
|
||||
@@ -117,5 +117,5 @@ internal object ReminderNotification {
|
||||
)
|
||||
|
||||
private const val CHANNEL = "reminders"
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
private const val TAG = LogTag.REMINDERS
|
||||
}
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -36,7 +36,7 @@ class ReminderReceiver : BroadcastReceiver() {
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return
|
||||
val core = (context.applicationContext as? InkwellApplication)?.core ?: return
|
||||
val action = intent.action
|
||||
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
|
||||
val app = context.applicationContext
|
||||
@@ -64,9 +64,9 @@ class ReminderReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE"
|
||||
const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE"
|
||||
const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE"
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
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 = LogTag.REMINDERS
|
||||
}
|
||||
}
|
||||
+14
-17
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
@@ -7,9 +7,10 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import java.time.OffsetDateTime
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import com.fabledsword.inkwell.ui.SNOOZE_HOUR
|
||||
import com.fabledsword.inkwell.ui.epochMillis
|
||||
|
||||
/**
|
||||
* Getting a reminder in front of someone at the time they asked for.
|
||||
@@ -25,7 +26,7 @@ import java.time.OffsetDateTime
|
||||
* ## 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
|
||||
* happens at 3:05 or 3:19. A reminder does. 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.
|
||||
@@ -51,8 +52,7 @@ object Reminders {
|
||||
*/
|
||||
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
|
||||
|
||||
private const val SNOOZE_MINUTES = 60L
|
||||
private const val TAG = "ThoughtSyncReminders"
|
||||
private const val TAG = LogTag.REMINDERS
|
||||
|
||||
/**
|
||||
* Announce what is due, then schedule the next one.
|
||||
@@ -63,7 +63,7 @@ object Reminders {
|
||||
*/
|
||||
fun refresh(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
) {
|
||||
ReminderNotification.ensureChannel(context)
|
||||
val notes =
|
||||
@@ -111,7 +111,7 @@ object Reminders {
|
||||
*/
|
||||
fun promptToNotifyDue(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
): Boolean =
|
||||
!Announced(context).askedToNotify &&
|
||||
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
|
||||
@@ -123,7 +123,7 @@ object Reminders {
|
||||
/** Clear the reminder, as the notification's Done action. */
|
||||
fun complete(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
noteId: String,
|
||||
) {
|
||||
runCatching { core.completeReminder(noteId) }
|
||||
@@ -134,10 +134,10 @@ object Reminders {
|
||||
/** Push the reminder an hour out, as the notification's Snooze action. */
|
||||
fun snooze(
|
||||
context: Context,
|
||||
core: ThoughtSync,
|
||||
core: Inkwell,
|
||||
noteId: String,
|
||||
) {
|
||||
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
|
||||
runCatching { core.snoozeReminder(noteId, SNOOZE_HOUR) }
|
||||
.onFailure { Log.w(TAG, "could not snooze reminder", it) }
|
||||
ReminderNotification.dismiss(context, noteId)
|
||||
}
|
||||
@@ -194,10 +194,7 @@ object Reminders {
|
||||
// ────────────────────────────── 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()
|
||||
}
|
||||
private fun at(note: Note): Long? = note.remindAt?.let(::epochMillis)
|
||||
|
||||
/**
|
||||
* Identity of one OCCURRENCE, not of the note.
|
||||
@@ -237,7 +234,7 @@ private class Announced(
|
||||
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
|
||||
|
||||
private companion object {
|
||||
const val FILE = "thoughtsync-reminders"
|
||||
const val FILE = "inkwell-reminders"
|
||||
const val KEY_SEEN = "announced"
|
||||
const val KEY_PRIMED = "primed"
|
||||
const val KEY_ASKED = "asked_to_notify"
|
||||
+14
-12
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.BackoffPolicy
|
||||
@@ -9,6 +9,7 @@ import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -38,8 +39,7 @@ object SyncSchedule {
|
||||
fun enable(context: Context) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<SyncWorker>(PERIOD_MINUTES, TimeUnit.MINUTES)
|
||||
.setConstraints(networkRequired())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
|
||||
.online()
|
||||
.build()
|
||||
|
||||
// UPDATE, not KEEP: this is called on every launch, and KEEP would ignore
|
||||
@@ -68,8 +68,7 @@ object SyncSchedule {
|
||||
fun pushSoon(context: Context) {
|
||||
val request =
|
||||
OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setConstraints(networkRequired())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
|
||||
.online()
|
||||
.build()
|
||||
|
||||
// REPLACE rather than KEEP: if an earlier attempt is sitting in a long
|
||||
@@ -79,14 +78,17 @@ object SyncSchedule {
|
||||
.enqueueUniqueWork(PUSH, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
private fun networkRequired(): Constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
/** What both requests share: a network to run on, and the same backoff. */
|
||||
private fun <B : WorkRequest.Builder<B, *>> B.online(): B =
|
||||
setConstraints(
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
).setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS)
|
||||
|
||||
private const val PERIODIC = "thoughtsync-periodic-sync"
|
||||
private const val PUSH = "thoughtsync-push-pending"
|
||||
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
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
|
||||
@@ -38,7 +38,7 @@ class SyncSettings(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE = "thoughtsync-sync"
|
||||
const val FILE = "inkwell-sync"
|
||||
const val KEY_AUTOMATIC = "automatic"
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
@@ -8,7 +8,7 @@ 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 [ThoughtSyncApplication.onCreate]
|
||||
* 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.
|
||||
*
|
||||
@@ -30,7 +30,7 @@ class SyncWorker(
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
val core = (applicationContext as? ThoughtSyncApplication)?.core
|
||||
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:
|
||||
@@ -67,6 +67,6 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ThoughtSyncWorker"
|
||||
const val TAG = LogTag.WORKER
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* When each tag's lens was last opened on this phone, for the drawer's recent tags.
|
||||
* Opening a tag counts as using it, alongside the notes that carry it.
|
||||
*
|
||||
* Kept on the device rather than synced: it is a convenience about where this
|
||||
* person's thumb goes on this screen, and losing it costs one tap.
|
||||
*/
|
||||
class TagViews(
|
||||
context: Context,
|
||||
) {
|
||||
// applicationContext, as in SyncSettings: this must not hold an Activity.
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
|
||||
fun all(): Map<String, Long> = prefs.all.mapNotNull { (id, at) -> (at as? Long)?.let { id to it } }.toMap()
|
||||
|
||||
/** Records a view now and returns every view, this one included. */
|
||||
fun record(tagId: String): Map<String, Long> {
|
||||
val now = System.currentTimeMillis()
|
||||
prefs.edit().putLong(tagId, now).apply()
|
||||
return all() + (tagId to now)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE = "inkwell-tag-views"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
+4
-5
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync
|
||||
package com.fabledsword.inkwell
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -12,8 +12,7 @@ import android.util.Log
|
||||
* 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).
|
||||
* happened.
|
||||
*
|
||||
* 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
|
||||
@@ -71,7 +70,7 @@ class UpdateReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED"
|
||||
private const val TAG = "ThoughtSyncUpdate"
|
||||
const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
|
||||
private const val TAG = LogTag.UPDATE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
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.annotation.StringRes
|
||||
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] made to hand a file to another app — an intent to start, a
|
||||
* clip for the clipboard — or why there isn't one.
|
||||
*/
|
||||
sealed interface Handout<out T> {
|
||||
data class Ready<T>(
|
||||
val value: T,
|
||||
) : Handout<T>
|
||||
|
||||
data class Failed(
|
||||
val message: String,
|
||||
) : Handout<Nothing>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Blocking; call it off the main thread, then start the intent from a screen.
|
||||
*/
|
||||
fun opener(attachment: Attachment): Handout<Intent> =
|
||||
handOut(attachment, R.string.attach_open_failed) { uri ->
|
||||
Intent(Intent.ACTION_VIEW)
|
||||
.setDataAndType(uri, attachment.mime)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
/**
|
||||
* A clip that puts the file itself on the clipboard, so it pastes into another
|
||||
* app as the picture rather than as a path.
|
||||
*
|
||||
* The clip carries the attachment's own type: built from the URI alone, its type
|
||||
* would be guessed from the file name's extension, and a pasted image saved
|
||||
* without one would paste as an unknown file. The clipboard grants whichever app
|
||||
* pastes it read access to the one URI.
|
||||
*
|
||||
* Blocking; call it off the main thread.
|
||||
*/
|
||||
fun clip(attachment: Attachment): Handout<ClipData> =
|
||||
handOut(attachment, R.string.attach_copy_failed) { uri ->
|
||||
val label = attachment.filename.orEmpty()
|
||||
ClipData(ClipDescription(label, arrayOf(attachment.mime)), ClipData.Item(uri))
|
||||
}
|
||||
|
||||
/**
|
||||
* The file as a URI another app may read, made into whatever [make] builds from
|
||||
* it.
|
||||
*
|
||||
* 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`).
|
||||
*/
|
||||
private fun <T> handOut(
|
||||
attachment: Attachment,
|
||||
@StringRes failed: Int,
|
||||
make: (Uri) -> T,
|
||||
): Handout<T> {
|
||||
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
|
||||
if (source == null) return Handout.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)
|
||||
Handout.Ready(make(FileProvider.getUriForFile(app, "${app.packageName}.files", copy)))
|
||||
} catch (e: IOException) {
|
||||
Handout.Failed(app.getString(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.
|
||||
* 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,387 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
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.combinedClickable
|
||||
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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
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()
|
||||
val copy = rememberCopier()
|
||||
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) },
|
||||
onLongClick = { copy(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.
|
||||
*
|
||||
* In the editor a tap opens it full size in the phone's viewer and a long press
|
||||
* copies it, the same gesture that copies an image in a browser.
|
||||
*
|
||||
* [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,
|
||||
onLongClick: (() -> 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 { click ->
|
||||
Modifier.combinedClickable(
|
||||
onClickLabel = stringResource(R.string.attach_view),
|
||||
onLongClickLabel = onLongClick?.let { stringResource(R.string.attach_copy) },
|
||||
onLongClick = onLongClick,
|
||||
onClick = click,
|
||||
)
|
||||
} ?: 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 Handout.Ready -> start(context, opener.value)
|
||||
is Handout.Failed -> toast(context, opener.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an image on the clipboard, or say why not.
|
||||
*
|
||||
* Android 13 and later show their own confirmation when anything is copied, so the
|
||||
* toast is only for the versions that don't — two notices for one copy reads as two
|
||||
* copies.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberCopier(): (Attachment) -> Unit {
|
||||
val files = LocalAttachmentFiles.current
|
||||
val context = LocalContext.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
return { attachment ->
|
||||
if (files != null) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
scope.launch {
|
||||
when (val clip = withContext(Dispatchers.IO) { files.clip(attachment) }) {
|
||||
is Handout.Ready -> {
|
||||
context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip.value)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
toast(context, context.getString(R.string.attach_copied))
|
||||
}
|
||||
}
|
||||
is Handout.Failed -> toast(context, clip.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
try {
|
||||
context.startActivity(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()
|
||||
+31
-20
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -20,7 +20,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.focus.FocusRequester
|
||||
@@ -28,18 +31,19 @@ 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.TextLayoutResult
|
||||
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.thoughtsync.R
|
||||
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.
|
||||
* than `- [ ] ` to read and edit around. The note's stored markup is the same.
|
||||
*/
|
||||
@Composable
|
||||
fun BlockBody(
|
||||
@@ -89,8 +93,8 @@ fun BlockBody(
|
||||
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.
|
||||
// its place. `index - 1` alone is -1 there, which would leave
|
||||
// the keyboard up with nothing focused.
|
||||
onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id)
|
||||
},
|
||||
)
|
||||
@@ -114,7 +118,7 @@ fun BlockBody(
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
|
||||
* A run of prose: one ordinary multi-line field.
|
||||
*
|
||||
* 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.
|
||||
@@ -161,8 +165,7 @@ private fun TaskBlock(
|
||||
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.
|
||||
// checklist that IS the row height.
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
@@ -203,25 +206,23 @@ private fun TaskBlock(
|
||||
* 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.
|
||||
* neighbouring item — visible, and undone by tapping again. Twelve of those dp buy a
|
||||
* list that fits on a screen.
|
||||
*/
|
||||
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.
|
||||
* `BasicTextField`, not the Material one [PlainTextField] wraps, for 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.
|
||||
*
|
||||
* 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:
|
||||
* `BasicTextField` has no container or indicator to strip. 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 text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK and is
|
||||
* 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.
|
||||
*
|
||||
@@ -241,6 +242,15 @@ private fun BlockField(
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
) {
|
||||
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
|
||||
// Kept for the arrow keys, which ask which line the caret is on. Read only when
|
||||
// one is pressed, never while composing.
|
||||
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val line = { offset: Int -> layout?.getLineForOffset(offset) ?: 0 }
|
||||
val arrows =
|
||||
Modifier.arrowKeysMoveCaret(
|
||||
atTop = { line(value.selection.end) == 0 },
|
||||
atBottom = { line(value.selection.end) == (layout?.lineCount ?: 1) - 1 },
|
||||
)
|
||||
Box(modifier = modifier) {
|
||||
if (hint != null && value.text.isEmpty()) {
|
||||
Text(
|
||||
@@ -252,12 +262,13 @@ private fun BlockField(
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().then(arrows),
|
||||
enabled = enabled,
|
||||
singleLine = singleLine,
|
||||
textStyle = style,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
onTextLayout = { layout = it },
|
||||
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: 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. 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,168 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.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.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).
|
||||
* 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. A search narrows whichever view is open,
|
||||
* and on the main board it combines with these.
|
||||
*/
|
||||
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 and the search box's [text], or null when there is
|
||||
* nothing to narrow by. The core ANDs them, the same as the server's query.
|
||||
*/
|
||||
fun facets(text: String = ""): NoteFacets? {
|
||||
val q = text.trim().ifEmpty { null }
|
||||
return if (count == 0 && q == null) {
|
||||
null
|
||||
} else {
|
||||
NoteFacets(
|
||||
q = q,
|
||||
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,
|
||||
) {
|
||||
Sheet(
|
||||
title = R.string.filters,
|
||||
onDismiss = onDismiss,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
+228
-50
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -26,6 +26,7 @@ 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
|
||||
@@ -52,19 +53,29 @@ 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.saveable.rememberSaveable
|
||||
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 com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
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
|
||||
@@ -74,7 +85,10 @@ fun BoardScreen(
|
||||
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,
|
||||
@@ -83,12 +97,14 @@ fun BoardScreen(
|
||||
) {
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val views = rememberTagViews(state.destination)
|
||||
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.
|
||||
@@ -129,6 +145,7 @@ fun BoardScreen(
|
||||
NavigationDrawer(
|
||||
current = state.destination,
|
||||
labels = state.labels,
|
||||
recency = tagRecency(state.labels, state.notes, views),
|
||||
syncSummary = sync.summary,
|
||||
onOpen = {
|
||||
onOpen(it)
|
||||
@@ -138,6 +155,10 @@ fun BoardScreen(
|
||||
onOpenSync()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onManageTags = {
|
||||
onManageTags()
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -177,6 +198,14 @@ fun BoardScreen(
|
||||
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)
|
||||
}
|
||||
@@ -228,6 +257,8 @@ fun BoardScreen(
|
||||
onToggleItem = onToggleItem,
|
||||
onNoteAction = onCardAction,
|
||||
onConfirmDelete = { confirmingDelete = it },
|
||||
reorderable = state.reorderable,
|
||||
onReorder = onReorder,
|
||||
)
|
||||
}
|
||||
// `PullToRefreshBox` would be less code, but it takes no
|
||||
@@ -242,6 +273,15 @@ fun BoardScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (filtering) {
|
||||
FilterSheet(
|
||||
filters = state.filters,
|
||||
labels = state.labels,
|
||||
onChange = onFilter,
|
||||
onDismiss = { filtering = false },
|
||||
)
|
||||
}
|
||||
|
||||
confirmingDelete?.let { note ->
|
||||
ConfirmDeleteDialog(
|
||||
onConfirm = {
|
||||
@@ -316,10 +356,9 @@ private fun SearchBar(
|
||||
) {
|
||||
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.
|
||||
// the system-bar insets to its content padding, so adding them again would
|
||||
// put a status bar's height of empty space above the search field. Insets
|
||||
// get consumed once, by whichever component owns the edge.
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -364,10 +403,16 @@ private fun SearchBar(
|
||||
private fun NavigationDrawer(
|
||||
current: Destination,
|
||||
labels: List<Label>,
|
||||
recency: Map<String, Long>,
|
||||
syncSummary: String?,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenSync: () -> Unit,
|
||||
onManageTags: () -> Unit,
|
||||
) {
|
||||
// Every tag, once asked for; until then the few in use.
|
||||
var allTags by rememberSaveable { mutableStateOf(false) }
|
||||
val shown = if (allTags) labels else recentTags(labels, { recency[it.id] ?: 0L }, { it.name })
|
||||
|
||||
ModalDrawerSheet {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
@@ -380,19 +425,6 @@ private fun NavigationDrawer(
|
||||
DrawerRow(destination, current, onOpen)
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.nav_labels),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
|
||||
)
|
||||
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)
|
||||
@@ -410,10 +442,73 @@ private fun NavigationDrawer(
|
||||
onClick = onOpenSync,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
|
||||
// Tags last, as on the web: it is the one list here that grows
|
||||
// without limit, so everything with a fixed place sits above it.
|
||||
//
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
shown.forEach { label ->
|
||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||
}
|
||||
if (labels.size > RECENT_TAG_COUNT) {
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(
|
||||
text =
|
||||
if (allTags) {
|
||||
stringResource(R.string.tags_show_fewer)
|
||||
} else {
|
||||
stringResource(R.string.tags_show_all, labels.size)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
selected = false,
|
||||
onClick = { allTags = !allTags },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Destination.title(): String =
|
||||
when (this) {
|
||||
Destination.Notes -> stringResource(R.string.nav_notes)
|
||||
Destination.Reminders -> stringResource(R.string.nav_reminders)
|
||||
Destination.Archive -> stringResource(R.string.nav_archive)
|
||||
Destination.Trash -> stringResource(R.string.nav_trash)
|
||||
is Destination.WithLabel -> name
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawerRow(
|
||||
destination: Destination,
|
||||
@@ -421,7 +516,7 @@ private fun DrawerRow(
|
||||
onOpen: (Destination) -> Unit,
|
||||
) {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(destination.title) },
|
||||
label = { Text(destination.title()) },
|
||||
selected = destination == current,
|
||||
onClick = { onOpen(destination) },
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
@@ -429,7 +524,7 @@ private fun DrawerRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* The board: a two-column masonry, matching the web and desktop.
|
||||
* 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
|
||||
@@ -443,10 +538,66 @@ private fun NoteBoard(
|
||||
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(BOARD_COLUMNS),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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,
|
||||
@@ -455,14 +606,38 @@ private fun NoteBoard(
|
||||
// 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 = notes, key = { it.id }) { note ->
|
||||
NoteCard(
|
||||
note = note,
|
||||
onOpen = { onOpenNote(note) },
|
||||
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
|
||||
onAction = { onNoteAction(note, it) },
|
||||
onConfirmDelete = { onConfirmDelete(note) },
|
||||
)
|
||||
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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,6 +656,8 @@ private fun EmptyBoard(state: BoardState) {
|
||||
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 ->
|
||||
@@ -503,13 +680,7 @@ private fun EmptyBoard(state: BoardState) {
|
||||
) {
|
||||
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,
|
||||
)
|
||||
TitleAndBody(title, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,20 +710,27 @@ fun StoreUnavailableScreen(reason: String?) {
|
||||
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,
|
||||
TitleAndBody(
|
||||
title = stringResource(R.string.store_unavailable_title),
|
||||
body = reason ?: stringResource(R.string.store_unavailable_body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val BOARD_COLUMNS = 2
|
||||
/** A whole-screen message: what is the case, then what it means, in the quieter ink. */
|
||||
@Composable
|
||||
private fun TitleAndBody(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = 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.
|
||||
+227
-76
@@ -1,17 +1,21 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
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.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteDraft
|
||||
import com.fabledsword.thoughtsync.core.NoteEdit
|
||||
import com.fabledsword.thoughtsync.core.NoteQuery
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
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
|
||||
@@ -26,27 +30,17 @@ import kotlinx.coroutines.withContext
|
||||
* empty board.
|
||||
*/
|
||||
sealed interface Destination {
|
||||
val title: String
|
||||
data object Notes : Destination
|
||||
|
||||
data object Notes : Destination {
|
||||
override val title = "Notes"
|
||||
}
|
||||
data object Reminders : Destination
|
||||
|
||||
data object Reminders : Destination {
|
||||
override val title = "Reminders"
|
||||
}
|
||||
data object Archive : Destination
|
||||
|
||||
data object Archive : Destination {
|
||||
override val title = "Archive"
|
||||
}
|
||||
|
||||
data object Trash : Destination {
|
||||
override val title = "Trash"
|
||||
}
|
||||
data object Trash : Destination
|
||||
|
||||
data class WithLabel(
|
||||
val id: String,
|
||||
override val title: String,
|
||||
val name: String,
|
||||
) : Destination
|
||||
}
|
||||
|
||||
@@ -56,6 +50,8 @@ data class BoardState(
|
||||
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,
|
||||
@@ -79,8 +75,22 @@ data class BoardState(
|
||||
*/
|
||||
val editingSession: Long = 0,
|
||||
) {
|
||||
/** Search overrides the destination while there is a query to run. */
|
||||
/**
|
||||
* A search narrows the board in view rather than replacing it, as on the web,
|
||||
* where the box writes `?q=` beside the Filters panel's facets and one query
|
||||
* answers both. "These words, in notes tagged grocery" is the question tags exist
|
||||
* to help ask.
|
||||
*/
|
||||
val searching: Boolean get() = query.isNotBlank()
|
||||
|
||||
/** The main board only, as on the web, whose Filters panel is the main board's. */
|
||||
val filterable: Boolean get() = destination == Destination.Notes
|
||||
|
||||
/**
|
||||
* 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 && !searching
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +109,7 @@ data class BoardState(
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
class BoardViewModel(
|
||||
private val core: ThoughtSync,
|
||||
private val core: Inkwell,
|
||||
/**
|
||||
* Called after any write that could have moved a reminder.
|
||||
*
|
||||
@@ -110,6 +120,11 @@ class BoardViewModel(
|
||||
* 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
|
||||
@@ -123,7 +138,7 @@ class BoardViewModel(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
fun open(destination: Destination) {
|
||||
@@ -131,7 +146,13 @@ class BoardViewModel(
|
||||
// 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 = "")
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -140,41 +161,86 @@ class BoardViewModel(
|
||||
state = state.copy(loading = true)
|
||||
state =
|
||||
try {
|
||||
val notes = withContext(Dispatchers.IO) { load(state.destination) }
|
||||
val destination = state.destination
|
||||
val filters = state.filters
|
||||
val text = state.query
|
||||
val notes = withContext(Dispatchers.IO) { load(destination, filters, text) }
|
||||
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)
|
||||
state.copy(loading = false, error = e.shownAs())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(destination: Destination): List<Note> =
|
||||
when (destination) {
|
||||
Destination.Notes -> core.listNotes(query(VIEW_NOTES))
|
||||
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
|
||||
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
|
||||
/** The notes [destination] shows, narrowed by the board's filters and by [text]. */
|
||||
private fun load(
|
||||
destination: Destination,
|
||||
filters: BoardFilters,
|
||||
text: String,
|
||||
): List<Note> {
|
||||
// Only the main board has filters; every board view takes the search text.
|
||||
val searchOnly = BoardFilters().facets(text)
|
||||
return when (destination) {
|
||||
Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets(text)))
|
||||
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE, facets = searchOnly))
|
||||
Destination.Trash -> core.listNotes(query(VIEW_TRASH, facets = searchOnly))
|
||||
// Not a board view: the core models reminders as its own query, since
|
||||
// "has a reminder" cuts across archived and active alike.
|
||||
// "has a reminder" cuts across archived and active alike. `search` moves
|
||||
// a search off this view, so it never has text to apply.
|
||||
Destination.Reminders -> core.reminderNotes()
|
||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id, facets = searchOnly))
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadLabels() {
|
||||
/**
|
||||
* 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 { state = state.copy(labels = it) }
|
||||
.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.
|
||||
// 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)
|
||||
// Reminders is a list, not a board view, so a search there runs over the main
|
||||
// board — which is where the web sends a search typed off the board.
|
||||
val destination =
|
||||
if (text.isNotBlank() && state.destination == Destination.Reminders) {
|
||||
Destination.Notes
|
||||
} else {
|
||||
state.destination
|
||||
}
|
||||
state = state.copy(query = text, destination = destination)
|
||||
searchJob?.cancel()
|
||||
|
||||
if (text.isBlank()) {
|
||||
@@ -190,10 +256,11 @@ class BoardViewModel(
|
||||
state = state.copy(loading = true)
|
||||
state =
|
||||
try {
|
||||
val hits = withContext(Dispatchers.IO) { core.searchNotes(text) }
|
||||
val filters = state.filters
|
||||
val hits = withContext(Dispatchers.IO) { load(destination, filters, text) }
|
||||
state.copy(notes = hits, loading = false, error = null)
|
||||
} catch (e: Exception) {
|
||||
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
|
||||
state.copy(loading = false, error = e.shownAs())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,21 +277,28 @@ class BoardViewModel(
|
||||
fun openNoteById(id: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
|
||||
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
|
||||
.onSuccess { beginSitting(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun openNote(note: Note) {
|
||||
beginSitting(note)
|
||||
}
|
||||
|
||||
/**
|
||||
* A new sitting in the editor, on [note]: bumps [BoardState.editingSession] so
|
||||
* the editor's field is re-keyed onto what it now shows. Saving within a
|
||||
* sitting never comes through here.
|
||||
*/
|
||||
private fun beginSitting(note: Note? = state.editing) {
|
||||
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 + button opens this rather than a separate capture sheet, so a note being
|
||||
* WRITTEN can be given a colour, a reminder or a checklist from the same toolbar.
|
||||
*
|
||||
* 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
|
||||
@@ -232,7 +306,40 @@ class BoardViewModel(
|
||||
*/
|
||||
fun compose() {
|
||||
draftDismissed = false
|
||||
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
|
||||
beginSitting(blankDraft())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
beginSitting()
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,6 +370,10 @@ class BoardViewModel(
|
||||
}
|
||||
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.
|
||||
@@ -311,7 +422,7 @@ class BoardViewModel(
|
||||
error = null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||
state.copy(saving = false, error = e.shownAs())
|
||||
}
|
||||
if (!draftDismissed) state.editing?.let(then)
|
||||
}
|
||||
@@ -327,7 +438,7 @@ class BoardViewModel(
|
||||
* mutation that lands between a tap and its dispatch cannot redirect the
|
||||
* action at a different note.
|
||||
*
|
||||
* Both suppressions have ONE cause: [EditorAction] has twenty variants, so a
|
||||
* 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
|
||||
@@ -349,7 +460,7 @@ class BoardViewModel(
|
||||
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
|
||||
// this often is affordable because a body write doesn't snapshot 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))) }
|
||||
@@ -382,12 +493,11 @@ class BoardViewModel(
|
||||
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())
|
||||
it.setNoteLabels(id, (note.manualLabelIds + label.id).distinct())
|
||||
}
|
||||
// The drawer lists labels with their note counts, and both
|
||||
// just changed.
|
||||
loadLabels()
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
||||
@@ -399,6 +509,35 @@ class BoardViewModel(
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,13 +564,17 @@ class BoardViewModel(
|
||||
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||
* lie than showing it one frame stale.
|
||||
*
|
||||
* Search results are left alone — they are the answer to a query, not a live
|
||||
* view, and re-running the board query underneath them would replace the hits
|
||||
* with the whole board.
|
||||
* 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,
|
||||
* and keeping the old list would leave a trashed hit on screen beside a snackbar
|
||||
* saying it was gone.
|
||||
*/
|
||||
private fun mutate(
|
||||
closeEditor: Boolean = false,
|
||||
block: (ThoughtSync) -> Note?,
|
||||
// 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)
|
||||
@@ -439,11 +582,7 @@ class BoardViewModel(
|
||||
try {
|
||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||
val notes =
|
||||
if (state.searching) {
|
||||
state.notes
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { load(state.destination) }
|
||||
}
|
||||
withContext(Dispatchers.IO) { load(state.destination, state.filters, state.query) }
|
||||
// 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.
|
||||
@@ -452,18 +591,17 @@ class BoardViewModel(
|
||||
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.
|
||||
// the reloaded note in unconditionally would throw the editor
|
||||
// open over the board whenever a card's checkbox is ticked.
|
||||
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
|
||||
saving = false,
|
||||
error = null,
|
||||
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)
|
||||
state.copy(saving = false, error = e.shownAs())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,7 +613,7 @@ class BoardViewModel(
|
||||
* 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).
|
||||
* `index` is the item's ordinal, which is its id.
|
||||
*/
|
||||
fun toggleItem(
|
||||
note: Note,
|
||||
@@ -483,12 +621,24 @@ class BoardViewModel(
|
||||
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
|
||||
@@ -498,14 +648,11 @@ class BoardViewModel(
|
||||
private const val VIEW_TRASH = "trash"
|
||||
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
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) as T
|
||||
}
|
||||
viewModelFactory { initializer { BoardViewModel(core, onRemindersChanged, readFile) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +665,8 @@ class BoardViewModel(
|
||||
private fun query(
|
||||
view: String,
|
||||
labelId: String? = null,
|
||||
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = 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
|
||||
@@ -553,4 +701,7 @@ private fun blankDraft(): Note =
|
||||
previews = emptyList(),
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
permission = NoteAccess.OWNER.wire,
|
||||
shared = false,
|
||||
sharedBy = null,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.inkwell.R
|
||||
|
||||
/** The corner of every chip on a card: tags, reminders, who shared it. */
|
||||
internal val CHIP_RADIUS = 6.dp
|
||||
|
||||
/** A one-line chip on a card, in the chip colours of the palette entry [tintKey]. */
|
||||
@Composable
|
||||
internal fun TintChip(
|
||||
text: String,
|
||||
tintKey: String,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(tintKey)
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
/** Marks a tag the note's text carries, beside its name, wherever tags are listed. */
|
||||
@Composable
|
||||
internal fun FromTextMark() {
|
||||
Text(
|
||||
text = stringResource(R.string.label_from_tag),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
+19
-19
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
// The colour a LABEL wears when nobody picked one for it.
|
||||
//
|
||||
@@ -6,11 +6,7 @@ package com.fabledsword.thoughtsync.ui
|
||||
// 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.
|
||||
// A note's fill is one neutral; only its tags carry hue.
|
||||
//
|
||||
// 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
|
||||
@@ -20,9 +16,8 @@ package com.fabledsword.thoughtsync.ui
|
||||
//
|
||||
// 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.
|
||||
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. See
|
||||
// the fixture comment in colors.ts.
|
||||
|
||||
/**
|
||||
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
|
||||
@@ -79,16 +74,14 @@ fun derivedTint(id: String): String {
|
||||
* `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.
|
||||
* Derived rather than stored when a label is created: most tags are born from typing
|
||||
* `#grocery` through `find_or_create_label`, not from a management screen, and
|
||||
* deriving has no creation point to miss and nothing to backfill. 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.
|
||||
* Folded because `#Todo` renamed to `#todo` is the same tag and should not change
|
||||
* colour. [foldTag] and the web's are both locale-independent lowercasing, so the
|
||||
* mirror holds.
|
||||
*/
|
||||
fun resolvedLabelColor(
|
||||
name: String,
|
||||
@@ -98,5 +91,12 @@ fun resolvedLabelColor(
|
||||
when {
|
||||
color.isNotEmpty() && color != "default" && color in known -> color
|
||||
name.isEmpty() -> "default"
|
||||
else -> derivedTint(name.lowercase())
|
||||
else -> derivedTint(foldTag(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* How two tag names compare: equal once every letter is lowercased, accented ones
|
||||
* included, so `#Café` and `#CAFÉ` are one tag. The core's `derive::fold`, the server's
|
||||
* `labeling.named` and the web's `foldTag` fold the same way.
|
||||
*/
|
||||
fun foldTag(name: String): String = name.lowercase()
|
||||
+36
-17
@@ -1,14 +1,14 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
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.
|
||||
* One `(EditorAction) -> Unit` rather than twenty same-shaped `(String, String) ->
|
||||
* Unit` callbacks, where two could get swapped with nothing to catch it. It costs a
|
||||
* `when` at the far end and buys EXHAUSTIVENESS: adding a variant here breaks the
|
||||
* dispatcher until it is handled.
|
||||
*
|
||||
* 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
|
||||
@@ -39,12 +39,10 @@ sealed interface 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.
|
||||
// No checklist actions. An item is a `- [ ] ` line of the body, so adding,
|
||||
// renaming, ticking or deleting one is editing text, through SaveText, with the
|
||||
// same autosave and revision window as any other edit. Routing them through the
|
||||
// store would hand back a note whose body disagrees with the field being typed in.
|
||||
|
||||
/**
|
||||
* The note's MANUAL labels, replacing whatever was there.
|
||||
@@ -78,10 +76,9 @@ sealed interface 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.
|
||||
* Distinct from [ClearReminder]: a recurring reminder moves on to its next
|
||||
* occurrence here, and only a one-off is cleared, so a recurring reminder
|
||||
* finished through the generic clear would stop recurring.
|
||||
*/
|
||||
data object CompleteReminder : EditorAction
|
||||
|
||||
@@ -93,4 +90,26 @@ sealed interface EditorAction {
|
||||
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
|
||||
}
|
||||
|
||||
/** The two snoozes offered: the editor's buttons, and an hour from the notification. */
|
||||
internal const val SNOOZE_HOUR = 60L
|
||||
internal const val SNOOZE_DAY = 1440L
|
||||
+18
-30
@@ -1,15 +1,15 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
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.thoughtsync.core.checklistItems
|
||||
import com.fabledsword.thoughtsync.core.checklistRender
|
||||
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
|
||||
* The note is still one markdown string underneath — 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.
|
||||
*
|
||||
@@ -36,8 +36,8 @@ data class EditorBlock(
|
||||
/**
|
||||
* 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.
|
||||
* Which lines are items comes from the core, not from a pattern here: the grammar has
|
||||
* three implementations already, and this file isn't a fourth.
|
||||
*/
|
||||
fun splitBlocks(
|
||||
body: String,
|
||||
@@ -95,13 +95,11 @@ val blocksSaver: Saver<List<EditorBlock>, String> =
|
||||
/**
|
||||
* 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.
|
||||
* `internal` rather than private because BlockBody.kt calls it: these helpers are the
|
||||
* block MODEL, and the composables in BlockBody.kt are the block UI.
|
||||
*
|
||||
* 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.
|
||||
* prose, which is how a list ENDS: 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.
|
||||
@@ -143,28 +141,18 @@ fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand.
|
||||
* 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.
|
||||
*
|
||||
* [splitBlocks] runs once, when the editor opens. After that the blocks are the state
|
||||
* and nothing reads the body again — every edit travels the other way, through
|
||||
* [joinBlocks]. So a marker typed by hand stayed literal text on screen until the note
|
||||
* was closed and reopened, even though it was already a real item in storage and the
|
||||
* card was already drawing a checkbox for it. The editor was the only place that
|
||||
* disagreed.
|
||||
* **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.
|
||||
*
|
||||
* **On blur, and only the block being left.** There is no good moment to convert while
|
||||
* someone is typing: re-splitting on a keystroke moves the caret out of the word being
|
||||
* written, and converting the instant `- [ ]` is complete does it before the item has
|
||||
* any text. Blur is the one moment the person has demonstrably finished with the block,
|
||||
* so a re-split costs no caret and cannot catch a half-typed line.
|
||||
* 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.
|
||||
*
|
||||
* Returns THIS LIST, not an equal copy, when there was nothing to promote — the caller
|
||||
* leans on that to leave the state alone, and a blur that changed nothing must not
|
||||
* re-key every field below it.
|
||||
*
|
||||
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, exactly as they
|
||||
* would have on reopen. That is the only case where this changes the body rather than
|
||||
* only the way it is drawn.
|
||||
* 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)
|
||||
+124
-106
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.background
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -39,25 +38,20 @@ 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.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
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.
|
||||
* Top rather than bottom because this surface is used for WRITING: the keyboard owns
|
||||
* the bottom of the display for most of a note's life, so a bar down there would ride
|
||||
* on the IME. That suits a send button, not a colour picker reached for between
|
||||
* thoughts. The top is further from a thumb, and 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,
|
||||
@@ -75,36 +69,39 @@ import com.fabledsword.thoughtsync.core.Note
|
||||
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: its text, at `edit`, and this account's own pin
|
||||
// and archive 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),
|
||||
)
|
||||
}
|
||||
// The only way out. Writes are continuous, so leaving IS saving, which is
|
||||
// what the line in the bottom corner says out loud.
|
||||
BackButton(onClose, R.string.editor_back)
|
||||
},
|
||||
actions = {
|
||||
if (!readOnly) {
|
||||
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
|
||||
// a checklist is text, 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) {
|
||||
@@ -114,22 +111,32 @@ fun EditorTopBar(
|
||||
)
|
||||
}
|
||||
}
|
||||
OverflowMenu(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
onPicker = onPicker,
|
||||
onConfirmDelete = onConfirmDelete,
|
||||
onAction = onAction,
|
||||
)
|
||||
// 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.
|
||||
// EXPLICIT, and not optional. 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, not a role, so without this the icons draw with no colour filter:
|
||||
// black vectors on a near-black bar, invisible in dark mode.
|
||||
//
|
||||
// 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
|
||||
@@ -147,36 +154,24 @@ fun EditorTopBar(
|
||||
/**
|
||||
* 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.
|
||||
* **Where the note stands.** There is no save button: a note is saved continuously,
|
||||
* so a button offering to do what already happened would be a lie with a tap
|
||||
* attached. The state says the work is safe instead, as a fact: Not saved yet →
|
||||
* Saving… → Edited just now is the whole lifecycle.
|
||||
*
|
||||
* **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 way out.** Down here because of where hands are. The top-left corner is the
|
||||
* furthest point on the display from a right-handed thumb, so the exit lives in the
|
||||
* bottom corner, which with the keyboard up sits directly above it. The top-left
|
||||
* arrow stays as well: it is what habit, the system back gesture and TalkBack all
|
||||
* expect of a full-screen surface.
|
||||
*
|
||||
* 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.
|
||||
* A checkmark in a filled tonal button, so it reads plainly as a control rather than
|
||||
* as a checklist tick. Its content description is "Done", which is what it says 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.
|
||||
* knows the difference between minutes, hours and yesterday, and it gets plurals
|
||||
* right in every language.
|
||||
*/
|
||||
@Composable
|
||||
fun EditorFooter(
|
||||
@@ -189,7 +184,7 @@ fun EditorFooter(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
// Rides above the keyboard, like the bar that used to be here. The
|
||||
// Rides above the keyboard. The
|
||||
// content Column deliberately does not also inset for the IME:
|
||||
// Scaffold measures this row at its lifted height and passes the
|
||||
// inset down.
|
||||
@@ -207,16 +202,10 @@ fun EditorFooter(
|
||||
)
|
||||
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.
|
||||
// The BRAND, matching the board's compose FAB: the app's one statement of
|
||||
// "this is the affirmative action here". Material's default
|
||||
// secondaryContainer is a baseline M3 colour this theme never sets, so the
|
||||
// default would put an off-brand lilac in the corner of the editor.
|
||||
colors =
|
||||
IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
@@ -256,8 +245,9 @@ private fun savedLabel(
|
||||
@Composable
|
||||
private fun OverflowMenu(
|
||||
note: Note,
|
||||
readOnly: Boolean,
|
||||
owner: Boolean,
|
||||
onPicker: (Picker) -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
@@ -268,25 +258,64 @@ private fun OverflowMenu(
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = close) {
|
||||
if (readOnly) {
|
||||
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
|
||||
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
|
||||
if (note.trashed) {
|
||||
TrashedNoteItems(close, onAction, onConfirmDelete)
|
||||
} else {
|
||||
MenuItem(
|
||||
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||
close,
|
||||
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
||||
MenuItem(
|
||||
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||
close,
|
||||
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
|
||||
PinItem(note, close, onAction)
|
||||
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)
|
||||
}
|
||||
ArchiveItem(note, close, onAction)
|
||||
if (owner) {
|
||||
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rows a note's menu shares between the editor's overflow and the board's
|
||||
// long-press menu: two ways to the same acts, worded the same way.
|
||||
|
||||
/** A trashed note's two ways out: back, or gone for good (which asks first). */
|
||||
@Composable
|
||||
internal fun TrashedNoteItems(
|
||||
close: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
) {
|
||||
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
|
||||
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PinItem(
|
||||
note: Note,
|
||||
close: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
MenuItem(
|
||||
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||
close,
|
||||
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ArchiveItem(
|
||||
note: Note,
|
||||
close: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
MenuItem(
|
||||
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||
close,
|
||||
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The note's labels, each removable.
|
||||
*
|
||||
@@ -313,7 +342,7 @@ fun EditorLabelRow(
|
||||
// `#` 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}",
|
||||
text = hashtag(label.name),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = tint.tagInk(dark),
|
||||
modifier =
|
||||
@@ -324,21 +353,13 @@ fun EditorLabelRow(
|
||||
.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),
|
||||
)
|
||||
FromTextMark()
|
||||
} 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 }
|
||||
val kept = note.manualLabelIds - label.id
|
||||
onAction(EditorAction.SetLabels(kept))
|
||||
}) {
|
||||
Icon(
|
||||
@@ -393,6 +414,3 @@ fun EditorReminderRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SNOOZE_HOUR = 60L
|
||||
private const val SNOOZE_DAY = 1440L
|
||||
+125
-122
@@ -1,8 +1,10 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -36,9 +38,9 @@ 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.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
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
|
||||
@@ -49,10 +51,6 @@ 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
|
||||
@@ -74,80 +72,62 @@ fun LabelSheet(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var typed by remember { mutableStateOf("") }
|
||||
val manual =
|
||||
note.labels
|
||||
.filterNot { it.viaTag }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
val manual = note.manualLabelIds.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)
|
||||
Sheet(
|
||||
title = R.string.label_picker_title,
|
||||
onDismiss = onDismiss,
|
||||
modifier = Modifier.imePadding(),
|
||||
) {
|
||||
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 = ""
|
||||
}),
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
// 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) FromTextMark()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,60 +150,53 @@ fun ReminderSheet(
|
||||
) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
Sheet(
|
||||
title = R.string.reminder_title,
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
reminderPresets().forEach { (labelRes, at) ->
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_pick),
|
||||
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { exact = true }
|
||||
.padding(vertical = 12.dp),
|
||||
.clickable {
|
||||
onAction(EditorAction.SetReminder(rfc3339(at)))
|
||||
onDismiss()
|
||||
}.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),
|
||||
)
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +259,7 @@ private fun ExactReminderPicker(
|
||||
) { Text(stringResource(R.string.picker_next)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
) {
|
||||
DatePicker(state = state)
|
||||
@@ -312,7 +285,7 @@ private fun ExactReminderPicker(
|
||||
}) { Text(stringResource(R.string.picker_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -337,8 +310,38 @@ private fun RecurrenceChips(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A bottom sheet with its title: full width, the screen's side margin, and clear of
|
||||
* the navigation bar. [modifier] adds to that, after the inset (ime padding, a
|
||||
* scroll, extra space at the bottom).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SheetTitle(labelRes: Int) {
|
||||
internal fun Sheet(
|
||||
@StringRes title: Int,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.navigationBarsPadding()
|
||||
.then(modifier),
|
||||
verticalArrangement = verticalArrangement,
|
||||
) {
|
||||
SheetTitle(title)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SheetTitle(labelRes: Int) {
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
+26
-12
@@ -1,9 +1,10 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -16,21 +17,39 @@ 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.thoughtsync.R
|
||||
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.
|
||||
// A failed save is most likely WHILE the editor is open, since that is where the
|
||||
// writes are, so the editor needs the banner as much as the board does.
|
||||
|
||||
@Composable
|
||||
fun ErrorBanner(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Banner("red") {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A strip across the top of a screen, tinted from the note palette by [tintKey]: a
|
||||
* message on the left and what can be done about it on the right.
|
||||
*/
|
||||
@Composable
|
||||
fun Banner(
|
||||
tintKey: String,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint("red")
|
||||
val tint = noteTint(tintKey)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -42,12 +61,7 @@ fun ErrorBanner(
|
||||
.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)) }
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
/** What a failure says when the core gave it no message of its own. */
|
||||
internal const val FALLBACK_ERROR = "Something went wrong."
|
||||
|
||||
/**
|
||||
* The message to show for a failure.
|
||||
*
|
||||
* The core writes its errors for people to read ("notes.example.com responded, but
|
||||
* not with Inkwell's configuration"), so they are shown as they are rather than
|
||||
* replaced with a generic line that would throw away the only useful part.
|
||||
* [fallback] is for the failure that carries no message at all.
|
||||
*/
|
||||
internal fun Throwable.shownAs(fallback: String = FALLBACK_ERROR): String = message ?: fallback
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
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`: 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, just as
|
||||
* a save per dialog would save the editor mid-sentence.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Callers want different halves of it: automatic sync uses both, the reminder
|
||||
* notice and the update card use the return to re-read a permission the person may
|
||||
* have just changed in the system settings, and the editor uses the leaving to
|
||||
* write down text that otherwise exists only in a composable — the process can be
|
||||
* killed while backgrounded without it being told again.
|
||||
*
|
||||
* 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, or saving the first composition's empty text over a
|
||||
* full note.
|
||||
*/
|
||||
@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) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [act] once per trip to the front, as soon as [ready] holds.
|
||||
*
|
||||
* A trip that arrives before [ready] is held, not dropped: it runs when [ready]
|
||||
* turns true. [onBackground] is passed through to [ForegroundTransitions].
|
||||
*
|
||||
* The trip is consumed inside the effect, so [act] fires once however many times
|
||||
* the effect restarts. Writing a key from inside the effect does restart it — but
|
||||
* there is no suspension point between consuming it and the call, so the block
|
||||
* runs to completion before the recomposition that would cancel it is scheduled.
|
||||
*/
|
||||
@Composable
|
||||
fun OnEachForeground(
|
||||
ready: Boolean,
|
||||
onBackground: () -> Unit = {},
|
||||
act: () -> Unit,
|
||||
) {
|
||||
var wanted by remember { mutableStateOf(false) }
|
||||
ForegroundTransitions(onForeground = { wanted = true }, onBackground = onBackground)
|
||||
LaunchedEffect(wanted, ready) {
|
||||
if (!wanted || !ready) return@LaunchedEffect
|
||||
wanted = false
|
||||
act()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.fabledsword.inkwell.core.LinkPreview
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
|
||||
private val PREVIEW_RADIUS = 8.dp
|
||||
|
||||
// The image's width on each size of card, matching the web's `w-24` and `w-12`.
|
||||
private val FULL_IMAGE_WIDTH = 96.dp
|
||||
private val COMPACT_IMAGE_WIDTH = 48.dp
|
||||
|
||||
/**
|
||||
* A URL that is the WHOLE body, whitespace either side allowed.
|
||||
*
|
||||
* Mirrors `LONE_URL_RE` in the web's `notes/links.ts` 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 sentence's punctuation, not the link's: the server trims it before unfurling. */
|
||||
private val TRAILING_PUNCTUATION = charArrayOf('.', ',', ';', ':', '!', '?')
|
||||
|
||||
/**
|
||||
* The link a note that is nothing but a link files its preview under, or null.
|
||||
*
|
||||
* Trimmed the way the server's `unfurl_queue.detect_urls` trims it, since that is the
|
||||
* key the preview arrives under. The web's `loneUrl` is the same rule, and
|
||||
* both run the `urls` cases in `core/testdata/grammar.json` (here, by hand).
|
||||
*/
|
||||
fun loneUrl(body: String): String? =
|
||||
LONE_URL
|
||||
.matchEntire(body)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.trimEnd { it in TRAILING_PUNCTUATION }
|
||||
?.ifEmpty { null }
|
||||
|
||||
/**
|
||||
* 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? {
|
||||
val url = loneUrl(note.body) ?: return null
|
||||
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 = loneUrl(note.body) != null
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The image sits in a strip down the left edge, cropped to the card's height, as in
|
||||
* the web's `LinkPreview.vue`. `image_url` is a REMOTE third-party address — the
|
||||
* linked page's og:image — so the phone fetches it from whatever host the link
|
||||
* points at, exactly as a browser does for the web card. Coil's disk cache means a
|
||||
* card scrolled past twice costs one download, not two.
|
||||
*/
|
||||
@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.
|
||||
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
||||
val padH = if (compact) 8.dp else 10.dp
|
||||
val padV = if (compact) 6.dp else 8.dp
|
||||
|
||||
// IntrinsicSize.Min so the image strip can fill exactly the text's height, which
|
||||
// is what the web's `self-stretch` does.
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.clip(shape)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape),
|
||||
) {
|
||||
PreviewImage(url = preview.imageUrl, compact = compact)
|
||||
PreviewText(
|
||||
preview = preview,
|
||||
compact = compact,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = padH, vertical = padV),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview's image, or nothing at all.
|
||||
*
|
||||
* Nothing rather than an empty box when there is no image, AND when it fails to
|
||||
* load: a page that names an og:image it no longer serves is common, and a grey
|
||||
* square says "broken" about a card whose words are perfectly good. Same outcome
|
||||
* as a preview that never had an image.
|
||||
*/
|
||||
@Composable
|
||||
private fun PreviewImage(
|
||||
url: String?,
|
||||
compact: Boolean,
|
||||
) {
|
||||
var failed by remember(url) { mutableStateOf(false) }
|
||||
if (url.isNullOrBlank() || failed) return
|
||||
AsyncImage(
|
||||
model = url,
|
||||
// Decoration: the title beside it already says what the link is.
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = { failed = true },
|
||||
modifier = Modifier.width(if (compact) COMPACT_IMAGE_WIDTH else FULL_IMAGE_WIDTH).fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewText(
|
||||
preview: LinkPreview,
|
||||
compact: Boolean,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
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,39 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
|
||||
/**
|
||||
* How this account holds a note, 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. The core
|
||||
* refuses the rest anyway — this only keeps the editor from offering what would be
|
||||
* refused.
|
||||
*/
|
||||
enum class NoteAccess(
|
||||
/** The core's `permission` string, which is also the server's and the web's. */
|
||||
val wire: String,
|
||||
) {
|
||||
OWNER("owner"),
|
||||
EDIT("edit"),
|
||||
VIEW("view"),
|
||||
}
|
||||
|
||||
val Note.access: NoteAccess
|
||||
get() = NoteAccess.entries.firstOrNull { it.wire == permission } ?: 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
|
||||
|
||||
/**
|
||||
* The tags attached by hand (the picker, or a lifted line), as opposed to the ones
|
||||
* the body's own `#tags` carry. A label write sets exactly these; a body tag is the
|
||||
* body's to keep.
|
||||
*/
|
||||
val Note.manualLabelIds: List<String>
|
||||
get() = labels.filterNot { it.viaTag }.map { it.id }
|
||||
+160
-168
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -38,12 +39,12 @@ 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.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.BodyItem
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||
import com.fabledsword.thoughtsync.core.bodyTags
|
||||
import com.fabledsword.thoughtsync.core.checklistItems
|
||||
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(
|
||||
@@ -52,16 +53,21 @@ fun NoteCard(
|
||||
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.
|
||||
// with a name.
|
||||
val opening =
|
||||
Modifier.combinedClickable(
|
||||
onClickLabel = stringResource(R.string.board_open_note),
|
||||
@@ -101,50 +107,7 @@ fun NoteCard(
|
||||
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// TAGS FIRST. They used to sit under everything else, which on a tall note put
|
||||
// the one thing that says what a note IS below the fold of a glance. A board is
|
||||
// scanned, not read, and the answer to "which of these is about the thing I am
|
||||
// looking for" should be the first thing the eye lands on rather than the last.
|
||||
//
|
||||
// Above the body rather than beside it, because the body's first line is the
|
||||
// note's NAME (M13 steps 3 and 4) and a chip floated next to it would compete
|
||||
// with the thing that identifies the note. A row of its own costs one line and
|
||||
// only on notes that have tags at all.
|
||||
//
|
||||
// ONLY the labels whose text is not still in the note. `via_tag` means exactly
|
||||
// "backed by body text" since M311, so a chip for one printed the same tag
|
||||
// twice — once where it was typed, once up here — and the card was carrying
|
||||
// furniture for information it was already showing. A tag left in prose is
|
||||
// tinted in place instead; see [tintTags]. What reaches this row is what the
|
||||
// body cannot say: a tag lifted off its own line, and a label added by hand.
|
||||
val chips = note.labels.filterNot { it.viaTag }
|
||||
if (chips.isNotEmpty()) {
|
||||
LabelChips(labels = chips)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// 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 (note.body.isNotBlank()) {
|
||||
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||
}
|
||||
|
||||
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||
// it reads as a rendering bug.
|
||||
if (note.body.isBlank()) {
|
||||
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)
|
||||
}
|
||||
CardContents(note = note, onToggleItem = onToggleItem)
|
||||
}
|
||||
|
||||
NoteMenu(
|
||||
@@ -157,35 +120,93 @@ fun NoteCard(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. The first line of the
|
||||
// body IS the note's name, at the same weight as the rest of it.
|
||||
if (lonePreview != null) {
|
||||
LinkPreviewCard(preview = lonePreview, compact = false)
|
||||
} else if (note.body.isNotBlank()) {
|
||||
// A note shared with us to view has inert boxes: 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 does the same.
|
||||
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.
|
||||
* What you can do to a note without opening it, on a long press.
|
||||
*
|
||||
* The board used to have none of this, and the operator's read of that was not "the
|
||||
* actions are in the editor" — it was *"there are no long hold context menus in the
|
||||
* app I have no way to delete notes."* Trash was three interactions deep (open, ⋮,
|
||||
* Move to trash), and on a phone that is far enough from the gesture people reach
|
||||
* for that it may as well not exist.
|
||||
* 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.
|
||||
*
|
||||
* **The same items as the editor's overflow, in the same words, from the same string
|
||||
* resources.** A note has one vocabulary of things that can be done to it, and two
|
||||
* surfaces that named them differently would be describing two different apps. It
|
||||
* dispatches [EditorAction] for the same reason — `BoardViewModel.onEditorAction` is
|
||||
* already the exhaustive dispatcher for every one of them, so the board reuses the
|
||||
* seam rather than growing a parallel one that could drift.
|
||||
* 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".
|
||||
*
|
||||
* **Gated on the NOTE, not on the destination.** `note.trashed` is what the editor
|
||||
* gates its own read-only mode on, and it is the only reading that survives the views
|
||||
* that mix piles: Reminders cuts across archived and active alike, and a search hits
|
||||
* whatever matches. A menu that offered "Move to trash" on a note already in the
|
||||
* trash would be offering to do something twice.
|
||||
*
|
||||
* **Colour is absent**, though #2946 suggested it. It was left out because `note.color`
|
||||
* was already scheduled for removal; M315 removed it. There is no colour to set on a
|
||||
* note any more — a card is one neutral surface and the only coloured thing on a board
|
||||
* is a tag — so the row this menu never grew is a row that could not exist.
|
||||
*
|
||||
* Labels are absent too, for a duller reason: the picker they open is editor state,
|
||||
* and hoisting it to the board is a bigger change than the friction actually reported.
|
||||
* No Labels item: the picker it opens is editor state.
|
||||
*/
|
||||
@Composable
|
||||
private fun NoteMenu(
|
||||
@@ -195,20 +216,19 @@ private fun NoteMenu(
|
||||
onAction: (EditorAction) -> Unit,
|
||||
onConfirmDelete: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
// Pin and archive are this account's own on someone else's note too;
|
||||
// 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)
|
||||
TrashedNoteItems(onDismiss, onAction, 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)) }
|
||||
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
|
||||
PinItem(note, onDismiss, onAction)
|
||||
ArchiveItem(note, onDismiss, onAction)
|
||||
if (owner) {
|
||||
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,12 +237,12 @@ private fun NoteMenu(
|
||||
* 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.
|
||||
* of the body, 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
|
||||
* Which lines are items is asked of the core rather than matched here. The grammar has
|
||||
* three implementations already; a fourth in Compose would be a fourth place for a
|
||||
* checklist to change shape when it syncs.
|
||||
*/
|
||||
@Composable
|
||||
@@ -311,12 +331,8 @@ private fun ChecklistRow(
|
||||
/**
|
||||
* 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.
|
||||
* A tag still living in the prose is coloured in place rather than repeated as a chip:
|
||||
* 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,
|
||||
@@ -344,11 +360,11 @@ private fun tintTags(
|
||||
// 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 }
|
||||
val picked = note.labels.associate { foldTag(it.name) to it.color }
|
||||
buildAnnotatedString {
|
||||
append(text)
|
||||
spans.forEach { tag ->
|
||||
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
|
||||
val tint = labelTint(tag.name, picked[foldTag(tag.name)].orEmpty())
|
||||
addStyle(
|
||||
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
|
||||
tag.start.toInt(),
|
||||
@@ -373,7 +389,7 @@ private fun LabelChips(labels: List<NoteLabel>) {
|
||||
// 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}",
|
||||
text = hashtag(label.name),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tint.tagInk(dark),
|
||||
maxLines = 1,
|
||||
@@ -393,31 +409,37 @@ private fun LabelChips(labels: List<NoteLabel>) {
|
||||
* 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.
|
||||
* red-100/red-700 and black/5 pairs, resolved through the shared tint table, so
|
||||
* "you missed this" and "coming up on Friday" don't look alike on a board 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),
|
||||
)
|
||||
TintChip(reminderLabel(instant, recurrence), if (isPast(instant)) "red" else "default")
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose note this is, when it is not only ours: "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, sharerName(note))
|
||||
note.shared -> stringResource(R.string.share_chip_shared)
|
||||
else -> return
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TintChip(text, "default")
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -434,31 +456,21 @@ private val CARD_ELEVATION = 1.dp
|
||||
// too, so a note opened is the same object as the note on the board.
|
||||
|
||||
/**
|
||||
* THE CARD SURFACE — one neutral per theme (M315).
|
||||
* THE CARD SURFACE — one neutral per theme.
|
||||
*
|
||||
* 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.
|
||||
* A card's fill carries no meaning: nine keys are too few to identify anything on a
|
||||
* board of any size, and a coloured board teaches the eye to read hue as significant.
|
||||
* Colour lives on the TAG, beside the thing it names.
|
||||
*
|
||||
* 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`
|
||||
* The values are `neutral-900` on dark and white on light, the same as the web card
|
||||
* and both editors. 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.
|
||||
* baseline M3 greys rather than to the web's neutrals.
|
||||
*
|
||||
* 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:
|
||||
* The card fill is deliberately the WEAKEST contrast on the card:
|
||||
*
|
||||
* card vs board light #FFFFFF on #FAFAFA 1.04
|
||||
* dark #171717 on #0A0A0A 1.10
|
||||
@@ -469,41 +481,21 @@ private val CARD_ELEVATION = 1.dp
|
||||
* 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.
|
||||
* The edge and the shadow separate a card from the board, not its fill, which is why
|
||||
* 1.04 is enough and why it has to stay near 1: a fill that separated on its own would
|
||||
* make a board of panels.
|
||||
*/
|
||||
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 grey, every card, both themes. Since M315 it is the only thing
|
||||
// that differs from the board by more than a hair, which makes it structure rather than
|
||||
// decoration: it is what a card IS.
|
||||
// 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.
|
||||
//
|
||||
// It was already neutral before the fill was. The version that came from the palette
|
||||
// was a `{hue}-900` border and failed twice over: the line measured 1.56-2.09 against
|
||||
// its own fill while the fill managed only 1.03-1.05 against the board, so it was the
|
||||
// loudest thing on the card — and it carried the same information the fill did, so a
|
||||
// field of cards read as a grid of outlines however different the colours inside were.
|
||||
// A neutral line carries no information at all, which is exactly what lets it be
|
||||
// structure instead of content. The fill is that same argument one size up.
|
||||
//
|
||||
// MEASURED AGAINST ONE FILL NOW, and deliberately left where it was. #B8B8B8 on white
|
||||
// is 1.98 and #404040 on #171717 is 1.73 — both inside the ranges these values already
|
||||
// shipped at across twenty fills (light 1.57-1.98, dark 1.58-1.73), but at the top of
|
||||
// them rather than the ~1.6-1.7 the pair was originally matched on. Softening the light
|
||||
// edge to re-match would weaken the only boundary a white card on a #FAFAFA board has,
|
||||
// and the complaint that started M315 was about fill, never about edge weight. If an
|
||||
// operator pass disagrees it is one constant, in two files.
|
||||
//
|
||||
// NOT a translucent black/white edge, which is the tidier way to write this and was
|
||||
// measured and rejected: a border composites over what is under it, so `White` at 20%
|
||||
// came out #56396D on a purple card and #A3C9C1 on a teal one. With one fill that
|
||||
// argument no longer bites — but an opaque grey is what NoteCard.vue must also write,
|
||||
// and two surfaces stating the same hex is how they stay the same card.
|
||||
// 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
|
||||
+65
-30
@@ -1,6 +1,8 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
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
|
||||
@@ -25,23 +27,21 @@ 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.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
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.
|
||||
* Shaped like a sheet — a rounded card that begins below the status bar — so opening
|
||||
* a note reads as something rising over the board rather than a place you navigated
|
||||
* to. It is full height rather than a real `ModalBottomSheet`: 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 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.
|
||||
* a note reads as the same object growing to fill the display.
|
||||
*
|
||||
* 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
|
||||
@@ -56,6 +56,7 @@ fun NoteEditorScreen(
|
||||
labels: List<Label>,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
onShare: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -66,7 +67,7 @@ fun NoteEditorScreen(
|
||||
// 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
|
||||
// checkbox 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
|
||||
@@ -88,7 +89,13 @@ fun NoteEditorScreen(
|
||||
// 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.
|
||||
val readOnly = note.trashed
|
||||
//
|
||||
// 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. 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
|
||||
@@ -116,10 +123,8 @@ fun NoteEditorScreen(
|
||||
// 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.
|
||||
// Saving this often is affordable because a body write doesn't cost a revision:
|
||||
// history snapshots once per editing session rather than once per save.
|
||||
//
|
||||
// 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
|
||||
@@ -132,10 +137,18 @@ fun NoteEditorScreen(
|
||||
|
||||
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 the flush on leaving 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)
|
||||
ForegroundTransitions(onBackground = 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
|
||||
@@ -156,8 +169,7 @@ fun NoteEditorScreen(
|
||||
// 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.
|
||||
// set its own colour draws black, invisible in dark mode.
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -167,6 +179,7 @@ fun NoteEditorScreen(
|
||||
EditorTopBar(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
access = access,
|
||||
onClose = leave,
|
||||
onStartChecklist = {
|
||||
val (next, id) = blocks.plusTask()
|
||||
@@ -174,11 +187,16 @@ fun NoteEditorScreen(
|
||||
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
|
||||
// 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.
|
||||
@@ -214,10 +232,12 @@ fun NoteEditorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// 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 line beneath it. Checklist items draw as boxes rather than as
|
||||
// the markup for boxes.
|
||||
BlockBody(
|
||||
blocks = blocks,
|
||||
@@ -227,22 +247,34 @@ fun NoteEditorScreen(
|
||||
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.
|
||||
// No checklist section. The items ARE lines of the field above;
|
||||
// rendering them again down here would put every list on screen
|
||||
// twice.
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
EditorReminderRow(
|
||||
at = at,
|
||||
recurrence = note.recurrence,
|
||||
readOnly = readOnly,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,13 +334,16 @@ private fun EditorOverlays(
|
||||
* 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
|
||||
* meaningful is at risk if the app dies. The flush on close and on leaving the app 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.
|
||||
* sheet uses.
|
||||
*/
|
||||
private val SHEET_CORNER = 28.dp
|
||||
+29
-52
@@ -1,8 +1,10 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.fabledsword.inkwell.R
|
||||
|
||||
/**
|
||||
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
|
||||
@@ -17,8 +19,8 @@ import androidx.compose.ui.graphics.Color
|
||||
* 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
|
||||
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE has none — 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.
|
||||
@@ -27,7 +29,7 @@ import androidx.compose.ui.graphics.Color
|
||||
* acid against the neutral surfaces.
|
||||
*/
|
||||
data class NoteTint(
|
||||
val label: String,
|
||||
@StringRes val label: Int,
|
||||
val lightBackground: Color,
|
||||
val lightBorder: Color,
|
||||
val darkBackground: Color,
|
||||
@@ -44,30 +46,17 @@ data class NoteTint(
|
||||
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].
|
||||
*
|
||||
* These were two columns until M315, and the split was real while it lasted: a chip
|
||||
* brought its own `-100` fill and could afford `-700`, while inline text sat on
|
||||
* whatever the card was, which included a gray-tagged card at `neutral-200` where
|
||||
* `-700` measured 3.98 (green), 4.11 (orange) and 4.34 (teal), all under the 4.5
|
||||
* body text needs. One step deeper cleared every fill at once.
|
||||
*
|
||||
* The twenty card fills that split was solving for are gone, so both jobs take this
|
||||
* one value. The direction is deliberate: since M311 a tag whose text is in the body
|
||||
* is drawn where it was typed and NOT repeated as a chip, so the inline token is the
|
||||
* common case and collapsing onto ITS column leaves what is seen most exactly as it
|
||||
* was. The chip is strictly better for the move — on its own fill it goes from
|
||||
* 4.52-8.23 to 6.37-12.01 in light. Dark needed no decision: the two columns already
|
||||
* held the same value for all ten hues.
|
||||
* 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.
|
||||
* places that borrow a hue to say what they are. Not a note's: 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
|
||||
|
||||
@@ -80,19 +69,16 @@ data class NoteTint(
|
||||
*
|
||||
* 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.
|
||||
* text is transcribed from NoteCard.vue's literal classes, so it follows those,
|
||||
* not [tagInk].
|
||||
*/
|
||||
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.
|
||||
* A tag whose text is in the body is tinted in place rather than repeated as a
|
||||
* chip: the thing you see IS the thing you would delete to remove the tag.
|
||||
*/
|
||||
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
|
||||
|
||||
@@ -105,7 +91,7 @@ data class NoteTint(
|
||||
* 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
|
||||
* shape would be the loudest thing on a board whose whole point is that the tag
|
||||
* is the one coloured thing on it.
|
||||
*/
|
||||
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
|
||||
@@ -113,18 +99,9 @@ data class NoteTint(
|
||||
|
||||
// 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.
|
||||
// SOLVED FOR, NOT GUESSED. The edge is the ink at alpha over the known card fill, and
|
||||
// 0.65 is the smallest alpha clearing WCAG 1.4.11's 3:1 for all ten hues: 3.03-4.36 in
|
||||
// light, 4.52-5.76 in dark. 0.60 misses for six of the ten in light.
|
||||
//
|
||||
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
|
||||
private const val CHIP_EDGE_ALPHA = 0.65f
|
||||
@@ -134,7 +111,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
mapOf(
|
||||
"default" to
|
||||
NoteTint(
|
||||
label = "Default",
|
||||
label = R.string.tint_default,
|
||||
lightBackground = Color(0xFFFFFFFF),
|
||||
lightBorder = Color(0xFFE5E5E5),
|
||||
darkBackground = Color(0xFF171717),
|
||||
@@ -148,7 +125,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"red" to
|
||||
NoteTint(
|
||||
label = "Red",
|
||||
label = R.string.tint_red,
|
||||
lightBackground = Color(0xFFFEF2F2),
|
||||
lightBorder = Color(0xFFFECACA),
|
||||
darkBackground = Color(0x66450A0A),
|
||||
@@ -162,7 +139,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"orange" to
|
||||
NoteTint(
|
||||
label = "Orange",
|
||||
label = R.string.tint_orange,
|
||||
lightBackground = Color(0xFFFFF7ED),
|
||||
lightBorder = Color(0xFFFED7AA),
|
||||
darkBackground = Color(0x66431407),
|
||||
@@ -176,7 +153,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"yellow" to
|
||||
NoteTint(
|
||||
label = "Yellow",
|
||||
label = R.string.tint_yellow,
|
||||
lightBackground = Color(0xFFFFFBEB),
|
||||
lightBorder = Color(0xFFFDE68A),
|
||||
darkBackground = Color(0x66451A03),
|
||||
@@ -190,7 +167,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"green" to
|
||||
NoteTint(
|
||||
label = "Green",
|
||||
label = R.string.tint_green,
|
||||
lightBackground = Color(0xFFF0FDF4),
|
||||
lightBorder = Color(0xFFBBF7D0),
|
||||
darkBackground = Color(0x66052E16),
|
||||
@@ -204,7 +181,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"teal" to
|
||||
NoteTint(
|
||||
label = "Teal",
|
||||
label = R.string.tint_teal,
|
||||
lightBackground = Color(0xFFF0FDFA),
|
||||
lightBorder = Color(0xFF99F6E4),
|
||||
darkBackground = Color(0x66042F2E),
|
||||
@@ -218,7 +195,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"blue" to
|
||||
NoteTint(
|
||||
label = "Blue",
|
||||
label = R.string.tint_blue,
|
||||
lightBackground = Color(0xFFEFF6FF),
|
||||
lightBorder = Color(0xFFBFDBFE),
|
||||
darkBackground = Color(0x66172554),
|
||||
@@ -232,7 +209,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"purple" to
|
||||
NoteTint(
|
||||
label = "Purple",
|
||||
label = R.string.tint_purple,
|
||||
lightBackground = Color(0xFFFAF5FF),
|
||||
lightBorder = Color(0xFFE9D5FF),
|
||||
darkBackground = Color(0x663B0764),
|
||||
@@ -246,7 +223,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"pink" to
|
||||
NoteTint(
|
||||
label = "Pink",
|
||||
label = R.string.tint_pink,
|
||||
lightBackground = Color(0xFFFDF2F8),
|
||||
lightBorder = Color(0xFFFBCFE8),
|
||||
darkBackground = Color(0x66500724),
|
||||
@@ -260,7 +237,7 @@ val NOTE_TINTS: Map<String, NoteTint> =
|
||||
),
|
||||
"gray" to
|
||||
NoteTint(
|
||||
label = "Gray",
|
||||
label = R.string.tint_gray,
|
||||
lightBackground = Color(0xFFF5F5F5),
|
||||
lightBorder = Color(0xFFD4D4D4),
|
||||
darkBackground = Color(0xFF262626),
|
||||
+72
-10
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
@@ -9,8 +9,12 @@ 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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -20,7 +24,7 @@ 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.thoughtsync.R
|
||||
import com.fabledsword.inkwell.R
|
||||
|
||||
/**
|
||||
* A bordered block, tinted from the same table the notes use.
|
||||
@@ -85,7 +89,7 @@ fun Notice(
|
||||
/**
|
||||
* 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
|
||||
* Lives here rather than beside either menu because there are two — 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.
|
||||
@@ -126,19 +130,77 @@ fun MenuItem(
|
||||
fun ConfirmDeleteDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.editor_delete_forever_title),
|
||||
body = stringResource(R.string.editor_delete_forever_body),
|
||||
confirm = stringResource(R.string.editor_delete_forever_confirm),
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = onConfirm,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation: what is about to happen, what it costs, and one way out.
|
||||
*
|
||||
* Every dialog that asks before acting is this one, so they all word their buttons
|
||||
* and lay out their consequences the same way.
|
||||
*/
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
title: String,
|
||||
body: String,
|
||||
confirm: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
footnote: String? = null,
|
||||
) {
|
||||
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))
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(confirm) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The arrow out of a full-screen surface, at the start of its top bar. */
|
||||
@Composable
|
||||
fun BackButton(
|
||||
onClick: () -> Unit,
|
||||
@StringRes label: Int,
|
||||
) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(label))
|
||||
}
|
||||
}
|
||||
|
||||
/** Secondary text under a field or a heading: smaller, and in the quieter ink. */
|
||||
@Composable
|
||||
fun Hint(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
+33
-6
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.material3.TextFieldColors
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -27,10 +29,9 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
* 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 note EDITOR doesn't use this: it uses `BasicTextField` (see `EditorBlock.kt`)
|
||||
* for density, since 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.
|
||||
*
|
||||
* 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
|
||||
@@ -54,7 +55,7 @@ fun PlainTextField(
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth().arrowKeysMoveCaret(atTop = { singleLine }, atBottom = { singleLine }),
|
||||
enabled = enabled,
|
||||
placeholder = hint?.let { { Text(stringResource(it)) } },
|
||||
singleLine = singleLine,
|
||||
@@ -86,3 +87,29 @@ private fun plainFieldColors(): TextFieldColors =
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
)
|
||||
|
||||
/**
|
||||
* Arrow keys on a hardware keyboard move the caret, not the focus.
|
||||
*
|
||||
* Compose's text field gives the arrow keys of any device that reports D-pad keys
|
||||
* to focus search before the field sees them, and a physical keyboard with arrow
|
||||
* keys reports exactly that — so without this, ← and → jump to whatever sits beside
|
||||
* the field instead of moving through the text. Cancelling the search in a
|
||||
* direction hands the key back: the search reports that nothing moved, and the
|
||||
* field takes the key as editing.
|
||||
*
|
||||
* ← and → never leave the field. ↑ and ↓ move between its lines, and from the first
|
||||
* or last line — [atTop], [atBottom] — on to the field above or below, the way a
|
||||
* caret leaves one paragraph for the next. Both are asked at the moment of the key
|
||||
* press, so they can read where the caret is now.
|
||||
*/
|
||||
fun Modifier.arrowKeysMoveCaret(
|
||||
atTop: () -> Boolean,
|
||||
atBottom: () -> Boolean,
|
||||
): Modifier =
|
||||
focusProperties {
|
||||
left = FocusRequester.Cancel
|
||||
right = FocusRequester.Cancel
|
||||
if (!atTop()) up = FocusRequester.Cancel
|
||||
if (!atBottom()) down = FocusRequester.Cancel
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
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.platform.LocalContext
|
||||
import com.fabledsword.inkwell.TagViews
|
||||
import com.fabledsword.inkwell.core.Label
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
|
||||
/**
|
||||
* The drawer lists the few tags in use rather than every tag, so a long
|
||||
* list never pushes Archive, Trash and Sync out of reach. Mirrors
|
||||
* `frontend/src/notes/recentTags.ts`.
|
||||
*/
|
||||
const val RECENT_TAG_COUNT = 5
|
||||
|
||||
/**
|
||||
* The [count] most recent of [tags], listed by name so a row does not move under
|
||||
* the thumb that tapped it. Every tag while there are no more than [count].
|
||||
*/
|
||||
fun <T> recentTags(
|
||||
tags: List<T>,
|
||||
latest: (T) -> Long,
|
||||
name: (T) -> String,
|
||||
count: Int = RECENT_TAG_COUNT,
|
||||
): List<T> {
|
||||
if (tags.size <= count) return tags
|
||||
return tags
|
||||
.sortedWith(compareByDescending(latest).thenBy(String.CASE_INSENSITIVE_ORDER, name))
|
||||
.take(count)
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, name))
|
||||
}
|
||||
|
||||
/**
|
||||
* How recently each tag was used or viewed, in epoch milliseconds: the later of its
|
||||
* newest note in the store ([Label.lastUsed]), its newest note on the board now —
|
||||
* so a tag applied a moment ago counts before the labels are next listed — and the
|
||||
* last time its lens was opened here ([views]).
|
||||
*/
|
||||
fun tagRecency(
|
||||
labels: List<Label>,
|
||||
notes: List<Note>,
|
||||
views: Map<String, Long>,
|
||||
): Map<String, Long> {
|
||||
// A missing or unreadable stamp counts as 0: such a tag simply ranks last.
|
||||
val latest = labels.associate { it.id to maxOf(it.lastUsed.millis(), views[it.id] ?: 0L) }.toMutableMap()
|
||||
for (note in notes) {
|
||||
val at = note.updatedAt.millis()
|
||||
for (label in note.labels) latest.computeIfPresent(label.id) { _, was -> maxOf(was, at) }
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
private fun String?.millis(): Long = this?.let(::epochMillis) ?: 0L
|
||||
|
||||
/**
|
||||
* When each tag's lens was last opened here. Opening one counts as using it,
|
||||
* however it was opened: from the drawer, or a tag on a card.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberTagViews(destination: Destination): Map<String, Long> {
|
||||
val context = LocalContext.current
|
||||
val tagViews = remember(context) { TagViews(context) }
|
||||
var views by remember { mutableStateOf(tagViews.all()) }
|
||||
LaunchedEffect(destination) {
|
||||
if (destination is Destination.WithLabel) views = tagViews.record(destination.id)
|
||||
}
|
||||
return views
|
||||
}
|
||||
+3
-4
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.content.Context
|
||||
@@ -17,8 +17,8 @@ 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.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.Reminders
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.Reminders
|
||||
|
||||
/**
|
||||
* Says so when a reminder would not actually reach anyone.
|
||||
@@ -48,7 +48,6 @@ fun ReminderNotice() {
|
||||
canNotify = notificationsAllowed(context)
|
||||
canBeExact = exactAllowed(context)
|
||||
},
|
||||
onBackground = {},
|
||||
)
|
||||
|
||||
if (canNotify && canBeExact) return
|
||||
@@ -0,0 +1,245 @@
|
||||
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.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.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
|
||||
|
||||
/** Who shared [note] with us, by name, or "Someone" when the server sent none. */
|
||||
@Composable
|
||||
internal fun sharerName(note: Note): String =
|
||||
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
|
||||
|
||||
/** "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 = sharerName(note)
|
||||
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 val VIEW = NoteAccess.VIEW.wire
|
||||
private val EDIT = NoteAccess.EDIT.wire
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
Sheet(
|
||||
title = R.string.share_title,
|
||||
onDismiss = onDismiss,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
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 {
|
||||
Hint(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,142 @@
|
||||
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 androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
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; 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.
|
||||
*
|
||||
* 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.shownAs())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.shownAs())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: Inkwell,
|
||||
onStoreChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory = viewModelFactory { initializer { ShareViewModel(core, onStoreChanged) } }
|
||||
}
|
||||
}
|
||||
+8
-24
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -27,9 +27,9 @@ 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.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Compatibility
|
||||
import com.fabledsword.thoughtsync.core.RevokeOutcome
|
||||
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.
|
||||
//
|
||||
@@ -201,11 +201,7 @@ private fun SignInFields(
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sync_device_name_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Hint(stringResource(R.string.sync_device_name_help))
|
||||
} else {
|
||||
PlainTextField(
|
||||
value = token,
|
||||
@@ -218,11 +214,7 @@ private fun SignInFields(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.sync_token_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Hint(stringResource(R.string.sync_token_help))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,11 +252,7 @@ private fun AddressSection(
|
||||
Text(stringResource(R.string.sync_check))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.sync_address_help),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Hint(stringResource(R.string.sync_address_help))
|
||||
}
|
||||
|
||||
/** Everything the probe produced: progress, failure, what answered, and the risk. */
|
||||
@@ -311,11 +299,7 @@ private fun ProbeCard(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
version?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.sync_server_version, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Hint(stringResource(R.string.sync_server_version, it))
|
||||
}
|
||||
Text(
|
||||
text = describeCompatibility(compatibility),
|
||||
+44
-49
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -9,14 +9,9 @@ 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
|
||||
@@ -31,18 +26,20 @@ 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.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
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.** ThoughtSync is local-first and completely usable having never opened
|
||||
* 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.
|
||||
@@ -75,14 +72,7 @@ fun SyncScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.sync_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.editor_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = { BackButton(onClose, R.string.editor_back) },
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
@@ -120,10 +110,35 @@ fun SyncScreen(
|
||||
onDismissRevokeNotice = onDismissRevokeNotice,
|
||||
)
|
||||
}
|
||||
|
||||
BuildLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
|
||||
* bottom of its rail.
|
||||
*
|
||||
* With no version tags, 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: a blank line looks like a layout bug.
|
||||
*
|
||||
* 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 }
|
||||
Hint(
|
||||
text = version,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ───────────────────────────────── linked ─────────────────────────────────
|
||||
|
||||
@Composable
|
||||
@@ -153,11 +168,7 @@ private fun LinkedPanel(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
state.linkedAs?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.sync_linked_as, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Hint(stringResource(R.string.sync_linked_as, it))
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
@@ -170,11 +181,7 @@ private fun LinkedPanel(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (state.pending) {
|
||||
Text(
|
||||
text = stringResource(R.string.sync_unsent),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Hint(stringResource(R.string.sync_unsent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,11 +202,7 @@ private fun LinkedPanel(
|
||||
|
||||
state.lastOutcome?.let { outcome ->
|
||||
if (state.syncError == null) {
|
||||
Text(
|
||||
text = syncSummary(outcome),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Hint(syncSummary(outcome))
|
||||
}
|
||||
// Rejections are the server refusing a SPECIFIC change. Surfaced, never
|
||||
// swallowed, because only a person can resolve them.
|
||||
@@ -239,10 +242,8 @@ private fun LinkedPanel(
|
||||
onOutcome = onInstallOutcome,
|
||||
)
|
||||
|
||||
Text(
|
||||
Hint(
|
||||
text = stringResource(R.string.sync_footer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
|
||||
@@ -250,20 +251,14 @@ private fun LinkedPanel(
|
||||
// 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))
|
||||
}
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.sync_disconnect_title),
|
||||
body = stringResource(R.string.sync_disconnect_body),
|
||||
confirm = stringResource(R.string.sync_disconnect),
|
||||
onDismiss = { confirmingUnlink = false },
|
||||
onConfirm = {
|
||||
confirmingUnlink = false
|
||||
onUnlink()
|
||||
},
|
||||
)
|
||||
}
|
||||
+13
-9
@@ -1,11 +1,11 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
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.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Compatibility
|
||||
import com.fabledsword.thoughtsync.core.SyncOutcome
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Compatibility
|
||||
import com.fabledsword.inkwell.core.SyncOutcome
|
||||
|
||||
// Turning sync results into sentences.
|
||||
//
|
||||
@@ -32,6 +32,8 @@ fun syncSummary(outcome: SyncOutcome): 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()) {
|
||||
@@ -44,11 +46,13 @@ fun syncSummary(outcome: SyncOutcome): String {
|
||||
// rather than an error — but saying nothing would leave a missing image
|
||||
// looking like data loss.
|
||||
val failed = outcome.pull.blobsFailed.toInt()
|
||||
return if (failed > 0) {
|
||||
line + " " + pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
|
||||
} else {
|
||||
line
|
||||
}
|
||||
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(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
+20
-31
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -6,12 +6,14 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.thoughtsync.core.Compatibility
|
||||
import com.fabledsword.thoughtsync.core.ProbeResult
|
||||
import com.fabledsword.thoughtsync.core.RevokeOutcome
|
||||
import com.fabledsword.thoughtsync.core.SyncOutcome
|
||||
import com.fabledsword.thoughtsync.core.SyncStatus
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
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
|
||||
@@ -25,8 +27,8 @@ enum class LinkMode { PASSWORD, TOKEN }
|
||||
* 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.
|
||||
* app — so a flat argument list would collect one in token mode and silently drop
|
||||
* it.
|
||||
*/
|
||||
sealed interface Credentials {
|
||||
data class Password(
|
||||
@@ -110,10 +112,10 @@ data class SyncState(
|
||||
* 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).
|
||||
* stored cursor next time.
|
||||
*/
|
||||
class SyncViewModel(
|
||||
private val core: ThoughtSync,
|
||||
private val core: Inkwell,
|
||||
/**
|
||||
* Called after a sync that changed the store.
|
||||
*
|
||||
@@ -143,7 +145,7 @@ class SyncViewModel(
|
||||
// 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())
|
||||
state.copy(loading = false, status = null, syncError = e.shownAs())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,7 +165,7 @@ class SyncViewModel(
|
||||
try {
|
||||
state.copy(probe = core.probe(url), probing = false)
|
||||
} catch (e: Exception) {
|
||||
state.copy(probing = false, probeError = e.describe())
|
||||
state.copy(probing = false, probeError = e.shownAs())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +219,7 @@ class SyncViewModel(
|
||||
status = withContext(Dispatchers.IO) { core.syncStatus() },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
state.copy(linking = false, linkError = e.describe())
|
||||
state.copy(linking = false, linkError = e.shownAs())
|
||||
}
|
||||
if (state.linked) syncNow()
|
||||
}
|
||||
@@ -259,7 +261,7 @@ class SyncViewModel(
|
||||
} catch (e: Exception) {
|
||||
state.copy(
|
||||
syncing = false,
|
||||
syncError = if (announce) e.describe() else state.syncError,
|
||||
syncError = if (announce) e.shownAs() else state.syncError,
|
||||
)
|
||||
}
|
||||
// Only when something actually arrived: a no-op sync must not make the
|
||||
@@ -293,7 +295,7 @@ class SyncViewModel(
|
||||
pending = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
state.copy(syncing = false, syncError = e.describe())
|
||||
state.copy(syncing = false, syncError = e.shownAs())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,13 +318,9 @@ class SyncViewModel(
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
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
|
||||
}
|
||||
): ViewModelProvider.Factory = viewModelFactory { initializer { SyncViewModel(core, onStoreChanged) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,12 +337,3 @@ private fun SyncOutcome.changedTheStore(): Boolean =
|
||||
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 ThoughtSync'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,544 @@
|
||||
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.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.pluralStringResource
|
||||
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 the ONLY colour control in the product, which 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 = { BackButton(onClose, 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, hashtag(open.into.name)),
|
||||
body = stringResource(R.string.tags_rename_merges_body, hashtag(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, hashtag(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 { pluralStringResource(R.plurals.tags_delete_body_counted, it.toInt(), it.toInt()) }
|
||||
?: 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 on a card and in the
|
||||
* editor (`NoteCard.kt`, `EditorChrome.kt`) and every dialog here.
|
||||
*
|
||||
* It is the reason these are called tags at all — a dialog that said "Delete
|
||||
* grocery?" would be talking about something else.
|
||||
*/
|
||||
internal fun hashtag(name: String): String = "#$name"
|
||||
|
||||
/** A tag's colour as a dot: the row's at-a-glance colour, and each choice in the picker. */
|
||||
@Composable
|
||||
private fun Swatch(
|
||||
tint: NoteTint,
|
||||
dark: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(SWATCH)
|
||||
.clip(CircleShape)
|
||||
.background(tint.chipBackground(dark))
|
||||
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||
.then(modifier),
|
||||
)
|
||||
}
|
||||
|
||||
@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:
|
||||
* typing `#tag` in a note is the easiest way to make a tag, and easy not to know
|
||||
* about.
|
||||
*/
|
||||
@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),
|
||||
) {
|
||||
Swatch(tint, dark, Modifier.clickable(enabled = enabled, onClick = onColour))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = hashtag(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)
|
||||
else -> pluralStringResource(R.plurals.tags_count, count.toInt(), 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 && foldTag(it.name) == foldTag(trimmed) }
|
||||
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, hashtag(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.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, hashtag(tag.name))) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.tags_merge_body, hashtag(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 = hashtag(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.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette: 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, hashtag(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),
|
||||
) {
|
||||
Swatch(tint, dark)
|
||||
Text(
|
||||
text = stringResource(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.cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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 androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
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. 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.shownAs()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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?.shownAs(),
|
||||
)
|
||||
// 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 = viewModelFactory { initializer { TagsViewModel(core, onStoreChanged) } }
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -59,10 +59,10 @@ private val DarkColors =
|
||||
)
|
||||
|
||||
/**
|
||||
* Material 3 in ThoughtSync's own colours, following the system light/dark setting.
|
||||
* 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
|
||||
* DELIBERATELY NOT Material You dynamic colour. 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
|
||||
@@ -72,7 +72,7 @@ private val DarkColors =
|
||||
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
|
||||
*/
|
||||
@Composable
|
||||
fun ThoughtSyncTheme(
|
||||
fun InkwellTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
+28
-45
@@ -1,8 +1,5 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
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
|
||||
@@ -10,7 +7,6 @@ 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
|
||||
@@ -19,15 +15,17 @@ 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.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.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.AppUpdate
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
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.
|
||||
@@ -50,6 +48,13 @@ fun UpdateCard(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Re-read on every return to the app, as ReminderNotice does: the grant is given
|
||||
// in a system screen this app cannot observe. Read once, the warning would still
|
||||
// be up after someone granted it and came back, and the offer below it would
|
||||
// look blocked when it is not.
|
||||
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
|
||||
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) })
|
||||
|
||||
// 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 ->
|
||||
@@ -57,11 +62,12 @@ fun UpdateCard(
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
Hint(stringResource(R.string.update_installed_version, state.installedVersion))
|
||||
|
||||
if (!state.selfUpdates) {
|
||||
Hint(stringResource(R.string.update_debug_build))
|
||||
return@Column
|
||||
}
|
||||
|
||||
val available = state.available
|
||||
if (available != null) {
|
||||
@@ -75,11 +81,7 @@ fun UpdateCard(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else if (state.upToDate) {
|
||||
Text(
|
||||
text = stringResource(R.string.update_current),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Hint(stringResource(R.string.update_current))
|
||||
}
|
||||
|
||||
if (state.working) {
|
||||
@@ -101,7 +103,7 @@ fun UpdateCard(
|
||||
// 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)) {
|
||||
if (available != null && !canInstall) {
|
||||
Notice(
|
||||
tone = Tone.WARN,
|
||||
title = stringResource(R.string.update_permission_title),
|
||||
@@ -123,11 +125,8 @@ fun UpdateCard(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The nag: an update is waiting, said where someone will actually see it rather than
|
||||
* only on the sync screen.
|
||||
*
|
||||
* 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
|
||||
@@ -144,19 +143,7 @@ fun UpdateBanner(
|
||||
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,
|
||||
) {
|
||||
Banner("blue") {
|
||||
Text(
|
||||
text = stringResource(R.string.update_banner_ready, version),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -172,7 +159,6 @@ fun UpdateBanner(
|
||||
}
|
||||
}
|
||||
|
||||
private val BANNER_RADIUS = 12.dp
|
||||
private val BANNER_SPINNER = 18.dp
|
||||
|
||||
/**
|
||||
@@ -184,12 +170,9 @@ private val BANNER_SPINNER = 18.dp
|
||||
*/
|
||||
@Composable
|
||||
fun UnlinkedUpdateNote() {
|
||||
Text(
|
||||
Hint(
|
||||
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.
|
||||
// The bottom breathing room: this is the last thing on the unlinked screen.
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
+28
-22
@@ -1,4 +1,4 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -7,10 +7,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.thoughtsync.AppUpdate
|
||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
||||
import com.fabledsword.thoughtsync.core.ClientUpdate
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
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
|
||||
@@ -31,6 +33,8 @@ data class UpdateState(
|
||||
val error: String? = null,
|
||||
/** The banner has been waved away — until the app next comes forward. */
|
||||
val nagDismissed: Boolean = false,
|
||||
/** False on a debug build, which can never install a release update over itself. */
|
||||
val selfUpdates: Boolean = true,
|
||||
) {
|
||||
val busy: Boolean get() = checking || downloading || working
|
||||
|
||||
@@ -54,21 +58,26 @@ data class UpdateState(
|
||||
* **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).
|
||||
* nothing.
|
||||
*
|
||||
* 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: ThoughtSync,
|
||||
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)))
|
||||
var state by mutableStateOf(
|
||||
UpdateState(
|
||||
installedVersion = AppUpdate.installedVersionCode(context),
|
||||
selfUpdates = AppUpdate.selfUpdates(context),
|
||||
),
|
||||
)
|
||||
private set
|
||||
|
||||
/** When the last check ran, so coming back to the app twice in a minute is one. */
|
||||
@@ -80,9 +89,8 @@ class UpdateViewModel(
|
||||
/**
|
||||
* 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.
|
||||
* Called when the app comes forward, so an update is found without anyone opening
|
||||
* the sync screen to look.
|
||||
*
|
||||
* 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.
|
||||
@@ -90,12 +98,14 @@ class UpdateViewModel(
|
||||
fun checkInBackground() {
|
||||
val now = System.currentTimeMillis()
|
||||
when {
|
||||
// Nothing a debug build could do with what it found.
|
||||
!state.selfUpdates -> Unit
|
||||
|
||||
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.
|
||||
// silence the update permanently.
|
||||
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
|
||||
|
||||
// Found one and never fetched it — almost always because the last look
|
||||
@@ -133,7 +143,7 @@ class UpdateViewModel(
|
||||
// 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)
|
||||
state.copy(checking = false, error = e.shownAs(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
|
||||
@@ -152,7 +162,7 @@ class UpdateViewModel(
|
||||
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
|
||||
state.copy(downloading = false, ready = true)
|
||||
} catch (e: Exception) {
|
||||
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
|
||||
state.copy(downloading = false, error = e.shownAs(FALLBACK_DOWNLOAD))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +190,7 @@ class UpdateViewModel(
|
||||
// Off the main thread: this streams ~55 MiB into the session.
|
||||
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
||||
} catch (e: Exception) {
|
||||
e.message ?: FALLBACK
|
||||
e.shownAs(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
|
||||
@@ -207,14 +217,10 @@ class UpdateViewModel(
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
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
|
||||
}
|
||||
viewModelFactory { initializer { UpdateViewModel(core, context.applicationContext) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.fabledsword.thoughtsync.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) }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user