Compare commits
132
Commits
96a6f6e691
...
dev
+24
-5
@@ -1,4 +1,4 @@
|
|||||||
# ThoughtSync production settings. Copy to `.env` and edit:
|
# Inkwell production settings. Copy to `.env` and edit:
|
||||||
#
|
#
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
#
|
#
|
||||||
@@ -29,15 +29,15 @@ POSTGRES_PASSWORD=
|
|||||||
#
|
#
|
||||||
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
|
# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing,
|
||||||
# check which branch it actually landed on before assuming a bug.
|
# check which branch it actually landed on before assuming a bug.
|
||||||
#THOUGHTSYNC_TAG=latest
|
#INKWELL_TAG=latest
|
||||||
|
|
||||||
# The host port the app is published on.
|
# The host port the app is published on.
|
||||||
#THOUGHTSYNC_PORT=5000
|
#INKWELL_PORT=5000
|
||||||
|
|
||||||
# Which interface to bind. The default (all interfaces) is what lets desktop
|
# Which interface to bind. The default (all interfaces) is what lets desktop
|
||||||
# clients on your network reach the server. Behind a reverse proxy, set this to
|
# clients on your network reach the server. Behind a reverse proxy, set this to
|
||||||
# 127.0.0.1 so only the proxy can talk to it.
|
# 127.0.0.1 so only the proxy can talk to it.
|
||||||
#THOUGHTSYNC_BIND=0.0.0.0
|
#INKWELL_BIND=0.0.0.0
|
||||||
|
|
||||||
# NOTE: how many proxies sit in front of this app is a SETTING, not an env var —
|
# 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
|
# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one
|
||||||
@@ -47,12 +47,31 @@ POSTGRES_PASSWORD=
|
|||||||
# How much the app says. Credential events (sign-ins, failures, throttles, new
|
# How much the app says. Credential events (sign-ins, failures, throttles, new
|
||||||
# accounts, device tokens issued) are logged at INFO and read with
|
# accounts, device tokens issued) are logged at INFO and read with
|
||||||
# `docker compose logs app`.
|
# `docker compose logs app`.
|
||||||
#THOUGHTSYNC_LOG_LEVEL=INFO
|
#INKWELL_LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Database identity. Changing these AFTER the first start does not rename anything
|
# Database identity. Changing these AFTER the first start does not rename anything
|
||||||
# that already exists — the volume keeps whatever the first run created.
|
# that already exists — the volume keeps whatever the first run created.
|
||||||
|
#POSTGRES_USER=inkwell
|
||||||
|
#POSTGRES_DB=inkwell
|
||||||
|
|
||||||
|
# --- Upgrading from ThoughtSync ---------------------------------------------
|
||||||
|
#
|
||||||
|
# Inkwell was called ThoughtSync, and a deployment installed under that name has
|
||||||
|
# its data in volumes and a database named for it. Point at them instead of
|
||||||
|
# renaming anything. Leaving these unset on such a deployment starts Inkwell
|
||||||
|
# against EMPTY volumes; the old data is untouched, but you would not see it.
|
||||||
|
#
|
||||||
|
# Use the full names `docker volume ls` prints — compose prefixes them with the
|
||||||
|
# project name, so they usually look like `thoughtsync_thoughtsync-db`:
|
||||||
|
#INKWELL_DB_VOLUME=thoughtsync_thoughtsync-db
|
||||||
|
#INKWELL_DATA_VOLUME=thoughtsync_thoughtsync-data
|
||||||
|
#
|
||||||
|
# And the database identity the first start created, which a volume keeps:
|
||||||
#POSTGRES_USER=thoughtsync
|
#POSTGRES_USER=thoughtsync
|
||||||
#POSTGRES_DB=thoughtsync
|
#POSTGRES_DB=thoughtsync
|
||||||
|
#
|
||||||
|
# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_* — the old
|
||||||
|
# names are no longer read.
|
||||||
|
|
||||||
# --- a note on HTTPS --------------------------------------------------------
|
# --- a note on HTTPS --------------------------------------------------------
|
||||||
#
|
#
|
||||||
|
|||||||
+141
-56
@@ -4,7 +4,7 @@ name: Android
|
|||||||
#
|
#
|
||||||
# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this
|
# 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
|
# 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
|
# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through
|
||||||
# uniffi.
|
# uniffi.
|
||||||
#
|
#
|
||||||
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
||||||
@@ -19,15 +19,9 @@ name: Android
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# NO `paths:` FILTER — the `decide` job below reads the real file set instead.
|
||||||
|
# See desktop.yml for why, and 85ead4d for what the duplication cost.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
paths:
|
|
||||||
- "android/**"
|
|
||||||
# The Rust the .so is built from. A core change reaches the phone exactly
|
|
||||||
# as it reaches the desktop, so this lane has to rebuild on it.
|
|
||||||
- "core/**"
|
|
||||||
- "Cargo.toml"
|
|
||||||
- "Cargo.lock"
|
|
||||||
- ".forgejo/workflows/android.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -42,8 +36,70 @@ env:
|
|||||||
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Does the APK need rebuilding, or is the channel already serving this source?
|
||||||
|
# See the equivalent job in desktop.yml — same reasoning, same replacement of a
|
||||||
|
# hand-kept `paths:` filter with the one file set in `packaging/version.sh`.
|
||||||
|
#
|
||||||
|
# The guard runs here so it covers the skip path too (§6.3).
|
||||||
|
#
|
||||||
|
# NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches
|
||||||
|
# ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch
|
||||||
|
# happens — and that is correct, because ci.yml's `gate` stands down only when the
|
||||||
|
# push touched Android's files, which is the same condition that makes this build.
|
||||||
|
# The two decisions agree because they read the same fact; they are still two
|
||||||
|
# readers of it, which is why the gate's grep carries a comment pointing here.
|
||||||
|
decide:
|
||||||
|
name: Build, or is the channel already serving this?
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
outputs:
|
||||||
|
build: ${{ steps.d.outputs.build }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide
|
||||||
|
id: d
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable ;;
|
||||||
|
*) channel=dev ;;
|
||||||
|
esac
|
||||||
|
sh packaging/guard-forward.sh android "$channel"
|
||||||
|
echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# The core ships inside the APK (through android/ffi), so its checks have to gate
|
||||||
|
# the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run
|
||||||
|
# there cannot stop this lane publishing (rule 177, #5237).
|
||||||
|
#
|
||||||
|
# On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the
|
||||||
|
# core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android
|
||||||
|
# image has OpenSSL vendored for the Android targets alone (run 8663 failed at
|
||||||
|
# `openssl-sys` trying this there). Same Rust pin, same Cargo.lock.
|
||||||
|
rust:
|
||||||
|
name: Core and FFI clippy and tests
|
||||||
|
needs: [decide]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --locked -p inkwell-core -p inkwell-ffi --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --locked -p inkwell-core -p inkwell-ffi
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Kotlin + Rust (APK)
|
name: Kotlin + Rust (APK)
|
||||||
|
needs: [decide, rust]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
||||||
# proven-working label that can pull our container images.
|
# proven-working label that can pull our container images.
|
||||||
runs-on: flutter-ci
|
runs-on: flutter-ci
|
||||||
@@ -54,8 +110,6 @@ jobs:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
# For the dispatch at the end: this lane starts the server image build.
|
|
||||||
actions: write
|
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -63,6 +117,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Derives a version, so it needs the whole history — see the note in
|
||||||
|
# desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1).
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Cache Gradle and Cargo
|
- name: Cache Gradle and Cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -85,16 +143,20 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
run: |
|
run: |
|
||||||
version="$(sh ../desktop/packaging/build-version.sh)"
|
# TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the
|
||||||
|
# same code?", so it comes from the COMMIT and a dev build and the main
|
||||||
|
# build of one commit read identically. The CODE answers "may this be
|
||||||
|
# installed over that?" and must be monotonic BY CONSTRUCTION, because
|
||||||
|
# Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and
|
||||||
|
# leaves a channel you cannot get out of — so it comes from BUILD time,
|
||||||
|
# which cannot go backwards. Commit time can.
|
||||||
|
version="$(sh ../packaging/version.sh display android)"
|
||||||
|
code="$(sh ../packaging/version.sh key android)"
|
||||||
echo "name=$version" >> $GITHUB_OUTPUT
|
echo "name=$version" >> $GITHUB_OUTPUT
|
||||||
# versionCode must RISE for Android to accept an update, and the run
|
echo "code=$code" >> $GITHUB_OUTPUT
|
||||||
# number is the same monotonic counter the desktop's version scheme
|
|
||||||
# already uses — no state carried between runs, and immune to the
|
|
||||||
# shallow checkout that makes a commit count useless here.
|
|
||||||
echo "code=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
|
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 "variant=Release" >> $GITHUB_OUTPUT
|
||||||
echo "label=release" >> $GITHUB_OUTPUT
|
echo "label=release" >> $GITHUB_OUTPUT
|
||||||
# DEBUG profile, in a release APK, deliberately — see the note above
|
# DEBUG profile, in a release APK, deliberately — see the note above
|
||||||
@@ -103,9 +165,9 @@ jobs:
|
|||||||
# outright (run 4077). Unpicking that is worth doing and is not worth
|
# outright (run 4077). Unpicking that is worth doing and is not worth
|
||||||
# blocking signed builds on.
|
# blocking signed builds on.
|
||||||
echo "profile=debug" >> $GITHUB_OUTPUT
|
echo "profile=debug" >> $GITHUB_OUTPUT
|
||||||
echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT
|
echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT
|
||||||
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT
|
||||||
echo "Signed release build — $version (versionCode $GITHUB_RUN_NUMBER)"
|
echo "Signed release build — $version (versionCode $code)"
|
||||||
else
|
else
|
||||||
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
|
echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update."
|
||||||
echo "variant=Debug" >> $GITHUB_OUTPUT
|
echo "variant=Debug" >> $GITHUB_OUTPUT
|
||||||
@@ -127,7 +189,7 @@ jobs:
|
|||||||
# from the built .so. Run as its own step so a Rust failure is legible as a
|
# 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.
|
# Rust failure instead of arriving inside a Gradle stack trace.
|
||||||
- name: Build the native library and bindings
|
- 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
|
# 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
|
# (M12 step 3) precisely so this lane needs no second image, and going
|
||||||
@@ -145,15 +207,15 @@ jobs:
|
|||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
# Host-JVM tests only. Anything touching the core needs an Android
|
# Host-JVM tests only. Anything touching the core needs an Android
|
||||||
# runtime to load the .so, so those are instrumented tests and belong on
|
# 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
|
# an emulator, not here — the Rust side is the `rust` job, which this
|
||||||
# tests in the desktop lane.
|
# one needs.
|
||||||
#
|
#
|
||||||
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
|
# DEBUG regardless of what is being packaged: AGP creates unit-test tasks
|
||||||
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
|
# only for `testBuildType`, which is debug, so `testReleaseUnitTest` does
|
||||||
# not exist (run 4082). It costs one extra Kotlin compile and buys the
|
# not exist (run 4082). It costs one extra Kotlin compile and buys the
|
||||||
# type-check on the debug variant, which is the one an emulator build
|
# type-check on the debug variant, which is the one an emulator build
|
||||||
# would use.
|
# 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
|
- name: Assemble the APK
|
||||||
env:
|
env:
|
||||||
@@ -163,9 +225,9 @@ jobs:
|
|||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
./gradlew assemble${{ steps.build.outputs.variant }} \
|
./gradlew assemble${{ steps.build.outputs.variant }} \
|
||||||
-PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
-PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \
|
||||||
-PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \
|
-PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \
|
||||||
-PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }}
|
-PINKWELL_VERSION_CODE=${{ steps.build.outputs.code }}
|
||||||
|
|
||||||
# Prints the certificate the APK was actually signed with, so the operator
|
# Prints the certificate the APK was actually signed with, so the operator
|
||||||
# can compare it against the fingerprint recorded when the key was
|
# can compare it against the fingerprint recorded when the key was
|
||||||
@@ -186,10 +248,10 @@ jobs:
|
|||||||
if: steps.build.outputs.keystore != ''
|
if: steps.build.outputs.keystore != ''
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk
|
cp "app/build/outputs/apk/release/app-release.apk" dist/inkwell.apk
|
||||||
size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')"
|
size="$(wc -c < dist/inkwell.apk | tr -d ' ')"
|
||||||
sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)"
|
sha="$(sha256sum dist/inkwell.apk | cut -d' ' -f1)"
|
||||||
cat > dist/thoughtsync-android.json <<JSON
|
cat > dist/inkwell-android.json <<JSON
|
||||||
{
|
{
|
||||||
"version_name": "${{ steps.build.outputs.name }}",
|
"version_name": "${{ steps.build.outputs.name }}",
|
||||||
"version_code": ${{ steps.build.outputs.code }},
|
"version_code": ${{ steps.build.outputs.code }},
|
||||||
@@ -197,52 +259,75 @@ jobs:
|
|||||||
"sha256": "$sha"
|
"sha256": "$sha"
|
||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
cat dist/thoughtsync-android.json
|
cat dist/inkwell-android.json
|
||||||
|
|
||||||
# The rolling dev channel, same fixed-tag release the desktop bundles use.
|
# The rolling channel for this branch, the same fixed-tag releases the desktop
|
||||||
# CI artifacts are per-run and auth-gated, so they are no use as a fetch
|
# bundles use. CI artifacts are per-run and auth-gated, so they are no use as a
|
||||||
# target; a release asset has a permanent URL. Only ever a SIGNED build —
|
# fetch target; a release asset has a permanent URL. Only ever a SIGNED build —
|
||||||
# publishing an unsigned APK would offer people something they cannot
|
# publishing an unsigned APK would offer people something they cannot install
|
||||||
# install over what they already have.
|
# over what they already have.
|
||||||
- name: Publish to the dev channel
|
#
|
||||||
if: github.ref == 'refs/heads/dev' && steps.build.outputs.keystore != ''
|
# `stable` from main is new in M314 step 3, and it is what lets the server image
|
||||||
|
# bake in a client that matches its own channel: a :latest image fetches the APK
|
||||||
|
# from `stable`, a :dev image from `dev`. Before this, main published no APK at
|
||||||
|
# all and every image — stable included — baked in the dev one.
|
||||||
|
- name: Publish to the channel for this branch
|
||||||
|
if: (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && steps.build.outputs.keystore != ''
|
||||||
working-directory: .
|
working-directory: .
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
RELEASE_TAG: dev
|
run: |
|
||||||
RELEASE_PRERELEASE: "true"
|
case "$GITHUB_REF_NAME" in
|
||||||
run: bash desktop/packaging/publish-release.sh
|
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||||
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
|
esac
|
||||||
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
|
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||||
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
|
echo "Publishing the APK to the $RELEASE_TAG channel."
|
||||||
|
bash desktop/packaging/publish-release.sh
|
||||||
|
|
||||||
- name: Upload the APK
|
- name: Upload the APK
|
||||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
# Stock action: it works on this forge since the runner moved to
|
||||||
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||||
# it reports success while Gitea serves artifacts back only through the
|
# out of the action bundle (Scribe snippet #2271). Never @v3 — it reports
|
||||||
# v4 API, so the upload is stored and invisible. Pinned by SHA because
|
# success while Gitea serves artifacts back only through the v4 API, so the
|
||||||
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
|
# upload is stored and invisible (Scribe 2270).
|
||||||
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
uses: actions/upload-artifact@v7
|
||||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
|
||||||
with:
|
with:
|
||||||
# The APK's variant, NOT the Cargo profile — those are the same word
|
# The APK's variant, NOT the Cargo profile — those are the same word
|
||||||
# for different things and the profile is pinned to debug (#2810).
|
# for different things and the profile is pinned to debug (#2810).
|
||||||
name: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
name: inkwell-android-${{ steps.build.outputs.label }}-${{ github.sha }}
|
||||||
path: ${{ steps.build.outputs.apk }}
|
path: ${{ steps.build.outputs.apk }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
server-image:
|
||||||
|
name: Build the server image
|
||||||
|
needs: [decide, rust, build]
|
||||||
|
if: always() && needs.decide.outputs.build == 'true' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||||
|
runs-on: flutter-ci
|
||||||
|
container:
|
||||||
|
# The image the dispatch has always run in, so its curl is a known quantity.
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
steps:
|
||||||
# The server image bakes in whatever client the dev release holds, so it has
|
# 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
|
# 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
|
# push that touches the Android app (its `gate` job) and waits to be called
|
||||||
# from here — that is the other half of this.
|
# from here — that is the other half of this.
|
||||||
#
|
#
|
||||||
# `always()`: a FAILED Android build must still let the server image through.
|
# `always()`: a FAILED Android build — or failed core checks, which skip the
|
||||||
# There is no new client in that case, so it bakes in the previous one, which
|
# build job entirely — must still let the server image through. There is no
|
||||||
# is exactly right — the alternative is a broken Android lane silently
|
# new client in that case, so it bakes in the previous one, which is exactly
|
||||||
# blocking server delivery.
|
# right — the alternative is a broken Android lane silently blocking server
|
||||||
|
# delivery. Its own job for that reason: a step inside `build` never runs
|
||||||
|
# when `build` is skipped.
|
||||||
#
|
#
|
||||||
# Not `if: success()` and not skipped on tags either: every ref that builds an
|
# Not `if: success()` and not skipped on tags either: every ref that builds an
|
||||||
# image needs the call, or nothing builds one at all.
|
# image needs the call, or nothing builds one at all.
|
||||||
- name: Build the server image now the client is published
|
- 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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+115
-83
@@ -1,12 +1,21 @@
|
|||||||
# CI runs first; build only proceeds if lint + typecheck pass.
|
# CI runs first; build only proceeds if lint + typecheck pass.
|
||||||
#
|
#
|
||||||
# Push to dev: typecheck + lint + test + build :dev + :<sha>
|
# Push to dev: typecheck + lint + test + build :dev
|
||||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
|
||||||
#
|
#
|
||||||
# main is the production line, so a merge to main rebuilds and moves :latest to its
|
# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane:
|
||||||
# tip (family rule 46) — no version release required. The :<sha> image is the
|
# nothing pins one — verified by looking for a consumer, not for whether one is
|
||||||
# immutable rollback unit for every build.
|
# imaginable — and the git release tag is a different object in a different system
|
||||||
|
# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date.
|
||||||
|
#
|
||||||
|
# A `v*` tag builds nothing at all. The merge to main already published everything,
|
||||||
|
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
|
||||||
|
# which rule 145 forbids even when they match.
|
||||||
|
#
|
||||||
|
# main is the production line, so a merge moves :latest to its tip (family rule 46)
|
||||||
|
# — no version release required. :<sha> is the immutable rollback unit, and it is
|
||||||
|
# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled,
|
||||||
|
# accumulating forever, for a channel whose entire contract is that it moves.
|
||||||
#
|
#
|
||||||
# Required secret (repo -> Settings -> Secrets -> Actions):
|
# Required secret (repo -> Settings -> Secrets -> Actions):
|
||||||
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope
|
||||||
@@ -16,34 +25,36 @@ name: CI & Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
|
||||||
|
# the image ALWAYS builds. Two reasons:
|
||||||
|
#
|
||||||
|
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
|
||||||
|
# production commit is addressable. A path filter quietly broke that promise
|
||||||
|
# for a docs-only merge: no trigger, no image, no sha tag for that commit.
|
||||||
|
# * It is the artifact most exposed to base-image staleness (`python:3.12-slim`
|
||||||
|
# is a floating tag and this can face the internet), and building every push
|
||||||
|
# picks those updates up. That is why note 3127 §4's base tension does not
|
||||||
|
# bite here — the one artifact it would apply to never skips.
|
||||||
|
#
|
||||||
|
# Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes
|
||||||
|
# for the clients, which is why THEY skip and this does not.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
tags: ["v*"]
|
|
||||||
paths:
|
|
||||||
- "src/**"
|
|
||||||
- "frontend/**"
|
|
||||||
- "tests/**"
|
|
||||||
- "pyproject.toml"
|
|
||||||
- "alembic/**"
|
|
||||||
- "alembic.ini"
|
|
||||||
- "Dockerfile"
|
|
||||||
- ".forgejo/workflows/ci.yml"
|
|
||||||
# Dispatched by the Android lane once it has published a client, so the image
|
# Dispatched by the Android lane once it has published a client, so the image
|
||||||
# that bakes it in is built AFTER the APK exists rather than racing it. See the
|
# that bakes it in is built AFTER the APK exists rather than racing it. See the
|
||||||
# `gate` job below for the other half.
|
# `gate` job below for the other half.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Cancel older runs on the same branch when a newer push lands. Tag runs get their
|
# Cancel older runs on the same branch when a newer push lands.
|
||||||
# own group implicitly and are never cancelled.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.fabledsword.com
|
REGISTRY: git.fabledsword.com
|
||||||
IMAGE: git.fabledsword.com/bvandeusen/thoughtsync
|
IMAGE: git.fabledsword.com/bvandeusen/inkwell
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Should this push build an image now, or is the Android lane about to publish a
|
# Should this push build an image now, or is the Android lane about to publish a
|
||||||
@@ -64,7 +75,7 @@ jobs:
|
|||||||
# than a config so at least it is inspectable in the log.
|
# than a config so at least it is inspectable in the log.
|
||||||
gate:
|
gate:
|
||||||
name: Build now, or wait for Android?
|
name: Build now, or wait for Android?
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -89,17 +100,6 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A tag. The Android lane does not run on tags, so nothing would ever
|
|
||||||
# call back — standing down here would mean a release tag that never
|
|
||||||
# produces an image at all.
|
|
||||||
case "${{ github.ref }}" in
|
|
||||||
refs/tags/*)
|
|
||||||
echo "Tag build — the Android lane does not run on tags. Building."
|
|
||||||
echo "build=true" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# No parent (first commit, or a force-push that orphaned it) — nothing to
|
# No parent (first commit, or a force-push that orphaned it) — nothing to
|
||||||
# compare, so build rather than stall.
|
# compare, so build rather than stall.
|
||||||
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
|
if ! git rev-parse --verify -q HEAD^ >/dev/null; then
|
||||||
@@ -125,7 +125,12 @@ jobs:
|
|||||||
echo "Changed in this push:"
|
echo "Changed in this push:"
|
||||||
echo "$changed" | sed 's/^/ /'
|
echo "$changed" | sed 's/^/ /'
|
||||||
|
|
||||||
if echo "$changed" | grep -qE '^(android/|core/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
|
# MUST match android's file set in packaging/version.sh. `packaging/` was
|
||||||
|
# missing here after step 4 added it there — so a packaging-only push had
|
||||||
|
# the Android lane rebuild and dispatch while this gate ALSO let the image
|
||||||
|
# build, producing two images for one commit and, on main, a second push of
|
||||||
|
# the same :<sha> with different bytes. Rule 145's exact prohibition.
|
||||||
|
if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "This push also changes the Android client. Standing down: the"
|
echo "This push also changes the Android client. Standing down: the"
|
||||||
echo "Android lane will publish a new APK and dispatch this workflow,"
|
echo "Android lane will publish a new APK and dispatch this workflow,"
|
||||||
@@ -138,9 +143,13 @@ jobs:
|
|||||||
echo "build=true" >> $GITHUB_OUTPUT
|
echo "build=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The web's whole verification: the type check, then its unit tests. Both in the
|
||||||
|
# job `build` already needs, so a red web test blocks the image like any other
|
||||||
|
# lane (rule 177). The unit tests arrived with #5166; before them the web copy of
|
||||||
|
# the note grammar, mirrored in Rust and Python, was guarded by nothing.
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript typecheck
|
name: Web typecheck and unit tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -151,13 +160,19 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
# tsconfig.test.json, not the default: the default leaves the unit tests out
|
||||||
|
# so the Docker build (which has only frontend/) can type-check the app.
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npx vue-tsc --noEmit
|
run: npx vue-tsc --noEmit -p tsconfig.test.json
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Python lint
|
name: Python lint
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -170,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -200,7 +215,7 @@ jobs:
|
|||||||
# discovery step below filters `docker ps` by it. Service hostnames are not routable
|
# 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 (rule 79), so the step resolves the container's bridge IP.
|
||||||
integration:
|
integration:
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
@@ -210,11 +225,11 @@ jobs:
|
|||||||
# Postgres it will actually meet.
|
# Postgres it will actually meet.
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: thoughtsync
|
POSTGRES_USER: inkwell
|
||||||
POSTGRES_PASSWORD: ci_integration
|
POSTGRES_PASSWORD: ci_integration
|
||||||
POSTGRES_DB: thoughtsync_test
|
POSTGRES_DB: inkwell_test
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "pg_isready -U thoughtsync"
|
--health-cmd "pg_isready -U inkwell"
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
@@ -234,11 +249,21 @@ jobs:
|
|||||||
set -eux
|
set -eux
|
||||||
echo "=== container landscape (diagnostic for the name filter) ==="
|
echo "=== container landscape (diagnostic for the name filter) ==="
|
||||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1)
|
# Only THIS job's services. The runner's docker daemon is shared, so another
|
||||||
test -n "$PG"
|
# repo's job can be running beside this one, and a job-name filter matches its
|
||||||
|
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
|
||||||
|
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
|
||||||
|
# <n> from our own container (its id is in the /etc/hostname bind mount) and
|
||||||
|
# require exactly one match.
|
||||||
|
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
||||||
|
SELF=${SELF:-$(hostname)}
|
||||||
|
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
|
||||||
|
test -n "$TASK"
|
||||||
|
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=postgres:16-alpine" -q)
|
||||||
|
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
|
||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
test -n "$PG_IP"
|
test -n "$PG_IP"
|
||||||
export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test"
|
export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test"
|
||||||
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
|
# Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) —
|
||||||
# no bash /dev/tcp — so use the Python that is always present here.
|
# no bash /dev/tcp — so use the Python that is always present here.
|
||||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||||
@@ -279,27 +304,35 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# Derives a version — see the note in desktop.yml. Depth-1 sees one commit
|
||||||
|
# and produces a too-low value silently, with the lane green (§6.1).
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate image tags and version
|
- name: Generate image tags and version
|
||||||
id: tags
|
id: tags
|
||||||
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
# run: steps execute under busybox sh (family rule 81), so use POSIX `case`,
|
||||||
# NOT bash `[[ ]]`.
|
# NOT bash `[[ ]]`.
|
||||||
run: |
|
run: |
|
||||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
# The image's version is DERIVED from its own shipped files — including the
|
||||||
BUILD_VERSION="dev"
|
# Android client it bakes in, which is why an APK-only change re-versions
|
||||||
|
# it. One value and no ordering key: nothing compares a server image, so
|
||||||
|
# §2 says do not invent one just because the other artifacts have one.
|
||||||
|
#
|
||||||
|
# This was a short sha on main and the literal "dev" elsewhere, which could
|
||||||
|
# not answer "how old is this instance?" — the question that actually gets
|
||||||
|
# asked of a self-hosted app running in several places.
|
||||||
|
BUILD_VERSION="$(sh packaging/version.sh display server)"
|
||||||
case "${{ github.ref }}" in
|
case "${{ github.ref }}" in
|
||||||
refs/heads/dev)
|
refs/heads/dev)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="${{ env.IMAGE }}:dev"
|
||||||
;;
|
;;
|
||||||
refs/heads/main)
|
refs/heads/main)
|
||||||
# Production line: :latest tracks main's tip (rule 46). No :main tag;
|
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
|
||||||
# the :<sha> above is the rollback unit. Version label = short sha.
|
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
|
||||||
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
|
|
||||||
;;
|
;;
|
||||||
refs/tags/*)
|
*)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
echo "::error::This lane builds images for dev and main only."
|
||||||
BUILD_VERSION="${{ github.ref_name }}"
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||||
@@ -310,42 +343,41 @@ jobs:
|
|||||||
docker system prune -af || true
|
docker system prune -af || true
|
||||||
docker builder prune --keep-storage 5g -f || true
|
docker builder prune --keep-storage 5g -f || true
|
||||||
|
|
||||||
# Bake the Android client in, on EVERY image build, so :dev, :latest and
|
# Bake EVERY client in, on every image build, so a self-hoster gets a working
|
||||||
# :<version> all carry one and a `docker compose pull` delivers a new client
|
# app for their machine from the server holding their notes — without an
|
||||||
# along with the new server.
|
# account on this forge, which is private (issue 2091) and is why serving them
|
||||||
|
# from a release page was never an option for anybody but the operator.
|
||||||
#
|
#
|
||||||
# Always the rolling `dev` release — the newest build there is. A versioned
|
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
||||||
# image therefore carries the newest client rather than one pinned to that
|
# the price of the product being complete (rule 23), and the AppImage is not
|
||||||
# version; the two negotiate a sync protocol version before linking, so
|
# optional within it: it is the ONLY bundle that can replace itself in place,
|
||||||
# "newest" is safe in a way "matching" would not buy anything over.
|
# 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
|
# Fetched by the JOB, not by the Dockerfile: the releases are private, and a
|
||||||
# token used inside a build lands in the context or a layer.
|
# token used inside a build lands in the context or a layer.
|
||||||
#
|
#
|
||||||
# NEVER fails the build. An image with no Android client advertises none and
|
# NEVER fails the build — see the script. A platform with nothing published
|
||||||
# hides the download — a supported state, and the only one available before
|
# means the server advertises nothing for it and the UI hides that download,
|
||||||
# the first Android build has ever published.
|
# which is a supported state and the only one available before that platform's
|
||||||
- name: Fetch the Android client to bake in
|
# first build has ever published.
|
||||||
|
- name: Fetch the clients to bake in
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p client
|
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients;
|
||||||
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
|
# :latest serves stable ones. This read `download/dev` unconditionally
|
||||||
ok=1
|
# until M314 step 3, on every branch — so every stable server shipped a
|
||||||
for f in thoughtsync.apk thoughtsync-android.json; do
|
# dev-channel APK to anyone who downloaded the client from it. Not a
|
||||||
curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0
|
# versioning gap; a plain defect, and the reason the channel is chosen here
|
||||||
done
|
# rather than inside the script: the caller is what knows which image it is
|
||||||
if [ "$ok" = 1 ]; then
|
# building.
|
||||||
echo "Baking in:"
|
case "${{ github.ref_name }}" in
|
||||||
cat client/thoughtsync-android.json
|
main) channel=stable ;;
|
||||||
ls -l client/thoughtsync.apk
|
*) channel=dev ;;
|
||||||
else
|
esac
|
||||||
# Both or neither. Half a pair is worse than none: the server would
|
sh packaging/fetch-clients.sh "$channel" client
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|||||||
+249
-113
@@ -16,33 +16,20 @@ name: Desktop (Tauri)
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
# NO `paths:` FILTER. It was a second, independent statement of this artifact's
|
||||||
|
# file set, hand-kept beside the one in `packaging/version.sh`, and it drifted
|
||||||
|
# from it within a day (85ead4d). The `decide` job below reads the real set and
|
||||||
|
# skips in seconds when nothing moved — one definition, one reader (§3).
|
||||||
|
#
|
||||||
|
# The cost is that this workflow starts on every push rather than on a matching
|
||||||
|
# one. That is a ~15s container for a decision, against a lane that cannot
|
||||||
|
# silently fail to run.
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
tags: ["v*"]
|
|
||||||
paths:
|
|
||||||
- "desktop/**"
|
|
||||||
# The shared client core (store + sync engine) the desktop wraps. Its own
|
|
||||||
# crate since the Android client binds the same code, so a change there is a
|
|
||||||
# change to this app even though nothing under desktop/ moved.
|
|
||||||
- "core/**"
|
|
||||||
# The Android uniffi shim. It builds no desktop artifact, but it is a
|
|
||||||
# workspace member, so this lane's `cargo clippy --all-targets` is what
|
|
||||||
# compiles and lints it — and until the Android lane exists (M12 step 5),
|
|
||||||
# it is the ONLY thing that does.
|
|
||||||
- "android/**"
|
|
||||||
# The workspace manifest and lockfile, which now live at the repo root.
|
|
||||||
- "Cargo.toml"
|
|
||||||
- "Cargo.lock"
|
|
||||||
# The whole frontend, not just the adapter/bridge seam: it is compiled INTO
|
|
||||||
# the desktop binary, so any part of it changing means the shipped app is out
|
|
||||||
# of date. Config and lockfile included — a dependency bump changes the bundle
|
|
||||||
# as surely as a component does.
|
|
||||||
- "frontend/**"
|
|
||||||
- ".forgejo/workflows/desktop.yml"
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: desktop-${{ github.ref }}
|
group: desktop-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# write (not read) so the tag build can publish a Release with the bundles
|
# write (not read) so the tag build can publish a Release with the bundles
|
||||||
@@ -51,32 +38,84 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
# Does anything need building at all?
|
||||||
name: Tauri desktop (Linux)
|
#
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
# ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing
|
||||||
|
# the `paths:` filters that used to state the same fact a second time. They drifted
|
||||||
|
# from it within a day: `packaging/` was added to the sets and not to the filters,
|
||||||
|
# so the commit fixing a derivation bug never ran on the two lanes it fixed
|
||||||
|
# (85ead4d). Note 3127 §3 warns about exactly that duplication.
|
||||||
|
#
|
||||||
|
# THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3).
|
||||||
|
# Skipping because "the channel already serves this version" is indistinguishable
|
||||||
|
# from "we derived a stale value that happens to match" unless something checks.
|
||||||
|
decide:
|
||||||
|
name: Build, or is the channel already serving this?
|
||||||
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
outputs:
|
||||||
|
build: ${{ steps.d.outputs.build }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# Derives a version — depth-1 is silently wrong (§6.1).
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Decide
|
||||||
|
id: d
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable ;;
|
||||||
|
*) channel=dev ;;
|
||||||
|
esac
|
||||||
|
sh packaging/guard-forward.sh desktop "$channel"
|
||||||
|
echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# The workspace checks, in their OWN job, so that BOTH publishing jobs can need
|
||||||
|
# them (rule 177: nothing publishes on red).
|
||||||
|
#
|
||||||
|
# They used to be steps inside `build`, which gated the Linux publish and nothing
|
||||||
|
# else. `windows` needed only `decide`, so on run 8411 clippy failed, the Linux job
|
||||||
|
# stopped, and the Windows installer built and published to the dev release in the
|
||||||
|
# same minute (#5184). An edge from each publisher to this job is the gate; a
|
||||||
|
# verdict inside a sibling job is only a report.
|
||||||
|
#
|
||||||
|
# Both publishers need it directly rather than through each other, so a Windows
|
||||||
|
# failure still never blocks the Linux bundles, and neither waits on the other's
|
||||||
|
# bundling. No `always()` or `continue-on-error` anywhere on this path: a skipped
|
||||||
|
# or failed verify must leave both publishers skipped.
|
||||||
|
verify:
|
||||||
|
name: Web tests, clippy, Rust tests and rustfmt
|
||||||
|
needs: [decide]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
env:
|
|
||||||
# AppImage tooling (linuxdeploy) FUSE-mounts itself by default; CI containers
|
|
||||||
# have no /dev/fuse, so tell it to extract-and-run instead. Without this the
|
|
||||||
# AppImage bundle step fails with a FUSE error.
|
|
||||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
|
||||||
steps:
|
steps:
|
||||||
|
# No version is derived here, so the default shallow checkout is enough.
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# tauri's generate_context! embeds the built frontend at compile time, so the
|
# tauri's generate_context! embeds the built frontend at compile time, so the
|
||||||
# frontend must exist before any cargo compile (clippy/test/build), not just
|
# frontend must exist before clippy or the tests can compile the desktop crate.
|
||||||
# at bundle time.
|
|
||||||
- name: Build the shared frontend
|
- name: Build the shared frontend
|
||||||
run: npm ci && npm run build
|
run: npm ci && npm run build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
# --locked on the FIRST cargo invocation of the job is the lockfile gate: it
|
# The web's unit tests, HERE as well as in ci.yml. The installers embed this
|
||||||
# fails the run if Cargo.toml and the committed Cargo.lock disagree, instead
|
# frontend, and ci.yml's verdict is invisible to this workflow — run there only,
|
||||||
# of silently re-resolving. Everything after it in this job then compiles the
|
# a red web test would still let both installers publish (rule 177).
|
||||||
# exact versions recorded in the lockfile, so the flag isn't repeated on the
|
- name: Web unit tests
|
||||||
# bundle build (issue 2102).
|
run: npm test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
# --locked on the FIRST cargo invocation is the lockfile gate: it fails the
|
||||||
|
# run if Cargo.toml and the committed Cargo.lock disagree, instead of silently
|
||||||
|
# re-resolving (issue 2102). Both publishing jobs need this job, so neither
|
||||||
|
# bundles a commit whose lockfile drifted.
|
||||||
#
|
#
|
||||||
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
|
# Run from the REPO ROOT with --workspace, not from desktop/src-tauri.
|
||||||
#
|
#
|
||||||
@@ -99,11 +138,40 @@ jobs:
|
|||||||
# but there is no Rust toolchain on the workstation (the desktop lane is
|
# 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
|
# 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
|
# 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
|
# means every push reports its real problems too. Still before either bundle
|
||||||
# bundle build, so a fmt failure doesn't burn that.
|
# build, so a fmt failure doesn't burn one.
|
||||||
- name: Rust format check
|
- name: Rust format check
|
||||||
run: cargo fmt --all --check
|
run: cargo fmt --all --check
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Tauri desktop (Linux)
|
||||||
|
needs: [decide, verify]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
|
env:
|
||||||
|
# AppImage tooling (linuxdeploy) FUSE-mounts itself by default; CI containers
|
||||||
|
# have no /dev/fuse, so tell it to extract-and-run instead. Without this the
|
||||||
|
# AppImage bundle step fails with a FUSE error.
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||||
|
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||||
|
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||||
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
|
# if it is ever dropped.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# tauri's generate_context! embeds the built frontend at compile time, so the
|
||||||
|
# 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
|
||||||
|
|
||||||
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
# Frontend already built above; skip the beforeBuildCommand rebuild.
|
||||||
#
|
#
|
||||||
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
|
# createUpdaterArtifacts is applied only when a signing key exists (M10.9):
|
||||||
@@ -123,8 +191,20 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
|
echo "No TAURI_SIGNING_PRIVATE_KEY — building unsigned, no updater artifacts."
|
||||||
fi
|
fi
|
||||||
version="$(sh ../packaging/build-version.sh)"
|
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||||
echo "Building version $version"
|
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||||
|
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||||
|
# separate value and arrives with the UI that shows it (#3181).
|
||||||
|
version="$(sh ../../packaging/version.sh key desktop)"
|
||||||
|
echo "Building desktop ordering key $version"
|
||||||
|
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||||
|
# A different value for a different audience: this is the one a person
|
||||||
|
# quotes in a bug report, the key above is the one only a comparator
|
||||||
|
# sees. Exported rather than passed as a flag because the macro that
|
||||||
|
# reads it is in Rust source, not in Tauri's config.
|
||||||
|
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||||
|
export INKWELL_DISPLAY_VERSION
|
||||||
|
echo "Baking display version $INKWELL_DISPLAY_VERSION"
|
||||||
cargo tauri build \
|
cargo tauri build \
|
||||||
--config '{"build":{"beforeBuildCommand":""}}' \
|
--config '{"build":{"beforeBuildCommand":""}}' \
|
||||||
--config "{\"version\":\"$version\"}" \
|
--config "{\"version\":\"$version\"}" \
|
||||||
@@ -185,18 +265,17 @@ jobs:
|
|||||||
run: bash desktop/packaging/arch/package-prebuilt.sh
|
run: bash desktop/packaging/arch/package-prebuilt.sh
|
||||||
|
|
||||||
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
# Make the built .deb + .AppImage downloadable from the run (for hand-testing).
|
||||||
# Mirrored action, never actions/upload-artifact: @v4+ throws
|
# Stock action: it works on this forge since the runner moved to
|
||||||
# GHESNotSupportedError on the hostname before it connects, and @v3 uploads
|
# gitea/runner 3.x, which edits upload-artifact's client-side GHES refusal
|
||||||
|
# out of the action bundle (Scribe snippet #2271). Never @v3 — it uploads
|
||||||
# something Gitea stores but will never serve back (it returns artifacts only
|
# something Gitea stores but will never serve back (it returns artifacts only
|
||||||
# through the v4 API, which filters on content_encoding='application/zip').
|
# 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
|
# No continue-on-error: a swallowed upload failure is exactly how 110
|
||||||
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
|
# unreachable artifacts accumulated here unnoticed. Fail loudly instead.
|
||||||
- name: Upload bundles
|
- name: Upload bundles
|
||||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: thoughtsync-linux
|
name: inkwell-linux
|
||||||
path: |
|
path: |
|
||||||
target/release/bundle/appimage/*.AppImage
|
target/release/bundle/appimage/*.AppImage
|
||||||
target/release/bundle/deb/*.deb
|
target/release/bundle/deb/*.deb
|
||||||
@@ -205,37 +284,43 @@ jobs:
|
|||||||
# failure, not as a green run with an empty artifact.
|
# failure, not as a green run with an empty artifact.
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# Tag builds only: publish a real, versioned Fabled-Git Release with the
|
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
||||||
# AppImage + .deb attached — the stable fetch target the install script and
|
# are releases whose tag never moves, so the updater has a permanent URL to
|
||||||
# the in-app updater consume (Actions artifacts above are ephemeral/test).
|
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
|
||||||
# Cutting the tag is the operator's action (rule 2); this only publishes a
|
# cannot be named in a URL.
|
||||||
# Release for a tag that already exists. Dormant on dev/main pushes.
|
#
|
||||||
- name: Publish release
|
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
# M314 step 3 this job built on main and published nothing, so the stable
|
||||||
env:
|
# channel moved only when somebody cut a tag — that section's diagnostic
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
# failing outright: main publishing was not sufficient for a user to receive
|
||||||
run: bash desktop/packaging/publish-release.sh
|
# the build.
|
||||||
|
|
||||||
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
|
|
||||||
# the updater has a permanent URL to read — Forgejo has no
|
|
||||||
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
|
|
||||||
#
|
#
|
||||||
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
||||||
# the secrets context isn't reliably available to step conditions. Publishing
|
# the secrets context isn't reliably available to step conditions. Publishing
|
||||||
# bundles the app would then refuse to verify is worse than publishing nothing:
|
# bundles the app would then refuse to verify is worse than publishing nothing:
|
||||||
# it looks like a working feed.
|
# it looks like a working feed.
|
||||||
- name: Publish to the dev channel
|
- name: Publish to the channel for this branch
|
||||||
if: github.ref == 'refs/heads/dev'
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
RELEASE_TAG: dev
|
|
||||||
RELEASE_PRERELEASE: "true"
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
|
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||||
|
# `prerelease` is true for dev so it does not read as a supported build,
|
||||||
|
# and false for stable, which is the real thing.
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||||
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
|
esac
|
||||||
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
|
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||||
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
|
echo "Publishing to the $RELEASE_TAG channel."
|
||||||
bash desktop/packaging/publish-release.sh
|
bash desktop/packaging/publish-release.sh
|
||||||
|
|
||||||
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
|
# Windows installer, CROSS-COMPILED from Linux — there is no Windows build host.
|
||||||
@@ -253,12 +338,21 @@ jobs:
|
|||||||
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
# built, not that it runs. A real-machine check stays mandatory before trusting it.
|
||||||
windows:
|
windows:
|
||||||
name: Windows installer (cross-compiled)
|
name: Windows installer (cross-compiled)
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
needs: [decide, verify]
|
||||||
|
if: needs.decide.outputs.build == 'true'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
image: git.fabledsword.com/bvandeusen/ci-tauri-win:1.97
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||||
|
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||||
|
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||||
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
|
# if it is ever dropped.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# Same reason as the Linux job: generate_context! embeds the built frontend
|
# Same reason as the Linux job: generate_context! embeds the built frontend
|
||||||
# at compile time, so it must exist before cargo runs.
|
# at compile time, so it must exist before cargo runs.
|
||||||
@@ -275,8 +369,8 @@ jobs:
|
|||||||
run: cargo tauri icon app-icon.png
|
run: cargo tauri icon app-icon.png
|
||||||
working-directory: desktop/src-tauri
|
working-directory: desktop/src-tauri
|
||||||
|
|
||||||
# This lane's lockfile gate (the Linux job gets it from `cargo clippy
|
# This lane's own lockfile check (`verify` has already run `cargo clippy
|
||||||
# --locked`). It has to be its own step here because the build is this job's
|
# --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
|
# 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
|
# cross-compile is the expensive way to learn it. Fetching for the Windows
|
||||||
# target also pre-warms exactly the crates the build will want.
|
# target also pre-warms exactly the crates the build will want.
|
||||||
@@ -292,8 +386,20 @@ jobs:
|
|||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
version="$(sh ../packaging/build-version.sh)"
|
# The ORDERING KEY, not the display version: this string is what Tauri's
|
||||||
echo "Building version $version"
|
# updater parses as semver, and what it stamps into bundle FILENAMES that
|
||||||
|
# `write-manifest.sh` then selects on. The human-readable version is a
|
||||||
|
# separate value and arrives with the UI that shows it (#3181).
|
||||||
|
version="$(sh ../../packaging/version.sh key desktop)"
|
||||||
|
echo "Building desktop ordering key $version"
|
||||||
|
# The DISPLAY version, baked into the binary by `option_env!` (#3181).
|
||||||
|
# A different value for a different audience: this is the one a person
|
||||||
|
# quotes in a bug report, the key above is the one only a comparator
|
||||||
|
# sees. Exported rather than passed as a flag because the macro that
|
||||||
|
# reads it is in Rust source, not in Tauri's config.
|
||||||
|
INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)"
|
||||||
|
export INKWELL_DISPLAY_VERSION
|
||||||
|
echo "Baking display version $INKWELL_DISPLAY_VERSION"
|
||||||
updater='{}'
|
updater='{}'
|
||||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||||
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
updater='{"bundle":{"createUpdaterArtifacts":true}}'
|
||||||
@@ -307,45 +413,51 @@ jobs:
|
|||||||
--config "$updater"
|
--config "$updater"
|
||||||
working-directory: desktop/src-tauri
|
working-directory: desktop/src-tauri
|
||||||
|
|
||||||
# Mirrored action, never actions/upload-artifact — see the Linux job's
|
# Stock action — see the Linux job's Upload bundles step for why.
|
||||||
# Upload bundles step for the full reasoning. Pinned by SHA because the
|
|
||||||
# mirror auto-syncs.
|
|
||||||
- name: Upload installer
|
- name: Upload installer
|
||||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: thoughtsync-windows
|
name: inkwell-windows
|
||||||
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# Publishes to the SAME release as the Linux job. Safe to run twice: the
|
# The rolling channel for this branch: `dev` from dev, `stable` from main. Both
|
||||||
# script reuses an existing release (409) and nullglob means each job uploads
|
# are releases whose tag never moves, so the updater has a permanent URL to
|
||||||
# only the bundles present in its own workspace.
|
# read — Forgejo has no /releases/latest/download/<asset> route, so "newest"
|
||||||
- name: Publish release
|
# cannot be named in a URL.
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
#
|
||||||
env:
|
# MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
# M314 step 3 this job built on main and published nothing, so the stable
|
||||||
run: bash desktop/packaging/publish-release.sh
|
# channel moved only when somebody cut a tag — that section's diagnostic
|
||||||
|
# failing outright: main publishing was not sufficient for a user to receive
|
||||||
# The rolling DEVELOPMENT channel (M10.9): a release whose tag never moves, so
|
# the build.
|
||||||
# the updater has a permanent URL to read — Forgejo has no
|
|
||||||
# /releases/latest/download/<asset> route, so "newest" can't be named in a URL.
|
|
||||||
#
|
#
|
||||||
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
# Gated on the signing key INSIDE the script rather than with an `if:`, because
|
||||||
# the secrets context isn't reliably available to step conditions. Publishing
|
# the secrets context isn't reliably available to step conditions. Publishing
|
||||||
# bundles the app would then refuse to verify is worse than publishing nothing:
|
# bundles the app would then refuse to verify is worse than publishing nothing:
|
||||||
# it looks like a working feed.
|
# it looks like a working feed.
|
||||||
- name: Publish to the dev channel
|
- name: Publish to the channel for this branch
|
||||||
if: github.ref == 'refs/heads/dev'
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
RELEASE_TAG: dev
|
|
||||||
RELEASE_PRERELEASE: "true"
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
|
||||||
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the dev channel publish."
|
echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81).
|
||||||
|
# `prerelease` is true for dev so it does not read as a supported build,
|
||||||
|
# and false for stable, which is the real thing.
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
main) channel=stable; RELEASE_PRERELEASE=false ;;
|
||||||
|
*) channel=dev; RELEASE_PRERELEASE=true ;;
|
||||||
|
esac
|
||||||
|
# The channel's release TAG, not its name: `dev` publishes on `dev-rolling`
|
||||||
|
# (packaging/channel-tag.sh). A tag named `dev` shadowed the branch.
|
||||||
|
RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")"
|
||||||
|
export RELEASE_TAG RELEASE_PRERELEASE
|
||||||
|
echo "Publishing to the $RELEASE_TAG channel."
|
||||||
bash desktop/packaging/publish-release.sh
|
bash desktop/packaging/publish-release.sh
|
||||||
|
|
||||||
# The updater manifest, written AFTER both bundle jobs — they run in separate
|
# The updater manifest, written AFTER both bundle jobs — they run in separate
|
||||||
@@ -359,12 +471,20 @@ jobs:
|
|||||||
manifest:
|
manifest:
|
||||||
name: Update manifest
|
name: Update manifest
|
||||||
needs: [build, windows]
|
needs: [build, windows]
|
||||||
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
image: git.fabledsword.com/bvandeusen/ci-tauri:1.97
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one
|
||||||
|
# commit and `git log -- <paths>` produces a too-LOW value, silently, with
|
||||||
|
# the lane green — note 3127 §6.1, and the direction you cannot recover
|
||||||
|
# from. `packaging/version.sh` fails loudly on an empty result rather than
|
||||||
|
# emitting something plausible, which is what turns this into a red lane
|
||||||
|
# if it is ever dropped.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Write and publish latest.json
|
- name: Write and publish latest.json
|
||||||
env:
|
env:
|
||||||
@@ -376,23 +496,39 @@ jobs:
|
|||||||
echo "manifest to write. Add the secret to enable in-app updates."
|
echo "manifest to write. Add the secret to enable in-app updates."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# The SAME helper the bundles were built with — a second derivation here
|
# The SAME helper AND the same request the bundles were built with — a
|
||||||
# could drift, and a manifest whose version doesn't match the binary it
|
# second derivation here could drift, and a manifest whose version doesn't
|
||||||
# points at is an updater that never settles.
|
# match the binary it points at is an updater that never settles. It must
|
||||||
version="$(sh desktop/packaging/build-version.sh)"
|
# be `key`: this value is matched against bundle filenames.
|
||||||
if [ "${GITHUB_REF_NAME}" = "dev" ]; then
|
version="$(sh packaging/version.sh key desktop)"
|
||||||
export RELEASE_TAG=dev
|
# The version a PERSON reads, published beside the manifest as
|
||||||
export RELEASE_NOTES="Development build from ${GITHUB_SHA}"
|
# `inkwell-desktop.json`. The image build reads it to describe the
|
||||||
# Rolling channel: drop the previous build's bundles once the manifest
|
# bundles it bakes in (packaging/fetch-clients.sh) without re-deriving
|
||||||
# points at this one. Nothing can reach them, and they're ~100 MB a push.
|
# 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
|
||||||
|
# triggers release.yml, which writes a changelog and builds nothing.
|
||||||
|
case "${GITHUB_REF_NAME}" in
|
||||||
|
main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)"
|
||||||
|
export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;;
|
||||||
|
*) RELEASE_TAG="$(sh packaging/channel-tag.sh dev)"
|
||||||
|
# TEMPORARY one-shot bridge (Scribe #2184). Desktop apps installed
|
||||||
|
# before the rename have .../download/dev/latest.json compiled in,
|
||||||
|
# so the manifest is also written to the old `dev` release; its
|
||||||
|
# URLs name dev-rolling assets, so those apps update once into a
|
||||||
|
# build that reads the new tag. Delete this line together with the
|
||||||
|
# old `dev` release and tag.
|
||||||
|
export BRIDGE_TAG=dev
|
||||||
|
export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;;
|
||||||
|
esac
|
||||||
|
# Assigned bare above so a failing channel-tag.sh fails this step;
|
||||||
|
# `export X="$(...)"` would swallow its exit status.
|
||||||
|
export RELEASE_TAG
|
||||||
export PRUNE_OLD_ASSETS=true
|
export PRUNE_OLD_ASSETS=true
|
||||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
APP_VERSION="$version" DISPLAY_VERSION="$display" \
|
||||||
else
|
bash desktop/packaging/write-manifest.sh
|
||||||
export RELEASE_TAG="${GITHUB_REF_NAME}"
|
|
||||||
export RELEASE_NOTES="ThoughtSync ${GITHUB_REF_NAME}"
|
|
||||||
# Twice: once onto the versioned release itself, and once onto the
|
|
||||||
# permanent `stable` pointer the app actually reads. Same manifest both
|
|
||||||
# times — its URLs point at the versioned assets either way.
|
|
||||||
APP_VERSION="$version" bash desktop/packaging/write-manifest.sh
|
|
||||||
APP_VERSION="$version" MANIFEST_TAG=stable bash desktop/packaging/write-manifest.sh
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7).
|
||||||
|
#
|
||||||
|
# The merge to `main` already published everything a user can receive: the server
|
||||||
|
# image as `:latest` + `:<sha>`, the desktop bundles and the APK to the `stable`
|
||||||
|
# channel, and the updater manifest that advertises them. A tag rebuilding that same
|
||||||
|
# source would produce identical artifacts under identical names, and would re-push
|
||||||
|
# `:<sha>` with different bytes — which rule 145 forbids even when they match.
|
||||||
|
#
|
||||||
|
# So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying
|
||||||
|
# what was in it. Note 3127 §5 — there are two halves to "what am I running", and
|
||||||
|
# the version answers only the first:
|
||||||
|
#
|
||||||
|
# which build is this? the footer, /api/config, the APK's versionName
|
||||||
|
# what changed since the one ← this
|
||||||
|
# I was running last month?
|
||||||
|
#
|
||||||
|
# Cutting the tag is the operator's act (rule 2). This only responds to one.
|
||||||
|
#
|
||||||
|
# THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on
|
||||||
|
# tags at all. The image is addressed by channel or by commit; the release by date.
|
||||||
|
# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different
|
||||||
|
# system.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notes:
|
||||||
|
name: Write the changelog
|
||||||
|
runs-on: python-ci
|
||||||
|
container:
|
||||||
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
# The whole history AND every tag: the notes are the commit range between
|
||||||
|
# this tag and the previous `v*` one, and neither end exists in a shallow
|
||||||
|
# clone. A depth-limited checkout here does not fail — it produces a
|
||||||
|
# shorter changelog, which is the kind of wrong nobody notices.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Publish the release notes
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
notes="$(sh packaging/release-notes.sh "$GITHUB_REF_NAME")"
|
||||||
|
echo "$notes"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
# JSON-escaped HERE rather than in publish-release.sh, which cannot assume
|
||||||
|
# python3 is on PATH in the three images that call it. `json.dumps` then
|
||||||
|
# strip the surrounding quotes — the script supplies those.
|
||||||
|
RELEASE_BODY_JSON="$(printf '%s' "$notes" \
|
||||||
|
| python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])')"
|
||||||
|
export RELEASE_BODY_JSON
|
||||||
|
|
||||||
|
# Through publish-release.sh for its create-or-PATCH-on-409 path: a
|
||||||
|
# release that is only ever POSTed keeps whatever body its first run
|
||||||
|
# wrote (#2182), so re-tagging or re-running must rewrite it. No bundles
|
||||||
|
# exist in this workspace, so its asset globs match nothing and it
|
||||||
|
# uploads none — which is the intended behaviour, not a side effect.
|
||||||
|
RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh
|
||||||
+2
-2
@@ -209,5 +209,5 @@ android/local.properties
|
|||||||
# The Android client CI bakes into the server image. Fetched fresh on every image
|
# 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
|
# 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.
|
# the Dockerfile's COPY always has a directory to copy.
|
||||||
client/thoughtsync.apk
|
client/inkwell.apk
|
||||||
client/thoughtsync-android.json
|
client/inkwell-android.json
|
||||||
|
|||||||
Generated
+681
-59
File diff suppressed because it is too large
Load Diff
+16
-10
@@ -20,22 +20,28 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
|||||||
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
|
# Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH
|
||||||
# points at /app/src so the runtime imports this source tree (with static/ present),
|
# points at /app/src so the runtime imports this source tree (with static/ present),
|
||||||
# not the pip-installed copy.
|
# not the pip-installed copy.
|
||||||
COPY --from=build-frontend /build/dist/ src/thoughtsync/static/
|
COPY --from=build-frontend /build/dist/ src/inkwell/static/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
|
|
||||||
# The Android client this server hands out. CI fetches the newest published build
|
# The clients this server hands out — the APK and all four desktop bundles. CI
|
||||||
# into ./client immediately before this runs (ci.yml), so every image tag — :dev,
|
# fetches the newest published build of each into ./client immediately before this
|
||||||
# :latest and :<version> alike — ships a client, and a `docker compose pull`
|
# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a
|
||||||
# delivers a new one with no file copying by hand.
|
# `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.
|
# 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
|
# 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
|
# that step never ran. An image with no clients — or with some and not others — is
|
||||||
# advertises nothing and the web UI hides the download (client_dist.py).
|
# a supported state: the server advertises what it has and the web UI hides the
|
||||||
COPY client/ src/thoughtsync/client/
|
# rest (client_dist.py).
|
||||||
|
COPY client/ src/inkwell/client/
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|
||||||
@@ -46,4 +52,4 @@ EXPOSE 5000
|
|||||||
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
# Wait for the database, run migrations, then serve. The DB wait keeps a briefly
|
||||||
# slow/unready database from crash-looping the container. Family convention
|
# slow/unready database from crash-looping the container. Family convention
|
||||||
# (rule 82): schema is built by real migrations, never metadata.create_all.
|
# (rule 82): schema is built by real migrations, never metadata.create_all.
|
||||||
CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# ThoughtSync
|
# Inkwell
|
||||||
|
|
||||||
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
Self-hosted personal thought-capture web app in the **FabledSword** family — a
|
||||||
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
Google-Keep-style **masonry post-it board** for capturing disparate thoughts in
|
||||||
@@ -13,7 +13,7 @@ under a second, designed to grow into a lightweight second brain (labels, search
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db)
|
src/inkwell/ Quart app (app factory, auth, models, ACL, config, db)
|
||||||
alembic/ async migrations (schema built via `alembic upgrade head`)
|
alembic/ async migrations (schema built via `alembic upgrade head`)
|
||||||
tests/ DB-free unit tests (pytest)
|
tests/ DB-free unit tests (pytest)
|
||||||
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
|
frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA
|
||||||
@@ -23,12 +23,12 @@ docker-compose.yml local app + Postgres stack
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`):
|
Backend (needs a Postgres reachable at `INKWELL_DATABASE_URL`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000
|
hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend (proxies `/api` to `:5000`):
|
Frontend (proxies `/api` to `:5000`):
|
||||||
@@ -64,49 +64,51 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: thoughtsync
|
POSTGRES_USER: inkwell
|
||||||
POSTGRES_PASSWORD: CHANGE_ME # change this
|
POSTGRES_PASSWORD: CHANGE_ME # change this
|
||||||
POSTGRES_DB: thoughtsync
|
POSTGRES_DB: inkwell
|
||||||
volumes:
|
volumes:
|
||||||
- thoughtsync-db:/var/lib/postgresql/data
|
- inkwell-db:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U thoughtsync"]
|
test: ["CMD-SHELL", "pg_isready -U inkwell"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build
|
image: git.fabledsword.com/bvandeusen/inkwell:latest # :dev for the current dev build
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync
|
INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell
|
||||||
volumes:
|
volumes:
|
||||||
- thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments
|
- inkwell-data:/var/inkwell # uploaded images; omit if you don't use attachments
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
thoughtsync-db:
|
inkwell-db:
|
||||||
thoughtsync-data:
|
inkwell-data:
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
Then open `http://<host>:5000` and register — **the first account becomes the admin**.
|
||||||
|
|
||||||
- **Only `THOUGHTSYNC_DATABASE_URL` is required.** `THOUGHTSYNC_SECRET_KEY` is optional; if
|
- **Only `INKWELL_DATABASE_URL` is required.** `INKWELL_SECRET_KEY` is optional; if
|
||||||
unset, a signing key is generated and persisted in the database (sessions survive restarts).
|
unset, a signing key is generated and persisted in the database (sessions survive restarts).
|
||||||
- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`.
|
- Uploaded images live under the `inkwell-data` volume at `/var/inkwell`.
|
||||||
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start.
|
||||||
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` build) ·
|
- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev`
|
||||||
`:<git-sha>` (immutable, for pinning / rollback).
|
build) · `:<git-sha>` on `main` only (immutable, the rollback unit). There are
|
||||||
|
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
|
- **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
|
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
|
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
|
[docs/public-hosting.md](docs/public-hosting.md), which also lists what the app
|
||||||
hardens on its own and what it deliberately doesn't.
|
hardens on its own and what it deliberately doesn't.
|
||||||
- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the
|
- **Install as an app (PWA):** Inkwell is installable ("Add to Home Screen" / the
|
||||||
browser's install button) for an app-like window. Browsers only offer install over a
|
browser's install button) for an app-like window. Browsers only offer install over a
|
||||||
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
**secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach
|
||||||
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
it via `localhost`) — plain `http://<host>:5000` won't show the install prompt.
|
||||||
|
|||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
# Alembic single-database async configuration for ThoughtSync.
|
# Alembic single-database async configuration for Inkwell.
|
||||||
|
|
||||||
[alembic]
|
[alembic]
|
||||||
script_location = %(here)s/alembic
|
script_location = %(here)s/alembic
|
||||||
prepend_sys_path = . src
|
prepend_sys_path = . src
|
||||||
path_separator = os
|
path_separator = os
|
||||||
|
|
||||||
# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py).
|
# Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py).
|
||||||
sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync
|
sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell
|
||||||
|
|
||||||
|
|
||||||
[loggers]
|
[loggers]
|
||||||
|
|||||||
+3
-3
@@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config
|
|||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
from thoughtsync.models import Base
|
from inkwell.models import Base
|
||||||
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata
|
import inkwell.models.all # noqa: F401 — registers every model on Base.metadata
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ if config.config_file_name is not None:
|
|||||||
|
|
||||||
config.set_main_option(
|
config.set_main_option(
|
||||||
"sqlalchemy.url",
|
"sqlalchemy.url",
|
||||||
os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
|
os.environ.get("INKWELL_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
|
||||||
)
|
)
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""lift standalone #tags out of note bodies
|
||||||
|
|
||||||
|
Revision ID: 0028
|
||||||
|
Revises: 0027
|
||||||
|
Create Date: 2026-08-26
|
||||||
|
|
||||||
|
M311. A `#tag` was being shown twice — once as the text you typed and once as a chip —
|
||||||
|
and with the chip moved to the top of the card the text is redundant. This removes it,
|
||||||
|
but only from notes where the tag was standing on its own.
|
||||||
|
|
||||||
|
## This migration rewrites note bodies
|
||||||
|
|
||||||
|
The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags`
|
||||||
|
applies from here on:
|
||||||
|
|
||||||
|
* A line containing nothing but tags and whitespace is REMOVED.
|
||||||
|
* Every other line is left exactly as written.
|
||||||
|
|
||||||
|
So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The
|
||||||
|
looser reading — also stripping a trailing tag off a prose line — was rejected because
|
||||||
|
the text does not say which kind it is: `buy milk #grocery` is filing, `remember to
|
||||||
|
call #mom` is the sentence's object, and lifting the second leaves "remember to call".
|
||||||
|
Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is
|
||||||
|
the worst possible place to make it.
|
||||||
|
|
||||||
|
Two guards, both of which cost a note nothing:
|
||||||
|
|
||||||
|
* A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a
|
||||||
|
snippet somebody pasted, and deleting it would eat a line of their example.
|
||||||
|
* A note that is NOTHING but tags keeps its text. Lifting would leave a blank card,
|
||||||
|
which is worse than the duplication this fixes.
|
||||||
|
|
||||||
|
## The label rows have to graduate in the same transaction
|
||||||
|
|
||||||
|
A `via_tag` row means "this label is backed by text still in the body". Once the text
|
||||||
|
is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags`
|
||||||
|
detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag
|
||||||
|
on its very next save. The flip to `via_tag = false` is what makes the label the record
|
||||||
|
instead — and what makes the chip's × appear in both editors, which is now the only way
|
||||||
|
to remove a tag whose text no longer exists.
|
||||||
|
|
||||||
|
## The transform is inlined, like 0027's
|
||||||
|
|
||||||
|
`split_body_tags` is deliberately NOT imported. A migration has to keep producing what
|
||||||
|
it produced the day it ran; if the app's rule is ever loosened, this file must not
|
||||||
|
loosen with it and start eating prose it previously left alone.
|
||||||
|
|
||||||
|
`_display_title` is inlined for the same reason, and is only recomputed for a note whose
|
||||||
|
body actually moved — a note named after a `#todo` line needs a new name, and reading it
|
||||||
|
from the app would couple this migration to a rule that has already changed once (M13).
|
||||||
|
|
||||||
|
## `updated_at` is left alone, and that is load-bearing
|
||||||
|
|
||||||
|
Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit
|
||||||
|
keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps
|
||||||
|
its edit instead of the migration silently winning.
|
||||||
|
|
||||||
|
The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes
|
||||||
|
pullable once and clients converge on the server's text. That is wanted here: unlike
|
||||||
|
0027, the clients do NOT yet apply this rule locally, so the server's copy is the only
|
||||||
|
correct one until they do.
|
||||||
|
|
||||||
|
## The downgrade is not a true inverse, and says so
|
||||||
|
|
||||||
|
It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that
|
||||||
|
was never there, and putting one back would be guessing at where in the note it went.
|
||||||
|
|
||||||
|
Nothing is lost, though, which is why that is acceptable: the tag still exists as a
|
||||||
|
label on the note, and the chip still shows it. What a downgrade cannot restore is the
|
||||||
|
DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back
|
||||||
|
to `via_tag = true` would be actively harmful: the text that flag claims to be backed by
|
||||||
|
is gone, so the next save would detach the label and lose the tag for real. So the
|
||||||
|
downgrade leaves both alone. The real rollback is a database restore.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0028"
|
||||||
|
down_revision = "0027"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Frozen copies. See "The transform is inlined" above — these must not follow the app.
|
||||||
|
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
|
||||||
|
_FENCE_RE = re.compile(r"^\s*(?:```|~~~)")
|
||||||
|
_TASK_RE = re.compile(r"^(?P<indent>\s*)(?P<bullet>[-*]) +\[(?P<mark>[ xX])\](?: +(?P<text>.*))?$")
|
||||||
|
_DISPLAY_TITLE_CAP = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tag(name: str) -> bool:
|
||||||
|
"""A tag must contain a letter, so #2024 and #_ are not tags — and a line holding
|
||||||
|
only those is therefore not a tag-only line and is left alone."""
|
||||||
|
return any(c.isalpha() for c in name)
|
||||||
|
|
||||||
|
|
||||||
|
def _split(body: str) -> tuple[list[str], str]:
|
||||||
|
"""(standalone tag names, body with their lines removed)."""
|
||||||
|
standalone: list[str] = []
|
||||||
|
kept: list[str] = []
|
||||||
|
in_fence = False
|
||||||
|
for line in body.split("\n"):
|
||||||
|
if _FENCE_RE.match(line):
|
||||||
|
in_fence = not in_fence
|
||||||
|
kept.append(line)
|
||||||
|
continue
|
||||||
|
matches = [m for m in _TAG_RE.finditer(line) if _is_tag(m.group(1))]
|
||||||
|
remainder = line
|
||||||
|
for m in reversed(matches):
|
||||||
|
remainder = remainder[: m.start()] + remainder[m.end() :]
|
||||||
|
if in_fence or not matches or remainder.strip():
|
||||||
|
kept.append(line)
|
||||||
|
else:
|
||||||
|
standalone.extend(m.group(1) for m in matches)
|
||||||
|
lifted = re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip("\n")
|
||||||
|
if body.strip() and not lifted.strip():
|
||||||
|
return [], body # nothing but tags: keep the note readable
|
||||||
|
# A tag still written in prose somewhere keeps its text, so it stays derived.
|
||||||
|
still_in_prose = {m.group(1).lower() for m in _TAG_RE.finditer(lifted) if _is_tag(m.group(1))}
|
||||||
|
return [n for n in standalone if n.lower() not in still_in_prose], lifted
|
||||||
|
|
||||||
|
|
||||||
|
def _display_title(body: str) -> str:
|
||||||
|
for line in body.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
match = _TASK_RE.match(stripped)
|
||||||
|
text = (match.group("text") or "") if match else stripped
|
||||||
|
text = text.strip()
|
||||||
|
if text:
|
||||||
|
return text[:_DISPLAY_TITLE_CAP]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(sa.text("SELECT id, body FROM notes WHERE body LIKE '%#%'")).fetchall()
|
||||||
|
|
||||||
|
flip = sa.text(
|
||||||
|
"UPDATE note_labels nl SET via_tag = false "
|
||||||
|
"FROM labels l "
|
||||||
|
"WHERE nl.label_id = l.id AND nl.note_id = :nid AND nl.via_tag = true "
|
||||||
|
"AND lower(l.name) IN :names"
|
||||||
|
).bindparams(sa.bindparam("names", expanding=True))
|
||||||
|
|
||||||
|
for note_id, body in rows:
|
||||||
|
if not body:
|
||||||
|
continue
|
||||||
|
standalone, lifted = _split(body)
|
||||||
|
if lifted != body:
|
||||||
|
bind.execute(
|
||||||
|
sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"),
|
||||||
|
{"body": lifted, "title": _display_title(lifted), "id": note_id},
|
||||||
|
)
|
||||||
|
# Even when the body did not move, a tag can be standalone only in the sense
|
||||||
|
# that its line was already removed by an earlier pass — so the flip is driven
|
||||||
|
# by the tag list, not by whether the text changed.
|
||||||
|
if standalone:
|
||||||
|
bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]})
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
"""Deliberately empty — see the module docstring.
|
||||||
|
|
||||||
|
Restoring the deleted lines would be guessing, and flipping the rows back to
|
||||||
|
`via_tag = true` would be worse than doing nothing: the text that flag claims backs
|
||||||
|
them is gone, so the next save would detach the label and lose the tag for real.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""drop notes.color — a card is one neutral surface, colour lives on the tag
|
||||||
|
|
||||||
|
Revision ID: 0029
|
||||||
|
Revises: 0028
|
||||||
|
Create Date: 2026-08-28
|
||||||
|
|
||||||
|
M315 step 3. A note's colour was set by a picker and read by three card renderers.
|
||||||
|
Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and
|
||||||
|
the only coloured thing on a board is a tag. This drops the column that nothing has
|
||||||
|
been reading since, and the picker goes with it.
|
||||||
|
|
||||||
|
`labels.color` is untouched. That is the colour that survived, and the one the whole
|
||||||
|
milestone was about keeping.
|
||||||
|
|
||||||
|
## What is lost, and why that is the change rather than a cost of it
|
||||||
|
|
||||||
|
Any colour a note was explicitly given. There is nowhere to preserve it TO — the field
|
||||||
|
it would be preserved in is the one being dropped — and nothing renders it, so a
|
||||||
|
preserved value would be a column kept warm for a feature that was deliberately
|
||||||
|
removed. A note that had a colour now takes its identity from its tags, which is what
|
||||||
|
the operator asked for: "strip color from the cards ... and keep the color for tags
|
||||||
|
just on the tag."
|
||||||
|
|
||||||
|
The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to
|
||||||
|
`colors.py` in the same change — labels still name a colour, and leaving the vocabulary
|
||||||
|
defined on the model that lost one would be an invitation to put the column back.
|
||||||
|
|
||||||
|
## The saved-filter sweep is not optional
|
||||||
|
|
||||||
|
`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and
|
||||||
|
a stored view could carry `"color": "teal"`. With the facet gone that key would sit
|
||||||
|
there forever, and `clean_params` only guards what is written FROM here on. A view that
|
||||||
|
silently filters on a field the app no longer has is worse than one that visibly lost a
|
||||||
|
criterion, so the stored rows are swept too.
|
||||||
|
|
||||||
|
Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no
|
||||||
|
try-cast: one malformed blob would abort the whole migration, and these rows are
|
||||||
|
somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it
|
||||||
|
holds and lets every other row be fixed.
|
||||||
|
|
||||||
|
## Search is not affected
|
||||||
|
|
||||||
|
`notes.search_vector` is a stored generated column over `display_title` and `body`
|
||||||
|
(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing
|
||||||
|
here to tear down and recreate.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Restores the column, empty, at its old default. The values are not recoverable — see
|
||||||
|
above. It is the schema that comes back, not the data.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0029"
|
||||||
|
down_revision = "0028"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("notes", "color")
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text("SELECT id, params FROM saved_filters WHERE params LIKE '%color%'")
|
||||||
|
).fetchall()
|
||||||
|
for sf_id, params in rows:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(params)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# A blob that does not parse cannot be edited safely. Leaving it is
|
||||||
|
# correct: it was already unreadable by the app, and this migration is not
|
||||||
|
# the place to decide what it should have said.
|
||||||
|
continue
|
||||||
|
if not isinstance(parsed, dict) or "color" not in parsed:
|
||||||
|
continue
|
||||||
|
parsed.pop("color")
|
||||||
|
bind.execute(
|
||||||
|
sa.text("UPDATE saved_filters SET params = :p WHERE id = :id"),
|
||||||
|
{"p": json.dumps(parsed), "id": sf_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Comes back at the default every note would have had anyway. Which notes once
|
||||||
|
# carried a chosen colour is not recorded anywhere after the upgrade.
|
||||||
|
op.add_column(
|
||||||
|
"notes",
|
||||||
|
sa.Column("color", sa.Text(), nullable=False, server_default="default"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""a stored site_name of the old default follows the rename to Inkwell
|
||||||
|
|
||||||
|
Revision ID: 0030
|
||||||
|
Revises: 0029
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
|
||||||
|
The product is renamed ThoughtSync → Inkwell (Scribe note 5071), and the `site_name`
|
||||||
|
registry default moves with it. On its own that only reaches servers whose settings
|
||||||
|
table has no `site_name` row — and most servers have one, because the Settings page
|
||||||
|
saves EVERY key on Save, not just the one that changed. An admin who opened Settings
|
||||||
|
to flip registration off has persisted `"ThoughtSync"` without ever choosing it.
|
||||||
|
|
||||||
|
So the row is rewritten, but only when it holds exactly the old default. A name the
|
||||||
|
admin actually typed is theirs and is left alone; the old default is the one value we
|
||||||
|
can be sure nobody chose.
|
||||||
|
|
||||||
|
The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so
|
||||||
|
`'"ThoughtSync"'` is the whole of what it looks for.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Puts the old default back on a row holding exactly the new one. A server where the
|
||||||
|
admin typed "Inkwell" themselves between the two cannot be told apart from one this
|
||||||
|
migration rewrote; on downgrade both read "ThoughtSync", which is what the old code
|
||||||
|
would have shown for either.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0030"
|
||||||
|
down_revision = "0029"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_OLD = '"ThoughtSync"'
|
||||||
|
_NEW = '"Inkwell"'
|
||||||
|
|
||||||
|
|
||||||
|
def _swap(old: str, new: str) -> None:
|
||||||
|
op.get_bind().execute(
|
||||||
|
sa.text("UPDATE settings SET value = :new WHERE key = 'site_name' AND value = :old"),
|
||||||
|
{"old": old, "new": new},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_swap(_OLD, _NEW)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_swap(_NEW, _OLD)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""a link preview's insert, update or delete bumps its note's sync revision
|
||||||
|
|
||||||
|
Revision ID: 0031
|
||||||
|
Revises: 0030
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
0015 made every child table bump its parent note's `sync_revision`, so a note syncs
|
||||||
|
as a whole. `note_link_previews` arrived later (0020) and was never added, and two
|
||||||
|
things have been missing on every linked device since:
|
||||||
|
|
||||||
|
- A preview fetched in the background after a save (`unfurl_queue.py`) never reached
|
||||||
|
a device that had already pulled the note. The note's revision was assigned when
|
||||||
|
the TEXT was saved, before the preview existed, and nothing moved it afterwards.
|
||||||
|
- A preview dismissed on the web stayed on every other device, for the same reason.
|
||||||
|
|
||||||
|
The trigger is the same function 0015 installs on the other child tables.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the trigger. Previews go back to not propagating; nothing is lost.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0031"
|
||||||
|
down_revision = "0030"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER trg_note_link_previews_bump_note AFTER INSERT OR UPDATE OR DELETE "
|
||||||
|
"ON note_link_previews FOR EACH ROW EXECUTE PROCEDURE ts_bump_parent_note_revision()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS trg_note_link_previews_bump_note ON note_link_previews")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""invites: single-use, expiring registration links an admin issues
|
||||||
|
|
||||||
|
Revision ID: 0032
|
||||||
|
Revises: 0031
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Until now the only way to add a second person was to re-open registration to the
|
||||||
|
whole internet while they signed up (#2939 §1). An invite lets one person register
|
||||||
|
while registration stays closed. Only the token's SHA-256 hash is stored.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table. Accounts created through invites are untouched; the record of who
|
||||||
|
invited them goes with it.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import CITEXT, UUID
|
||||||
|
|
||||||
|
revision = "0032"
|
||||||
|
down_revision = "0031"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"invites",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("email", CITEXT(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("redeemed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("invites")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""password resets: an admin-issued, one-hour link; sessions an account can outlive
|
||||||
|
|
||||||
|
Revision ID: 0033
|
||||||
|
Revises: 0032
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
A forgotten password used to need a hand on the database (#2939 §2), and the app has
|
||||||
|
no mail path to send a reset by. An admin makes a reset link for the account and
|
||||||
|
hands it over (#5173). Only the token's SHA-256 hash is stored.
|
||||||
|
|
||||||
|
`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions
|
||||||
|
are signed cookies held by the browser, so the server can't delete them; each one
|
||||||
|
carries the epoch it was signed in under, and a reset moves the account's epoch on.
|
||||||
|
It starts at 0, the value a cookie from before this migration is read as, so nobody
|
||||||
|
is signed out by the upgrade itself.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table and the column. Outstanding reset links stop working; sessions keep
|
||||||
|
working, since nothing checks an epoch any more.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0033"
|
||||||
|
down_revision = "0032"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
op.create_table(
|
||||||
|
"password_resets",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("token_hash", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_password_resets_user_id", table_name="password_resets")
|
||||||
|
op.drop_table("password_resets")
|
||||||
|
op.drop_column("users", "session_epoch")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""shares: one grant per note and person, and only the permissions the app knows
|
||||||
|
|
||||||
|
Revision ID: 0034
|
||||||
|
Revises: 0033
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Nothing wrote a share until #5174, so the table never needed these. Now the Share
|
||||||
|
dialog does:
|
||||||
|
- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is
|
||||||
|
the target, so sharing a note with someone twice updates the one grant rather than
|
||||||
|
stacking two (the same for a group, ahead of step 15).
|
||||||
|
- A check that `permission` is `view` or `edit`.
|
||||||
|
- An index on `shared_with_user_id` for "Shared with me".
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the indexes and the check. The rows stay.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0034"
|
||||||
|
down_revision = "0033"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"uq_shares_resource_user",
|
||||||
|
"shares",
|
||||||
|
["resource_type", "resource_id", "shared_with_user_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="shared_with_user_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_shares_resource_group",
|
||||||
|
"shares",
|
||||||
|
["resource_type", "resource_id", "shared_with_group_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where="shared_with_group_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
op.create_index("ix_shares_user", "shares", ["shared_with_user_id"])
|
||||||
|
op.create_check_constraint("ck_shares_permission", "shares", "permission IN ('view', 'edit')")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_shares_permission", "shares", type_="check")
|
||||||
|
op.drop_index("ix_shares_user", table_name="shares")
|
||||||
|
op.drop_index("uq_shares_resource_group", table_name="shares")
|
||||||
|
op.drop_index("uq_shares_resource_user", table_name="shares")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""share_revocations: telling a recipient's devices a shared note has left them
|
||||||
|
|
||||||
|
Revision ID: 0035
|
||||||
|
Revises: 0034
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
The change feed carries every note a person can see, including notes shared with
|
||||||
|
them (#5175). When a share ends, the note stops being visible, so it simply stops
|
||||||
|
appearing in the feed. A device that already holds a copy would keep it forever. A
|
||||||
|
revocation is that missing signal: one row per (note, person) who lost the note,
|
||||||
|
stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits
|
||||||
|
on the one cursor every client already pages by.
|
||||||
|
|
||||||
|
Sharing the note with that person again deletes the row, so a device that never
|
||||||
|
heard of the revocation never gets told to delete a note it is meant to have.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table. Devices that missed a revocation keep their copy.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0035"
|
||||||
|
down_revision = "0034"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"share_revocations",
|
||||||
|
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"sync_revision",
|
||||||
|
sa.BigInteger(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("nextval('sync_revision_seq')"),
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint("note_id", "user_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_share_revocations_user_revision", "share_revocations", ["user_id", "sync_revision"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_share_revocations_user_revision", table_name="share_revocations")
|
||||||
|
op.drop_table("share_revocations")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""note_user_state: a recipient's own pin, archive and place for a shared note
|
||||||
|
|
||||||
|
Revision ID: 0036
|
||||||
|
Revises: 0035
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Pin, archive and board order are personal organization, and until now they were
|
||||||
|
columns on `notes`, so on a shared note they could only ever mean the owner's
|
||||||
|
(#5176). This table holds them for everyone else: one row per (note, person it is
|
||||||
|
shared with) who has pinned, archived or moved it. The owner keeps the note's own
|
||||||
|
columns; a recipient with no row sees the note unpinned, unarchived, in the owner's
|
||||||
|
order.
|
||||||
|
|
||||||
|
`sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes
|
||||||
|
and labels use (0015), so a recipient's change reaches their own devices on the one
|
||||||
|
cursor they already page by, and never moves the note on anyone else's.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Drops the table. Recipients lose their own pins and archives; the notes are
|
||||||
|
untouched.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0036"
|
||||||
|
down_revision = "0035"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"note_user_state",
|
||||||
|
sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("sync_revision", sa.BigInteger(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("note_id", "user_id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_note_user_state_user_revision", "note_user_state", ["user_id", "sync_revision"])
|
||||||
|
op.execute(
|
||||||
|
"CREATE TRIGGER trg_note_user_state_sync_revision BEFORE INSERT OR UPDATE ON note_user_state "
|
||||||
|
"FOR EACH ROW EXECUTE PROCEDURE ts_set_sync_revision()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS trg_note_user_state_sync_revision ON note_user_state")
|
||||||
|
op.drop_index("ix_note_user_state_user_revision", table_name="note_user_state")
|
||||||
|
op.drop_table("note_user_state")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""users: drop email_verified and avatar_path, which nothing used
|
||||||
|
|
||||||
|
Revision ID: 0037
|
||||||
|
Revises: 0036
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
Both came with the foundation (0001) for features that were never built (#5178).
|
||||||
|
Nothing ever set `email_verified`, so it was false for every account on the server
|
||||||
|
while the offline desktop reported it as true; nothing read or wrote `avatar_path`.
|
||||||
|
A field whose value means nothing is worse than no field: a later feature would
|
||||||
|
trust it. If address verification is ever built, it adds its own column, with a
|
||||||
|
migration that says what the existing accounts are.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Adds both columns back empty: every account unverified, no avatar. That is what
|
||||||
|
they held before.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0037"
|
||||||
|
down_revision = "0036"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("users", "email_verified")
|
||||||
|
op.drop_column("users", "avatar_path")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("avatar_path", sa.Text(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""saved_filters: drop the table, saved views are gone
|
||||||
|
|
||||||
|
Revision ID: 0038
|
||||||
|
Revises: 0037
|
||||||
|
Create Date: 2026-10-08
|
||||||
|
|
||||||
|
Saved views were removed in #5180 (note 2897: recall first, organizing second). They
|
||||||
|
lived only on the web; the desktop kept its own set that never synced, and Android
|
||||||
|
had none. Tags in the drawer already give one-click recall.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Recreates the table empty, as 0021 made it. The views that were stored are not
|
||||||
|
recoverable.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0038"
|
||||||
|
down_revision = "0037"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_index("ix_saved_filters_owner", table_name="saved_filters")
|
||||||
|
op.drop_table("saved_filters")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"saved_filters",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("params", sa.Text(), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_saved_filters_owner", "saved_filters", ["owner_id", "position"])
|
||||||
@@ -16,7 +16,7 @@ val workspaceRoot: Directory = layout.projectDirectory.dir("../..")
|
|||||||
val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
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.
|
* `.so` into jniLibs, where AGP packages it.
|
||||||
*
|
*
|
||||||
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
|
* `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in
|
||||||
@@ -49,7 +49,7 @@ abstract class CargoNdkBuild : DefaultTask() {
|
|||||||
args += "-t"
|
args += "-t"
|
||||||
args += abi
|
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
|
// --locked so an Android build cannot silently re-resolve the workspace
|
||||||
// lockfile the desktop lanes are gated on.
|
// lockfile the desktop lanes are gated on.
|
||||||
args += "--locked"
|
args += "--locked"
|
||||||
@@ -94,7 +94,7 @@ abstract class UniffiBindgen : DefaultTask() {
|
|||||||
"run",
|
"run",
|
||||||
"--locked",
|
"--locked",
|
||||||
"-p",
|
"-p",
|
||||||
"thoughtsync-uniffi-bindgen",
|
"inkwell-uniffi-bindgen",
|
||||||
"--",
|
"--",
|
||||||
"generate",
|
"generate",
|
||||||
"--library",
|
"--library",
|
||||||
@@ -141,7 +141,7 @@ val rustInputs =
|
|||||||
* build, and neither is worth holding signed APKs up for. Scribe #2810.
|
* build, and neither is worth holding signed APKs up for. Scribe #2810.
|
||||||
*/
|
*/
|
||||||
val rustProfile =
|
val rustProfile =
|
||||||
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
(project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
|
||||||
?: "debug"
|
?: "debug"
|
||||||
|
|
||||||
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
||||||
@@ -149,7 +149,7 @@ val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
|||||||
|
|
||||||
val cargoNdk =
|
val cargoNdk =
|
||||||
tasks.register<CargoNdkBuild>("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)
|
rustSources.from(rustInputs)
|
||||||
abis.set(androidAbis)
|
abis.set(androidAbis)
|
||||||
cargoProfile.set(rustProfile)
|
cargoProfile.set(rustProfile)
|
||||||
@@ -163,17 +163,17 @@ val generateBindings =
|
|||||||
dependsOn(cargoNdk)
|
dependsOn(cargoNdk)
|
||||||
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
||||||
// reading one is cheaper than reading four.
|
// 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)
|
workspaceDir.set(workspaceRoot)
|
||||||
outputDir.set(bindingsOut)
|
outputDir.set(bindingsOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.fabledsword.thoughtsync"
|
namespace = "com.fabledsword.inkwell"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.fabledsword.thoughtsync"
|
applicationId = "com.fabledsword.inkwell"
|
||||||
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
|
// 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with
|
||||||
// room to spare.
|
// room to spare.
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
@@ -181,9 +181,9 @@ android {
|
|||||||
// Injected by CI from the git tag + commit count for a release; "dev"
|
// 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.
|
// locally so the About screen reads honestly rather than claiming 1.0.
|
||||||
val nameOverride =
|
val nameOverride =
|
||||||
(project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
(project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
|
||||||
val codeOverride =
|
val codeOverride =
|
||||||
(project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull()
|
(project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull()
|
||||||
versionCode = codeOverride ?: 1
|
versionCode = codeOverride ?: 1
|
||||||
versionName = nameOverride ?: "dev"
|
versionName = nameOverride ?: "dev"
|
||||||
|
|
||||||
@@ -215,7 +215,10 @@ android {
|
|||||||
// Hardcoded, and NOT a secret: the alias is fixed for the life of
|
// Hardcoded, and NOT a secret: the alias is fixed for the life of
|
||||||
// this app and is written into the certificate every install
|
// this app and is written into the certificate every install
|
||||||
// already carries. Hiding it would buy nothing and stop this file
|
// already carries. Hiding it would buy nothing and stop this file
|
||||||
// describing its own signing setup.
|
// describing its own signing setup. It still says `thoughtsync`
|
||||||
|
// after the rename to Inkwell, because it names the key inside the
|
||||||
|
// existing keystore, and renaming it would only stop that key being
|
||||||
|
// found. Same key, so the signing certificate did not change either.
|
||||||
keyAlias = "thoughtsync"
|
keyAlias = "thoughtsync"
|
||||||
// PKCS12 cannot hold a key password distinct from the store
|
// PKCS12 cannot hold a key password distinct from the store
|
||||||
// password — keytool refuses to set one — so this is the same
|
// password — keytool refuses to set one — so this is the same
|
||||||
|
|||||||
Vendored
+1
-1
@@ -4,4 +4,4 @@
|
|||||||
# is the worst possible time to learn it.
|
# is the worst possible time to learn it.
|
||||||
-keep class com.sun.jna.** { *; }
|
-keep class com.sun.jna.** { *; }
|
||||||
-keepclassmembers class * extends com.sun.jna.** { public *; }
|
-keepclassmembers class * extends com.sun.jna.** { public *; }
|
||||||
-keep class com.fabledsword.thoughtsync.core.** { *; }
|
-keep class com.fabledsword.inkwell.core.** { *; }
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
this permission never exercised.
|
this permission never exercised.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Only to answer "is this connection metered?" before the app downloads its own
|
||||||
|
update in the background. Normal permission, no prompt, no location. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Four more permissions are NOT declared here and still reach the merged
|
Four more permissions are NOT declared here and still reach the merged
|
||||||
@@ -88,23 +91,71 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ThoughtSyncApplication"
|
android:name=".InkwellApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ThoughtSync"
|
android:theme="@style/Theme.Inkwell"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
<!--
|
||||||
|
launchMode="singleTop" exists for the SHARE filters below.
|
||||||
|
|
||||||
|
The reminder notification adds FLAG_ACTIVITY_SINGLE_TOP to its own
|
||||||
|
intent, so onNewIntent already worked for that one. A share intent is
|
||||||
|
built by the OTHER app — Chrome, a reader, the text-selection toolbar —
|
||||||
|
and nothing here can add a flag to it. Without singleTop declared on the
|
||||||
|
activity itself, every share while the app is running would stack a
|
||||||
|
second MainActivity on top of the first: a second view model, a second
|
||||||
|
board, and a back press that lands on a stale copy of the same app.
|
||||||
|
-->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:theme="@style/Theme.ThoughtSync">
|
android:theme="@style/Theme.Inkwell">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Capture without opening the app first: Share → Inkwell from
|
||||||
|
anywhere, and the selection toolbar in any text field.
|
||||||
|
|
||||||
|
Text, and images one at a time or several at once. A shared image
|
||||||
|
becomes a note carrying it as an attachment, stored on the phone
|
||||||
|
and uploaded on the next sync that reaches a server (#5169).
|
||||||
|
|
||||||
|
image/* rather than */*: a photo or a screenshot is what people
|
||||||
|
share into a notes app. Claiming every type would put Inkwell in the
|
||||||
|
share sheet for APKs, contacts and calendar entries, where it would
|
||||||
|
be noise. Other files attach from inside the editor, whose picker
|
||||||
|
takes any type.
|
||||||
|
-->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The label is what appears in the text-selection menu beside Copy and
|
||||||
|
Share, where "Inkwell" would say who rather than what.
|
||||||
|
-->
|
||||||
|
<intent-filter android:label="@string/capture_process_text">
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -123,6 +174,22 @@
|
|||||||
PackageInstaller. Without it a failed install would be indistinguishable
|
PackageInstaller. Without it a failed install would be indistinguishable
|
||||||
from someone declining the dialog (Scribe #2438).
|
from someone declining the dialog (Scribe #2438).
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
Hands an attachment to the app that opens its type. Not exported, as a
|
||||||
|
FileProvider must not be: access is granted one URI at a time, on the
|
||||||
|
intent that opens it. It serves only the cache copies listed in
|
||||||
|
res/xml/file_paths.xml, never the note store.
|
||||||
|
-->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.files"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".UpdateReceiver"
|
android:name=".UpdateReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|||||||
+29
-3
@@ -1,9 +1,11 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentSender
|
import android.content.IntentSender
|
||||||
import android.content.pm.PackageInstaller
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
@@ -40,7 +42,7 @@ import java.io.File
|
|||||||
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
|
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
|
||||||
*/
|
*/
|
||||||
object AppUpdate {
|
object AppUpdate {
|
||||||
private const val TAG = "ThoughtSyncUpdate"
|
private const val TAG = "InkwellUpdate"
|
||||||
|
|
||||||
/** This build's versionCode — what the server's is compared against. */
|
/** This build's versionCode — what the server's is compared against. */
|
||||||
fun installedVersionCode(context: Context): Long =
|
fun installedVersionCode(context: Context): Long =
|
||||||
@@ -62,6 +64,30 @@ object AppUpdate {
|
|||||||
.setData(Uri.fromParts("package", context.packageName, null))
|
.setData(Uri.fromParts("package", context.packageName, null))
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is wifi somebody is not paying by the megabyte for.
|
||||||
|
*
|
||||||
|
* The app fetches its own update in the background, and fifty-odd megabytes over
|
||||||
|
* mobile data is a bill nobody agreed to. Anywhere else it simply waits — the
|
||||||
|
* update is found, nothing is downloaded, and nothing is said until it can be.
|
||||||
|
*
|
||||||
|
* BOTH conditions, deliberately. Wifi alone would still download over a tethered
|
||||||
|
* hotspot, which is mobile data wearing a different hat and the exact bill this
|
||||||
|
* avoids. Unmetered alone would download over an unmetered cellular plan, which
|
||||||
|
* is not what "on wifi" means to the person who asked for it.
|
||||||
|
*
|
||||||
|
* Every uncertain answer is `false`: the cautious one costs nothing.
|
||||||
|
*/
|
||||||
|
fun onWifi(context: Context): Boolean {
|
||||||
|
val caps =
|
||||||
|
context
|
||||||
|
.getSystemService(ConnectivityManager::class.java)
|
||||||
|
?.let { manager -> manager.activeNetwork?.let(manager::getNetworkCapabilities) }
|
||||||
|
return caps != null &&
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
}
|
||||||
|
|
||||||
/** Where a download goes: app-private, so no storage permission is involved. */
|
/** Where a download goes: app-private, so no storage permission is involved. */
|
||||||
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
|
fun downloadTarget(context: Context): File = File(context.cacheDir, "update.apk")
|
||||||
|
|
||||||
@@ -147,5 +173,5 @@ object AppUpdate {
|
|||||||
.intentSender
|
.intentSender
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val WRITE_NAME = "thoughtsync-update"
|
private const val WRITE_NAME = "inkwell-update"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `versionName` of the INSTALLED package, or null when it cannot be read.
|
||||||
|
*
|
||||||
|
* From the package manager rather than from `BuildConfig`: this reports what is
|
||||||
|
* actually on the phone, which is the question both callers are asking — a bug
|
||||||
|
* report reading the foot of Sync, and a server log reading the client header. It
|
||||||
|
* also needs no `buildFeatures.buildConfig`, which this module does not enable.
|
||||||
|
*
|
||||||
|
* Returns null rather than a fallback string, because the two callers want
|
||||||
|
* different ones: the UI wants a localized "unknown" from string resources, the
|
||||||
|
* client header wants the literal the core recognizes. Note 3127 §5 governs both —
|
||||||
|
* with no version tags, the artifact's self-report is the only answer to "which
|
||||||
|
* build is this?", so a missing name must read as missing and never as a plausible
|
||||||
|
* default that nothing can contradict.
|
||||||
|
*/
|
||||||
|
fun Context.installedVersionName(): String? =
|
||||||
|
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()
|
||||||
+15
-6
@@ -1,8 +1,9 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.Log
|
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.
|
* 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
|
* 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.
|
* 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
|
* Null only if the store could not be opened — a corrupt or unwritable
|
||||||
* database. The UI reports that honestly rather than crashing on first
|
* 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
|
* touch, because a user whose notes won't open needs a message, not a
|
||||||
* stack trace.
|
* stack trace.
|
||||||
*/
|
*/
|
||||||
var core: ThoughtSync? = null
|
var core: Inkwell? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var openFailure: String? = null
|
var openFailure: String? = null
|
||||||
@@ -30,8 +31,16 @@ class ThoughtSyncApplication : Application() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
// Introduce this app to any server it links to, before anything can sync.
|
||||||
|
// The core cannot name us — the same crate is compiled into the desktop app,
|
||||||
|
// and it used to announce every phone as `inkwell-desktop` carrying the
|
||||||
|
// core crate's own version. "unknown" rather than a guess when the package
|
||||||
|
// manager will not say (note 3127 §5).
|
||||||
|
setClientAgent("inkwell-android", installedVersionName() ?: "unknown")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val handle = ThoughtSync(filesDir.absolutePath)
|
val handle = Inkwell(filesDir.absolutePath)
|
||||||
core = handle
|
core = handle
|
||||||
Log.i(TAG, "local store ready — ${handle.summary()}")
|
Log.i(TAG, "local store ready — ${handle.summary()}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -44,6 +53,6 @@ class ThoughtSyncApplication : Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "ThoughtSync"
|
const val TAG = "Inkwell"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+225
-22
@@ -1,7 +1,8 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -11,6 +12,7 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -21,20 +23,28 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
import com.fabledsword.inkwell.ui.AttachmentFiles
|
||||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
import com.fabledsword.inkwell.ui.BoardScreen
|
||||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
import com.fabledsword.inkwell.ui.BoardSync
|
||||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
import com.fabledsword.inkwell.ui.BoardUpdate
|
||||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
import com.fabledsword.inkwell.ui.BoardViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
import com.fabledsword.inkwell.ui.ForegroundTransitions
|
||||||
import com.fabledsword.thoughtsync.ui.SyncScreen
|
import com.fabledsword.inkwell.ui.InkwellTheme
|
||||||
import com.fabledsword.thoughtsync.ui.SyncState
|
import com.fabledsword.inkwell.ui.LocalAttachmentFiles
|
||||||
import com.fabledsword.thoughtsync.ui.SyncViewModel
|
import com.fabledsword.inkwell.ui.NoteEditorScreen
|
||||||
import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme
|
import com.fabledsword.inkwell.ui.ShareSheet
|
||||||
import com.fabledsword.thoughtsync.ui.UpdateViewModel
|
import com.fabledsword.inkwell.ui.ShareViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.olderThan
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -50,15 +60,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
private val requestedNote = mutableStateOf<String?>(null)
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
val app = application as ThoughtSyncApplication
|
val app = application as InkwellApplication
|
||||||
requestedNote.value = takeRequestedNote(intent)
|
requestedNote.value = takeRequestedNote(intent)
|
||||||
|
shared.value = takeShared(intent)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ThoughtSyncTheme {
|
InkwellTheme {
|
||||||
val core = app.core
|
val core = app.core
|
||||||
if (core == null) {
|
if (core == null) {
|
||||||
// The store never opened. There is no board to show and no
|
// The store never opened. There is no board to show and no
|
||||||
@@ -66,7 +88,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
// than render an empty board that looks like data loss.
|
// than render an empty board that looks like data loss.
|
||||||
StoreUnavailableScreen(reason = app.openFailure)
|
StoreUnavailableScreen(reason = app.openFailure)
|
||||||
} else {
|
} 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +103,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
requestedNote.value = takeRequestedNote(intent)
|
requestedNote.value = takeRequestedNote(intent)
|
||||||
|
shared.value = takeShared(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,10 +119,83 @@ class MainActivity : ComponentActivity() {
|
|||||||
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
|
intent.removeExtra(Reminders.EXTRA_NOTE_ID)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read what a share or a text selection brought in, and CONSUME it.
|
||||||
|
*
|
||||||
|
* Consumed for the same reason [takeRequestedNote] is: the activity keeps the
|
||||||
|
* intent it was launched with, so without removing the extras a rotation would
|
||||||
|
* replay the share and mint the same note again, with nothing on screen to
|
||||||
|
* explain where the duplicates were coming from.
|
||||||
|
*/
|
||||||
|
private fun takeShared(intent: Intent?): SharedIn? {
|
||||||
|
val incoming =
|
||||||
|
when (intent?.action) {
|
||||||
|
Intent.ACTION_SEND -> SharedIn(intent.takeSendText(), intent.takeStreams())
|
||||||
|
Intent.ACTION_SEND_MULTIPLE -> SharedIn(intent.takeSendText(), intent.takeStreams())
|
||||||
|
Intent.ACTION_PROCESS_TEXT -> SharedIn(intent.takeProcessText(), emptyList())
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return incoming?.takeIf { !it.text.isNullOrBlank() || it.files.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a share brought: words, files, or both — a photo shared from the gallery
|
||||||
|
* often comes with a caption.
|
||||||
|
*
|
||||||
|
* The files are content URIs the sending app granted this one read access to. The
|
||||||
|
* grant lasts while this activity does, which is longer than reading them takes.
|
||||||
|
*/
|
||||||
|
data class SharedIn(
|
||||||
|
val text: String?,
|
||||||
|
val files: List<Uri>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The file or files a SEND or SEND_MULTIPLE carried, consumed like the text. */
|
||||||
|
private fun Intent.takeStreams(): List<Uri> {
|
||||||
|
val streams =
|
||||||
|
if (action == Intent.ACTION_SEND_MULTIPLE) {
|
||||||
|
IntentCompat.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java).orEmpty()
|
||||||
|
} else {
|
||||||
|
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java))
|
||||||
|
}
|
||||||
|
removeExtra(Intent.EXTRA_STREAM)
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared text, with a subject line above it when the sender gave one.
|
||||||
|
*
|
||||||
|
* Sharing a page from a browser sends EXTRA_SUBJECT as the page title and
|
||||||
|
* EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because
|
||||||
|
* the core names a note by its first line — so this is not decoration, it is what
|
||||||
|
* turns a board of identical-looking links into a board you can scan.
|
||||||
|
*
|
||||||
|
* `distinct` because plenty of apps put the same string in both, and a note that
|
||||||
|
* says the URL twice is worse than one that says it once.
|
||||||
|
*/
|
||||||
|
private fun Intent.takeSendText(): String? {
|
||||||
|
val body = getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
val subject = getStringExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
removeExtra(Intent.EXTRA_TEXT)
|
||||||
|
removeExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
return listOfNotNull(subject, body)
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selection from another app's text field, via the selection toolbar. */
|
||||||
|
private fun Intent.takeProcessText(): String? {
|
||||||
|
val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
|
||||||
|
removeExtra(Intent.EXTRA_PROCESS_TEXT)
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which screen is up. Exactly one at a time. */
|
/** 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.
|
* The whole app, once the store is open.
|
||||||
@@ -103,21 +204,23 @@ private enum class Screen { BOARD, EDITOR, SYNC }
|
|||||||
* both cover the display completely, so keeping the board's two-column grid
|
* both cover the display completely, so keeping the board's two-column grid
|
||||||
* measuring and recomposing underneath one would be pure waste.
|
* measuring and recomposing underneath one would be pure waste.
|
||||||
*
|
*
|
||||||
* Still no navigation library. Three destinations, each entered from exactly one
|
* Still no navigation library. Four destinations, each entered from exactly one
|
||||||
* place and left by back — a nav graph would be ceremony around an enum, and the
|
* 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)
|
* state that actually matters (which note is open, whether this device is linked)
|
||||||
* already lives in view models.
|
* already lives in view models.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun App(
|
private fun App(
|
||||||
core: ThoughtSync,
|
core: Inkwell,
|
||||||
|
files: AttachmentFiles,
|
||||||
requestedNote: MutableState<String?>,
|
requestedNote: MutableState<String?>,
|
||||||
|
shared: MutableState<SharedIn?>,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val board: BoardViewModel =
|
val board: BoardViewModel =
|
||||||
viewModel(
|
viewModel(
|
||||||
factory =
|
factory =
|
||||||
BoardViewModel.factory(core) {
|
BoardViewModel.factory(core, readFile = files::read) {
|
||||||
// Any store write can have moved the next reminder. Called on
|
// Any store write can have moved the next reminder. Called on
|
||||||
// the IO dispatcher by the view model, which is where it has to
|
// the IO dispatcher by the view model, which is where it has to
|
||||||
// be — this reads every note carrying a reminder.
|
// be — this reads every note carrying a reminder.
|
||||||
@@ -134,6 +237,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)
|
ReminderAlarms(core)
|
||||||
// A pull can rewrite every note the board is holding, so a sync that changed
|
// 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.
|
// anything tells it to reload. Wired here, at the one place that owns both.
|
||||||
@@ -148,17 +260,33 @@ private fun App(
|
|||||||
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||||
// same surface with the same toolbar.
|
// same surface with the same toolbar.
|
||||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
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 update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||||
val settings = remember(context) { SyncSettings(context) }
|
val settings = remember(context) { SyncSettings(context) }
|
||||||
var automatic by remember { mutableStateOf(settings.automatic) }
|
var automatic by remember { mutableStateOf(settings.automatic) }
|
||||||
|
|
||||||
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
AutomaticSync(state = sync.state, enabled = automatic, onSync = sync::syncQuietly)
|
||||||
|
AutomaticUpdate(linked = sync.state.linked, onCheck = update::checkInBackground)
|
||||||
|
|
||||||
val editing = board.state.editing
|
val editing = board.state.editing
|
||||||
val screen =
|
val screen =
|
||||||
when {
|
when {
|
||||||
showingSync -> Screen.SYNC
|
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
|
editing != null -> Screen.EDITOR
|
||||||
else -> Screen.BOARD
|
else -> Screen.BOARD
|
||||||
}
|
}
|
||||||
@@ -186,6 +314,18 @@ private fun App(
|
|||||||
onInstallOutcome = update::consumeInstallOutcome,
|
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 ->
|
Screen.EDITOR ->
|
||||||
NoteEditorScreen(
|
NoteEditorScreen(
|
||||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||||
@@ -194,6 +334,7 @@ private fun App(
|
|||||||
labels = board.state.labels,
|
labels = board.state.labels,
|
||||||
saving = board.state.saving,
|
saving = board.state.saving,
|
||||||
error = board.state.error,
|
error = board.state.error,
|
||||||
|
onShare = { share.open(editing.id) },
|
||||||
// The one seam between the editor and the store. Exhaustive at the
|
// The one seam between the editor and the store. Exhaustive at the
|
||||||
// other end, so a new action cannot be added without being handled.
|
// other end, so a new action cannot be added without being handled.
|
||||||
onAction = { board.onEditorAction(editing, it) },
|
onAction = { board.onEditorAction(editing, it) },
|
||||||
@@ -216,17 +357,49 @@ private fun App(
|
|||||||
onDismissError = sync::dismissSyncError,
|
onDismissError = sync::dismissSyncError,
|
||||||
),
|
),
|
||||||
onOpenSync = { showingSync = true },
|
onOpenSync = { showingSync = true },
|
||||||
|
onManageTags = { showingTags = true },
|
||||||
onSearch = board::search,
|
onSearch = board::search,
|
||||||
|
onFilter = board::filter,
|
||||||
|
onReorder = board::reorder,
|
||||||
onCompose = board::compose,
|
onCompose = board::compose,
|
||||||
onToggleItem = board::toggleItem,
|
onToggleItem = board::toggleItem,
|
||||||
|
// The SAME seam the editor uses. `onEditorAction` is already the
|
||||||
|
// exhaustive dispatcher for every action a note has, and it takes
|
||||||
|
// the note to act on rather than reading the open one — so the board
|
||||||
|
// can hand it a card without a second dispatcher existing to drift.
|
||||||
|
onNoteAction = board::onEditorAction,
|
||||||
|
// Null unless there is genuinely something to say — the board is
|
||||||
|
// handed a decision, not a state to interpret.
|
||||||
|
update =
|
||||||
|
update.state.available
|
||||||
|
?.takeIf { update.state.nagging }
|
||||||
|
?.let {
|
||||||
|
BoardUpdate(
|
||||||
|
version = it.version,
|
||||||
|
busy = update.state.busy,
|
||||||
|
onInstall = update::downloadAndInstall,
|
||||||
|
onDismiss = update::dismissNag,
|
||||||
|
)
|
||||||
|
},
|
||||||
onDismissError = board::dismissError,
|
onDismissError = board::dismissError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Over whichever screen opened it; a ModalBottomSheet handles its own back.
|
||||||
|
if (share.state.noteId != null) {
|
||||||
|
ShareSheet(
|
||||||
|
state = share.state,
|
||||||
|
onShare = share::share,
|
||||||
|
onUnshare = share::unshare,
|
||||||
|
onDismiss = share::close,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// The sync screen has no back handler of its own, so one lives here. The
|
// 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.
|
// editor keeps its own, because it has to save the open note before leaving.
|
||||||
BackHandler(enabled = showingSync) { showingSync = false }
|
BackHandler(enabled = showingSync) { showingSync = false }
|
||||||
|
BackHandler(enabled = showingTags) { showingTags = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -243,7 +416,7 @@ private fun App(
|
|||||||
* notifications this app would send — is spending it on nothing.
|
* notifications this app would send — is spending it on nothing.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReminderAlarms(core: ThoughtSync) {
|
private fun ReminderAlarms(core: Inkwell) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Off the main thread: this reads every note that has a reminder, and a phone
|
// Off the main thread: this reads every note that has a reminder, and a phone
|
||||||
@@ -271,6 +444,36 @@ 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.
|
||||||
|
*
|
||||||
|
* On coming forward rather than on a timer: it is the moment the person is present,
|
||||||
|
* and the view model rate-limits so flicking between two apps is not a re-check.
|
||||||
|
* Unlinked devices are skipped entirely — updates come from a linked server, and
|
||||||
|
* there is nothing to ask.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AutomaticUpdate(
|
||||||
|
linked: Boolean,
|
||||||
|
onCheck: () -> Unit,
|
||||||
|
) {
|
||||||
|
var wanted by remember { mutableStateOf(false) }
|
||||||
|
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
|
||||||
|
|
||||||
|
LaunchedEffect(wanted, linked) {
|
||||||
|
if (!wanted || !linked) return@LaunchedEffect
|
||||||
|
// Consumed here, so this fires once per trip to the foreground however many
|
||||||
|
// times the effect restarts. There is no suspension point before the call, so
|
||||||
|
// the block completes before the recomposition that would cancel it.
|
||||||
|
wanted = false
|
||||||
|
onCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncing without being asked.
|
* Syncing without being asked.
|
||||||
*
|
*
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
@@ -8,7 +8,7 @@ import android.content.Intent
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
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.
|
* What a due reminder looks like in the shade.
|
||||||
@@ -117,5 +117,5 @@ internal object ReminderNotification {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private const val CHANNEL = "reminders"
|
private const val CHANNEL = "reminders"
|
||||||
private const val TAG = "ThoughtSyncReminders"
|
private const val TAG = "InkwellReminders"
|
||||||
}
|
}
|
||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -36,7 +36,7 @@ class ReminderReceiver : BroadcastReceiver() {
|
|||||||
context: Context,
|
context: Context,
|
||||||
intent: Intent,
|
intent: Intent,
|
||||||
) {
|
) {
|
||||||
val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return
|
val core = (context.applicationContext as? InkwellApplication)?.core ?: return
|
||||||
val action = intent.action
|
val action = intent.action
|
||||||
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
|
val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID)
|
||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
@@ -64,9 +64,9 @@ class ReminderReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE"
|
const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE"
|
||||||
const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE"
|
const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE"
|
||||||
const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE"
|
const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE"
|
||||||
private const val TAG = "ThoughtSyncReminders"
|
private const val TAG = "InkwellReminders"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-9
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.app.AlarmManager
|
import android.app.AlarmManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
@@ -7,8 +7,8 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
import com.fabledsword.inkwell.core.Note
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +52,7 @@ object Reminders {
|
|||||||
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
|
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
private const val SNOOZE_MINUTES = 60L
|
private const val SNOOZE_MINUTES = 60L
|
||||||
private const val TAG = "ThoughtSyncReminders"
|
private const val TAG = "InkwellReminders"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Announce what is due, then schedule the next one.
|
* Announce what is due, then schedule the next one.
|
||||||
@@ -63,7 +63,7 @@ object Reminders {
|
|||||||
*/
|
*/
|
||||||
fun refresh(
|
fun refresh(
|
||||||
context: Context,
|
context: Context,
|
||||||
core: ThoughtSync,
|
core: Inkwell,
|
||||||
) {
|
) {
|
||||||
ReminderNotification.ensureChannel(context)
|
ReminderNotification.ensureChannel(context)
|
||||||
val notes =
|
val notes =
|
||||||
@@ -111,7 +111,7 @@ object Reminders {
|
|||||||
*/
|
*/
|
||||||
fun promptToNotifyDue(
|
fun promptToNotifyDue(
|
||||||
context: Context,
|
context: Context,
|
||||||
core: ThoughtSync,
|
core: Inkwell,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
!Announced(context).askedToNotify &&
|
!Announced(context).askedToNotify &&
|
||||||
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
|
!NotificationManagerCompat.from(context).areNotificationsEnabled() &&
|
||||||
@@ -123,7 +123,7 @@ object Reminders {
|
|||||||
/** Clear the reminder, as the notification's Done action. */
|
/** Clear the reminder, as the notification's Done action. */
|
||||||
fun complete(
|
fun complete(
|
||||||
context: Context,
|
context: Context,
|
||||||
core: ThoughtSync,
|
core: Inkwell,
|
||||||
noteId: String,
|
noteId: String,
|
||||||
) {
|
) {
|
||||||
runCatching { core.completeReminder(noteId) }
|
runCatching { core.completeReminder(noteId) }
|
||||||
@@ -134,7 +134,7 @@ object Reminders {
|
|||||||
/** Push the reminder an hour out, as the notification's Snooze action. */
|
/** Push the reminder an hour out, as the notification's Snooze action. */
|
||||||
fun snooze(
|
fun snooze(
|
||||||
context: Context,
|
context: Context,
|
||||||
core: ThoughtSync,
|
core: Inkwell,
|
||||||
noteId: String,
|
noteId: String,
|
||||||
) {
|
) {
|
||||||
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
|
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
|
||||||
@@ -237,7 +237,7 @@ private class Announced(
|
|||||||
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
|
fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply()
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FILE = "thoughtsync-reminders"
|
const val FILE = "inkwell-reminders"
|
||||||
const val KEY_SEEN = "announced"
|
const val KEY_SEEN = "announced"
|
||||||
const val KEY_PRIMED = "primed"
|
const val KEY_PRIMED = "primed"
|
||||||
const val KEY_ASKED = "asked_to_notify"
|
const val KEY_ASKED = "asked_to_notify"
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.work.BackoffPolicy
|
||||||
@@ -85,8 +85,8 @@ object SyncSchedule {
|
|||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private const val PERIODIC = "thoughtsync-periodic-sync"
|
private const val PERIODIC = "inkwell-periodic-sync"
|
||||||
private const val PUSH = "thoughtsync-push-pending"
|
private const val PUSH = "inkwell-push-pending"
|
||||||
|
|
||||||
/** WorkManager's own minimum for periodic work. Asking for less gets this. */
|
/** WorkManager's own minimum for periodic work. Asking for less gets this. */
|
||||||
private const val PERIOD_MINUTES = 15L
|
private const val PERIOD_MINUTES = 15L
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ class SyncSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val FILE = "thoughtsync-sync"
|
const val FILE = "inkwell-sync"
|
||||||
const val KEY_AUTOMATIC = "automatic"
|
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.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -8,7 +8,7 @@ import androidx.work.WorkerParameters
|
|||||||
/**
|
/**
|
||||||
* One sync cycle, run by the system rather than by a person.
|
* 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
|
* has already opened the store by the time [doWork] is called — the same handle
|
||||||
* the UI uses, so there is never a second SQLite connection racing the first.
|
* the UI uses, so there is never a second SQLite connection racing the first.
|
||||||
*
|
*
|
||||||
@@ -30,7 +30,7 @@ class SyncWorker(
|
|||||||
params: WorkerParameters,
|
params: WorkerParameters,
|
||||||
) : CoroutineWorker(context, params) {
|
) : CoroutineWorker(context, params) {
|
||||||
override suspend fun doWork(): Result {
|
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
|
// Both of these are "nothing to do", not "something went wrong", so both
|
||||||
// report success and let the run retire quietly:
|
// report success and let the run retire quietly:
|
||||||
@@ -67,6 +67,6 @@ class SyncWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "ThoughtSyncWorker"
|
const val TAG = "InkwellWorker"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync
|
package com.fabledsword.inkwell
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -71,7 +71,7 @@ class UpdateReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED"
|
const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
|
||||||
private const val TAG = "ThoughtSyncUpdate"
|
private const val TAG = "InkwellUpdate"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.media.ExifInterface
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Attachment
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file picked or shared into the app, read and ready to attach — or the reason
|
||||||
|
* it could not be.
|
||||||
|
*/
|
||||||
|
sealed interface PickedFile {
|
||||||
|
// A plain class: a data class would compare `bytes` by identity, and nothing
|
||||||
|
// here compares two picked files anyway.
|
||||||
|
class Ready(
|
||||||
|
val name: String,
|
||||||
|
val mime: String,
|
||||||
|
val bytes: ByteArray,
|
||||||
|
) : PickedFile
|
||||||
|
|
||||||
|
data class Refused(
|
||||||
|
val reason: String,
|
||||||
|
) : PickedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */
|
||||||
|
sealed interface Opener {
|
||||||
|
data class Ready(
|
||||||
|
val intent: Intent,
|
||||||
|
) : Opener
|
||||||
|
|
||||||
|
data class Failed(
|
||||||
|
val message: String,
|
||||||
|
) : Opener
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the app does with attachment FILES, as opposed to attachment rows:
|
||||||
|
* reading a picked file, decoding an image for display, and handing a file to
|
||||||
|
* another app to open.
|
||||||
|
*
|
||||||
|
* Holds the application context, never an Activity, so the board's view model can
|
||||||
|
* keep a reference to [read] without leaking a screen.
|
||||||
|
*/
|
||||||
|
class AttachmentFiles(
|
||||||
|
context: Context,
|
||||||
|
private val core: Inkwell,
|
||||||
|
) {
|
||||||
|
private val app = context.applicationContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded images, keyed by hash and size. Sized in bytes against an eighth of
|
||||||
|
* the heap, which is the platform's own guidance for an in-memory image cache.
|
||||||
|
*/
|
||||||
|
private val images =
|
||||||
|
object : LruCache<String, ImageBitmap>(cacheBytes()) {
|
||||||
|
override fun sizeOf(
|
||||||
|
key: String,
|
||||||
|
value: ImageBitmap,
|
||||||
|
): Int = value.width * value.height * BYTES_PER_PIXEL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a picked or shared file, all of it, with its name and type.
|
||||||
|
*
|
||||||
|
* Blocking; call it off the main thread. Refuses anything over
|
||||||
|
* [MAX_ATTACH_MB] — before reading it when the sender says how big it is — so
|
||||||
|
* a long video shared by mistake is a message rather than an out-of-memory
|
||||||
|
* crash.
|
||||||
|
*/
|
||||||
|
fun read(uri: Uri): PickedFile {
|
||||||
|
val (label, declared) = describe(uri)
|
||||||
|
val overDeclared = (declared ?: 0) > MAX_ATTACH_BYTES
|
||||||
|
val bytes = if (overDeclared) null else readCapped(uri)
|
||||||
|
return when {
|
||||||
|
overDeclared -> tooLarge(label)
|
||||||
|
bytes == null -> PickedFile.Refused(app.getString(R.string.attach_unreadable, label))
|
||||||
|
bytes.size > MAX_ATTACH_BYTES -> tooLarge(label)
|
||||||
|
else -> PickedFile.Ready(label, app.contentResolver.getType(uri) ?: GENERIC_MIME, bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The image, decoded no larger than it will be drawn, or null when this device
|
||||||
|
* doesn't hold the file yet or it isn't an image Android can read.
|
||||||
|
*
|
||||||
|
* Blocking; call it off the main thread.
|
||||||
|
*/
|
||||||
|
fun image(
|
||||||
|
attachment: Attachment,
|
||||||
|
maxPx: Int,
|
||||||
|
): ImageBitmap? {
|
||||||
|
val sha = attachment.sha256 ?: return null
|
||||||
|
val key = "$sha@$maxPx"
|
||||||
|
return images.get(key)
|
||||||
|
?: core.blobPath(sha)?.let { decode(it, maxPx) }?.also { images.put(key, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An intent that hands the file to whatever app opens its type, or the reason
|
||||||
|
* there can't be one.
|
||||||
|
*
|
||||||
|
* Copied out of the blob store first, under its real name: the store names files
|
||||||
|
* by hash with no extension, and a viewer shown `3f2a…` cannot tell a PDF from a
|
||||||
|
* spreadsheet. The copy lives in the cache, which the system reclaims, and only
|
||||||
|
* that directory is shared (`res/xml/file_paths.xml`).
|
||||||
|
*
|
||||||
|
* Blocking; call it off the main thread, then start the intent from a screen.
|
||||||
|
*/
|
||||||
|
fun opener(attachment: Attachment): Opener {
|
||||||
|
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File)
|
||||||
|
if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here))
|
||||||
|
val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}")
|
||||||
|
val copy = File(dir, safeName(attachment.filename))
|
||||||
|
return try {
|
||||||
|
dir.mkdirs()
|
||||||
|
if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true)
|
||||||
|
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", copy)
|
||||||
|
val view =
|
||||||
|
Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, attachment.mime)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
Opener.Ready(view)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name the sender gives the file and the size it declares, when it does. */
|
||||||
|
private fun describe(uri: Uri): Pair<String, Long?> {
|
||||||
|
val row =
|
||||||
|
try {
|
||||||
|
app.contentResolver.query(uri, COLUMNS, null, null, null)?.use { firstRow(it) }
|
||||||
|
} catch (expected: SecurityException) {
|
||||||
|
// No grant to read it at all; the read that follows says so.
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val name = row?.first?.takeIf { it.isNotBlank() } ?: uri.lastPathSegment ?: FALLBACK_NAME
|
||||||
|
return name to row?.second
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file's bytes, at most one past the cap — enough to know it is over without
|
||||||
|
* reading the rest — or null when it can't be read.
|
||||||
|
*/
|
||||||
|
private fun readCapped(uri: Uri): ByteArray? =
|
||||||
|
try {
|
||||||
|
app.contentResolver.openInputStream(uri)?.use { it.readUpTo(MAX_ATTACH_BYTES + 1) }
|
||||||
|
} catch (expected: IOException) {
|
||||||
|
null
|
||||||
|
} catch (expected: SecurityException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tooLarge(label: String) =
|
||||||
|
PickedFile.Refused(app.getString(R.string.attach_too_large, label, MAX_ATTACH_MB))
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BYTES_PER_PIXEL = 4
|
||||||
|
const val CACHE_FRACTION = 8
|
||||||
|
const val OPEN_DIR = "open"
|
||||||
|
const val GENERIC_MIME = "application/octet-stream"
|
||||||
|
const val FALLBACK_NAME = "file"
|
||||||
|
val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)
|
||||||
|
|
||||||
|
/** How far each EXIF orientation is turned from upright. */
|
||||||
|
val ROTATIONS =
|
||||||
|
mapOf(
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 to 90f,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 to 180f,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 to 270f,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** DISPLAY_NAME and SIZE from the first row of a [COLUMNS] query; either may be absent. */
|
||||||
|
fun firstRow(cursor: Cursor): Pair<String?, Long?>? =
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
cursor.getString(0) to (if (cursor.isNull(1)) null else cursor.getLong(1))
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheBytes(): Int =
|
||||||
|
(Runtime.getRuntime().maxMemory() / CACHE_FRACTION)
|
||||||
|
.coerceAtMost(Int.MAX_VALUE.toLong())
|
||||||
|
.toInt()
|
||||||
|
|
||||||
|
fun decode(
|
||||||
|
path: String,
|
||||||
|
maxPx: Int,
|
||||||
|
): ImageBitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeFile(path, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
val options =
|
||||||
|
BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, maxPx)
|
||||||
|
}
|
||||||
|
return BitmapFactory.decodeFile(path, options)?.let { upright(it, path).asImageBitmap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera photos are stored sideways with a tag saying so; BitmapFactory ignores it. */
|
||||||
|
fun upright(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
path: String,
|
||||||
|
): Bitmap {
|
||||||
|
val orientation =
|
||||||
|
try {
|
||||||
|
ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
|
||||||
|
} catch (expected: IOException) {
|
||||||
|
// No readable EXIF: draw it as stored.
|
||||||
|
0
|
||||||
|
}
|
||||||
|
val degrees = ROTATIONS[orientation] ?: return bitmap
|
||||||
|
val turn = Matrix().apply { postRotate(degrees) }
|
||||||
|
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, turn, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's [AttachmentFiles], for the card and the editor. Null in previews and
|
||||||
|
* anywhere it was never provided, where attachments draw as plain file rows.
|
||||||
|
*/
|
||||||
|
val LocalAttachmentFiles = staticCompositionLocalOf<AttachmentFiles?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest file the phone will attach. Read whole into memory and copied once
|
||||||
|
* into the core, so the cap is about the phone's heap. The server has its own
|
||||||
|
* limit (25 MB by default, `max_attachment_mb`); a file over that one is kept here
|
||||||
|
* and its upload reported as refused.
|
||||||
|
*/
|
||||||
|
const val MAX_ATTACH_MB = 50
|
||||||
|
private const val MAX_ATTACH_BYTES = MAX_ATTACH_MB * 1024 * 1024
|
||||||
|
|
||||||
|
/** `InputStream.readNBytes(int)` is API 33; this app supports 26. */
|
||||||
|
private fun InputStream.readUpTo(limit: Int): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var total = 0
|
||||||
|
while (total < limit) {
|
||||||
|
val read = read(buffer, 0, minOf(buffer.size, limit - total))
|
||||||
|
if (read < 0) break
|
||||||
|
out.write(buffer, 0, read)
|
||||||
|
total += read
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
// The attachment decisions that need no Android: which files draw as pictures, how
|
||||||
|
// far to scale a decode, what to name a copy, how to print a size. Kept apart from
|
||||||
|
// AttachmentFiles so the JVM unit tests can load them without a device.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a type is drawn as a picture rather than offered as a file. SVG is
|
||||||
|
* excluded on every surface: it is a document that can carry script (#1981).
|
||||||
|
* The same rule as `rendersInline` in the web's `notes/attachments.ts`.
|
||||||
|
*/
|
||||||
|
fun rendersInline(mime: String): Boolean {
|
||||||
|
val type = mime.lowercase().substringBefore(';').trim()
|
||||||
|
return type.startsWith("image/") && type != "image/svg+xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The power-of-two step BitmapFactory decodes at, so the result's longer side is
|
||||||
|
* still at least [maxPx]: never upscaled on screen, never decoded at full camera
|
||||||
|
* resolution for a thumbnail.
|
||||||
|
*/
|
||||||
|
fun sampleSize(
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
maxPx: Int,
|
||||||
|
): Int {
|
||||||
|
val longest = max(width, height)
|
||||||
|
var sample = 1
|
||||||
|
while (longest / (sample * 2) >= maxPx) sample *= 2
|
||||||
|
return sample
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A name safe to create in the cache: no path separators, never empty. */
|
||||||
|
fun safeName(filename: String?): String {
|
||||||
|
val base =
|
||||||
|
filename
|
||||||
|
?.substringAfterLast('/')
|
||||||
|
?.substringAfterLast('\\')
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
return base.takeIf { it.isNotEmpty() && it != "." && it != ".." } ?: "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "12 KB", "3.4 MB" — the same rounding as `fmtSize` in the web's NoteEditor.vue. */
|
||||||
|
fun sizeLabel(bytes: Long): String =
|
||||||
|
when {
|
||||||
|
bytes < KIB -> "$bytes B"
|
||||||
|
bytes < KIB * KIB -> "${(bytes / KIB).roundToInt()} KB"
|
||||||
|
else -> "%.1f MB".format(Locale.ROOT, bytes / (KIB * KIB))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val KIB = 1024.0
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Attachment
|
||||||
|
import com.fabledsword.inkwell.core.LinkPreview
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private val ATTACHMENT_RADIUS = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded no larger than this on the card. A board column on a phone is around
|
||||||
|
* 180dp wide, which is about 540px at the common densities, so this is sharp on
|
||||||
|
* every screen the app runs on without decoding a 12-megapixel photo for it.
|
||||||
|
*/
|
||||||
|
private const val CARD_IMAGE_PX = 640
|
||||||
|
|
||||||
|
/** The editor draws images at the sheet's full width. */
|
||||||
|
private const val EDITOR_IMAGE_PX = 1440
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The narrowest an image may draw, as width over height. A tall screenshot drawn
|
||||||
|
* at its own ratio would push the rest of the card off the board, so it is cropped
|
||||||
|
* to 3:4 instead; anything wider than that draws whole.
|
||||||
|
*/
|
||||||
|
private const val MIN_ASPECT = 0.75f
|
||||||
|
|
||||||
|
/** How many file names the card lists before it says "and N more". */
|
||||||
|
private const val CARD_FILE_ROWS = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A note's attachments on its board card: the first image as a picture, then the
|
||||||
|
* other files by name.
|
||||||
|
*
|
||||||
|
* One picture, not a gallery. The card is a glance at the note, and a strip of
|
||||||
|
* thumbnails would make an image note the tallest thing on the board whatever its
|
||||||
|
* words say. The editor shows them all.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CardAttachments(attachments: List<Attachment>) {
|
||||||
|
val (images, files) = attachments.partition { rendersInline(it.mime) }
|
||||||
|
images.firstOrNull()?.let { first ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
AttachmentImage(attachment = first, maxPx = CARD_IMAGE_PX) {
|
||||||
|
FileRow(attachment = first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rest = images.drop(1) + files
|
||||||
|
rest.take(CARD_FILE_ROWS).forEach { file ->
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
FileRow(attachment = file)
|
||||||
|
}
|
||||||
|
if (rest.size > CARD_FILE_ROWS) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.attach_more, rest.size - CARD_FILE_ROWS),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every attachment, in the editor: images at full width, files as rows, each one
|
||||||
|
* opening with the system and each removable.
|
||||||
|
*
|
||||||
|
* A file the server refused says so under it, in its own words — the upload is
|
||||||
|
* not retried, so without this a file that never reaches the other devices would
|
||||||
|
* look exactly like one that did.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EditorAttachments(
|
||||||
|
attachments: List<Attachment>,
|
||||||
|
readOnly: Boolean,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
val open = rememberOpener()
|
||||||
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
attachments.forEach { attachment ->
|
||||||
|
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
|
||||||
|
Box(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
|
if (rendersInline(attachment.mime)) {
|
||||||
|
AttachmentImage(
|
||||||
|
attachment = attachment,
|
||||||
|
maxPx = EDITOR_IMAGE_PX,
|
||||||
|
onClick = { open(attachment) },
|
||||||
|
) {
|
||||||
|
FileRow(attachment = attachment, onClick = { open(attachment) })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FileRow(attachment = attachment, onClick = { open(attachment) })
|
||||||
|
}
|
||||||
|
if (!readOnly) {
|
||||||
|
IconButton(
|
||||||
|
onClick = remove,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.attach_remove),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attachment.uploadError?.let { reason ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.attach_refused, reason),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's link previews in the editor, each dismissable.
|
||||||
|
*
|
||||||
|
* Dismissing is the only control: the server made the preview and will not make
|
||||||
|
* it again, so removing one is a decision about this note rather than a refresh.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EditorPreviews(
|
||||||
|
previews: List<LinkPreview>,
|
||||||
|
readOnly: Boolean,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
|
previews.forEach { preview ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
LinkPreviewCard(preview = preview, compact = true, modifier = Modifier.weight(1f))
|
||||||
|
if (!readOnly) {
|
||||||
|
IconButton(onClick = { onAction(EditorAction.RemovePreview(preview.id)) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(R.string.preview_remove),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loading, drawn, or not drawable (not downloaded yet, or not an image Android reads). */
|
||||||
|
private sealed interface ImageLoad {
|
||||||
|
data object Loading : ImageLoad
|
||||||
|
|
||||||
|
data class Ready(
|
||||||
|
val bitmap: ImageBitmap,
|
||||||
|
) : ImageLoad
|
||||||
|
|
||||||
|
data object Missing : ImageLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An attachment drawn as a picture, decoded off the main thread.
|
||||||
|
*
|
||||||
|
* [fallback] is what draws when it can't be: a file this device hasn't downloaded
|
||||||
|
* yet, or bytes that don't decode. It is a file row, so the note still shows that
|
||||||
|
* something is attached instead of a gap.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AttachmentImage(
|
||||||
|
attachment: Attachment,
|
||||||
|
maxPx: Int,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
fallback: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val files = LocalAttachmentFiles.current
|
||||||
|
val load by produceState<ImageLoad>(ImageLoad.Loading, attachment.sha256, maxPx, files) {
|
||||||
|
val bitmap = files?.let { withContext(Dispatchers.IO) { it.image(attachment, maxPx) } }
|
||||||
|
value = bitmap?.let { ImageLoad.Ready(it) } ?: ImageLoad.Missing
|
||||||
|
}
|
||||||
|
val shape = RoundedCornerShape(ATTACHMENT_RADIUS)
|
||||||
|
when (val state = load) {
|
||||||
|
ImageLoad.Loading ->
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(4f / 3f)
|
||||||
|
.clip(shape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
)
|
||||||
|
is ImageLoad.Ready -> {
|
||||||
|
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT)
|
||||||
|
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
|
||||||
|
Image(
|
||||||
|
bitmap = state.bitmap,
|
||||||
|
contentDescription = attachment.filename,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(ratio)
|
||||||
|
.clip(shape)
|
||||||
|
.then(tap),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ImageLoad.Missing -> fallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file by name and size, behind a paperclip. Tappable in the editor, not on the card. */
|
||||||
|
@Composable
|
||||||
|
private fun FileRow(
|
||||||
|
attachment: Attachment,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(ATTACHMENT_RADIUS)
|
||||||
|
val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||||
|
.then(tap)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_attach),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = attachment.filename?.takeIf { it.isNotBlank() } ?: stringResource(R.string.attach_unnamed),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
attachment.size?.let { bytes ->
|
||||||
|
Text(
|
||||||
|
text = sizeLabel(bytes),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
// Clear of the editor's remove button, which sits over this corner.
|
||||||
|
modifier = Modifier.padding(start = 8.dp, end = if (onClick != null) 32.dp else 0.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an attachment with whatever app handles its type, or say why not.
|
||||||
|
*
|
||||||
|
* The copy out of the blob store runs on IO; the activity starts on the main
|
||||||
|
* thread, from the screen's own context, so the viewer opens over this app rather
|
||||||
|
* than in a task of its own.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberOpener(): (Attachment) -> Unit {
|
||||||
|
val files = LocalAttachmentFiles.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
return { attachment ->
|
||||||
|
if (files != null) {
|
||||||
|
scope.launch {
|
||||||
|
when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) {
|
||||||
|
is Opener.Ready -> start(context, opener)
|
||||||
|
is Opener.Failed -> toast(context, opener.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start(
|
||||||
|
context: Context,
|
||||||
|
opener: Opener.Ready,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
context.startActivity(opener.intent)
|
||||||
|
} catch (expected: ActivityNotFoundException) {
|
||||||
|
toast(context, context.getString(R.string.attach_no_app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(
|
||||||
|
context: Context,
|
||||||
|
message: String,
|
||||||
|
) = Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
+44
-4
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -14,15 +14,18 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -30,7 +33,7 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
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 note's body, as fields and checkboxes rather than as markup.
|
||||||
@@ -97,24 +100,45 @@ fun BlockBody(
|
|||||||
readOnly = readOnly,
|
readOnly = readOnly,
|
||||||
requester = requester,
|
requester = requester,
|
||||||
onChange = { replace(index, it) },
|
onChange = { replace(index, it) },
|
||||||
|
onBlur = {
|
||||||
|
// Compared by IDENTITY, not equality: `promotingTasks` hands
|
||||||
|
// back the same list when there was nothing to promote, and a
|
||||||
|
// blur that changed nothing must not touch the state at all.
|
||||||
|
val promoted = blocks.promotingTasks(index)
|
||||||
|
if (promoted !== blocks) onChange(promoted)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A run of prose: one ordinary multi-line field, exactly as the editor always had. */
|
/**
|
||||||
|
* A run of prose: one ordinary multi-line field, exactly as the editor always had.
|
||||||
|
*
|
||||||
|
* Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see
|
||||||
|
* [promotingTasks] for why blur is the only safe moment to do that.
|
||||||
|
*
|
||||||
|
* `onFocusChanged` also fires with `isFocused = false` on the first composition, before
|
||||||
|
* the field has ever held focus. Deliberately not guarded: [splitBlocks] ran when the
|
||||||
|
* editor opened, so a prose block nobody has typed in cannot contain a task line, and
|
||||||
|
* the promotion is a no-op that the caller's identity check drops on the floor.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProseBlock(
|
private fun ProseBlock(
|
||||||
block: EditorBlock,
|
block: EditorBlock,
|
||||||
readOnly: Boolean,
|
readOnly: Boolean,
|
||||||
requester: FocusRequester,
|
requester: FocusRequester,
|
||||||
onChange: (EditorBlock) -> Unit,
|
onChange: (EditorBlock) -> Unit,
|
||||||
|
onBlur: () -> Unit,
|
||||||
) {
|
) {
|
||||||
BlockField(
|
BlockField(
|
||||||
value = block.value,
|
value = block.value,
|
||||||
onValueChange = { onChange(block.copy(value = it)) },
|
onValueChange = { onChange(block.copy(value = it)) },
|
||||||
modifier = Modifier.focusRequester(requester),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(requester)
|
||||||
|
.onFocusChanged { if (!it.isFocused) onBlur() },
|
||||||
enabled = !readOnly,
|
enabled = !readOnly,
|
||||||
hint = R.string.editor_body_hint,
|
hint = R.string.editor_body_hint,
|
||||||
)
|
)
|
||||||
@@ -136,6 +160,10 @@ private fun TaskBlock(
|
|||||||
onEnter: () -> Unit,
|
onEnter: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Material sizes every interactive component to a 48dp touch target, and on a
|
||||||
|
// checklist that IS the row height — which is why six items filled a phone screen
|
||||||
|
// even after the field's own padding came off.
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = block.checked == true,
|
checked = block.checked == true,
|
||||||
@@ -167,6 +195,18 @@ private fun TaskBlock(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The touch target for a checklist row's controls.
|
||||||
|
*
|
||||||
|
* Material's floor is 48dp and this is deliberately under it. That floor is sized for
|
||||||
|
* a control somebody has to find; a checklist box sits in a predictable column with an
|
||||||
|
* identical box directly above and below, and the cost of a near miss is ticking the
|
||||||
|
* neighbouring item — visible, and undone by tapping again. Trading twelve of those
|
||||||
|
* dp for a list that fits on a screen is what was asked for, twice.
|
||||||
|
*/
|
||||||
|
private val ROW_TOUCH = 36.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The field a block is typed into.
|
* The field a block is typed into.
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.PointerId
|
||||||
|
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
|
// Reordering the board by dragging a card, as on the web (#5176): hold a card, then
|
||||||
|
// move it. The hold is the same long press that opens the card's menu, so the menu
|
||||||
|
// appears first and closes as the card starts to move. Lifting without moving
|
||||||
|
// leaves the menu open, as before. Moving BEFORE the hold completes is a scroll.
|
||||||
|
//
|
||||||
|
// Watched on the Initial pass, ahead of the grid's scrolling and the card's own
|
||||||
|
// click handling. Nothing is consumed until the hold has completed and the finger
|
||||||
|
// has moved, so taps, long presses and scrolls all reach the grid and the card
|
||||||
|
// untouched. After that point every change is consumed, so the grid cannot scroll
|
||||||
|
// under a card that is being carried.
|
||||||
|
|
||||||
|
/** A card being carried. Every offset is in root coordinates. */
|
||||||
|
internal data class CardDrag(
|
||||||
|
val id: String,
|
||||||
|
/** Where the finger went down. */
|
||||||
|
val from: Offset,
|
||||||
|
/** Where the finger is now. */
|
||||||
|
val at: Offset,
|
||||||
|
/** Where the card's corner was when the drag began. */
|
||||||
|
val startTopLeft: Offset,
|
||||||
|
) {
|
||||||
|
/** Where the card's corner should be drawn, so it stays under the finger. */
|
||||||
|
val topLeft: Offset get() = startTopLeft + (at - from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where each card on screen was last laid out, in root coordinates.
|
||||||
|
*
|
||||||
|
* A plain map rather than snapshot state: only the gesture reads it, so a write on
|
||||||
|
* every layout pass must not invalidate any composition.
|
||||||
|
*/
|
||||||
|
internal class CardBounds {
|
||||||
|
private val bounds = HashMap<String, Rect>()
|
||||||
|
|
||||||
|
fun record(
|
||||||
|
id: String,
|
||||||
|
rect: Rect,
|
||||||
|
) {
|
||||||
|
bounds[id] = rect
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forget(id: String) {
|
||||||
|
bounds.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun get(id: String): Rect? = bounds[id]
|
||||||
|
|
||||||
|
/** The card under [point], other than [except]. */
|
||||||
|
fun idAt(
|
||||||
|
point: Offset,
|
||||||
|
except: String? = null,
|
||||||
|
): String? = bounds.entries.firstOrNull { (id, rect) -> id != except && rect.contains(point) }?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the board does as a card is carried. Every offset is in root coordinates. */
|
||||||
|
internal class CardCarrier(
|
||||||
|
val onStart: (id: String, at: Offset) -> Unit,
|
||||||
|
val onMove: (at: Offset) -> Unit,
|
||||||
|
/** Runs however the gesture finishes, cancelled included, so no card is left held. */
|
||||||
|
val onEnd: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry a card: hold, then move.
|
||||||
|
*
|
||||||
|
* [toRoot] maps a position in this element to root coordinates, which is where
|
||||||
|
* [bounds] keeps the cards.
|
||||||
|
*/
|
||||||
|
internal fun Modifier.dragToReorder(
|
||||||
|
enabled: Boolean,
|
||||||
|
bounds: CardBounds,
|
||||||
|
toRoot: (Offset) -> Offset,
|
||||||
|
carrier: CardCarrier,
|
||||||
|
): Modifier =
|
||||||
|
if (enabled) {
|
||||||
|
pointerInput(bounds) { awaitEachGesture { carry(bounds, toRoot, carrier) } }
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun AwaitPointerEventScope.carry(
|
||||||
|
bounds: CardBounds,
|
||||||
|
toRoot: (Offset) -> Offset,
|
||||||
|
carrier: CardCarrier,
|
||||||
|
) {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
val id = bounds.idAt(toRoot(down.position))
|
||||||
|
if (id == null || !holdsStill(down) || !movesAfterHold(down)) return
|
||||||
|
carrier.onStart(id, toRoot(down.position))
|
||||||
|
try {
|
||||||
|
followUntilUp(down.id) { carrier.onMove(toRoot(it)) }
|
||||||
|
} finally {
|
||||||
|
carrier.onEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the finger stayed down and inside the touch slop for a long press. */
|
||||||
|
private suspend fun AwaitPointerEventScope.holdsStill(down: PointerInputChange): Boolean {
|
||||||
|
val endedEarly =
|
||||||
|
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
||||||
|
var ended = false
|
||||||
|
while (!ended) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id }
|
||||||
|
ended = change == null || !change.pressed || change.movedPast(down, viewConfiguration.touchSlop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return endedEarly == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After the hold: true once the finger moves past the slop, false if it lifts first. */
|
||||||
|
private suspend fun AwaitPointerEventScope.movesAfterHold(down: PointerInputChange): Boolean {
|
||||||
|
var moved: Boolean? = null
|
||||||
|
while (moved == null) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id }
|
||||||
|
if (change == null || !change.pressed) {
|
||||||
|
moved = false
|
||||||
|
} else if (change.movedPast(down, viewConfiguration.touchSlop)) {
|
||||||
|
// Consumed so the grid's scrolling, which has not claimed this gesture
|
||||||
|
// yet, never does.
|
||||||
|
change.consume()
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moved == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report every position until the finger lifts, consuming each change. */
|
||||||
|
private suspend fun AwaitPointerEventScope.followUntilUp(
|
||||||
|
pointer: PointerId,
|
||||||
|
onMove: (Offset) -> Unit,
|
||||||
|
) {
|
||||||
|
var pressed = true
|
||||||
|
while (pressed) {
|
||||||
|
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == pointer }
|
||||||
|
pressed = change?.pressed == true
|
||||||
|
change?.consume()
|
||||||
|
if (change != null && pressed) onMove(change.position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PointerInputChange.movedPast(
|
||||||
|
down: PointerInputChange,
|
||||||
|
slop: Float,
|
||||||
|
): Boolean = (position - down.position).getDistance() > slop
|
||||||
|
|
||||||
|
/** The grid's own layout, so a position in it can be taken to root coordinates. */
|
||||||
|
internal class GridOrigin {
|
||||||
|
var coords: LayoutCoordinates? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [order] with the carried card moved to the place of the card under the finger, the
|
||||||
|
* way the web's drop splices it. Unchanged when there is no card there, or when it is
|
||||||
|
* on the other side of the pinned line: the store sorts pinned notes first, so a
|
||||||
|
* card dropped across the line would only snap back.
|
||||||
|
*/
|
||||||
|
internal fun moved(
|
||||||
|
order: List<String>?,
|
||||||
|
drag: CardDrag,
|
||||||
|
bounds: CardBounds,
|
||||||
|
notes: List<Note>,
|
||||||
|
): List<String>? {
|
||||||
|
val target = bounds.idAt(drag.at, except = drag.id)
|
||||||
|
val pinned = notes.associate { it.id to it.pinned }
|
||||||
|
val from = order?.indexOf(drag.id) ?: -1
|
||||||
|
val to = if (target != null && pinned[target] == pinned[drag.id]) order?.indexOf(target) ?: -1 else -1
|
||||||
|
return if (order == null || from < 0 || to < 0) {
|
||||||
|
order
|
||||||
|
} else {
|
||||||
|
order.toMutableList().apply {
|
||||||
|
removeAt(from)
|
||||||
|
add(to, drag.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import com.fabledsword.inkwell.core.NoteFacets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board's filters: the same three the web's Filters panel offers (FilterBar.vue,
|
||||||
|
* trimmed to these in #5180). Tags a note must ALL carry, has an attachment, and
|
||||||
|
* shared with me. Reminders and dates have their own lenses, here as on the web.
|
||||||
|
*
|
||||||
|
* Only the main board filters, as on the web. Opening Archive, Trash or a tag from
|
||||||
|
* the drawer starts that view unfiltered, and search runs over everything.
|
||||||
|
*/
|
||||||
|
data class BoardFilters(
|
||||||
|
val labelIds: Set<String> = emptySet(),
|
||||||
|
val hasAttachment: Boolean = false,
|
||||||
|
val sharedWithMe: Boolean = false,
|
||||||
|
) {
|
||||||
|
/** How many are on, counted the way the web's badge counts them: each tag is one. */
|
||||||
|
val count: Int
|
||||||
|
get() = labelIds.size + (if (hasAttachment) 1 else 0) + (if (sharedWithMe) 1 else 0)
|
||||||
|
|
||||||
|
/** The core's facets for these, or null when none are on. */
|
||||||
|
fun facets(): NoteFacets? =
|
||||||
|
if (count == 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
NoteFacets(
|
||||||
|
q = null,
|
||||||
|
label = labelIds.toList().ifEmpty { null },
|
||||||
|
hasAttachment = if (hasAttachment) true else null,
|
||||||
|
createdAfter = null,
|
||||||
|
createdBefore = null,
|
||||||
|
shared = if (sharedWithMe) SHARED_WITH_ME else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleLabel(id: String) = copy(labelIds = if (id in labelIds) labelIds - id else labelIds + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The facet value the core reads as "only notes someone else shared with me". */
|
||||||
|
private const val SHARED_WITH_ME = "with_me"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row under the search bar: a Filters chip that opens the sheet, showing how many
|
||||||
|
* are on, and a Clear beside it once any are.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun FilterRow(
|
||||||
|
filters: BoardFilters,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = GUTTER),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.count > 0,
|
||||||
|
onClick = onOpen,
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
if (filters.count > 0) {
|
||||||
|
stringResource(R.string.filters_count, filters.count)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.filters)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (filters.count > 0) {
|
||||||
|
TextButton(onClick = onClear) { Text(stringResource(R.string.filters_clear)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the filters. Each tap applies at once, as on the web, so the board behind the
|
||||||
|
* sheet is already showing the answer when it is dismissed.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun FilterSheet(
|
||||||
|
filters: BoardFilters,
|
||||||
|
labels: List<Label>,
|
||||||
|
onChange: (BoardFilters) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
SheetTitle(R.string.filters)
|
||||||
|
|
||||||
|
FilterHeading(R.string.filters_show)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.hasAttachment,
|
||||||
|
onClick = { onChange(filters.copy(hasAttachment = !filters.hasAttachment)) },
|
||||||
|
label = { Text(stringResource(R.string.filters_has_attachment)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = filters.sharedWithMe,
|
||||||
|
onClick = { onChange(filters.copy(sharedWithMe = !filters.sharedWithMe)) },
|
||||||
|
label = { Text(stringResource(R.string.filters_shared_with_me)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No heading and no empty row when there are no tags yet: a section that
|
||||||
|
// can only ever say "none" is noise in a sheet this small.
|
||||||
|
if (labels.isNotEmpty()) {
|
||||||
|
FilterHeading(R.string.filters_tags)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
labels.forEach { label ->
|
||||||
|
FilterChip(
|
||||||
|
selected = label.id in filters.labelIds,
|
||||||
|
onClick = { onChange(filters.toggleLabel(label.id)) },
|
||||||
|
label = { Text(label.name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.count > 0) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onChange(BoardFilters()) },
|
||||||
|
modifier = Modifier.align(Alignment.End),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.filters_clear))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterHeading(labelRes: Int) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(labelRes),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp, bottom = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
+264
-13
@@ -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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||||
@@ -23,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
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.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -37,6 +41,11 @@ import androidx.compose.material3.ModalDrawerSheet
|
|||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
@@ -44,15 +53,28 @@ import androidx.compose.material3.pulltorefresh.pullToRefresh
|
|||||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import androidx.compose.ui.zIndex
|
||||||
import com.fabledsword.thoughtsync.core.Label
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -62,13 +84,58 @@ fun BoardScreen(
|
|||||||
onOpenNote: (Note) -> Unit,
|
onOpenNote: (Note) -> Unit,
|
||||||
sync: BoardSync,
|
sync: BoardSync,
|
||||||
onOpenSync: () -> Unit,
|
onOpenSync: () -> Unit,
|
||||||
|
onManageTags: () -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
|
onFilter: (BoardFilters) -> Unit,
|
||||||
|
onReorder: (List<String>) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||||
|
onNoteAction: (Note, EditorAction) -> Unit,
|
||||||
|
update: BoardUpdate?,
|
||||||
onDismissError: () -> Unit,
|
onDismissError: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbars = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Held HERE rather than on the card. A card lives in a lazy grid and is disposed
|
||||||
|
// the moment it scrolls out of view, which would take its dialog down with it —
|
||||||
|
// and the board can scroll under an open dialog.
|
||||||
|
var confirmingDelete by remember { mutableStateOf<Note?>(null) }
|
||||||
|
var filtering by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Resolved in composition, not inside the coroutine: `stringResource` is a
|
||||||
|
// composable read and cannot be called from a suspend block.
|
||||||
|
val trashedMessage = stringResource(R.string.board_trashed)
|
||||||
|
val undoLabel = stringResource(R.string.board_undo)
|
||||||
|
|
||||||
|
// Trash gets an UNDO rather than a confirmation, and the two are not
|
||||||
|
// interchangeable. A long press is a gesture you can make by accident — resting a
|
||||||
|
// thumb while reading is enough — so the mistake worth designing for is the one
|
||||||
|
// nobody meant to make, and a dialog only helps someone who is paying attention
|
||||||
|
// in the moment they were not. Trash is already recoverable; the snackbar just
|
||||||
|
// says so where it happened, instead of leaving you to find the Trash view and
|
||||||
|
// work out which note went missing.
|
||||||
|
//
|
||||||
|
// Delete forever keeps its dialog. That one does not undo.
|
||||||
|
val onCardAction: (Note, EditorAction) -> Unit = { note, action ->
|
||||||
|
onNoteAction(note, action)
|
||||||
|
if (action == EditorAction.Trash) {
|
||||||
|
scope.launch {
|
||||||
|
val outcome =
|
||||||
|
snackbars.showSnackbar(
|
||||||
|
message = trashedMessage,
|
||||||
|
actionLabel = undoLabel,
|
||||||
|
duration = SnackbarDuration.Short,
|
||||||
|
)
|
||||||
|
// `note` is the pre-trash copy and deliberately so: Restore only needs
|
||||||
|
// its id, and the id is the one thing trashing does not change.
|
||||||
|
if (outcome == SnackbarResult.ActionPerformed) {
|
||||||
|
onNoteAction(note, EditorAction.Restore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -85,10 +152,30 @@ fun BoardScreen(
|
|||||||
onOpenSync()
|
onOpenSync()
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
},
|
},
|
||||||
|
onManageTags = {
|
||||||
|
onManageTags()
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
// The IME, added to what the Scaffold already insets for. `enableEdgeToEdge`
|
||||||
|
// makes the manifest's `adjustResize` a no-op on API 30+, so nothing resizes
|
||||||
|
// for the keyboard unless the app asks — and `ScaffoldDefaults.contentWindowInsets`
|
||||||
|
// is systemBars, which the IME is not part of. The Scaffold positions the FAB
|
||||||
|
// AND the snackbar host from this value, so without it both sit behind the
|
||||||
|
// keyboard whenever the search field has focus. That is not theoretical: the
|
||||||
|
// undo on a trashed search hit is exactly the control you cannot reach.
|
||||||
|
//
|
||||||
|
// `union` rather than `add` — the two are the same edge, not two stacked ones.
|
||||||
|
// Adding them would inset by the navigation bar a second time underneath a
|
||||||
|
// keyboard that already covers it.
|
||||||
|
//
|
||||||
|
// One owner for the edge, as with the search bar's missing statusBarsPadding:
|
||||||
|
// set here, the content Column gets it through `padding` and must not repeat it.
|
||||||
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
|
snackbarHost = { SnackbarHost(snackbars) },
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
// The + is the ONLY way in, by design: one obvious target rather
|
// The + is the ONLY way in, by design: one obvious target rather
|
||||||
// than a capture bar and a button competing for the same job.
|
// than a capture bar and a button competing for the same job.
|
||||||
@@ -108,6 +195,14 @@ fun BoardScreen(
|
|||||||
onMenu = { scope.launch { drawerState.open() } },
|
onMenu = { scope.launch { drawerState.open() } },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (state.filterable) {
|
||||||
|
FilterRow(
|
||||||
|
filters = state.filters,
|
||||||
|
onOpen = { filtering = true },
|
||||||
|
onClear = { onFilter(BoardFilters()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
state.error?.let { message ->
|
state.error?.let { message ->
|
||||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||||
}
|
}
|
||||||
@@ -118,6 +213,17 @@ fun BoardScreen(
|
|||||||
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Below the failures and above the notes: an update is worth saying,
|
||||||
|
// and never worth saying before a note failed to save.
|
||||||
|
update?.let {
|
||||||
|
UpdateBanner(
|
||||||
|
version = it.version,
|
||||||
|
busy = it.busy,
|
||||||
|
onInstall = it.onInstall,
|
||||||
|
onDismiss = it.onDismiss,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Only where someone is already thinking about reminders. On the
|
// Only where someone is already thinking about reminders. On the
|
||||||
// main board it would nag people who have never set one.
|
// main board it would nag people who have never set one.
|
||||||
if (state.destination == Destination.Reminders) ReminderNotice()
|
if (state.destination == Destination.Reminders) ReminderNotice()
|
||||||
@@ -146,6 +252,10 @@ fun BoardScreen(
|
|||||||
notes = state.notes,
|
notes = state.notes,
|
||||||
onOpenNote = onOpenNote,
|
onOpenNote = onOpenNote,
|
||||||
onToggleItem = onToggleItem,
|
onToggleItem = onToggleItem,
|
||||||
|
onNoteAction = onCardAction,
|
||||||
|
onConfirmDelete = { confirmingDelete = it },
|
||||||
|
reorderable = state.reorderable,
|
||||||
|
onReorder = onReorder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// `PullToRefreshBox` would be less code, but it takes no
|
// `PullToRefreshBox` would be less code, but it takes no
|
||||||
@@ -159,6 +269,25 @@ fun BoardScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filtering) {
|
||||||
|
FilterSheet(
|
||||||
|
filters = state.filters,
|
||||||
|
labels = state.labels,
|
||||||
|
onChange = onFilter,
|
||||||
|
onDismiss = { filtering = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmingDelete?.let { note ->
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
onConfirm = {
|
||||||
|
confirmingDelete = null
|
||||||
|
onNoteAction(note, EditorAction.DeleteForever)
|
||||||
|
},
|
||||||
|
onDismiss = { confirmingDelete = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +316,25 @@ data class BoardSync(
|
|||||||
val onDismissError: () -> Unit,
|
val onDismissError: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The waiting app update, or null when there is nothing to say.
|
||||||
|
*
|
||||||
|
* A holder rather than five loose parameters, for the same reason [BoardSync] is one:
|
||||||
|
* `version` and a pair of booleans as positional arguments could be swapped with
|
||||||
|
* nothing to catch it.
|
||||||
|
*
|
||||||
|
* Null covers every reason there is nothing to show — unlinked, up to date, found but
|
||||||
|
* not yet downloaded, dismissed for this sitting — so the board never has to know
|
||||||
|
* which.
|
||||||
|
*/
|
||||||
|
data class BoardUpdate(
|
||||||
|
val version: String,
|
||||||
|
/** An install is in flight — the banner stays and reports it. */
|
||||||
|
val busy: Boolean,
|
||||||
|
val onInstall: () -> Unit,
|
||||||
|
val onDismiss: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A search field IS the top bar, following the phone convention rather than the
|
* A search field IS the top bar, following the phone convention rather than the
|
||||||
* desktop's title-plus-sidebar.
|
* desktop's title-plus-sidebar.
|
||||||
@@ -256,6 +404,7 @@ private fun NavigationDrawer(
|
|||||||
syncSummary: String?,
|
syncSummary: String?,
|
||||||
onOpen: (Destination) -> Unit,
|
onOpen: (Destination) -> Unit,
|
||||||
onOpenSync: () -> Unit,
|
onOpenSync: () -> Unit,
|
||||||
|
onManageTags: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
@@ -269,18 +418,36 @@ private fun NavigationDrawer(
|
|||||||
DrawerRow(destination, current, onOpen)
|
DrawerRow(destination, current, onOpen)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labels.isNotEmpty()) {
|
// 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))
|
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(
|
||||||
text = stringResource(R.string.nav_labels),
|
text = stringResource(R.string.nav_labels),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, bottom = 4.dp),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = onManageTags) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Edit,
|
||||||
|
contentDescription = stringResource(R.string.tags_manage),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
labels.forEach { label ->
|
labels.forEach { label ->
|
||||||
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
DrawerRow(Destination.WithLabel(label.id, label.name), current, onOpen)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||||
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
listOf(Destination.Archive, Destination.Trash).forEach { destination ->
|
||||||
@@ -318,7 +485,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
|
* 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
|
* — a one-line thought beside a twelve-item checklist — and forcing them to a
|
||||||
@@ -330,10 +497,68 @@ private fun NoteBoard(
|
|||||||
notes: List<Note>,
|
notes: List<Note>,
|
||||||
onOpenNote: (Note) -> Unit,
|
onOpenNote: (Note) -> Unit,
|
||||||
onToggleItem: (Note, Int, Boolean) -> Unit,
|
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||||
|
onNoteAction: (Note, EditorAction) -> Unit,
|
||||||
|
onConfirmDelete: (Note) -> Unit,
|
||||||
|
/** Whether cards can be dragged into a new order: the plain main board only. */
|
||||||
|
reorderable: Boolean,
|
||||||
|
onReorder: (List<String>) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// The web's breakpoints (NoteGrid.vue: three columns from `lg`, 1024px, four from
|
||||||
|
// `xl`, 1280px), on the window's width, so a tablet lays out like a browser of the
|
||||||
|
// same size. Below that it stays at two, where the web drops to one on a phone.
|
||||||
|
val windowWidth = LocalConfiguration.current.screenWidthDp
|
||||||
|
val columns =
|
||||||
|
when {
|
||||||
|
windowWidth >= 1280 -> 4
|
||||||
|
windowWidth >= 1024 -> 3
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// The drag (BoardDrag.kt). `order` is the board as the drag has rearranged it, and
|
||||||
|
// it stays until the store's next list arrives, so a drop does not flash back to
|
||||||
|
// the old order while the write is in flight.
|
||||||
|
val bounds = remember { CardBounds() }
|
||||||
|
val grid = remember { GridOrigin() }
|
||||||
|
var drag by remember { mutableStateOf<CardDrag?>(null) }
|
||||||
|
var order by remember { mutableStateOf<List<String>?>(null) }
|
||||||
|
val current by rememberUpdatedState(notes)
|
||||||
|
val reorder by rememberUpdatedState(onReorder)
|
||||||
|
LaunchedEffect(notes) { order = null }
|
||||||
|
// Which card is carried, read by every item; where it is, read only when its layer
|
||||||
|
// is drawn. A move then redraws one card instead of recomposing them all.
|
||||||
|
val carriedId by remember { derivedStateOf { drag?.id } }
|
||||||
|
|
||||||
|
val byId = notes.associateBy { it.id }
|
||||||
|
val shown = order?.mapNotNull { byId[it] } ?: notes
|
||||||
|
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(BOARD_COLUMNS),
|
columns = StaggeredGridCells.Fixed(columns),
|
||||||
modifier = Modifier.fillMaxSize(),
|
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.
|
// 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),
|
contentPadding = PaddingValues(start = GUTTER, end = GUTTER, top = 4.dp, bottom = 88.dp),
|
||||||
verticalItemSpacing = 8.dp,
|
verticalItemSpacing = 8.dp,
|
||||||
@@ -342,13 +567,39 @@ private fun NoteBoard(
|
|||||||
// Keyed by id so Compose reuses cards across a refresh rather than
|
// Keyed by id so Compose reuses cards across a refresh rather than
|
||||||
// rebuilding them — and so a newly captured note slides in instead of
|
// rebuilding them — and so a newly captured note slides in instead of
|
||||||
// making every card below it flicker.
|
// making every card below it flicker.
|
||||||
items(items = notes, key = { it.id }) { 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(
|
NoteCard(
|
||||||
note = note,
|
note = note,
|
||||||
onOpen = { onOpenNote(note) },
|
onOpen = { onOpenNote(note) },
|
||||||
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
|
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
|
||||||
|
onAction = { onNoteAction(note, it) },
|
||||||
|
onConfirmDelete = { onConfirmDelete(note) },
|
||||||
|
dragging = carried,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DisposableEffect(note.id) { onDispose { bounds.forget(note.id) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +617,8 @@ private fun EmptyBoard(state: BoardState) {
|
|||||||
state.searching ->
|
state.searching ->
|
||||||
stringResource(R.string.empty_search_title) to
|
stringResource(R.string.empty_search_title) to
|
||||||
stringResource(R.string.empty_search_body, state.query)
|
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 ->
|
state.destination == Destination.Trash ->
|
||||||
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
|
stringResource(R.string.empty_trash_title) to stringResource(R.string.empty_trash_body)
|
||||||
state.destination == Destination.Archive ->
|
state.destination == Destination.Archive ->
|
||||||
@@ -437,8 +690,6 @@ fun StoreUnavailableScreen(reason: String?) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val BOARD_COLUMNS = 2
|
|
||||||
|
|
||||||
// Not private: the reminder notice is board content and has to line up with the
|
// 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.
|
// search bar and the cards, so it shares the board's gutter rather than guessing.
|
||||||
internal val GUTTER = 12.dp
|
internal val GUTTER = 12.dp
|
||||||
+179
-35
@@ -1,17 +1,19 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.thoughtsync.core.Label
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.inkwell.core.Label
|
||||||
import com.fabledsword.thoughtsync.core.NoteDraft
|
import com.fabledsword.inkwell.core.Note
|
||||||
import com.fabledsword.thoughtsync.core.NoteEdit
|
import com.fabledsword.inkwell.core.NoteDraft
|
||||||
import com.fabledsword.thoughtsync.core.NoteQuery
|
import com.fabledsword.inkwell.core.NoteEdit
|
||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
import com.fabledsword.inkwell.core.NoteFacets
|
||||||
|
import com.fabledsword.inkwell.core.NoteQuery
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -56,6 +58,8 @@ data class BoardState(
|
|||||||
val notes: List<Note> = emptyList(),
|
val notes: List<Note> = emptyList(),
|
||||||
val labels: List<Label> = emptyList(),
|
val labels: List<Label> = emptyList(),
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
|
/** The main board's filters. Only [Destination.Notes] applies them. */
|
||||||
|
val filters: BoardFilters = BoardFilters(),
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val saving: Boolean = false,
|
val saving: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
@@ -81,6 +85,18 @@ data class BoardState(
|
|||||||
) {
|
) {
|
||||||
/** Search overrides the destination while there is a query to run. */
|
/** Search overrides the destination while there is a query to run. */
|
||||||
val searching: Boolean get() = query.isNotBlank()
|
val searching: Boolean get() = query.isNotBlank()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main board only, and not while searching, as on the web: a search spans
|
||||||
|
* everything, so a filter over its hits would be a control that does nothing.
|
||||||
|
*/
|
||||||
|
val filterable: Boolean get() = destination == Destination.Notes && !searching
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole board in one order, as on the web: not a search, another view, or a
|
||||||
|
* filtered subset, whose order would be renumbered against notes it can't see.
|
||||||
|
*/
|
||||||
|
val reorderable: Boolean get() = filterable && filters.count == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,7 +115,7 @@ data class BoardState(
|
|||||||
*/
|
*/
|
||||||
@Suppress("TooManyFunctions")
|
@Suppress("TooManyFunctions")
|
||||||
class BoardViewModel(
|
class BoardViewModel(
|
||||||
private val core: ThoughtSync,
|
private val core: Inkwell,
|
||||||
/**
|
/**
|
||||||
* Called after any write that could have moved a reminder.
|
* Called after any write that could have moved a reminder.
|
||||||
*
|
*
|
||||||
@@ -110,6 +126,11 @@ class BoardViewModel(
|
|||||||
* view model's `onStoreChanged`.
|
* view model's `onStoreChanged`.
|
||||||
*/
|
*/
|
||||||
private val onRemindersChanged: () -> Unit = {},
|
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() {
|
) : ViewModel() {
|
||||||
var state by mutableStateOf(BoardState())
|
var state by mutableStateOf(BoardState())
|
||||||
private set
|
private set
|
||||||
@@ -123,7 +144,7 @@ class BoardViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
loadLabels()
|
refreshLabels()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun open(destination: Destination) {
|
fun open(destination: Destination) {
|
||||||
@@ -131,7 +152,13 @@ class BoardViewModel(
|
|||||||
// running should show the archive, not search results filtered by a box
|
// running should show the archive, not search results filtered by a box
|
||||||
// the user has visually moved on from.
|
// the user has visually moved on from.
|
||||||
searchJob?.cancel()
|
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()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +167,9 @@ class BoardViewModel(
|
|||||||
state = state.copy(loading = true)
|
state = state.copy(loading = true)
|
||||||
state =
|
state =
|
||||||
try {
|
try {
|
||||||
val notes = withContext(Dispatchers.IO) { load(state.destination) }
|
val destination = state.destination
|
||||||
|
val filters = state.filters
|
||||||
|
val notes = withContext(Dispatchers.IO) { load(destination, filters) }
|
||||||
state.copy(notes = notes, loading = false, error = null)
|
state.copy(notes = notes, loading = false, error = null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Broad by intent: the board must render something for any
|
// Broad by intent: the board must render something for any
|
||||||
@@ -151,9 +180,12 @@ class BoardViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun load(destination: Destination): List<Note> =
|
private fun load(
|
||||||
|
destination: Destination,
|
||||||
|
filters: BoardFilters,
|
||||||
|
): List<Note> =
|
||||||
when (destination) {
|
when (destination) {
|
||||||
Destination.Notes -> core.listNotes(query(VIEW_NOTES))
|
Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets()))
|
||||||
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
|
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE))
|
||||||
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
|
Destination.Trash -> core.listNotes(query(VIEW_TRASH))
|
||||||
// Not a board view: the core models reminders as its own query, since
|
// Not a board view: the core models reminders as its own query, since
|
||||||
@@ -162,13 +194,38 @@ class BoardViewModel(
|
|||||||
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
viewModelScope.launch {
|
||||||
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
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
|
// A drawer that cannot list labels is a degraded drawer, not a
|
||||||
// broken board — the notes are still there. Failing quietly here
|
// 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()) }
|
.onFailure { state = state.copy(labels = emptyList()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,6 +292,39 @@ class BoardViewModel(
|
|||||||
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
|
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture text shared into the app from somewhere else, and open it.
|
||||||
|
*
|
||||||
|
* The note is CREATED here rather than opened as a pre-filled draft, and that
|
||||||
|
* is the whole design of this path. The editor only flushes when its text
|
||||||
|
* differs from the note it was handed (`NoteEditorScreen`'s `flush`), so a
|
||||||
|
* draft arriving already full of the shared text is a draft with nothing to
|
||||||
|
* save — share a link, press back without typing, and it would be gone. A
|
||||||
|
* share has already said "keep this"; making the row first is what honours it.
|
||||||
|
*
|
||||||
|
* Opening the editor afterwards is then free of that risk: the note exists,
|
||||||
|
* back leaves it alone, and adding a line of context is optional rather than
|
||||||
|
* load-bearing.
|
||||||
|
*/
|
||||||
|
fun captureShared(
|
||||||
|
text: String?,
|
||||||
|
files: List<Uri> = emptyList(),
|
||||||
|
) {
|
||||||
|
val content = text?.trim().orEmpty()
|
||||||
|
if (content.isEmpty() && files.isEmpty()) return
|
||||||
|
// A share is a new sitting even if the editor was already open on
|
||||||
|
// something, so the field must be re-keyed onto what arrives. `createFrom
|
||||||
|
// Draft` deliberately does not bump this — it is written for the autosave
|
||||||
|
// case, where re-keying mid-typing would be the bug.
|
||||||
|
draftDismissed = false
|
||||||
|
state = state.copy(editingSession = state.editingSession + 1)
|
||||||
|
// A shared photo arrives with no words, and the note it makes is still a
|
||||||
|
// note: the picture is its content.
|
||||||
|
createFromDraft(content, allowEmpty = files.isNotEmpty()) { created ->
|
||||||
|
if (files.isNotEmpty()) onEditorAction(created, EditorAction.Attach(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set when a draft's editor closes, so a create still in flight does not reopen
|
* Set when a draft's editor closes, so a create still in flight does not reopen
|
||||||
* it. The editor flushes its text and then closes, and the flush is a coroutine —
|
* it. The editor flushes its text and then closes, and the flush is a coroutine —
|
||||||
@@ -263,6 +353,10 @@ class BoardViewModel(
|
|||||||
}
|
}
|
||||||
EditorAction.DismissError -> dismissError()
|
EditorAction.DismissError -> dismissError()
|
||||||
is EditorAction.SaveText -> createFromDraft(action.body)
|
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
|
// 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
|
// 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.
|
// anyone reaches the toolbar; before that there is nothing to attribute.
|
||||||
@@ -327,7 +421,7 @@ class BoardViewModel(
|
|||||||
* mutation that lands between a tap and its dispatch cannot redirect the
|
* mutation that lands between a tap and its dispatch cannot redirect the
|
||||||
* action at a different note.
|
* 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
|
* 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 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
|
* it, and each of those would need an `else` — which throws away precisely the
|
||||||
@@ -354,8 +448,6 @@ class BoardViewModel(
|
|||||||
is EditorAction.SaveText ->
|
is EditorAction.SaveText ->
|
||||||
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||||
|
|
||||||
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
|
|
||||||
|
|
||||||
// Pinning re-sorts the board rather than emptying it, and on a phone
|
// Pinning re-sorts the board rather than emptying it, and on a phone
|
||||||
// you often pin while still reading — so unlike the three below, it
|
// you often pin while still reading — so unlike the three below, it
|
||||||
// deliberately leaves the editor open.
|
// deliberately leaves the editor open.
|
||||||
@@ -389,7 +481,7 @@ class BoardViewModel(
|
|||||||
}
|
}
|
||||||
// The drawer lists labels with their note counts, and both
|
// The drawer lists labels with their note counts, and both
|
||||||
// just changed.
|
// just changed.
|
||||||
loadLabels()
|
refreshLabels()
|
||||||
}
|
}
|
||||||
|
|
||||||
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
is EditorAction.SetReminder -> edit(id, NoteEdit.RemindAt(action.at))
|
||||||
@@ -401,6 +493,35 @@ class BoardViewModel(
|
|||||||
id,
|
id,
|
||||||
action.rule?.let { NoteEdit.Recurrence(it) } ?: NoteEdit.ClearRecurrence,
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,13 +548,20 @@ class BoardViewModel(
|
|||||||
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
* correct-until-a-moment-ago content, and flashing it empty would be a worse
|
||||||
* lie than showing it one frame stale.
|
* lie than showing it one frame stale.
|
||||||
*
|
*
|
||||||
* Search results are left alone — they are the answer to a query, not a live
|
* While a search is running the QUERY is re-run rather than the board's
|
||||||
* view, and re-running the board query underneath them would replace the hits
|
* destination — running `load` here would replace the hits with the whole
|
||||||
* with the whole board.
|
* board, which is why this branch exists at all. It used to keep the existing
|
||||||
|
* list instead, and that was right for a note whose place in the pile changed
|
||||||
|
* and wrong for one that left it: trashing a hit left the card sitting there,
|
||||||
|
* with a snackbar saying it was gone, until the query happened to re-run
|
||||||
|
* (#3111). Re-asking is still the answer to the query, just a current one.
|
||||||
*/
|
*/
|
||||||
private fun mutate(
|
private fun mutate(
|
||||||
closeEditor: Boolean = false,
|
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 {
|
viewModelScope.launch {
|
||||||
state = state.copy(saving = true)
|
state = state.copy(saving = true)
|
||||||
@@ -441,10 +569,12 @@ class BoardViewModel(
|
|||||||
try {
|
try {
|
||||||
val updated = withContext(Dispatchers.IO) { block(core) }
|
val updated = withContext(Dispatchers.IO) { block(core) }
|
||||||
val notes =
|
val notes =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
if (state.searching) {
|
if (state.searching) {
|
||||||
state.notes
|
core.searchNotes(state.query)
|
||||||
} else {
|
} else {
|
||||||
withContext(Dispatchers.IO) { load(state.destination) }
|
load(state.destination, state.filters)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// On IO, not here: re-deriving the alarm reads every note
|
// On IO, not here: re-deriving the alarm reads every note
|
||||||
// that carries a reminder, and this line runs on the main
|
// that carries a reminder, and this line runs on the main
|
||||||
@@ -459,7 +589,7 @@ class BoardViewModel(
|
|||||||
// which is exactly what ticking a checkbox on a card did.
|
// which is exactly what ticking a checkbox on a card did.
|
||||||
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
|
editing = if (closeEditor) null else state.editing?.let { updated ?: it },
|
||||||
saving = false,
|
saving = false,
|
||||||
error = null,
|
error = notice(),
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Broad by intent, as elsewhere: the core reports every failure
|
// Broad by intent, as elsewhere: the core reports every failure
|
||||||
@@ -485,6 +615,19 @@ class BoardViewModel(
|
|||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
|
) = 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() {
|
fun dismissError() {
|
||||||
state = state.copy(error = null)
|
state = state.copy(error = null)
|
||||||
}
|
}
|
||||||
@@ -500,20 +643,18 @@ class BoardViewModel(
|
|||||||
private const val VIEW_TRASH = "trash"
|
private const val VIEW_TRASH = "trash"
|
||||||
|
|
||||||
fun factory(
|
fun factory(
|
||||||
core: ThoughtSync,
|
core: Inkwell,
|
||||||
|
readFile: (Uri) -> PickedFile,
|
||||||
onRemindersChanged: () -> Unit,
|
onRemindersChanged: () -> Unit,
|
||||||
): ViewModelProvider.Factory =
|
): ViewModelProvider.Factory =
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
BoardViewModel(core, onRemindersChanged) as T
|
BoardViewModel(core, onRemindersChanged, readFile) as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The palette key a note starts on, matching the web and the desktop. */
|
|
||||||
private const val DEFAULT_COLOR = "default"
|
|
||||||
|
|
||||||
// ── pure builders ───────────────────────────────────────────────────────────
|
// ── pure builders ───────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Neither of these reads or writes view-model state; they only shape a core input
|
// Neither of these reads or writes view-model state; they only shape a core input
|
||||||
@@ -523,13 +664,14 @@ private const val DEFAULT_COLOR = "default"
|
|||||||
private fun query(
|
private fun query(
|
||||||
view: String,
|
view: String,
|
||||||
labelId: String? = null,
|
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 =
|
private fun draft(content: String): NoteDraft =
|
||||||
// The core names the note from the body's first line, so a captured thought is
|
// The core names the note from the body's first line, so a captured thought is
|
||||||
// findable without anyone being asked to name it. A checklist is added afterwards,
|
// findable without anyone being asked to name it. A checklist is added afterwards,
|
||||||
// in the editor — it is something a note HAS, not a different thing to capture.
|
// in the editor — it is something a note HAS, not a different thing to capture.
|
||||||
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
|
NoteDraft(body = content, items = null)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id a note has before it has been saved.
|
* The id a note has before it has been saved.
|
||||||
@@ -545,7 +687,6 @@ private fun blankDraft(): Note =
|
|||||||
id = DRAFT_ID,
|
id = DRAFT_ID,
|
||||||
displayTitle = "",
|
displayTitle = "",
|
||||||
body = "",
|
body = "",
|
||||||
color = DEFAULT_COLOR,
|
|
||||||
position = 0,
|
position = 0,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
archived = false,
|
archived = false,
|
||||||
@@ -559,4 +700,7 @@ private fun blankDraft(): Note =
|
|||||||
previews = emptyList(),
|
previews = emptyList(),
|
||||||
createdAt = null,
|
createdAt = null,
|
||||||
updatedAt = null,
|
updatedAt = null,
|
||||||
|
permission = "owner",
|
||||||
|
shared = false,
|
||||||
|
sharedBy = null,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
// The colour a LABEL wears when nobody picked one for it.
|
||||||
|
//
|
||||||
|
// Every `#tag` is born colourless, so without this a board of tags is a board of
|
||||||
|
// identical grey chips. Hashing the tag's NAME is deterministic, identical on every
|
||||||
|
// surface, costs no column and no migration, and a tag keeps its colour for life.
|
||||||
|
//
|
||||||
|
// THIS WAS THE CARD'S COLOUR TOO, ONCE. It is not any more (M315): a note's fill is
|
||||||
|
// one neutral and only its tags carry hue. The hash survived that removal because the
|
||||||
|
// job it still does — give a name a stable colour — was never the job that failed.
|
||||||
|
// What failed was asking a colour that means "which tag" to also mean nothing at all
|
||||||
|
// on an untagged note, at which point the board had two vocabularies and neither read.
|
||||||
|
//
|
||||||
|
// THIS IS HALF A MIRRORED PAIR. `frontend/src/notes/colors.ts` computes the same hash
|
||||||
|
// over the same key order, and the two must agree exactly or a tag is one colour on
|
||||||
|
// the phone and another in the browser. Same discipline as the checklist grammar's
|
||||||
|
// three implementations, and the same reason: a value that disagrees across surfaces
|
||||||
|
// is a bug you cannot unsee and cannot explain.
|
||||||
|
//
|
||||||
|
// NO COMPOSE IN THIS FILE, deliberately. It is the half of the pair that CAN be
|
||||||
|
// pinned by a host-JVM test, and staying free of `androidx.compose` is what keeps
|
||||||
|
// `DerivedTintTest` runnable in the Unit tests step rather than on an emulator. The
|
||||||
|
// web side has no test runner at all, so this test is the only mechanical guard the
|
||||||
|
// mirror gets — see the fixture comment in colors.ts.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colours a derived hue can land on: `NOTE_TINTS`' keys minus `default`, which is
|
||||||
|
* the ABSENCE of a colour — a tag that derived it would be indistinguishable from one
|
||||||
|
* nobody has tagged. `gray` stays: as a chip it reads as a deliberate choice.
|
||||||
|
*
|
||||||
|
* Order is load-bearing and matches `DERIVED_TINT_KEYS` in colors.ts. Reordering this
|
||||||
|
* list silently recolours every tag on one surface only.
|
||||||
|
*/
|
||||||
|
val DERIVED_TINT_KEYS: List<String> =
|
||||||
|
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray")
|
||||||
|
|
||||||
|
private const val FNV_OFFSET_BASIS = -0x7ee3623b // 0x811c9dc5 as a signed Int
|
||||||
|
private const val FNV_PRIME = 0x01000193
|
||||||
|
private const val BYTE_MASK = 0xFF
|
||||||
|
private const val UNSIGNED_MASK = 0xFFFFFFFFL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNV-1a over the id's bytes, 32-bit.
|
||||||
|
*
|
||||||
|
* Chosen because both languages compute it identically in ten lines with no library.
|
||||||
|
* Explicitly NOT `String.hashCode()`: Kotlin's is specified but JS has no equivalent,
|
||||||
|
* and reimplementing Java's from memory in TypeScript is exactly how a mirror drifts.
|
||||||
|
*
|
||||||
|
* `and BYTE_MASK` is a no-op for the ASCII of a UUID, and is kept because it states
|
||||||
|
* the intent — this hashes BYTES, so the TypeScript side reading `charCodeAt(i) &
|
||||||
|
* 0xff` is the same function rather than a coincidence.
|
||||||
|
*
|
||||||
|
* Overflow is the point: Kotlin's `Int` wraps on multiply, which is what the web's
|
||||||
|
* `Math.imul` exists to reproduce.
|
||||||
|
*/
|
||||||
|
fun tintHash(id: String): Int {
|
||||||
|
var hash = FNV_OFFSET_BASIS
|
||||||
|
for (ch in id) {
|
||||||
|
hash = hash xor (ch.code and BYTE_MASK)
|
||||||
|
hash *= FNV_PRIME
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The colour a name maps to, stable for as long as the name is. Called with a
|
||||||
|
* label's lowercased name; `id` is the parameter's history, not its meaning. */
|
||||||
|
fun derivedTint(id: String): String {
|
||||||
|
// Through Long to read the hash as unsigned. A signed remainder would be negative
|
||||||
|
// for half of all ids and index out of the list.
|
||||||
|
val index = (tintHash(id).toLong() and UNSIGNED_MASK) % DERIVED_TINT_KEYS.size
|
||||||
|
return DERIVED_TINT_KEYS[index.toInt()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour key for a LABEL — its chip, and its `#tag` where it sits in the prose.
|
||||||
|
*
|
||||||
|
* Derived from the tag's NAME when nobody has picked one. Every `#tag` ever typed is
|
||||||
|
* `default`: the server mints one as `Label(owner_id=…, name=name)` with no colour,
|
||||||
|
* so without deriving, a board of tags would be a board of identical grey chips.
|
||||||
|
*
|
||||||
|
* DERIVED RATHER THAN PERSISTED AT MINT TIME, reversing #2965's plan. That plan wanted
|
||||||
|
* a hashed colour written at each of the four places a label can be born — and named
|
||||||
|
* the risk itself: `find_or_create_label` is "easy to miss, and it is the common one",
|
||||||
|
* since most tags are born from typing `#grocery`, not from a management screen.
|
||||||
|
* Deriving has no mint points to miss and no backfill for the tags already out there.
|
||||||
|
* The cost is that renaming a tag recolours it, which is fair: the name IS the tag.
|
||||||
|
*
|
||||||
|
* Lowercased because tags dedupe case-insensitively — `#Todo` renamed to `#todo` is
|
||||||
|
* the same tag and should not change colour. Kotlin's `lowercase()` and the web's
|
||||||
|
* `toLowerCase()` are both locale-independent, so the mirror holds.
|
||||||
|
*/
|
||||||
|
fun resolvedLabelColor(
|
||||||
|
name: String,
|
||||||
|
color: String,
|
||||||
|
known: Set<String>,
|
||||||
|
): String =
|
||||||
|
when {
|
||||||
|
color.isNotEmpty() && color != "default" && color in known -> color
|
||||||
|
name.isEmpty() -> "default"
|
||||||
|
else -> derivedTint(name.lowercase())
|
||||||
|
}
|
||||||
+21
-5
@@ -1,4 +1,6 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the editor can ask for, as one type.
|
* Everything the editor can ask for, as one type.
|
||||||
@@ -25,10 +27,6 @@ sealed interface EditorAction {
|
|||||||
val body: String,
|
val body: String,
|
||||||
) : EditorAction
|
) : EditorAction
|
||||||
|
|
||||||
data class SetColor(
|
|
||||||
val color: String,
|
|
||||||
) : EditorAction
|
|
||||||
|
|
||||||
data class SetPinned(
|
data class SetPinned(
|
||||||
val pinned: Boolean,
|
val pinned: Boolean,
|
||||||
) : EditorAction
|
) : EditorAction
|
||||||
@@ -97,4 +95,22 @@ sealed interface EditorAction {
|
|||||||
data class SetRecurrence(
|
data class SetRecurrence(
|
||||||
val rule: String?,
|
val rule: String?,
|
||||||
) : EditorAction
|
) : 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
|
||||||
}
|
}
|
||||||
+27
-3
@@ -1,10 +1,10 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.saveable.Saver
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.text.TextRange
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import com.fabledsword.thoughtsync.core.checklistItems
|
import com.fabledsword.inkwell.core.checklistItems
|
||||||
import com.fabledsword.thoughtsync.core.checklistRender
|
import com.fabledsword.inkwell.core.checklistRender
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One piece of a note body, as the editor DRAWS it.
|
* One piece of a note body, as the editor DRAWS it.
|
||||||
@@ -142,6 +142,30 @@ fun List<EditorBlock>.plusTask(): Pair<List<EditorBlock>, Long> {
|
|||||||
return (this + EditorBlock(id, TextFieldValue(""), false)) to id
|
return (this + EditorBlock(id, TextFieldValue(""), false)) to id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read ONE prose block for `- [ ] ` lines somebody typed by hand, so a marker
|
||||||
|
* typed in the editor becomes an item without closing and reopening the note.
|
||||||
|
*
|
||||||
|
* **On blur, and only the block being left:** re-splitting on a keystroke would move
|
||||||
|
* the caret, and converting the moment `- [ ]` is complete would catch an item with
|
||||||
|
* no text yet.
|
||||||
|
*
|
||||||
|
* Returns THIS LIST when there was nothing to promote; the caller relies on that to
|
||||||
|
* leave the state alone, so a blur that changed nothing doesn't re-key every field.
|
||||||
|
*
|
||||||
|
* Non-canonical markers (`- [X]`, an odd bullet) come back canonical, as they would on
|
||||||
|
* reopen.
|
||||||
|
*/
|
||||||
|
internal fun List<EditorBlock>.promotingTasks(index: Int): List<EditorBlock> {
|
||||||
|
val block = getOrNull(index)
|
||||||
|
if (block == null || block.isTask) return this
|
||||||
|
val split = splitBlocks(block.value.text, nextId())
|
||||||
|
// A single prose block back means there was nothing to promote. `splitBlocks` never
|
||||||
|
// returns an empty list, so `first()` is safe.
|
||||||
|
val changed = split.size > 1 || split.first().isTask
|
||||||
|
return if (changed) take(index) + split + drop(index + 1) else this
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Put the caret at the end of the last block, for an editor that has just opened.
|
* Put the caret at the end of the last block, for an editor that has just opened.
|
||||||
*
|
*
|
||||||
+65
-56
@@ -1,7 +1,6 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -13,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
@@ -23,7 +21,6 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -42,10 +39,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The editor's action bar, along the top of the surface.
|
* The editor's action bar, along the top of the surface.
|
||||||
@@ -78,13 +76,19 @@ import com.fabledsword.thoughtsync.core.Note
|
|||||||
fun EditorTopBar(
|
fun EditorTopBar(
|
||||||
note: Note,
|
note: Note,
|
||||||
readOnly: Boolean,
|
readOnly: Boolean,
|
||||||
tint: NoteTint,
|
access: NoteAccess,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onStartChecklist: () -> Unit,
|
onStartChecklist: () -> Unit,
|
||||||
onPicker: (Picker) -> Unit,
|
onPicker: (Picker) -> Unit,
|
||||||
|
onAttach: () -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Someone else's note (#5175): its text, at `edit`, and this account's own pin
|
||||||
|
// and archive (#5176) are all that may change here, so the bar keeps the
|
||||||
|
// checklist button and an overflow of those two, and nothing that is the owner's.
|
||||||
|
val owner = access == NoteAccess.OWNER
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {},
|
title = {},
|
||||||
@@ -100,25 +104,15 @@ fun EditorTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!readOnly) {
|
if (!readOnly && owner) {
|
||||||
// A dot in the note's CURRENT colour rather than a palette icon: it
|
|
||||||
// shows what the colour is as well as what the button does.
|
|
||||||
IconButton(onClick = { onPicker(Picker.COLOR) }) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(SWATCH_DOT)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(tint.chipBackground(dark))
|
|
||||||
.border(1.dp, tint.border(dark), CircleShape),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
IconButton(onClick = { onPicker(Picker.REMINDER) }) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Notifications,
|
Icons.Filled.Notifications,
|
||||||
contentDescription = stringResource(R.string.editor_reminder),
|
contentDescription = stringResource(R.string.editor_reminder),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (!readOnly) {
|
||||||
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
// 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 now (M304), so there is no section to be
|
||||||
// already-showing and no reason a second list cannot start further
|
// already-showing and no reason a second list cannot start further
|
||||||
@@ -130,27 +124,42 @@ fun EditorTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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(
|
OverflowMenu(
|
||||||
note = note,
|
note = note,
|
||||||
readOnly = readOnly,
|
owner = owner,
|
||||||
onPicker = onPicker,
|
onPicker = onPicker,
|
||||||
|
onShare = onShare,
|
||||||
onConfirmDelete = onConfirmDelete,
|
onConfirmDelete = onConfirmDelete,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
|
// EXPLICIT, and not optional — the same lesson the old bottom bar learned.
|
||||||
// Material derives a bar's content colour from its container via
|
// Material derives a bar's content colour from its container via
|
||||||
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
|
// contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and
|
||||||
// returns Unspecified for anything else. A note tint is never a role, so the
|
// returns Unspecified for anything else. The card surface is a plain constant
|
||||||
// icons drew with no colour filter: black vectors on a near-black bar, a
|
// and not a role, so the icons drew with no colour filter: black vectors on a
|
||||||
// toolbar that rendered the whole time and was invisible in dark mode.
|
// near-black bar, a toolbar that rendered the whole time and was invisible in
|
||||||
|
// dark mode. STILL TRUE with one neutral surface — it is the same kind of
|
||||||
|
// value, so this stays exactly as it is.
|
||||||
//
|
//
|
||||||
// onSurface for the actions too, not the default onSurfaceVariant: these sit
|
// onSurface for the actions too, not the default onSurfaceVariant: the bar has
|
||||||
// on a tinted bar rather than a scheme surface, and the muted variant does
|
// to read against the card rather than the board, and the muted variant does
|
||||||
// not have the contrast to spare.
|
// not have the contrast to spare.
|
||||||
colors =
|
colors =
|
||||||
TopAppBarDefaults.topAppBarColors(
|
TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = tint.background(dark),
|
containerColor = noteCardSurface(dark),
|
||||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
@@ -196,11 +205,9 @@ fun EditorTopBar(
|
|||||||
fun EditorFooter(
|
fun EditorFooter(
|
||||||
updatedAt: String?,
|
updatedAt: String?,
|
||||||
saving: Boolean,
|
saving: Boolean,
|
||||||
tint: NoteTint,
|
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
@@ -223,12 +230,20 @@ fun EditorFooter(
|
|||||||
)
|
)
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onClose,
|
onClick = onClose,
|
||||||
// The note's own colour rather than the scheme's secondaryContainer,
|
// The BRAND, matching the board's compose FAB — the app's one existing
|
||||||
// which would be the one element on a tinted card ignoring the tint.
|
// statement of "this is the affirmative action here", now reused rather
|
||||||
|
// than a second one invented.
|
||||||
|
//
|
||||||
|
// This wore the note's own tint until M315, on the argument that it would
|
||||||
|
// otherwise be the one element on a tinted card ignoring the tint. There is
|
||||||
|
// no tint to ignore any more, and the alternative — Material's default
|
||||||
|
// secondaryContainer — is a baseline M3 colour this theme never sets, so
|
||||||
|
// taking the default would put an off-brand lilac in the corner of the
|
||||||
|
// editor.
|
||||||
colors =
|
colors =
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
containerColor = tint.chipBackground(dark),
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
contentColor = tint.chipForeground(dark),
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
|
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
|
||||||
@@ -264,8 +279,9 @@ private fun savedLabel(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun OverflowMenu(
|
private fun OverflowMenu(
|
||||||
note: Note,
|
note: Note,
|
||||||
readOnly: Boolean,
|
owner: Boolean,
|
||||||
onPicker: (Picker) -> Unit,
|
onPicker: (Picker) -> Unit,
|
||||||
|
onShare: () -> Unit,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -276,7 +292,7 @@ private fun OverflowMenu(
|
|||||||
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
|
Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.editor_more))
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = open, onDismissRequest = close) {
|
DropdownMenu(expanded = open, onDismissRequest = close) {
|
||||||
if (readOnly) {
|
if (note.trashed) {
|
||||||
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
|
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
|
||||||
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
|
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
|
||||||
} else {
|
} else {
|
||||||
@@ -284,33 +300,23 @@ private fun OverflowMenu(
|
|||||||
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||||
close,
|
close,
|
||||||
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||||
|
if (owner) {
|
||||||
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
|
||||||
|
}
|
||||||
|
// Not on a draft: the server has nothing to share until it is saved.
|
||||||
|
if (owner && note.id != DRAFT_ID) {
|
||||||
|
MenuItem(R.string.editor_share, close, onShare)
|
||||||
|
}
|
||||||
MenuItem(
|
MenuItem(
|
||||||
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||||
close,
|
close,
|
||||||
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||||
|
if (owner) {
|
||||||
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
|
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MenuItem(
|
|
||||||
@StringRes labelRes: Int,
|
|
||||||
onClose: () -> Unit,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(labelRes)) },
|
|
||||||
onClick = {
|
|
||||||
// Close BEFORE acting. An overflow menu left hanging over the sheet
|
|
||||||
// that just opened underneath it is the classic version of this bug,
|
|
||||||
// and doing it here means no call site can forget.
|
|
||||||
onClose()
|
|
||||||
onClick()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,19 +336,23 @@ fun EditorLabelRow(
|
|||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||||
note.labels.forEach { label ->
|
note.labels.forEach { label ->
|
||||||
val tint = noteTint(label.color)
|
val tint = labelTintFor(label.name, label.color)
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = 2.dp),
|
modifier = Modifier.padding(vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label.name,
|
// `#` on every chip, matching the card. This row still shows the
|
||||||
|
// tags the BODY owns as well — it is the control surface, and the
|
||||||
|
// "from tag" hint beside one is what says why it has no cross.
|
||||||
|
text = "#${label.name}",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = tint.chipForeground(dark),
|
color = tint.tagInk(dark),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(tint.chipBackground(dark))
|
.background(tint.chipBackground(dark))
|
||||||
|
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
)
|
)
|
||||||
if (label.viaTag) {
|
if (label.viaTag) {
|
||||||
@@ -416,6 +426,5 @@ fun EditorReminderRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val SWATCH_DOT = 22.dp
|
|
||||||
private const val SNOOZE_HOUR = 60L
|
private const val SNOOZE_HOUR = 60L
|
||||||
private const val SNOOZE_DAY = 1440L
|
private const val SNOOZE_DAY = 1440L
|
||||||
+10
-85
@@ -1,11 +1,7 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -13,21 +9,16 @@ import androidx.compose.foundation.layout.heightIn
|
|||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DatePicker
|
import androidx.compose.material3.DatePicker
|
||||||
import androidx.compose.material3.DatePickerDialog
|
import androidx.compose.material3.DatePickerDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -42,13 +33,12 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.thoughtsync.core.Label
|
import com.fabledsword.inkwell.core.Label
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.inkwell.core.Note
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -57,80 +47,17 @@ import java.time.LocalTime
|
|||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.temporal.TemporalAdjusters
|
import java.time.temporal.TemporalAdjusters
|
||||||
|
|
||||||
// The three things you pick rather than type: a colour, a set of labels, a time.
|
// 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
|
// 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
|
// 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
|
// the note still visible above it — which matters when the choice you are making
|
||||||
// is about the thing you are looking at.
|
// is about the thing you are looking at.
|
||||||
|
|
||||||
/** The note palette, as swatches. Order and colours come from [NOTE_TINTS]. */
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun ColorSheet(
|
|
||||||
selected: String,
|
|
||||||
onPick: (String) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.navigationBarsPadding(),
|
|
||||||
) {
|
|
||||||
SheetTitle(R.string.color_picker_title)
|
|
||||||
// Chunked into fixed rows rather than a flow layout: ten swatches
|
|
||||||
// always lay out as two rows of five on every phone width, and a flow
|
|
||||||
// would reshuffle them between devices for no gain.
|
|
||||||
NOTE_TINTS.entries.chunked(SWATCHES_PER_ROW).forEach { row ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
row.forEach { (key, tint) ->
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(SWATCH_SIZE)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(tint.background(dark))
|
|
||||||
.border(
|
|
||||||
// The selected swatch gets a heavier ring
|
|
||||||
// as well as a tick: on the pale tints the
|
|
||||||
// tick alone is nearly invisible.
|
|
||||||
if (key == selected) 2.dp else 1.dp,
|
|
||||||
if (key == selected) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
tint.border(dark)
|
|
||||||
},
|
|
||||||
CircleShape,
|
|
||||||
).clickable(onClickLabel = tint.label) { onPick(key) },
|
|
||||||
) {
|
|
||||||
if (key == selected) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Check,
|
|
||||||
contentDescription = tint.label,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Pad a short final row so its swatches line up with the row
|
|
||||||
// above instead of spreading across the full width.
|
|
||||||
repeat(SWATCHES_PER_ROW - row.size) {
|
|
||||||
Box(modifier = Modifier.size(SWATCH_SIZE))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every label, ticked where it is on the note.
|
* Every label, ticked where it is on the note.
|
||||||
*
|
*
|
||||||
@@ -411,7 +338,7 @@ private fun RecurrenceChips(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SheetTitle(labelRes: Int) {
|
internal fun SheetTitle(labelRes: Int) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(labelRes),
|
text = stringResource(labelRes),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
@@ -461,8 +388,6 @@ private val RECURRENCE_RULES: List<Pair<String?, Int>> =
|
|||||||
"yearly" to R.string.recurrence_yearly,
|
"yearly" to R.string.recurrence_yearly,
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val SWATCHES_PER_ROW = 5
|
|
||||||
private const val EVENING_HOUR = 18
|
private const val EVENING_HOUR = 18
|
||||||
private const val MORNING_HOUR = 8
|
private const val MORNING_HOUR = 8
|
||||||
private val SWATCH_SIZE = 44.dp
|
|
||||||
private val LABEL_LIST_MAX_HEIGHT = 320.dp
|
private val LABEL_LIST_MAX_HEIGHT = 320.dp
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.R
|
||||||
|
|
||||||
// Shared by the board and the editor.
|
// Shared by the board and the editor.
|
||||||
//
|
//
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.core.LinkPreview
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
|
private val PREVIEW_RADIUS = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL that is the WHOLE body, whitespace either side allowed.
|
||||||
|
*
|
||||||
|
* Mirrors `LONE_URL_RE` in `NoteCard.vue` deliberately — the two surfaces have to
|
||||||
|
* agree on what counts as "this note is a link", or the same note reads as a card
|
||||||
|
* on one and a paragraph on the other. Someone pasting a link rarely trims it,
|
||||||
|
* which is why the surrounding whitespace is tolerated rather than rejected.
|
||||||
|
*/
|
||||||
|
private val LONE_URL = Regex("""^\s*(https?://[^\s<>"'\]\)]+)\s*$""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preview for a note that is nothing but a URL, or null.
|
||||||
|
*
|
||||||
|
* Null covers three different situations that all render the same way — the body
|
||||||
|
* is not a lone URL, the server has not unfurled it yet, or it never could. The
|
||||||
|
* card falls back to showing the URL as text in every one of them, so it is never
|
||||||
|
* blank and the link is never unreachable.
|
||||||
|
*
|
||||||
|
* A note written on the phone and not yet synced is permanently in the middle
|
||||||
|
* case: the unfurl happens server-side (`unfurl_queue.py`) and arrives on a later
|
||||||
|
* pull. That is the honest behaviour and it has to look deliberate, which showing
|
||||||
|
* the URL does.
|
||||||
|
*/
|
||||||
|
fun loneUrlPreview(note: Note): LinkPreview? {
|
||||||
|
if (!LONE_URL.matches(note.body)) return null
|
||||||
|
val url = note.body.trim()
|
||||||
|
return note.previews.firstOrNull { it.url == url }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the body is a lone URL, whether or not a preview has arrived for it. */
|
||||||
|
fun isLoneUrl(note: Note): Boolean = LONE_URL.matches(note.body)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fetched link preview, in one of two sizes.
|
||||||
|
*
|
||||||
|
* [compact] is a single row — one line of title and the site — for a URL mentioned
|
||||||
|
* *inside* a note that has its own words. The note is the thing; the link is a
|
||||||
|
* footnote to it. Full size is for a note that IS a URL, where the link is the
|
||||||
|
* note and a compact strip would be a card with nothing on it.
|
||||||
|
*
|
||||||
|
* No image, unlike the web's `LinkPreview.vue`. `image_url` is a REMOTE
|
||||||
|
* third-party address, so drawing it would have this app fetch from whatever host
|
||||||
|
* a link happens to point at — on a phone, on possibly metered data, and as the
|
||||||
|
* first image loading anywhere in this client. That is a decision about privacy
|
||||||
|
* and data use rather than a rendering detail, so the text card ships and the
|
||||||
|
* image is left to be asked for (Scribe #3307).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LinkPreviewCard(
|
||||||
|
preview: LinkPreview,
|
||||||
|
compact: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Every element of the Modifier chain stays on ONE line, which is why the shape
|
||||||
|
// and the two paddings are named first. `standard:chain-method-continuation`
|
||||||
|
// wants a `.` that follows a MULTILINE element glued to its closing paren —
|
||||||
|
// `).padding(…)` — which is unreadable, so the multiline element is avoided
|
||||||
|
// instead (Scribe #3110).
|
||||||
|
val shape = RoundedCornerShape(PREVIEW_RADIUS)
|
||||||
|
val padH = if (compact) 8.dp else 10.dp
|
||||||
|
val padV = if (compact) 6.dp else 8.dp
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||||
|
.padding(horizontal = padH, vertical = padV),
|
||||||
|
) {
|
||||||
|
preview.siteName?.takeIf { it.isNotBlank() }?.let { site ->
|
||||||
|
Text(
|
||||||
|
text = site.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
// The URL stands in for a missing title so the row always says SOMETHING
|
||||||
|
// about where it goes.
|
||||||
|
text = preview.title?.takeIf { it.isNotBlank() } ?: preview.url,
|
||||||
|
style = if (compact) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
// First thing to go when there is no room — the compact row is a footnote and
|
||||||
|
// a description would make it the loudest part of the card.
|
||||||
|
if (!compact) {
|
||||||
|
preview.description?.takeIf { it.isNotBlank() }?.let { body ->
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How this account holds a note (#5175), read from the core's `permission`.
|
||||||
|
*
|
||||||
|
* Someone else's note at [EDIT] may have its TEXT changed here; at [VIEW] it may
|
||||||
|
* not. Either way its pin and archive are this account's own (#5176). The core
|
||||||
|
* refuses the rest anyway — this only keeps the editor from offering what would be
|
||||||
|
* refused.
|
||||||
|
*/
|
||||||
|
enum class NoteAccess { OWNER, EDIT, VIEW }
|
||||||
|
|
||||||
|
val Note.access: NoteAccess
|
||||||
|
get() =
|
||||||
|
when (permission) {
|
||||||
|
"edit" -> NoteAccess.EDIT
|
||||||
|
"view" -> NoteAccess.VIEW
|
||||||
|
else -> NoteAccess.OWNER
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Its content can't change here: it is in the trash, or shared with us to view. */
|
||||||
|
val Note.readOnlyHere: Boolean
|
||||||
|
get() = trashed || access == NoteAccess.VIEW
|
||||||
|
|
||||||
|
/** The owner's own controls apply: tags, reminder, files, previews, share, trash. */
|
||||||
|
val Note.ownerControlsHere: Boolean
|
||||||
|
get() = !readOnlyHere && access == NoteAccess.OWNER
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.BodyItem
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import com.fabledsword.inkwell.core.NoteLabel
|
||||||
|
import com.fabledsword.inkwell.core.bodyTags
|
||||||
|
import com.fabledsword.inkwell.core.checklistItems
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NoteCard(
|
||||||
|
note: Note,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
onConfirmDelete: () -> Unit,
|
||||||
|
/** The board is carrying this card (BoardDrag.kt). */
|
||||||
|
dragging: Boolean = false,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// A drag starts from the same long press that opens the menu, so the menu is
|
||||||
|
// already up when the card begins to move. It goes as the card does.
|
||||||
|
LaunchedEffect(dragging) { if (dragging) menuOpen = false }
|
||||||
|
|
||||||
|
// NAMED rather than written inline in the chain below, and not for taste: ktlint's
|
||||||
|
// chain-method-continuation wants the next `.` glued to the closing paren of a
|
||||||
|
// multiline element — `).background(…)` — which is worse to read than a modifier
|
||||||
|
// with a name. Every other multiline element in this codebase happens to be last
|
||||||
|
// in its chain, so this is the first place the rule bites.
|
||||||
|
val opening =
|
||||||
|
Modifier.combinedClickable(
|
||||||
|
onClickLabel = stringResource(R.string.board_open_note),
|
||||||
|
onLongClickLabel = stringResource(R.string.board_note_actions),
|
||||||
|
onLongClick = {
|
||||||
|
// Fired HERE rather than when the menu appears. A long press is
|
||||||
|
// confirmed by the system before the popup has laid out, and the whole
|
||||||
|
// point of the buzz is to say "that registered" at the moment your
|
||||||
|
// finger has been still long enough — a menu that arrives with no tick
|
||||||
|
// under it reads as a phone that missed the gesture and then changed
|
||||||
|
// its mind.
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
menuOpen = true
|
||||||
|
},
|
||||||
|
onClick = onOpen,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Box exists only to anchor the menu. A DropdownMenu is a popup and takes no
|
||||||
|
// space, so the card's size is still the Column's.
|
||||||
|
Box {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Depth, not the boundary — the edge below is that. 1dp: enough to
|
||||||
|
// separate a white card from a #fafafa board, and the web's own
|
||||||
|
// `shadow-sm` is the value it is matching.
|
||||||
|
.shadow(CARD_ELEVATION, RoundedCornerShape(CARD_RADIUS))
|
||||||
|
// Clipped BEFORE the click modifier, so the ripple is bounded by the
|
||||||
|
// card's rounded corners instead of a rectangle overhanging them —
|
||||||
|
// and BEFORE padding, so the padded edge is still a tap target.
|
||||||
|
.clip(RoundedCornerShape(CARD_RADIUS))
|
||||||
|
.then(opening)
|
||||||
|
// ONE surface and ONE edge on every card, both neutral, neither
|
||||||
|
// asking the note anything. See noteCardSurface and CARD_EDGE_DARK.
|
||||||
|
.background(noteCardSurface(dark))
|
||||||
|
.border(1.dp, if (dark) CARD_EDGE_DARK else CARD_EDGE_LIGHT, RoundedCornerShape(CARD_RADIUS))
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
CardContents(note = note, onToggleItem = onToggleItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteMenu(
|
||||||
|
note = note,
|
||||||
|
expanded = menuOpen,
|
||||||
|
onDismiss = { menuOpen = false },
|
||||||
|
onAction = onAction,
|
||||||
|
onConfirmDelete = onConfirmDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a card shows, top to bottom: tags, body, links, attachments, reminder, sharing. */
|
||||||
|
@Composable
|
||||||
|
private fun CardContents(
|
||||||
|
note: Note,
|
||||||
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
// TAGS FIRST, on a row of their own above the body: a board is scanned, and
|
||||||
|
// what a note is about should be the first thing the eye lands on. Not beside
|
||||||
|
// the body, whose first line is the note's name.
|
||||||
|
//
|
||||||
|
// Only labels whose text is not still in the note (`via_tag` is false). A tag
|
||||||
|
// left in prose is tinted in place instead (see [tintTags]), so this row holds
|
||||||
|
// what the body cannot say: a tag lifted off its own line, or one added by hand.
|
||||||
|
val chips = note.labels.filterNot { it.viaTag }
|
||||||
|
if (chips.isNotEmpty()) {
|
||||||
|
LabelChips(labels = chips)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note that is NOTHING but a URL renders as its preview and nothing
|
||||||
|
// else — printing the raw address under a card that already says where it
|
||||||
|
// goes is saying the same thing twice, badly. Until the unfurl lands, or
|
||||||
|
// if it never does, `preview` is null and the body falls through to
|
||||||
|
// NoteBody, which shows the URL. Never a blank card.
|
||||||
|
val lonePreview = remember(note.body, note.previews) { loneUrlPreview(note) }
|
||||||
|
|
||||||
|
// Body then checklist, in order — a note can carry both (M13 step 2). The
|
||||||
|
// first line of the body IS the note's name, at the same weight as the rest of
|
||||||
|
// it (M13 steps 3 and 4).
|
||||||
|
if (lonePreview != null) {
|
||||||
|
LinkPreviewCard(preview = lonePreview, compact = false)
|
||||||
|
} else if (note.body.isNotBlank()) {
|
||||||
|
// A note shared with us to view has inert boxes (#5175): ticking one is
|
||||||
|
// changing its text, which is not ours to do.
|
||||||
|
NoteBody(
|
||||||
|
note = note,
|
||||||
|
onToggleItem = if (note.access == NoteAccess.VIEW) INERT_TOGGLE else onToggleItem,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links mentioned INSIDE a note: a compact strip at the foot of the card,
|
||||||
|
// under the note's own words rather than stacked on top of them. Putting
|
||||||
|
// them above would set a stranger's headline where the note's first line
|
||||||
|
// should be — the web learned that in M13 and moved them down.
|
||||||
|
if (!isLoneUrl(note) && note.previews.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
note.previews.forEach { preview ->
|
||||||
|
LinkPreviewCard(preview = preview, compact = true)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under the words and the links, like the web's card: a photo is often what a
|
||||||
|
// note is ABOUT, but its first line is still its name.
|
||||||
|
if (note.attachments.isNotEmpty()) {
|
||||||
|
CardAttachments(attachments = note.attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||||
|
// it reads as a rendering bug.
|
||||||
|
if (note.body.isBlank() && note.previews.isEmpty() && note.attachments.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.board_empty_note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
note.remindAt?.let { at ->
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
ReminderChip(instant = at, recurrence = note.recurrence)
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedChip(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What you can do to a note without opening it, on a long press.
|
||||||
|
*
|
||||||
|
* The same items as the editor's overflow, in the same words from the same string
|
||||||
|
* resources, dispatched through [EditorAction] so `BoardViewModel.onEditorAction`
|
||||||
|
* stays the one place they are handled.
|
||||||
|
*
|
||||||
|
* Gated on the NOTE (`note.trashed`), not on the view, because Reminders and search
|
||||||
|
* mix piles: a note already in Trash must not offer "Move to trash".
|
||||||
|
*
|
||||||
|
* No Labels item: the picker it opens is editor state.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NoteMenu(
|
||||||
|
note: Note,
|
||||||
|
expanded: Boolean,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAction: (EditorAction) -> Unit,
|
||||||
|
onConfirmDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Pin and archive are this account's own on someone else's note too (#5176);
|
||||||
|
// trash is the owner's. A note shared with us never reaches our Trash, so the
|
||||||
|
// trashed set is only ever the owner's.
|
||||||
|
val owner = note.access == NoteAccess.OWNER
|
||||||
|
DropdownMenu(expanded = expanded && (owner || !note.trashed), onDismissRequest = onDismiss) {
|
||||||
|
if (note.trashed) {
|
||||||
|
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
|
||||||
|
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
|
||||||
|
} else {
|
||||||
|
MenuItem(
|
||||||
|
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
|
||||||
|
onDismiss,
|
||||||
|
) { onAction(EditorAction.SetPinned(!note.pinned)) }
|
||||||
|
MenuItem(
|
||||||
|
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
|
||||||
|
onDismiss,
|
||||||
|
) { onAction(EditorAction.SetArchived(!note.archived)) }
|
||||||
|
if (owner) {
|
||||||
|
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's body, with its checklist drawn where it actually sits.
|
||||||
|
*
|
||||||
|
* Rendered line by line rather than as one block of text, because an item is a line
|
||||||
|
* of the body now (M304) and a card that showed the prose and then the list would put
|
||||||
|
* every list in the wrong place — and, since the body already contains those lines,
|
||||||
|
* would show each one twice.
|
||||||
|
*
|
||||||
|
* Which lines are items is asked of the core rather than matched here. The grammar is
|
||||||
|
* already written three times; a fourth in Compose would be a fourth place for a
|
||||||
|
* checklist to change shape when it syncs.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NoteBody(
|
||||||
|
note: Note,
|
||||||
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val lines = remember(note.body) { note.body.split("\n") }
|
||||||
|
// Read from the BODY rather than from note.items, which is the same list by a
|
||||||
|
// longer route — and one that can lag the text by a save.
|
||||||
|
val itemAtLine =
|
||||||
|
remember(note.body) {
|
||||||
|
checklistItems(note.body)
|
||||||
|
.mapIndexed { index, item -> item.line.toInt() to (index to item) }
|
||||||
|
.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
|
||||||
|
val found = itemAtLine[n]
|
||||||
|
when {
|
||||||
|
found != null ->
|
||||||
|
ChecklistRow(note, found.second) { onToggleItem(found.first, !found.second.checked) }
|
||||||
|
// Kept as a gap rather than dropped: it is the paragraph break
|
||||||
|
// somebody typed, and the card reads as a wall without it.
|
||||||
|
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
||||||
|
else ->
|
||||||
|
Text(
|
||||||
|
text = tintTags(line, note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = MAX_WRAPPED_LINES,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.size > MAX_PREVIEW_LINES) {
|
||||||
|
Text(
|
||||||
|
text = "…",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One checklist row on a card, with a box you can actually tick.
|
||||||
|
*
|
||||||
|
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
|
||||||
|
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
|
||||||
|
* space a list out like a form. The tap target is the glyph's own padding, which is
|
||||||
|
* why it carries `clickable` rather than the row — clicking the TEXT should open the
|
||||||
|
* note, the way clicking anywhere else on the card does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistRow(
|
||||||
|
note: Note,
|
||||||
|
item: BodyItem,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Text(
|
||||||
|
text = if (item.checked) "☑" else "☐",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.padding(end = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = tintTags(item.text, note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
||||||
|
color =
|
||||||
|
if (item.checked) {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
maxLines = MAX_WRAPPED_LINES,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One string of a note's own words, with every `#tag` in it drawn in that tag's colour.
|
||||||
|
*
|
||||||
|
* This is what replaced the chip for a tag still living in the prose. The card used to
|
||||||
|
* print such a tag twice — once where it was typed and once in the row above — and the
|
||||||
|
* duplicate was the loud copy, which made a tagged note read as "tag, then some text
|
||||||
|
* that happens to start with the same word". Colouring it in place says the same thing
|
||||||
|
* with no furniture, and says it more honestly: the token you can see IS the text you
|
||||||
|
* would delete to remove the tag.
|
||||||
|
*
|
||||||
|
* WHICH characters are a tag is asked of the core, exactly as [NoteBody] asks it which
|
||||||
|
* lines are checklist items. The grammar already exists three times (Rust, Python,
|
||||||
|
* TypeScript); a fourth in Compose would be a fourth thing to disagree — and this one
|
||||||
|
* would fail silently, as the wrong characters tinted rather than an error anywhere.
|
||||||
|
* The core's offsets are UTF-16 code units for this call site specifically, which is
|
||||||
|
* the only unit `addStyle` can take.
|
||||||
|
*
|
||||||
|
* Called per rendered STRING rather than once per body so a checklist item's text can
|
||||||
|
* be handled with no arithmetic: an item is a line minus a `- [ ] ` prefix of a length
|
||||||
|
* nothing carries, and shifting spans by a guessed prefix is the kind of off-by-one
|
||||||
|
* that shows up only on the one note that had a tag in a list.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun tintTags(
|
||||||
|
text: String,
|
||||||
|
note: Note,
|
||||||
|
): AnnotatedString {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
return remember(text, note.labels, dark) {
|
||||||
|
val spans = bodyTags(text)
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
AnnotatedString(text)
|
||||||
|
} else {
|
||||||
|
// A tag the note does not carry as a label yet — just typed, not yet
|
||||||
|
// derived — still gets a colour: `labelTint` falls back to deriving one
|
||||||
|
// from the name, which is what the chip would have shown anyway.
|
||||||
|
val picked = note.labels.associate { it.name.lowercase() to it.color }
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
spans.forEach { tag ->
|
||||||
|
val tint = labelTint(tag.name, picked[tag.name.lowercase()].orEmpty())
|
||||||
|
addStyle(
|
||||||
|
SpanStyle(color = tint.tagInk(dark), fontWeight = FontWeight.Medium),
|
||||||
|
tag.start.toInt(),
|
||||||
|
tag.end.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LabelChips(labels: List<NoteLabel>) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
// A plain row that clips rather than wraps: a card with eight labels should
|
||||||
|
// not grow taller than its content. The editor shows the full set.
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
||||||
|
val tint = labelTintFor(label.name, label.color)
|
||||||
|
Text(
|
||||||
|
// The `#` is carried on every chip, because everything that reaches
|
||||||
|
// this row is a tag — a tag lifted off its own line, or one attached
|
||||||
|
// through the picker — and the hash is how you would type either. It
|
||||||
|
// also keeps a lifted chip reading as the `#todo` somebody wrote.
|
||||||
|
text = "#${label.name}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tint.tagInk(dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.border(1.dp, tint.chipBorder(dark), RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reminder, red once it has passed.
|
||||||
|
*
|
||||||
|
* Red for overdue and neutral otherwise, matching the web card exactly — the same
|
||||||
|
* red-100/red-700 and black/5 pairs, resolved through the shared tint table. It
|
||||||
|
* used to be blue for every reminder here, which made "you missed this" and
|
||||||
|
* "coming up on Friday" look identical on a board full of both.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ReminderChip(
|
||||||
|
instant: String,
|
||||||
|
recurrence: String?,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint(if (isPast(instant)) "red" else "default")
|
||||||
|
Text(
|
||||||
|
text = reminderLabel(instant, recurrence),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tint.chipForeground(dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whose note this is, when it is not only ours (#5175): "From Robin" on a note someone
|
||||||
|
* shared with us, "Shared" on one of ours that we shared. Nothing on a private note.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SharedChip(note: Note) {
|
||||||
|
val text =
|
||||||
|
when {
|
||||||
|
note.access != NoteAccess.OWNER ->
|
||||||
|
stringResource(
|
||||||
|
R.string.share_chip_by,
|
||||||
|
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone),
|
||||||
|
)
|
||||||
|
note.shared -> stringResource(R.string.share_chip_shared)
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint("default")
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = tint.chipForeground(dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(CHIP_RADIUS))
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A box that does nothing when tapped: someone else's note, shared to view. */
|
||||||
|
private val INERT_TOGGLE: (Int, Boolean) -> Unit = { _, _ -> }
|
||||||
|
|
||||||
|
private const val MAX_PREVIEW_LINES = 8
|
||||||
|
|
||||||
|
/** How far one long line of a card may wrap before it is cut. */
|
||||||
|
private const val MAX_WRAPPED_LINES = 2
|
||||||
|
private const val MAX_LABEL_CHIPS = 3
|
||||||
|
private val CARD_RADIUS = 12.dp
|
||||||
|
private val CARD_ELEVATION = 1.dp
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WHAT A CARD IS: one surface and one edge, neither of which asks the note anything.
|
||||||
|
//
|
||||||
|
// Both are constants HERE rather than columns in NoteTint precisely so the palette
|
||||||
|
// CANNOT vary them; uniformity is the feature. The editor reads the surface from here
|
||||||
|
// too, so a note opened is the same object as the note on the board.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE CARD SURFACE — one neutral per theme (M315).
|
||||||
|
*
|
||||||
|
* This used to be a function of the note: a palette fill for a tagged one, a colour
|
||||||
|
* generated from the id for the rest. Both are gone. The operator's verdict after four
|
||||||
|
* passes — "my coloring attempt has failed and nothing looks right… we've tried a lot
|
||||||
|
* to make the color work and somehow it never seems to land" — and the diagnosis under
|
||||||
|
* it is that a card's fill was being asked to carry meaning it could not carry. Nine
|
||||||
|
* keys is too few to identify anything on a board of any size, and a generated fill
|
||||||
|
* identifies nothing by construction, so a coloured board taught the eye to read hue
|
||||||
|
* as significant and then handed it noise. Colour lives on the TAG now, where the
|
||||||
|
* thing it names is right beside it.
|
||||||
|
*
|
||||||
|
* The values are `neutral-900` on dark and white on light — exactly what the palette's
|
||||||
|
* `default` always was, and exactly what the web card and both editors already use, so
|
||||||
|
* this is a collapse onto a surface every surface already had rather than a new colour
|
||||||
|
* anybody has to like. Mirrored as `NOTE_CARD_SURFACE` in `frontend/src/notes/colors.ts`
|
||||||
|
* (`bg-white dark:bg-neutral-900`).
|
||||||
|
*
|
||||||
|
* NOT a colour-scheme role: `surface` is the BOARD in this theme (neutral-50 / -950),
|
||||||
|
* and Material's `surfaceContainer` roles are unset here so they would resolve to
|
||||||
|
* baseline M3 greys rather than to the web's neutrals. Two hexes matching the web beats
|
||||||
|
* a role that nearly does.
|
||||||
|
*
|
||||||
|
* Measured, against the operator's "not the same color as their background but close
|
||||||
|
* to it" — the card fill is deliberately the WEAKEST number on the card:
|
||||||
|
*
|
||||||
|
* card vs board light #FFFFFF on #FAFAFA 1.04
|
||||||
|
* dark #171717 on #0A0A0A 1.10
|
||||||
|
* edge vs card light #B8B8B8 on #FFFFFF 1.98
|
||||||
|
* dark #404040 on #171717 1.73
|
||||||
|
* body vs card light #171717 on #FFFFFF 17.93 (needs 4.5)
|
||||||
|
* dark #FAFAFA on #171717 17.17
|
||||||
|
* muted vs card light #404040 on #FFFFFF 10.37
|
||||||
|
* dark #E5E5E5 on #171717 14.23
|
||||||
|
*
|
||||||
|
* A card is not separated from the board by its fill and never was — the edge and the
|
||||||
|
* shadow do that, which is why 1.04 is enough and why it has to stay near 1. A fill
|
||||||
|
* that separated on its own would be a panel, and a board of panels is the wall this
|
||||||
|
* whole line of work started from.
|
||||||
|
*/
|
||||||
|
fun noteCardSurface(dark: Boolean): Color = if (dark) CARD_SURFACE_DARK else CARD_SURFACE_LIGHT
|
||||||
|
|
||||||
|
private val CARD_SURFACE_LIGHT = Color(0xFFFFFFFF)
|
||||||
|
private val CARD_SURFACE_DARK = Color(0xFF171717)
|
||||||
|
|
||||||
|
// THE CARD'S EDGE — one neutral grey, every card, both themes. The fill barely differs
|
||||||
|
// from the board, so this line is what makes a card a card. Neutral on purpose: a
|
||||||
|
// coloured edge would carry information and turn a board into a grid of outlines.
|
||||||
|
//
|
||||||
|
// Opaque rather than a translucent black/white, which would composite differently over
|
||||||
|
// whatever is under it. NoteCard.vue writes the same two hex values, which is how the
|
||||||
|
// surfaces stay the same card. Contrast: #B8B8B8 on white 1.98, #404040 on #171717 1.73.
|
||||||
|
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
|
||||||
|
private val CARD_EDGE_DARK = Color(0xFF404040)
|
||||||
|
private val CHIP_RADIUS = 6.dp
|
||||||
+57
-45
@@ -1,6 +1,8 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
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.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -12,13 +14,10 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -27,11 +26,9 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.core.Label
|
||||||
import com.fabledsword.thoughtsync.core.Label
|
import com.fabledsword.inkwell.core.Note
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,8 +41,9 @@ import kotlinx.coroutines.delay
|
|||||||
* negotiating with the IME for the bottom half of the display, and the swipe-down it
|
* 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.
|
* buys is a gesture back already does. The shape is what was worth keeping.
|
||||||
*
|
*
|
||||||
* The note's own colour paints the WHOLE card rather than a panel inside it, so
|
* The card's surface paints the WHOLE sheet rather than a panel inside it, so opening
|
||||||
* opening a note reads as the same object growing to fill the display.
|
* a note reads as the same object growing to fill the display — the more literally
|
||||||
|
* true since M315, where the board and the editor became the same one neutral.
|
||||||
*
|
*
|
||||||
* No save button, deliberately. Writes are continuous, so a button offering to do
|
* 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
|
* what already happened would be a lie with a tap attached; [EditorFooter] in the
|
||||||
@@ -60,10 +58,10 @@ fun NoteEditorScreen(
|
|||||||
labels: List<Label>,
|
labels: List<Label>,
|
||||||
saving: Boolean,
|
saving: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
|
onShare: () -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val tint = noteTint(note.color)
|
|
||||||
|
|
||||||
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
||||||
// needs a key — but a draft's id changes the moment it is first saved, and
|
// needs a key — but a draft's id changes the moment it is first saved, and
|
||||||
@@ -93,7 +91,13 @@ fun NoteEditorScreen(
|
|||||||
// A note in the trash is a record, not a document: editing one would silently
|
// 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
|
// 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.
|
// 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 (#5175). At edit, the text is, and the
|
||||||
|
// rows that are the owner's (tags, reminder, files, previews) stay inert.
|
||||||
|
val access = note.access
|
||||||
|
val readOnly = note.readOnlyHere
|
||||||
|
val ownerControls = note.ownerControlsHere
|
||||||
|
|
||||||
// Persist the text, if it changed. The baseline check is what makes "open a
|
// 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
|
// note, read it, back out" write nothing at all — without it every glance
|
||||||
@@ -137,6 +141,14 @@ fun NoteEditorScreen(
|
|||||||
|
|
||||||
BackHandler(onBack = leave)
|
BackHandler(onBack = leave)
|
||||||
|
|
||||||
|
// The system picker, for any type: a note can carry a PDF as readily as a photo.
|
||||||
|
// Leaving for it stops this screen, so FlushOnStop has already saved the text by
|
||||||
|
// the time the files come back.
|
||||||
|
val pickFiles =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||||
|
if (uris.isNotEmpty()) onAction(EditorAction.Attach(uris))
|
||||||
|
}
|
||||||
|
|
||||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
// 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
|
// 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.
|
// is exactly the failure that makes someone stop trusting a notes app.
|
||||||
@@ -155,24 +167,24 @@ fun NoteEditorScreen(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
|
shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER),
|
||||||
color = tint.background(dark),
|
color = noteCardSurface(dark),
|
||||||
// Both content colours are spelled out for the reason the toolbar had to
|
// Both content colours are spelled out for the reason the toolbar had to
|
||||||
// be: Surface and Scaffold each default theirs to contentColorFor(their
|
// be: Surface and Scaffold each default theirs to contentColorFor(their
|
||||||
// container), which returns Unspecified for anything that is not a
|
// container), which returns Unspecified for anything that is not a
|
||||||
// colour-SCHEME ROLE. A note tint never is, so the default publishes
|
// colour-SCHEME ROLE. The card surface is not one, so the default publishes
|
||||||
// Unspecified as LocalContentColor and everything inside that does not
|
// Unspecified as LocalContentColor and everything inside that does not
|
||||||
// set its own colour draws black — which is how the last toolbar became
|
// set its own colour draws black — which is how the last toolbar became
|
||||||
// invisible in dark mode.
|
// invisible in dark mode.
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = tint.background(dark),
|
containerColor = noteCardSurface(dark),
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
topBar = {
|
topBar = {
|
||||||
EditorTopBar(
|
EditorTopBar(
|
||||||
note = note,
|
note = note,
|
||||||
readOnly = readOnly,
|
readOnly = readOnly,
|
||||||
tint = tint,
|
access = access,
|
||||||
onClose = leave,
|
onClose = leave,
|
||||||
onStartChecklist = {
|
onStartChecklist = {
|
||||||
val (next, id) = blocks.plusTask()
|
val (next, id) = blocks.plusTask()
|
||||||
@@ -180,6 +192,11 @@ fun NoteEditorScreen(
|
|||||||
focus = id
|
focus = id
|
||||||
},
|
},
|
||||||
onPicker = { picker = it },
|
onPicker = { picker = it },
|
||||||
|
onAttach = { pickFiles.launch(ANY_TYPE) },
|
||||||
|
onShare = {
|
||||||
|
flush()
|
||||||
|
onShare()
|
||||||
|
},
|
||||||
onConfirmDelete = { confirmingDelete = true },
|
onConfirmDelete = { confirmingDelete = true },
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
)
|
)
|
||||||
@@ -192,7 +209,6 @@ fun NoteEditorScreen(
|
|||||||
EditorFooter(
|
EditorFooter(
|
||||||
updatedAt = note.updatedAt,
|
updatedAt = note.updatedAt,
|
||||||
saving = saving,
|
saving = saving,
|
||||||
tint = tint,
|
|
||||||
onClose = leave,
|
onClose = leave,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -221,6 +237,9 @@ 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
|
// 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
|
// 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
|
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
||||||
@@ -239,17 +258,29 @@ fun NoteEditorScreen(
|
|||||||
// list on screen twice.
|
// list on screen twice.
|
||||||
|
|
||||||
if (note.labels.isNotEmpty()) {
|
if (note.labels.isNotEmpty()) {
|
||||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
note.remindAt?.let { at ->
|
note.remindAt?.let { at ->
|
||||||
EditorReminderRow(
|
EditorReminderRow(
|
||||||
at = at,
|
at = at,
|
||||||
recurrence = note.recurrence,
|
recurrence = note.recurrence,
|
||||||
readOnly = readOnly,
|
readOnly = !ownerControls,
|
||||||
onAction = onAction,
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,31 +295,18 @@ fun NoteEditorScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (confirmingDelete) {
|
if (confirmingDelete) {
|
||||||
// The only irreversible action in the app earns the only confirmation in
|
ConfirmDeleteDialog(
|
||||||
// it. Everything else — archive, trash, even unlinking a server — undoes.
|
onConfirm = {
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { confirmingDelete = false },
|
|
||||||
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
|
|
||||||
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
confirmingDelete = false
|
confirmingDelete = false
|
||||||
onAction(EditorAction.DeleteForever)
|
onAction(EditorAction.DeleteForever)
|
||||||
}) {
|
|
||||||
Text(stringResource(R.string.editor_delete_forever_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { confirmingDelete = false }) {
|
|
||||||
Text(stringResource(R.string.editor_cancel))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
onDismiss = { confirmingDelete = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
|
/** Which overlay is open. One at a time, so they cannot stack on a phone screen. */
|
||||||
enum class Picker { NONE, COLOR, LABELS, REMINDER }
|
enum class Picker { NONE, LABELS, REMINDER }
|
||||||
|
|
||||||
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
|
/** The pickers, hoisted out so the screen above reads as a layout rather than a switch. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -302,15 +320,6 @@ private fun EditorOverlays(
|
|||||||
val dismiss = { onPicker(Picker.NONE) }
|
val dismiss = { onPicker(Picker.NONE) }
|
||||||
when (picker) {
|
when (picker) {
|
||||||
Picker.NONE -> Unit
|
Picker.NONE -> Unit
|
||||||
Picker.COLOR ->
|
|
||||||
ColorSheet(
|
|
||||||
selected = note.color,
|
|
||||||
onPick = {
|
|
||||||
onAction(EditorAction.SetColor(it))
|
|
||||||
dismiss()
|
|
||||||
},
|
|
||||||
onDismiss = dismiss,
|
|
||||||
)
|
|
||||||
Picker.LABELS ->
|
Picker.LABELS ->
|
||||||
LabelSheet(
|
LabelSheet(
|
||||||
note = note,
|
note = note,
|
||||||
@@ -336,6 +345,9 @@ private fun EditorOverlays(
|
|||||||
*/
|
*/
|
||||||
private const val AUTOSAVE_IDLE_MS = 1_000L
|
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
|
* 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. Same shape as the capture surface this replaced, on purpose.
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
|
||||||
|
*
|
||||||
|
* A colour is stored by the core as a key ("red", "teal", …) and every surface
|
||||||
|
* resolves it to its own tints. The web app resolves through Tailwind classes; this
|
||||||
|
* table is those same Tailwind colours as literals, so a tag that is amber on the
|
||||||
|
* desktop is the same amber on the phone rather than a near-miss. Generated from
|
||||||
|
* tailwindcss 3.4's palette rather than transcribed by eye.
|
||||||
|
*
|
||||||
|
* Dark tints keep the web's ALPHA instead of a precomputed blend — Compose composites
|
||||||
|
* a translucent colour over what's beneath exactly as CSS does, so a panel sits on the
|
||||||
|
* background the same way in both.
|
||||||
|
*
|
||||||
|
* This table is the palette of MEANINGFUL colours: a tag's. A NOTE no longer has one
|
||||||
|
* at all (M315) — the card is one neutral per theme, held in NoteCard.kt beside the
|
||||||
|
* edge, where the palette cannot reach either of them. What is left here is the chip,
|
||||||
|
* the inline `#tag`, and the panels and banners that borrow a hue to say what they
|
||||||
|
* are.
|
||||||
|
*
|
||||||
|
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
|
||||||
|
* acid against the neutral surfaces.
|
||||||
|
*/
|
||||||
|
data class NoteTint(
|
||||||
|
val label: String,
|
||||||
|
val lightBackground: Color,
|
||||||
|
val lightBorder: Color,
|
||||||
|
val darkBackground: Color,
|
||||||
|
val darkBorder: Color,
|
||||||
|
val lightChipBackground: Color,
|
||||||
|
val darkChipBackground: Color,
|
||||||
|
/**
|
||||||
|
* The REMINDER pill's ink, and nothing else's — see [chipForeground].
|
||||||
|
*
|
||||||
|
* Only two of these ten are ever read (`red` when a reminder has passed, `default`
|
||||||
|
* otherwise). They stay a per-hue column because they are transcribed from the
|
||||||
|
* web's literals rather than derived from anything here.
|
||||||
|
*/
|
||||||
|
val lightChipForeground: Color,
|
||||||
|
val darkChipForeground: Color,
|
||||||
|
/**
|
||||||
|
* THE INK A TAG IS DRAWN IN — inline in the prose and as a chip's text — see
|
||||||
|
* [tagInk]. One value serves both: `-800` light / `-300` dark clears body-text
|
||||||
|
* contrast on the card and on a chip's own fill.
|
||||||
|
*/
|
||||||
|
val lightTagInk: Color,
|
||||||
|
val darkTagInk: Color,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* The pale fill of a PANEL, a banner, an update card or a picker swatch — the
|
||||||
|
* places that borrow a hue to say what they are. Not a note's: since M315 a card
|
||||||
|
* has one neutral surface and does not come through this table at all.
|
||||||
|
*/
|
||||||
|
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
|
||||||
|
|
||||||
|
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
|
||||||
|
|
||||||
|
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The REMINDER pill's ink. NOT a tag's — a tag takes [tagInk] wherever it is drawn.
|
||||||
|
*
|
||||||
|
* Kept apart from [tagInk] because the reminder pill is not a tag: it borrows the
|
||||||
|
* chip's shape and its `red-100`/`black-5` fills, and its `red-700`/`neutral-600`
|
||||||
|
* text is transcribed from NoteCard.vue's literal classes. The two happened to be
|
||||||
|
* one value; making the tag ink one step deeper (M315) is where they parted, and
|
||||||
|
* moving the reminder with it would have silently broken that mirror instead.
|
||||||
|
*/
|
||||||
|
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour a `#tag` is drawn in — in the note's own words, or as a chip.
|
||||||
|
*
|
||||||
|
* A tag whose text is in the body is no longer repeated as a chip (the card was
|
||||||
|
* printing every tag twice — once where it was typed, once at the top). It is
|
||||||
|
* tinted in place instead, which is both less furniture and a more honest card:
|
||||||
|
* the thing you see IS the thing you would delete to remove the tag.
|
||||||
|
*/
|
||||||
|
fun tagInk(dark: Boolean): Color = if (dark) darkTagInk else lightTagInk
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hairline edge for a chip, in its own ink at low alpha.
|
||||||
|
*
|
||||||
|
* THE EDGE IS THE PILL. Against the one card surface a chip's fill measures
|
||||||
|
* 1.02-1.26 in light and 1.02-1.73 in dark — very nearly nothing, and dark red at
|
||||||
|
* 1.02 is literally invisible. Without this the tag name would read as loose text.
|
||||||
|
* The fill only tints a shape the edge is drawing.
|
||||||
|
*
|
||||||
|
* An edge rather than a heavier fill, because a fill loud enough to hold its own
|
||||||
|
* shape would be the loudest thing on a board whose whole point is now that the tag
|
||||||
|
* is the one coloured thing on it.
|
||||||
|
*/
|
||||||
|
fun chipBorder(dark: Boolean): Color = tagInk(dark).copy(alpha = CHIP_EDGE_ALPHA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// How strongly a chip's edge is drawn, as a fraction of its own ink.
|
||||||
|
//
|
||||||
|
// SOLVED FOR, NOT GUESSED — and re-solved once the answer became solvable. 0.60 was
|
||||||
|
// picked against the worst case of the time: a chip on a card of its OWN colour, back
|
||||||
|
// when a note took its first tag's fill. It gave 2.32:1 there, missed the 3:1 of WCAG
|
||||||
|
// 1.4.11, and the comment here reasoned its way out of that on the grounds that a
|
||||||
|
// chip's information is its text.
|
||||||
|
//
|
||||||
|
// M315 removed that worst case. The edge is now the ink at alpha over a KNOWN fill, so
|
||||||
|
// the smallest alpha clearing 3:1 for all ten hues is arithmetic rather than judgment:
|
||||||
|
// 0.60 gives 2.75-3.82 in light and misses for six of the ten, 0.65 gives 3.03-4.36 and
|
||||||
|
// misses for none. Dark runs 4.52-5.76. The old comment named 0.80 as the fallback if
|
||||||
|
// the judgment were ever overruled; it is not needed, and it draws a hard outline where
|
||||||
|
// a hairline does the job.
|
||||||
|
//
|
||||||
|
// Mirrored on the web as the `/65` in LABEL_CHIP_SHELL's ring.
|
||||||
|
private const val CHIP_EDGE_ALPHA = 0.65f
|
||||||
|
|
||||||
|
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
|
||||||
|
val NOTE_TINTS: Map<String, NoteTint> =
|
||||||
|
mapOf(
|
||||||
|
"default" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Default",
|
||||||
|
lightBackground = Color(0xFFFFFFFF),
|
||||||
|
lightBorder = Color(0xFFE5E5E5),
|
||||||
|
darkBackground = Color(0xFF171717),
|
||||||
|
darkBorder = Color(0xFF404040),
|
||||||
|
lightChipBackground = Color(0x0D000000),
|
||||||
|
lightChipForeground = Color(0xFF525252),
|
||||||
|
darkChipBackground = Color(0x1AFFFFFF),
|
||||||
|
darkChipForeground = Color(0xFFD4D4D4),
|
||||||
|
lightTagInk = Color(0xFF404040),
|
||||||
|
darkTagInk = Color(0xFFD4D4D4),
|
||||||
|
),
|
||||||
|
"red" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Red",
|
||||||
|
lightBackground = Color(0xFFFEF2F2),
|
||||||
|
lightBorder = Color(0xFFFECACA),
|
||||||
|
darkBackground = Color(0x66450A0A),
|
||||||
|
darkBorder = Color(0xFF7F1D1D),
|
||||||
|
lightChipBackground = Color(0xFFFEE2E2),
|
||||||
|
lightChipForeground = Color(0xFFB91C1C),
|
||||||
|
darkChipBackground = Color(0x80450A0A),
|
||||||
|
darkChipForeground = Color(0xFFFCA5A5),
|
||||||
|
lightTagInk = Color(0xFF991B1B),
|
||||||
|
darkTagInk = Color(0xFFFCA5A5),
|
||||||
|
),
|
||||||
|
"orange" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Orange",
|
||||||
|
lightBackground = Color(0xFFFFF7ED),
|
||||||
|
lightBorder = Color(0xFFFED7AA),
|
||||||
|
darkBackground = Color(0x66431407),
|
||||||
|
darkBorder = Color(0xFF7C2D12),
|
||||||
|
lightChipBackground = Color(0xFFFFEDD5),
|
||||||
|
lightChipForeground = Color(0xFFC2410C),
|
||||||
|
darkChipBackground = Color(0x80431407),
|
||||||
|
darkChipForeground = Color(0xFFFDBA74),
|
||||||
|
lightTagInk = Color(0xFF9A3412),
|
||||||
|
darkTagInk = Color(0xFFFDBA74),
|
||||||
|
),
|
||||||
|
"yellow" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Yellow",
|
||||||
|
lightBackground = Color(0xFFFFFBEB),
|
||||||
|
lightBorder = Color(0xFFFDE68A),
|
||||||
|
darkBackground = Color(0x66451A03),
|
||||||
|
darkBorder = Color(0xFF78350F),
|
||||||
|
lightChipBackground = Color(0xFFFEF3C7),
|
||||||
|
lightChipForeground = Color(0xFF92400E),
|
||||||
|
darkChipBackground = Color(0x80451A03),
|
||||||
|
darkChipForeground = Color(0xFFFCD34D),
|
||||||
|
lightTagInk = Color(0xFF92400E),
|
||||||
|
darkTagInk = Color(0xFFFCD34D),
|
||||||
|
),
|
||||||
|
"green" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Green",
|
||||||
|
lightBackground = Color(0xFFF0FDF4),
|
||||||
|
lightBorder = Color(0xFFBBF7D0),
|
||||||
|
darkBackground = Color(0x66052E16),
|
||||||
|
darkBorder = Color(0xFF14532D),
|
||||||
|
lightChipBackground = Color(0xFFDCFCE7),
|
||||||
|
lightChipForeground = Color(0xFF15803D),
|
||||||
|
darkChipBackground = Color(0x80052E16),
|
||||||
|
darkChipForeground = Color(0xFF86EFAC),
|
||||||
|
lightTagInk = Color(0xFF166534),
|
||||||
|
darkTagInk = Color(0xFF86EFAC),
|
||||||
|
),
|
||||||
|
"teal" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Teal",
|
||||||
|
lightBackground = Color(0xFFF0FDFA),
|
||||||
|
lightBorder = Color(0xFF99F6E4),
|
||||||
|
darkBackground = Color(0x66042F2E),
|
||||||
|
darkBorder = Color(0xFF134E4A),
|
||||||
|
lightChipBackground = Color(0xFFCCFBF1),
|
||||||
|
lightChipForeground = Color(0xFF0F766E),
|
||||||
|
darkChipBackground = Color(0x80042F2E),
|
||||||
|
darkChipForeground = Color(0xFF5EEAD4),
|
||||||
|
lightTagInk = Color(0xFF115E59),
|
||||||
|
darkTagInk = Color(0xFF5EEAD4),
|
||||||
|
),
|
||||||
|
"blue" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Blue",
|
||||||
|
lightBackground = Color(0xFFEFF6FF),
|
||||||
|
lightBorder = Color(0xFFBFDBFE),
|
||||||
|
darkBackground = Color(0x66172554),
|
||||||
|
darkBorder = Color(0xFF1E3A8A),
|
||||||
|
lightChipBackground = Color(0xFFDBEAFE),
|
||||||
|
lightChipForeground = Color(0xFF1D4ED8),
|
||||||
|
darkChipBackground = Color(0x80172554),
|
||||||
|
darkChipForeground = Color(0xFF93C5FD),
|
||||||
|
lightTagInk = Color(0xFF1E40AF),
|
||||||
|
darkTagInk = Color(0xFF93C5FD),
|
||||||
|
),
|
||||||
|
"purple" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Purple",
|
||||||
|
lightBackground = Color(0xFFFAF5FF),
|
||||||
|
lightBorder = Color(0xFFE9D5FF),
|
||||||
|
darkBackground = Color(0x663B0764),
|
||||||
|
darkBorder = Color(0xFF581C87),
|
||||||
|
lightChipBackground = Color(0xFFF3E8FF),
|
||||||
|
lightChipForeground = Color(0xFF7E22CE),
|
||||||
|
darkChipBackground = Color(0x803B0764),
|
||||||
|
darkChipForeground = Color(0xFFD8B4FE),
|
||||||
|
lightTagInk = Color(0xFF6B21A8),
|
||||||
|
darkTagInk = Color(0xFFD8B4FE),
|
||||||
|
),
|
||||||
|
"pink" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Pink",
|
||||||
|
lightBackground = Color(0xFFFDF2F8),
|
||||||
|
lightBorder = Color(0xFFFBCFE8),
|
||||||
|
darkBackground = Color(0x66500724),
|
||||||
|
darkBorder = Color(0xFF831843),
|
||||||
|
lightChipBackground = Color(0xFFFCE7F3),
|
||||||
|
lightChipForeground = Color(0xFFBE185D),
|
||||||
|
darkChipBackground = Color(0x80500724),
|
||||||
|
darkChipForeground = Color(0xFFF9A8D4),
|
||||||
|
lightTagInk = Color(0xFF9D174D),
|
||||||
|
darkTagInk = Color(0xFFF9A8D4),
|
||||||
|
),
|
||||||
|
"gray" to
|
||||||
|
NoteTint(
|
||||||
|
label = "Gray",
|
||||||
|
lightBackground = Color(0xFFF5F5F5),
|
||||||
|
lightBorder = Color(0xFFD4D4D4),
|
||||||
|
darkBackground = Color(0xFF262626),
|
||||||
|
darkBorder = Color(0xFF404040),
|
||||||
|
lightChipBackground = Color(0xFFE5E5E5),
|
||||||
|
lightChipForeground = Color(0xFF404040),
|
||||||
|
darkChipBackground = Color(0xFF404040),
|
||||||
|
darkChipForeground = Color(0xFFE5E5E5),
|
||||||
|
lightTagInk = Color(0xFF262626),
|
||||||
|
darkTagInk = Color(0xFFE5E5E5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a stored colour key.
|
||||||
|
*
|
||||||
|
* An unknown key falls back to `default` rather than throwing: colours are data
|
||||||
|
* that arrives from a server which may be newer than this client, and a note
|
||||||
|
* whose tint we don't recognise should still be readable.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tint for a LABEL, derived from its name when nobody has picked one.
|
||||||
|
*
|
||||||
|
* Every `#tag` is born colourless, so without this a board of tags is a board of
|
||||||
|
* identical grey chips. See `DerivedTint.kt` for why this derives rather than
|
||||||
|
* persisting a colour when the tag is minted.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
@ReadOnlyComposable
|
||||||
|
fun labelTintFor(
|
||||||
|
name: String,
|
||||||
|
color: String,
|
||||||
|
): NoteTint = labelTint(name, color)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [labelTintFor] with no composable context, for a caller building its value inside
|
||||||
|
* `remember` — where a `@Composable` call is not allowed. The card's inline tag
|
||||||
|
* colours are computed there, once per body rather than once per recomposition.
|
||||||
|
*
|
||||||
|
* One implementation, two entry points: the composable one delegates here rather than
|
||||||
|
* repeating the lookup, so the chip and the inline token cannot resolve differently.
|
||||||
|
*/
|
||||||
|
fun labelTint(
|
||||||
|
name: String,
|
||||||
|
color: String,
|
||||||
|
): NoteTint = NOTE_TINTS[resolvedLabelColor(name, color, NOTE_TINTS.keys)] ?: NOTE_TINTS.getValue("default")
|
||||||
+65
-2
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -8,6 +9,8 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -17,7 +20,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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.
|
* A bordered block, tinted from the same table the notes use.
|
||||||
@@ -79,6 +82,66 @@ 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
|
||||||
|
* editor's overflow and the board's long-press menu — and they offer the same
|
||||||
|
* actions in the same words. A second copy of this would be a second place for the
|
||||||
|
* closing order to be got wrong.
|
||||||
|
*
|
||||||
|
* Closing FIRST is the whole point: an action that raises a sheet or a dialog would
|
||||||
|
* otherwise do it underneath a menu still hanging over the screen. Doing it in here
|
||||||
|
* means no call site can forget.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MenuItem(
|
||||||
|
@StringRes labelRes: Int,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(labelRes)) },
|
||||||
|
onClick = {
|
||||||
|
onClose()
|
||||||
|
onClick()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one confirmation in the app.
|
||||||
|
*
|
||||||
|
* Delete-forever is the only irreversible thing a note can be asked to do —
|
||||||
|
* archive, trash, even unlinking a server all undo — so it is the only one that
|
||||||
|
* interrupts. Both surfaces that offer it raise THIS dialog: the editor's overflow
|
||||||
|
* and the board's long-press menu are two ways to the same act, and two dialogs
|
||||||
|
* would be two chances to word the consequences differently.
|
||||||
|
*
|
||||||
|
* Nothing about a note is passed in. The caller already knows which note it is
|
||||||
|
* asking about and holds it while this is on screen; taking one here would only let
|
||||||
|
* the dialog and the action that follows it disagree.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConfirmDeleteDialog(
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.editor_delete_forever_title)) },
|
||||||
|
text = { Text(stringResource(R.string.editor_delete_forever_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.editor_delete_forever_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.editor_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The three tones a panel or notice can take, mapped onto the note palette. */
|
/** The three tones a panel or notice can take, mapped onto the note palette. */
|
||||||
enum class Tone { NEUTRAL, WARN, ERROR }
|
enum class Tone { NEUTRAL, WARN, ERROR }
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import android.app.AlarmManager
|
import android.app.AlarmManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -17,8 +17,8 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.thoughtsync.Reminders
|
import com.fabledsword.inkwell.Reminders
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Says so when a reminder would not actually reach anyone.
|
* Says so when a reminder would not actually reach anyone.
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Member
|
||||||
|
import com.fabledsword.inkwell.core.Note
|
||||||
|
import com.fabledsword.inkwell.core.NoteShare
|
||||||
|
import com.fabledsword.inkwell.core.ShareGroup
|
||||||
|
import com.fabledsword.inkwell.core.ShareTarget
|
||||||
|
|
||||||
|
/** "Shared by Robin · view only" over someone else's note; nothing over our own. */
|
||||||
|
@Composable
|
||||||
|
fun SharedByLine(note: Note) {
|
||||||
|
if (note.access == NoteAccess.OWNER) return
|
||||||
|
val who = note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
|
||||||
|
val line =
|
||||||
|
if (note.access == NoteAccess.EDIT) {
|
||||||
|
stringResource(R.string.share_by_can_edit, who)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.share_by_view_only, who)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val VIEW = "view"
|
||||||
|
private const val EDIT = "edit"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Share sheet: who the note is shared with, and adding someone or a group.
|
||||||
|
*
|
||||||
|
* A peer of the web's `ShareDialog.vue`. Each share's permission is a pair of chips
|
||||||
|
* rather than a menu, because there are exactly two and both fit.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ShareSheet(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||||
|
onUnshare: (shareId: String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
SheetTitle(R.string.share_title)
|
||||||
|
when {
|
||||||
|
state.loading -> Muted(stringResource(R.string.share_loading))
|
||||||
|
state.needsServer -> Muted(stringResource(R.string.share_needs_server))
|
||||||
|
state.loadError != null -> ErrorText(state.loadError)
|
||||||
|
else -> ShareBody(state, onShare, onUnshare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareBody(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||||
|
onUnshare: (shareId: String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (state.shares.isEmpty()) {
|
||||||
|
Muted(stringResource(R.string.share_none))
|
||||||
|
}
|
||||||
|
state.shares.forEach { share ->
|
||||||
|
ShareRow(
|
||||||
|
share = share,
|
||||||
|
busy = state.busy,
|
||||||
|
onPermission = { onShare(share.target, it) },
|
||||||
|
onRemove = { onUnshare(share.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.available.isEmpty()) {
|
||||||
|
Muted(stringResource(R.string.share_everyone))
|
||||||
|
} else {
|
||||||
|
AddPerson(state = state, onShare = onShare)
|
||||||
|
}
|
||||||
|
state.error?.let { ErrorText(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareRow(
|
||||||
|
share: NoteShare,
|
||||||
|
busy: Boolean,
|
||||||
|
onPermission: (String) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TargetName(share.member, share.group, Modifier.weight(1f))
|
||||||
|
IconButton(onClick = onRemove, enabled = !busy) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionChips(selected = share.permission, enabled = !busy, onSelect = onPermission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddPerson(
|
||||||
|
state: ShareState,
|
||||||
|
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||||
|
) {
|
||||||
|
var pick by remember(state.noteId) { mutableStateOf<ShareCandidate?>(null) }
|
||||||
|
var permission by remember(state.noteId) { mutableStateOf(VIEW) }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.share_add),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
// Capped, like the tag picker: a sheet that grows past the screen makes its own
|
||||||
|
// scroll fight the sheet's drag.
|
||||||
|
LazyColumn(modifier = Modifier.heightIn(max = MEMBER_LIST_MAX_HEIGHT)) {
|
||||||
|
items(items = state.available, key = { it.key }) { candidate ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { pick = candidate },
|
||||||
|
) {
|
||||||
|
RadioButton(selected = pick?.key == candidate.key, onClick = { pick = candidate })
|
||||||
|
TargetName(candidate.member, candidate.group, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PermissionChips(selected = permission, enabled = !state.busy, onSelect = { permission = it })
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
enabled = pick != null && !state.busy,
|
||||||
|
onClick = {
|
||||||
|
pick?.let { onShare(it.target, permission) }
|
||||||
|
pick = null
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.share_action)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionChips(
|
||||||
|
selected: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == VIEW,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { onSelect(VIEW) },
|
||||||
|
label = { Text(stringResource(R.string.share_can_view)) },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selected == EDIT,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { onSelect(EDIT) },
|
||||||
|
label = { Text(stringResource(R.string.share_can_edit)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A person (name over email) or a group (name over how many are in it). */
|
||||||
|
@Composable
|
||||||
|
private fun TargetName(
|
||||||
|
member: Member?,
|
||||||
|
group: ShareGroup?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val title = group?.name ?: member?.let { it.displayName.ifBlank { it.email } }.orEmpty()
|
||||||
|
val detail =
|
||||||
|
when {
|
||||||
|
group != null ->
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.share_group_members,
|
||||||
|
group.memberCount.toInt(),
|
||||||
|
group.memberCount.toInt(),
|
||||||
|
)
|
||||||
|
member != null && member.displayName.isNotBlank() -> member.email
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
detail?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Muted(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ErrorText(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MEMBER_LIST_MAX_HEIGHT = 240.dp
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.inkwell.core.CoreException
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.core.Member
|
||||||
|
import com.fabledsword.inkwell.core.NoteShare
|
||||||
|
import com.fabledsword.inkwell.core.ShareGroup
|
||||||
|
import com.fabledsword.inkwell.core.ShareTarget
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Everything the Share sheet renders from. [noteId] null means the sheet is closed. */
|
||||||
|
data class ShareState(
|
||||||
|
val noteId: String? = null,
|
||||||
|
val loading: Boolean = false,
|
||||||
|
val members: List<Member> = emptyList(),
|
||||||
|
/** The groups the admin made (#5177); a note shared with one reaches its members. */
|
||||||
|
val groups: List<ShareGroup> = emptyList(),
|
||||||
|
val shares: List<NoteShare> = emptyList(),
|
||||||
|
/** This device has no server, and sharing is between people on one. */
|
||||||
|
val needsServer: Boolean = false,
|
||||||
|
/** Why the sheet couldn't load, when it has a server and still failed. */
|
||||||
|
val loadError: String? = null,
|
||||||
|
/** An in-flight write, for disabling the controls that would race it. */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
/** The people and groups it isn't shared with yet, people first. */
|
||||||
|
val available: List<ShareCandidate>
|
||||||
|
get() {
|
||||||
|
val people = shares.mapNotNull { it.member?.id }.toSet()
|
||||||
|
val grouped = shares.mapNotNull { it.group?.id }.toSet()
|
||||||
|
return members.filterNot { it.id in people }.map { ShareCandidate(member = it) } +
|
||||||
|
groups.filterNot { it.id in grouped }.map { ShareCandidate(group = it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Someone, or a group, the sheet offers to share with. Exactly one is set. */
|
||||||
|
data class ShareCandidate(
|
||||||
|
val member: Member? = null,
|
||||||
|
val group: ShareGroup? = null,
|
||||||
|
) {
|
||||||
|
val key: String
|
||||||
|
get() = group?.let { "g:${it.id}" } ?: "u:${member?.id}"
|
||||||
|
val target: ShareTarget
|
||||||
|
get() = targetOf(member, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who an existing share goes to, to change its permission. */
|
||||||
|
val NoteShare.target: ShareTarget
|
||||||
|
get() = targetOf(member, group)
|
||||||
|
|
||||||
|
private fun targetOf(
|
||||||
|
member: Member?,
|
||||||
|
group: ShareGroup?,
|
||||||
|
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sharing a note with other people on the linked server (#5175).
|
||||||
|
*
|
||||||
|
* A peer of the web's `ShareDialog.vue`. Shares belong to the server, so every call
|
||||||
|
* here is a network round-trip through the core — `suspend` functions on uniffi's
|
||||||
|
* tokio runtime, not blocking store calls, so no IO dispatcher is needed. The one
|
||||||
|
* thing the core keeps locally is the note's `shared` flag, which is why a write
|
||||||
|
* that landed tells the board to reload: its card chip reads that flag.
|
||||||
|
*/
|
||||||
|
class ShareViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/** Called after a share was granted, changed or removed. */
|
||||||
|
private val onStoreChanged: () -> Unit,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(ShareState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun open(noteId: String) {
|
||||||
|
state = ShareState(noteId = noteId, loading = true)
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Unlinked is not a failure: it is the phone working as intended, so the
|
||||||
|
// sheet says what sharing needs rather than showing an error.
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
if (!withContext(Dispatchers.IO) { core.syncStatus().linked }) {
|
||||||
|
state.copy(loading = false, needsServer = true)
|
||||||
|
} else {
|
||||||
|
val directory = core.shareDirectory()
|
||||||
|
val shares = core.noteShares(noteId)
|
||||||
|
state.copy(
|
||||||
|
loading = false,
|
||||||
|
members = directory.members,
|
||||||
|
groups = directory.groups,
|
||||||
|
shares = shares,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: CoreException) {
|
||||||
|
state.copy(loading = false, loadError = e.describeShareFailure())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
state = ShareState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share with someone or a group, or change what they may do. */
|
||||||
|
fun share(
|
||||||
|
target: ShareTarget,
|
||||||
|
permission: String,
|
||||||
|
) = write { noteId -> core.shareNote(noteId, target, permission) }
|
||||||
|
|
||||||
|
fun unshare(shareId: String) = write { noteId -> core.unshareNote(noteId, shareId) }
|
||||||
|
|
||||||
|
private fun write(call: suspend (String) -> List<NoteShare>) {
|
||||||
|
val noteId = state.noteId ?: return
|
||||||
|
state = state.copy(busy = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val shares = call(noteId)
|
||||||
|
onStoreChanged()
|
||||||
|
state.copy(busy = false, shares = shares)
|
||||||
|
} catch (e: CoreException) {
|
||||||
|
state.copy(busy = false, error = e.describeShareFailure())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
onStoreChanged: () -> Unit,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = ShareViewModel(core, onStoreChanged) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The core's message is written to be shown ("The server doesn't have this note yet…"). */
|
||||||
|
private fun Throwable.describeShareFailure(): String = message ?: "Something went wrong."
|
||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.thoughtsync.core.Compatibility
|
import com.fabledsword.inkwell.core.Compatibility
|
||||||
import com.fabledsword.thoughtsync.core.RevokeOutcome
|
import com.fabledsword.inkwell.core.RevokeOutcome
|
||||||
|
|
||||||
// Becoming linked: the probe-then-sign-in flow, and the notices around it.
|
// Becoming linked: the probe-then-sign-in flow, and the notices around it.
|
||||||
//
|
//
|
||||||
+34
-4
@@ -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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -31,18 +31,20 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
import com.fabledsword.inkwell.UpdateOutcome
|
||||||
|
import com.fabledsword.inkwell.installedVersionName
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opt-in server pairing.
|
* Opt-in server pairing.
|
||||||
*
|
*
|
||||||
* The whole screen is written around one idea: **being unlinked is not a
|
* 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"
|
* 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
|
* and explains what connecting would ADD, rather than presenting an empty form as
|
||||||
* unfinished setup.
|
* unfinished setup.
|
||||||
@@ -120,10 +122,38 @@ fun SyncScreen(
|
|||||||
onDismissRevokeNotice = onDismissRevokeNotice,
|
onDismissRevokeNotice = onDismissRevokeNotice,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BuildLine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The build, dim, at the foot of Sync — the same thing the web UI puts at the
|
||||||
|
* bottom of its rail (#3181).
|
||||||
|
*
|
||||||
|
* Note 3127 §5 is why it is here at all. With version tags gone, an artifact's own
|
||||||
|
* self-report is the only answer to "which build is this?" — so it renders
|
||||||
|
* "unknown" rather than nothing when the name is absent, because a blank line looks
|
||||||
|
* like a layout bug and a plausible default cannot be caught by anything.
|
||||||
|
*
|
||||||
|
* The read itself is `installedVersionName()`, shared with the client header the
|
||||||
|
* app sends its server: one answer to "which build is on this phone", so the line
|
||||||
|
* a person quotes in a bug report and the line in the server's log cannot disagree.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BuildLine() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val unknown = stringResource(R.string.build_unknown)
|
||||||
|
val version = remember(context) { context.installedVersionName() ?: unknown }
|
||||||
|
Text(
|
||||||
|
text = version,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────────── linked ─────────────────────────────────
|
// ───────────────────────────────── linked ─────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
+13
-9
@@ -1,11 +1,11 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.thoughtsync.core.Compatibility
|
import com.fabledsword.inkwell.core.Compatibility
|
||||||
import com.fabledsword.thoughtsync.core.SyncOutcome
|
import com.fabledsword.inkwell.core.SyncOutcome
|
||||||
|
|
||||||
// Turning sync results into sentences.
|
// 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 (sent > 0) parts += stringResource(R.string.sync_summary_sent, sent)
|
||||||
if (received > 0) parts += stringResource(R.string.sync_summary_received, received)
|
if (received > 0) parts += stringResource(R.string.sync_summary_received, received)
|
||||||
if (blobs > 0) parts += pluralStringResource(R.plurals.sync_summary_attachments, blobs, blobs)
|
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 =
|
val line =
|
||||||
if (parts.isEmpty()) {
|
if (parts.isEmpty()) {
|
||||||
@@ -44,11 +46,13 @@ fun syncSummary(outcome: SyncOutcome): String {
|
|||||||
// rather than an error — but saying nothing would leave a missing image
|
// rather than an error — but saying nothing would leave a missing image
|
||||||
// looking like data loss.
|
// looking like data loss.
|
||||||
val failed = outcome.pull.blobsFailed.toInt()
|
val failed = outcome.pull.blobsFailed.toInt()
|
||||||
return if (failed > 0) {
|
val notUploaded = outcome.push.uploadFailed.toInt()
|
||||||
line + " " + pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
|
val notes = mutableListOf(line)
|
||||||
} else {
|
if (failed > 0) notes += pluralStringResource(R.plurals.sync_summary_attachments_failed, failed, failed)
|
||||||
line
|
// 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(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
+10
-10
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -6,12 +6,12 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.thoughtsync.core.Compatibility
|
import com.fabledsword.inkwell.core.Compatibility
|
||||||
import com.fabledsword.thoughtsync.core.ProbeResult
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
import com.fabledsword.thoughtsync.core.RevokeOutcome
|
import com.fabledsword.inkwell.core.ProbeResult
|
||||||
import com.fabledsword.thoughtsync.core.SyncOutcome
|
import com.fabledsword.inkwell.core.RevokeOutcome
|
||||||
import com.fabledsword.thoughtsync.core.SyncStatus
|
import com.fabledsword.inkwell.core.SyncOutcome
|
||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
import com.fabledsword.inkwell.core.SyncStatus
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -113,7 +113,7 @@ data class SyncState(
|
|||||||
* stored cursor next time (Scribe #2736).
|
* stored cursor next time (Scribe #2736).
|
||||||
*/
|
*/
|
||||||
class SyncViewModel(
|
class SyncViewModel(
|
||||||
private val core: ThoughtSync,
|
private val core: Inkwell,
|
||||||
/**
|
/**
|
||||||
* Called after a sync that changed the store.
|
* Called after a sync that changed the store.
|
||||||
*
|
*
|
||||||
@@ -316,7 +316,7 @@ class SyncViewModel(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun factory(
|
fun factory(
|
||||||
core: ThoughtSync,
|
core: Inkwell,
|
||||||
onStoreChanged: () -> Unit,
|
onStoreChanged: () -> Unit,
|
||||||
): ViewModelProvider.Factory =
|
): ViewModelProvider.Factory =
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
@@ -344,7 +344,7 @@ private fun SyncOutcome.changedTheStore(): Boolean =
|
|||||||
* The message to show for a failure.
|
* The message to show for a failure.
|
||||||
*
|
*
|
||||||
* The core writes these for people to read — "notes.example.com responded, but not
|
* 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
|
* with Inkwell's configuration" — so they are shown as-is rather than
|
||||||
* replaced with a generic string that would throw away the only useful part.
|
* replaced with a generic string that would throw away the only useful part.
|
||||||
*/
|
*/
|
||||||
private fun Exception.describe(): String = message ?: "Something went wrong."
|
private fun Exception.describe(): String = message ?: "Something went wrong."
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.fabledsword.inkwell.R
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
|
||||||
|
/** The swatch shown beside a tag, and tapped to change its colour. */
|
||||||
|
private val SWATCH = 22.dp
|
||||||
|
|
||||||
|
/** What the row's overflow menu is currently asking about. */
|
||||||
|
private sealed interface TagDialog {
|
||||||
|
data class Rename(
|
||||||
|
val tag: Label,
|
||||||
|
) : TagDialog
|
||||||
|
|
||||||
|
/** A rename whose new name another tag already holds — see [RenameDialog]. */
|
||||||
|
data class ConfirmMerge(
|
||||||
|
val tag: Label,
|
||||||
|
val into: Label,
|
||||||
|
val name: String,
|
||||||
|
) : TagDialog
|
||||||
|
|
||||||
|
data class Merge(
|
||||||
|
val tag: Label,
|
||||||
|
) : TagDialog
|
||||||
|
|
||||||
|
data class Delete(
|
||||||
|
val tag: Label,
|
||||||
|
) : TagDialog
|
||||||
|
|
||||||
|
data class Colour(
|
||||||
|
val tag: Label,
|
||||||
|
) : TagDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag management: list, create, rename, recolour, delete, merge.
|
||||||
|
*
|
||||||
|
* A destination you go to, not a modal. The web's `LabelsModal.vue` is a modal
|
||||||
|
* because a desktop has room to float one over the board; on a phone this is a
|
||||||
|
* place you visit to tidy up, and a full screen is what that is.
|
||||||
|
*
|
||||||
|
* It is also, since the per-note colour picker was removed, the ONLY colour
|
||||||
|
* control in the product. That is why the swatch is a first-class tap target on
|
||||||
|
* every row rather than something behind the overflow menu.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun TagsScreen(
|
||||||
|
state: TagsState,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onCreate: (String) -> Unit,
|
||||||
|
onRename: (String, String) -> Unit,
|
||||||
|
onColour: (String, String) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
onMerge: (String, String) -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
) {
|
||||||
|
var dialog by remember { mutableStateOf<TagDialog?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.tags_title)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.tags_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
// An indeterminate bar rather than blocking the list: a tag write is a
|
||||||
|
// local SQLite call and usually finishes before this is seen at all.
|
||||||
|
if (state.busy) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same banner the board and the editor use. A third way of saying
|
||||||
|
// "that did not work" would be a third thing to keep consistent.
|
||||||
|
state.error?.let { message ->
|
||||||
|
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||||
|
}
|
||||||
|
|
||||||
|
NewTagField(
|
||||||
|
enabled = !state.busy,
|
||||||
|
onCreate = onCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!state.loading && state.tags.isEmpty()) {
|
||||||
|
EmptyTags()
|
||||||
|
}
|
||||||
|
|
||||||
|
// weight, NOT fillMaxSize: this has siblings above it, and filling the
|
||||||
|
// whole height would measure the list against space the field and the
|
||||||
|
// banner have already taken — pushing the end of the list off-screen.
|
||||||
|
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||||
|
items(state.tags, key = { it.id }) { tag ->
|
||||||
|
TagRow(
|
||||||
|
tag = tag,
|
||||||
|
enabled = !state.busy,
|
||||||
|
onColour = { dialog = TagDialog.Colour(tag) },
|
||||||
|
onRename = { dialog = TagDialog.Rename(tag) },
|
||||||
|
onMerge = { dialog = TagDialog.Merge(tag) },
|
||||||
|
onDelete = { dialog = TagDialog.Delete(tag) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val open = dialog) {
|
||||||
|
null -> Unit
|
||||||
|
|
||||||
|
is TagDialog.Rename ->
|
||||||
|
RenameDialog(
|
||||||
|
tag = open.tag,
|
||||||
|
others = state.tags,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onRename = { name ->
|
||||||
|
dialog = null
|
||||||
|
onRename(open.tag.id, name)
|
||||||
|
},
|
||||||
|
// Renaming onto a name another tag holds MERGES the two, and that
|
||||||
|
// cannot be undone by repeating it, so the confirmation replaces
|
||||||
|
// this dialog rather than the rename just happening.
|
||||||
|
onWouldMerge = { into, name -> dialog = TagDialog.ConfirmMerge(open.tag, into, name) },
|
||||||
|
)
|
||||||
|
|
||||||
|
is TagDialog.ConfirmMerge ->
|
||||||
|
ConfirmDialog(
|
||||||
|
title = stringResource(R.string.tags_rename_merges_title, hash(open.into.name)),
|
||||||
|
body = stringResource(R.string.tags_rename_merges_body, hash(open.into.name)),
|
||||||
|
confirm = stringResource(R.string.tags_rename_merges_confirm),
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onConfirm = {
|
||||||
|
dialog = null
|
||||||
|
onRename(open.tag.id, open.name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
is TagDialog.Merge ->
|
||||||
|
MergeDialog(
|
||||||
|
tag = open.tag,
|
||||||
|
others = state.tags.filter { it.id != open.tag.id },
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onMerge = { target ->
|
||||||
|
dialog = null
|
||||||
|
onMerge(open.tag.id, target.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
is TagDialog.Delete ->
|
||||||
|
ConfirmDialog(
|
||||||
|
title = stringResource(R.string.tags_delete_title, hash(open.tag.name)),
|
||||||
|
// The count is the part that makes the consequence real — "it is on
|
||||||
|
// 40 notes" is a different decision from "delete this tag?". It comes
|
||||||
|
// from the LIST, the only call the core populates a count on.
|
||||||
|
body =
|
||||||
|
open.tag.count
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { stringResource(R.string.tags_delete_body_counted, it) }
|
||||||
|
?: stringResource(R.string.tags_delete_body),
|
||||||
|
footnote = stringResource(R.string.tags_delete_from_text),
|
||||||
|
confirm = stringResource(R.string.tags_delete_confirm),
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onConfirm = {
|
||||||
|
dialog = null
|
||||||
|
onDelete(open.tag.id)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
is TagDialog.Colour ->
|
||||||
|
ColourDialog(
|
||||||
|
tag = open.tag,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
onPick = { key ->
|
||||||
|
dialog = null
|
||||||
|
onColour(open.tag.id, key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `#` on the name, everywhere it is spoken about.
|
||||||
|
*
|
||||||
|
* The chips already wear it (`NoteCard.kt`, `EditorChrome.kt`) and it is the
|
||||||
|
* reason these are called tags at all — a dialog that said "Delete grocery?" would
|
||||||
|
* be talking about something else.
|
||||||
|
*/
|
||||||
|
private fun hash(name: String): String = "#$name"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NewTagField(
|
||||||
|
enabled: Boolean,
|
||||||
|
onCreate: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
val submit = {
|
||||||
|
if (text.isNotBlank()) {
|
||||||
|
onCreate(text)
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
PlainTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
hint = R.string.tags_new_hint,
|
||||||
|
enabled = enabled,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||||
|
)
|
||||||
|
TextButton(onClick = submit, enabled = enabled && text.isNotBlank()) {
|
||||||
|
Text(stringResource(R.string.tags_create))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Said out loud rather than left as a blank screen — and it names the `#` route,
|
||||||
|
* because the operator did not know `#tag` extraction existed at all (Scribe
|
||||||
|
* #2949) and this is the natural place to say so.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun EmptyTags() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tags_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tags_empty_body),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TagRow(
|
||||||
|
tag: Label,
|
||||||
|
enabled: Boolean,
|
||||||
|
onColour: () -> Unit,
|
||||||
|
onRename: () -> Unit,
|
||||||
|
onMerge: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = labelTintFor(tag.name, tag.color)
|
||||||
|
var menuOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(SWATCH)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.border(1.dp, tint.chipBorder(dark), CircleShape)
|
||||||
|
.clickable(enabled = enabled, onClick = onColour),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = hash(tag.name),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = tint.tagInk(dark),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = countLabel(tag.count),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
IconButton(onClick = { menuOpen = true }, enabled = enabled) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.tags_actions),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||||
|
// Panel.kt's MenuItem, not a bare DropdownMenuItem: every one of
|
||||||
|
// these raises a dialog, and it closes the menu BEFORE acting so the
|
||||||
|
// dialog cannot open underneath a menu still hanging over it.
|
||||||
|
val close = { menuOpen = false }
|
||||||
|
MenuItem(R.string.tags_rename, close, onRename)
|
||||||
|
MenuItem(R.string.tags_merge, close, onMerge)
|
||||||
|
MenuItem(R.string.tags_delete, close, onDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zero is its own sentence, not "0 notes".
|
||||||
|
*
|
||||||
|
* A count of null means the core did not populate one — only `list_labels` does —
|
||||||
|
* which is a different thing from a tag with no notes, so it reads as unknown
|
||||||
|
* rather than as empty.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun countLabel(count: Long?): String =
|
||||||
|
when {
|
||||||
|
count == null -> ""
|
||||||
|
count <= 0L -> stringResource(R.string.tags_count_none)
|
||||||
|
count == 1L -> stringResource(R.string.tags_count_one)
|
||||||
|
else -> stringResource(R.string.tags_count, count.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename, with the merge caught before it happens.
|
||||||
|
*
|
||||||
|
* The collision is detected HERE, against the list, rather than from what the
|
||||||
|
* core returns: the merge survivor is whichever tag is older, so it may well be
|
||||||
|
* the one being renamed, and an unchanged id afterwards would prove nothing.
|
||||||
|
* Matching is case-insensitive because the core's is.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RenameDialog(
|
||||||
|
tag: Label,
|
||||||
|
others: List<Label>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onRename: (String) -> Unit,
|
||||||
|
onWouldMerge: (Label, String) -> Unit,
|
||||||
|
) {
|
||||||
|
var text by remember(tag.id) { mutableStateOf(tag.name) }
|
||||||
|
val trimmed = text.trim()
|
||||||
|
val clash =
|
||||||
|
others.firstOrNull { it.id != tag.id && it.name.equals(trimmed, ignoreCase = true) }
|
||||||
|
val submit = {
|
||||||
|
when {
|
||||||
|
trimmed.isEmpty() -> Unit
|
||||||
|
clash != null -> onWouldMerge(clash, trimmed)
|
||||||
|
else -> onRename(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.tags_rename_title, hash(tag.name))) },
|
||||||
|
text = {
|
||||||
|
PlainTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
hint = R.string.tags_new_hint,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = submit, enabled = trimmed.isNotEmpty()) {
|
||||||
|
Text(stringResource(R.string.tags_rename_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge, with the direction stated and the survivor named.
|
||||||
|
*
|
||||||
|
* Unlike a rename — where the OLDER tag survives so that the outcome cannot
|
||||||
|
* depend on which way round it was typed — this one is deliberate, so the
|
||||||
|
* direction the person chooses IS the intent and is honoured. The price of that
|
||||||
|
* is that the direction has to be unmissable, which is why the body names the tag
|
||||||
|
* that stops existing and every row here is the one that survives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MergeDialog(
|
||||||
|
tag: Label,
|
||||||
|
others: List<Label>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onMerge: (Label) -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.tags_merge_title, hash(tag.name))) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tags_merge_body, hash(tag.name)),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
if (others.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.tags_merge_none),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
others.forEach { other ->
|
||||||
|
val tint = labelTintFor(other.name, other.color)
|
||||||
|
Text(
|
||||||
|
text = hash(other.name),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = tint.tagInk(dark),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onMerge(other) }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The palette, and since the per-note picker was removed this is the only place in
|
||||||
|
* the product a colour is chosen.
|
||||||
|
*
|
||||||
|
* Every key from [NOTE_TINTS], `default` included: a tag whose colour is
|
||||||
|
* `default` gets a hue derived from its name (`DerivedTint.kt`), so "default" here
|
||||||
|
* means "let it pick" rather than "grey", and taking it away would leave no way
|
||||||
|
* back to that.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ColourDialog(
|
||||||
|
tag: Label,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onPick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.tags_colour_of, hash(tag.name))) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
NOTE_TINTS.forEach { (key, tint) ->
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onPick(key) }
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(SWATCH)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(tint.chipBackground(dark))
|
||||||
|
.border(1.dp, tint.chipBorder(dark), CircleShape),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = tint.label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color =
|
||||||
|
if (key == tag.color) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A destructive confirmation: what it is, what it costs, and one way out. */
|
||||||
|
@Composable
|
||||||
|
private fun ConfirmDialog(
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
confirm: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
footnote: String? = null,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(text = body, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
footnote?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) { Text(confirm) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.tags_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import com.fabledsword.inkwell.core.Label
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the Tags screen renders from.
|
||||||
|
*
|
||||||
|
* [tags] comes from `list_labels`, which is the only call that populates a
|
||||||
|
* `count` — the single-tag returns leave it null by design. So the counts a
|
||||||
|
* confirmation dialog quotes are always the LIST's, never an operation's result.
|
||||||
|
*/
|
||||||
|
data class TagsState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val tags: List<Label> = emptyList(),
|
||||||
|
/** An in-flight write, for disabling the controls that would race it. */
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create, rename, recolour, delete and merge tags.
|
||||||
|
*
|
||||||
|
* A peer of the web's `LabelsModal.vue`, not a reduced companion — the same six
|
||||||
|
* operations over the same core the desktop uses.
|
||||||
|
*
|
||||||
|
* ## Why every write re-lists
|
||||||
|
*
|
||||||
|
* A tag operation changes more than the row it names. A merge deletes one tag and
|
||||||
|
* moves its notes; a delete changes nothing else's count but removes a drawer
|
||||||
|
* lens; a rename can MERGE (see below) and so can make a different row vanish.
|
||||||
|
* Re-listing after each write costs one cheap local SQLite read and removes a
|
||||||
|
* whole class of "the screen thinks there are still two" bugs. Patching the list
|
||||||
|
* in place would mean re-deriving, in Kotlin, rules the core already owns.
|
||||||
|
*
|
||||||
|
* ## Renaming can merge
|
||||||
|
*
|
||||||
|
* `rename_label` folds two tags together when the new name is one another tag
|
||||||
|
* already holds, and the OLDER row survives (Scribe #3324). So it can return a
|
||||||
|
* tag whose id is not the one passed in, and it can make another tag stop
|
||||||
|
* existing. The screen asks first; this view model does not, because a
|
||||||
|
* confirmation belongs to the surface with a person in front of it.
|
||||||
|
*
|
||||||
|
* ## Threading
|
||||||
|
*
|
||||||
|
* All of these are ordinary blocking FFI into SQLite — no async, no network — so
|
||||||
|
* they take [Dispatchers.IO], exactly like the board's calls. Sync happens later:
|
||||||
|
* the core marks the rows dirty and the next sync carries them.
|
||||||
|
*/
|
||||||
|
class TagsViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/**
|
||||||
|
* Called after any write that landed.
|
||||||
|
*
|
||||||
|
* The board holds its own snapshot of the tag list for the drawer, and its
|
||||||
|
* current destination may BE one of these tags — deleting or merging that one
|
||||||
|
* leaves it looking at a lens that no longer exists. Wiring the two together
|
||||||
|
* explicitly is less magic than a shared event bus and makes the dependency
|
||||||
|
* visible at the construction site, the same way [SyncViewModel] does it.
|
||||||
|
*/
|
||||||
|
private val onStoreChanged: () -> Unit,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(TagsState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||||
|
.fold(
|
||||||
|
onSuccess = { state.copy(tags = it, loading = false, error = null) },
|
||||||
|
// Unlike the drawer, this screen cannot fail quietly: it is
|
||||||
|
// the only thing on the display, and an empty list here
|
||||||
|
// would read as "you have no tags" rather than "I couldn't
|
||||||
|
// look".
|
||||||
|
onFailure = { state.copy(loading = false, error = it.describeTagFailure()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(name: String) {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
// Find-or-create in the core: typing a name that exists in another case
|
||||||
|
// attaches the existing tag rather than minting a near-duplicate.
|
||||||
|
write { it.createLabel(trimmed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rename(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
) {
|
||||||
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
write { it.renameLabel(id, trimmed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setColour(
|
||||||
|
id: String,
|
||||||
|
colour: String,
|
||||||
|
) = write { it.setLabelColor(id, colour) }
|
||||||
|
|
||||||
|
fun remove(id: String) = write { it.removeLabel(id) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold [sourceId] into [targetId]. The source stops existing.
|
||||||
|
*
|
||||||
|
* Directional and not undone by repeating it — the caller has to have said
|
||||||
|
* which one survives before this runs, because afterwards there is nothing
|
||||||
|
* left to read the direction from.
|
||||||
|
*/
|
||||||
|
fun merge(
|
||||||
|
sourceId: String,
|
||||||
|
targetId: String,
|
||||||
|
) {
|
||||||
|
if (sourceId == targetId) return
|
||||||
|
write { it.mergeLabels(sourceId, targetId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
state = state.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one store write, then re-list and tell the board.
|
||||||
|
*
|
||||||
|
* `busy` is cleared in the same assignment that stores the result, so no path
|
||||||
|
* out of here can leave the screen stuck with its controls disabled.
|
||||||
|
*/
|
||||||
|
private fun write(block: (Inkwell) -> Unit) {
|
||||||
|
if (state.busy) return
|
||||||
|
state = state.copy(busy = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
val failure =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { block(core) } }
|
||||||
|
.exceptionOrNull()
|
||||||
|
val tags =
|
||||||
|
runCatching { withContext(Dispatchers.IO) { core.listLabels() } }
|
||||||
|
.getOrDefault(state.tags)
|
||||||
|
state =
|
||||||
|
state.copy(
|
||||||
|
tags = tags,
|
||||||
|
busy = false,
|
||||||
|
error = failure?.describeTagFailure(),
|
||||||
|
)
|
||||||
|
// Even a FAILED write can have changed the store — a merge that threw
|
||||||
|
// partway still moved rows — so the board is told either way.
|
||||||
|
onStoreChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
onStoreChanged: () -> Unit,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T = TagsViewModel(core, onStoreChanged) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core reports problems as one error type carrying a message meant to be
|
||||||
|
* shown, so the message is used when there is one.
|
||||||
|
*/
|
||||||
|
private fun Throwable.describeTagFailure(): String = message ?: "Something went wrong."
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -59,7 +59,7 @@ 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
|
* 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
|
* saw the first build. Dynamic colour is the more Android-native choice and it
|
||||||
@@ -72,7 +72,7 @@ private val DarkColors =
|
|||||||
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
|
* If dynamic colour is ever wanted it belongs behind a setting, not as the default.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ThoughtSyncTheme(
|
fun InkwellTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
content: @Composable () -> Unit,
|
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.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
+66
-4
@@ -1,24 +1,33 @@
|
|||||||
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.fabledsword.thoughtsync.AppUpdate
|
import com.fabledsword.inkwell.AppUpdate
|
||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.inkwell.R
|
||||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
import com.fabledsword.inkwell.UpdateOutcome
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updating the app from the server it is linked to.
|
* Updating the app from the server it is linked to.
|
||||||
@@ -113,6 +122,59 @@ 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.
|
||||||
|
*
|
||||||
|
* Only ever shown once the build is DOWNLOADED, so the offer is a single tap rather
|
||||||
|
* than the start of a wait — and so nothing is said at all until the app has been on
|
||||||
|
* wifi, which is where the fetch happens.
|
||||||
|
*
|
||||||
|
* Dismissible, but not permanently. "Later" clears it for this sitting; the next time
|
||||||
|
* the app comes forward it says so again. That is the difference between a reminder
|
||||||
|
* and a notice you can lose.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateBanner(
|
||||||
|
version: String,
|
||||||
|
busy: Boolean,
|
||||||
|
onInstall: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val tint = noteTint("blue")
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(BANNER_RADIUS))
|
||||||
|
.background(tint.background(dark))
|
||||||
|
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
|
||||||
|
.padding(start = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.update_banner_ready, version),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (busy) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(BANNER_SPINNER), strokeWidth = 2.dp)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.update_later)) }
|
||||||
|
TextButton(onClick = onInstall) { Text(stringResource(R.string.update_install)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val BANNER_RADIUS = 12.dp
|
||||||
|
private val BANNER_SPINNER = 18.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one line an unlinked device gets.
|
* The one line an unlinked device gets.
|
||||||
*
|
*
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.inkwell.AppUpdate
|
||||||
|
import com.fabledsword.inkwell.UpdateOutcome
|
||||||
|
import com.fabledsword.inkwell.core.ClientUpdate
|
||||||
|
import com.fabledsword.inkwell.core.Inkwell
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** Everything the update card renders from. */
|
||||||
|
data class UpdateState(
|
||||||
|
/** What is running now. Shown even when there is nothing to update to. */
|
||||||
|
val installedVersion: Long = 0,
|
||||||
|
val checking: Boolean = false,
|
||||||
|
/** Only ever set to something NEWER — the core does that comparison. */
|
||||||
|
val available: ClientUpdate? = null,
|
||||||
|
/** A check completed and found nothing. Distinct from "not checked yet". */
|
||||||
|
val upToDate: Boolean = false,
|
||||||
|
/** The available build has been fetched and is sitting in the cache. */
|
||||||
|
val ready: Boolean = false,
|
||||||
|
val downloading: Boolean = false,
|
||||||
|
val working: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
/** The banner has been waved away — until the app next comes forward. */
|
||||||
|
val nagDismissed: Boolean = false,
|
||||||
|
) {
|
||||||
|
val busy: Boolean get() = checking || downloading || working
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worth interrupting the board for.
|
||||||
|
*
|
||||||
|
* Gated on [ready], so the banner never appears until the bytes are on disk. An
|
||||||
|
* update that has been FOUND is not news anyone can act on quickly — offering it
|
||||||
|
* off wifi would turn one tap into a download somebody did not plan.
|
||||||
|
*
|
||||||
|
* Deliberately still true while [working]: the install is the one moment the
|
||||||
|
* banner has something to report, and hiding it there would look like the tap
|
||||||
|
* did nothing.
|
||||||
|
*/
|
||||||
|
val nagging: Boolean get() = ready && available != null && !nagDismissed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updating the app from the server it syncs with.
|
||||||
|
*
|
||||||
|
* **Linked-only, and said out loud.** The app is local-first and completely usable
|
||||||
|
* having never touched a server, so an unlinked install has no update path at all.
|
||||||
|
* The card says that rather than offering a Check button that silently finds
|
||||||
|
* nothing — the same lesson as the desktop's unlink copy (issue 2110).
|
||||||
|
*
|
||||||
|
* The core does the network work, not this class: the device token lives in the
|
||||||
|
* Rust store and pulling it into Kotlin to make an HTTP call would spread the one
|
||||||
|
* secret this app holds across two languages for no gain.
|
||||||
|
*/
|
||||||
|
class UpdateViewModel(
|
||||||
|
private val core: Inkwell,
|
||||||
|
/**
|
||||||
|
* MUST be the application context — it outlives this view model, and holding an
|
||||||
|
* Activity here is the textbook way to leak a window.
|
||||||
|
*/
|
||||||
|
private val context: Context,
|
||||||
|
) : ViewModel() {
|
||||||
|
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** When the last check ran, so coming back to the app twice in a minute is one. */
|
||||||
|
private var lastCheckAt = 0L
|
||||||
|
|
||||||
|
/** Ask the linked server what it has. The Check button on the sync screen. */
|
||||||
|
fun check() = runCheck(fetch = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The automatic path: look, fetch, then nag.
|
||||||
|
*
|
||||||
|
* Called when the app comes forward. Until this existed an update was only ever
|
||||||
|
* found by someone opening the sync screen and pressing a button — so the ones
|
||||||
|
* that mattered were the ones nobody went looking for.
|
||||||
|
*
|
||||||
|
* Skipped when a check is already in flight, when a build is already waiting, and
|
||||||
|
* when one ran recently: flicking between two apps is not a request to re-check.
|
||||||
|
*/
|
||||||
|
fun checkInBackground() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
when {
|
||||||
|
state.busy -> Unit
|
||||||
|
|
||||||
|
// Already fetched and waved away — say so again. "Later" is for that
|
||||||
|
// sitting, not forever, and without this branch a single dismissal would
|
||||||
|
// silence the update permanently. Which is precisely the "lost" this whole
|
||||||
|
// path exists to prevent.
|
||||||
|
state.ready -> if (state.nagDismissed) state = state.copy(nagDismissed = false)
|
||||||
|
|
||||||
|
// Found one and never fetched it — almost always because the last look
|
||||||
|
// happened on mobile data. Retry the FETCH rather than the check, and
|
||||||
|
// ignore the interval: this is what makes an update found on the train
|
||||||
|
// arrive when the person gets home instead of waiting out six hours.
|
||||||
|
state.available != null ->
|
||||||
|
if (AppUpdate.onWifi(context)) viewModelScope.launch { download() }
|
||||||
|
|
||||||
|
// Flicking between two apps is not a request to re-check.
|
||||||
|
now - lastCheckAt < CHECK_INTERVAL_MS -> Unit
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
lastCheckAt = now
|
||||||
|
runCheck(fetch = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runCheck(fetch: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(checking = true, error = null, upToDate = false)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
val found = core.clientUpdate(state.installedVersion)
|
||||||
|
state.copy(
|
||||||
|
checking = false,
|
||||||
|
available = found,
|
||||||
|
upToDate = found == null,
|
||||||
|
// A build that is still there is worth mentioning again. The
|
||||||
|
// dismissal was for that sitting, not for this version.
|
||||||
|
nagDismissed = if (found == null) state.nagDismissed else false,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Broad by intent, as everywhere the core is called: it reports
|
||||||
|
// every failure as one error type carrying a message written to
|
||||||
|
// be read, and a failed check must not take the screen down.
|
||||||
|
state.copy(checking = false, error = e.message ?: FALLBACK)
|
||||||
|
}
|
||||||
|
// Fetched before anything is said, so the banner is a one-tap install
|
||||||
|
// rather than the start of a wait. Off wifi this simply does not happen
|
||||||
|
// and the app stays quiet — the next foreground on wifi picks it up.
|
||||||
|
if (fetch && state.available != null && AppUpdate.onWifi(context)) {
|
||||||
|
download()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the waiting build into the cache, leaving it for [install]. */
|
||||||
|
private suspend fun download() {
|
||||||
|
state = state.copy(downloading = true, error = null)
|
||||||
|
state =
|
||||||
|
try {
|
||||||
|
core.downloadClientUpdate(AppUpdate.downloadTarget(context).absolutePath)
|
||||||
|
state.copy(downloading = false, ready = true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
state.copy(downloading = false, error = e.message ?: FALLBACK_DOWNLOAD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop nagging for this sitting. The next trip to the foreground says it again. */
|
||||||
|
fun dismissNag() {
|
||||||
|
state = state.copy(nagDismissed = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the update to the system installer, downloading first if it is not already
|
||||||
|
* in the cache.
|
||||||
|
*
|
||||||
|
* Still one action from the outside. A downloaded APK is not a state anyone wants
|
||||||
|
* to think about, so whether the fetch already happened in the background is this
|
||||||
|
* class's problem rather than the person's.
|
||||||
|
*/
|
||||||
|
fun downloadAndInstall() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(working = true, error = null)
|
||||||
|
UpdateOutcome.clear()
|
||||||
|
val failure =
|
||||||
|
try {
|
||||||
|
val target = AppUpdate.downloadTarget(context)
|
||||||
|
if (!state.ready) core.downloadClientUpdate(target.absolutePath)
|
||||||
|
// Off the main thread: this streams ~55 MiB into the session.
|
||||||
|
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.message ?: FALLBACK
|
||||||
|
}
|
||||||
|
// `working` stays TRUE on success: the install is still in flight, and
|
||||||
|
// on a silent update this process is about to be replaced. Clearing it
|
||||||
|
// here would flash "ready" a moment before the app disappears.
|
||||||
|
state =
|
||||||
|
if (failure == null) state else state.copy(working = false, error = failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take whatever the system finally said about the install.
|
||||||
|
*
|
||||||
|
* Called from the composition, because the answer arrives at a BroadcastReceiver
|
||||||
|
* the system owns and there is no other way back into this class.
|
||||||
|
*/
|
||||||
|
fun consumeInstallOutcome(result: UpdateOutcome.Result) {
|
||||||
|
UpdateOutcome.clear()
|
||||||
|
state = state.copy(working = false, error = result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
state = state.copy(error = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun factory(
|
||||||
|
core: Inkwell,
|
||||||
|
context: Context,
|
||||||
|
): ViewModelProvider.Factory =
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||||
|
UpdateViewModel(core, context.applicationContext) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val FALLBACK = "The update couldn't be checked."
|
||||||
|
private const val FALLBACK_DOWNLOAD = "The update couldn't be downloaded."
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a background check stays good for.
|
||||||
|
*
|
||||||
|
* Long enough that switching to another app and back is not a re-check; short enough
|
||||||
|
* that a build published this morning is offered today. The same reasoning as sync's
|
||||||
|
* STALE_MINUTES, at a slower cadence — an app update is not urgent, it is just
|
||||||
|
* something that must not get lost.
|
||||||
|
*/
|
||||||
|
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package com.fabledsword.thoughtsync.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.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
|
||||||
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.checklistItems
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NoteCard(
|
|
||||||
note: Note,
|
|
||||||
onOpen: () -> Unit,
|
|
||||||
onToggleItem: (Int, Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val tint = noteTint(note.color)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
// Clipped BEFORE clickable, so the ripple is bounded by the card's
|
|
||||||
// rounded corners instead of a rectangle overhanging them.
|
|
||||||
.clip(RoundedCornerShape(CARD_RADIUS))
|
|
||||||
.clickable(onClickLabel = stringResource(R.string.board_open_note), onClick = onOpen)
|
|
||||||
.background(tint.background(dark))
|
|
||||||
.border(1.dp, tint.border(dark), RoundedCornerShape(CARD_RADIUS))
|
|
||||||
.padding(12.dp),
|
|
||||||
) {
|
|
||||||
// Body then checklist, in order — a note can carry both (M13 step 2), and
|
|
||||||
// nothing above them: 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note.labels.isNotEmpty()) {
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
LabelChips(labels = note.labels)
|
|
||||||
}
|
|
||||||
|
|
||||||
note.remindAt?.let { at ->
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
ReminderChip(instant = at, recurrence = note.recurrence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The note's body, with its checklist drawn where it actually sits.
|
|
||||||
*
|
|
||||||
* Rendered line by line rather than as one block of text, because an item is a line
|
|
||||||
* of the body now (M304) and a card that showed the prose and then the list would put
|
|
||||||
* every list in the wrong place — and, since the body already contains those lines,
|
|
||||||
* would show each one twice.
|
|
||||||
*
|
|
||||||
* Which lines are items is asked of the core rather than matched here. The grammar is
|
|
||||||
* already written three times; a fourth in Compose would be a fourth place for a
|
|
||||||
* checklist to change shape when it syncs.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun NoteBody(
|
|
||||||
note: Note,
|
|
||||||
onToggleItem: (Int, Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
val lines = remember(note.body) { note.body.split("\n") }
|
|
||||||
// Read from the BODY rather than from note.items, which is the same list by a
|
|
||||||
// longer route — and one that can lag the text by a save.
|
|
||||||
val itemAtLine =
|
|
||||||
remember(note.body) {
|
|
||||||
checklistItems(note.body)
|
|
||||||
.mapIndexed { index, item -> item.line.toInt() to (index to item) }
|
|
||||||
.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
|
||||||
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
|
|
||||||
val found = itemAtLine[n]
|
|
||||||
when {
|
|
||||||
found != null ->
|
|
||||||
ChecklistRow(found.second) { onToggleItem(found.first, !found.second.checked) }
|
|
||||||
// Kept as a gap rather than dropped: it is the paragraph break
|
|
||||||
// somebody typed, and the card reads as a wall without it.
|
|
||||||
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
|
||||||
else ->
|
|
||||||
Text(
|
|
||||||
text = line,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
maxLines = MAX_WRAPPED_LINES,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lines.size > MAX_PREVIEW_LINES) {
|
|
||||||
Text(
|
|
||||||
text = "…",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One checklist row on a card, with a box you can actually tick.
|
|
||||||
*
|
|
||||||
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
|
|
||||||
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
|
|
||||||
* space a list out like a form. The tap target is the glyph's own padding, which is
|
|
||||||
* why it carries `clickable` rather than the row — clicking the TEXT should open the
|
|
||||||
* note, the way clicking anywhere else on the card does.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ChecklistRow(
|
|
||||||
item: BodyItem,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
|
||||||
Text(
|
|
||||||
text = if (item.checked) "☑" else "☐",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.clickable(onClick = onToggle)
|
|
||||||
.padding(end = 6.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = item.text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
|
||||||
color =
|
|
||||||
if (item.checked) {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
maxLines = MAX_WRAPPED_LINES,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LabelChips(labels: List<NoteLabel>) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
// A plain row that clips rather than wraps: a card with eight labels should
|
|
||||||
// not grow taller than its content. The editor shows the full set.
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
labels.take(MAX_LABEL_CHIPS).forEach { label ->
|
|
||||||
val tint = noteTint(label.color)
|
|
||||||
Text(
|
|
||||||
text = label.name,
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The reminder, red once it has passed.
|
|
||||||
*
|
|
||||||
* Red for overdue and neutral otherwise, matching the web card exactly — the same
|
|
||||||
* red-100/red-700 and black/5 pairs, resolved through the shared tint table. It
|
|
||||||
* used to be blue for every reminder here, which made "you missed this" and
|
|
||||||
* "coming up on Friday" look identical on a board full of both.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ReminderChip(
|
|
||||||
instant: String,
|
|
||||||
recurrence: String?,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val tint = noteTint(if (isPast(instant)) "red" else "default")
|
|
||||||
Text(
|
|
||||||
text = reminderLabel(instant, recurrence),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = tint.chipForeground(dark),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(CHIP_RADIUS))
|
|
||||||
.background(tint.chipBackground(dark))
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val MAX_PREVIEW_LINES = 8
|
|
||||||
|
|
||||||
/** How far one long line of a card may wrap before it is cut. */
|
|
||||||
private const val MAX_WRAPPED_LINES = 2
|
|
||||||
private const val MAX_LABEL_CHIPS = 3
|
|
||||||
private val CARD_RADIUS = 12.dp
|
|
||||||
private val CHIP_RADIUS = 6.dp
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The note colour palette, matching `frontend/src/notes/colors.ts` VALUE FOR VALUE.
|
|
||||||
*
|
|
||||||
* A note's colour is stored by the core as a key ("red", "teal", …) and every
|
|
||||||
* surface resolves it to its own tints. The web app resolves through Tailwind
|
|
||||||
* classes; this table is those same Tailwind colours as literals, so a note that
|
|
||||||
* is amber on the desktop is the same amber on the phone rather than a near-miss.
|
|
||||||
* Generated from tailwindcss 3.4's palette rather than transcribed by eye.
|
|
||||||
*
|
|
||||||
* Dark tints keep the web's ALPHA (`dark:bg-red-950/40`) instead of a
|
|
||||||
* precomputed blend — Compose composites a translucent colour over what's beneath
|
|
||||||
* exactly as CSS does, so the card sits on the background the same way in both.
|
|
||||||
*
|
|
||||||
* `yellow` maps to Tailwind's *amber*, matching colors.ts; plain yellow is too
|
|
||||||
* acid against the neutral surfaces.
|
|
||||||
*/
|
|
||||||
data class NoteTint(
|
|
||||||
val label: String,
|
|
||||||
val lightBackground: Color,
|
|
||||||
val lightBorder: Color,
|
|
||||||
val darkBackground: Color,
|
|
||||||
val darkBorder: Color,
|
|
||||||
val lightChipBackground: Color,
|
|
||||||
val lightChipForeground: Color,
|
|
||||||
val darkChipBackground: Color,
|
|
||||||
val darkChipForeground: Color,
|
|
||||||
) {
|
|
||||||
fun background(dark: Boolean): Color = if (dark) darkBackground else lightBackground
|
|
||||||
|
|
||||||
fun border(dark: Boolean): Color = if (dark) darkBorder else lightBorder
|
|
||||||
|
|
||||||
fun chipBackground(dark: Boolean): Color = if (dark) darkChipBackground else lightChipBackground
|
|
||||||
|
|
||||||
fun chipForeground(dark: Boolean): Color = if (dark) darkChipForeground else lightChipForeground
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keyed by the core's colour vocabulary. Order matches the web's picker. */
|
|
||||||
val NOTE_TINTS: Map<String, NoteTint> =
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Default",
|
|
||||||
lightBackground = Color(0xFFFFFFFF),
|
|
||||||
lightBorder = Color(0xFFE5E5E5),
|
|
||||||
darkBackground = Color(0xFF171717),
|
|
||||||
darkBorder = Color(0xFF404040),
|
|
||||||
lightChipBackground = Color(0x0D000000),
|
|
||||||
lightChipForeground = Color(0xFF525252),
|
|
||||||
darkChipBackground = Color(0x1AFFFFFF),
|
|
||||||
darkChipForeground = Color(0xFFD4D4D4),
|
|
||||||
),
|
|
||||||
"red" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Red",
|
|
||||||
lightBackground = Color(0xFFFEF2F2),
|
|
||||||
lightBorder = Color(0xFFFECACA),
|
|
||||||
darkBackground = Color(0x66450A0A),
|
|
||||||
darkBorder = Color(0xFF7F1D1D),
|
|
||||||
lightChipBackground = Color(0xFFFEE2E2),
|
|
||||||
lightChipForeground = Color(0xFFB91C1C),
|
|
||||||
darkChipBackground = Color(0x80450A0A),
|
|
||||||
darkChipForeground = Color(0xFFFCA5A5),
|
|
||||||
),
|
|
||||||
"orange" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Orange",
|
|
||||||
lightBackground = Color(0xFFFFF7ED),
|
|
||||||
lightBorder = Color(0xFFFED7AA),
|
|
||||||
darkBackground = Color(0x66431407),
|
|
||||||
darkBorder = Color(0xFF7C2D12),
|
|
||||||
lightChipBackground = Color(0xFFFFEDD5),
|
|
||||||
lightChipForeground = Color(0xFFC2410C),
|
|
||||||
darkChipBackground = Color(0x80431407),
|
|
||||||
darkChipForeground = Color(0xFFFDBA74),
|
|
||||||
),
|
|
||||||
"yellow" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Yellow",
|
|
||||||
lightBackground = Color(0xFFFFFBEB),
|
|
||||||
lightBorder = Color(0xFFFDE68A),
|
|
||||||
darkBackground = Color(0x66451A03),
|
|
||||||
darkBorder = Color(0xFF78350F),
|
|
||||||
lightChipBackground = Color(0xFFFEF3C7),
|
|
||||||
lightChipForeground = Color(0xFF92400E),
|
|
||||||
darkChipBackground = Color(0x80451A03),
|
|
||||||
darkChipForeground = Color(0xFFFCD34D),
|
|
||||||
),
|
|
||||||
"green" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Green",
|
|
||||||
lightBackground = Color(0xFFF0FDF4),
|
|
||||||
lightBorder = Color(0xFFBBF7D0),
|
|
||||||
darkBackground = Color(0x66052E16),
|
|
||||||
darkBorder = Color(0xFF14532D),
|
|
||||||
lightChipBackground = Color(0xFFDCFCE7),
|
|
||||||
lightChipForeground = Color(0xFF15803D),
|
|
||||||
darkChipBackground = Color(0x80052E16),
|
|
||||||
darkChipForeground = Color(0xFF86EFAC),
|
|
||||||
),
|
|
||||||
"teal" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Teal",
|
|
||||||
lightBackground = Color(0xFFF0FDFA),
|
|
||||||
lightBorder = Color(0xFF99F6E4),
|
|
||||||
darkBackground = Color(0x66042F2E),
|
|
||||||
darkBorder = Color(0xFF134E4A),
|
|
||||||
lightChipBackground = Color(0xFFCCFBF1),
|
|
||||||
lightChipForeground = Color(0xFF0F766E),
|
|
||||||
darkChipBackground = Color(0x80042F2E),
|
|
||||||
darkChipForeground = Color(0xFF5EEAD4),
|
|
||||||
),
|
|
||||||
"blue" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Blue",
|
|
||||||
lightBackground = Color(0xFFEFF6FF),
|
|
||||||
lightBorder = Color(0xFFBFDBFE),
|
|
||||||
darkBackground = Color(0x66172554),
|
|
||||||
darkBorder = Color(0xFF1E3A8A),
|
|
||||||
lightChipBackground = Color(0xFFDBEAFE),
|
|
||||||
lightChipForeground = Color(0xFF1D4ED8),
|
|
||||||
darkChipBackground = Color(0x80172554),
|
|
||||||
darkChipForeground = Color(0xFF93C5FD),
|
|
||||||
),
|
|
||||||
"purple" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Purple",
|
|
||||||
lightBackground = Color(0xFFFAF5FF),
|
|
||||||
lightBorder = Color(0xFFE9D5FF),
|
|
||||||
darkBackground = Color(0x663B0764),
|
|
||||||
darkBorder = Color(0xFF581C87),
|
|
||||||
lightChipBackground = Color(0xFFF3E8FF),
|
|
||||||
lightChipForeground = Color(0xFF7E22CE),
|
|
||||||
darkChipBackground = Color(0x803B0764),
|
|
||||||
darkChipForeground = Color(0xFFD8B4FE),
|
|
||||||
),
|
|
||||||
"pink" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Pink",
|
|
||||||
lightBackground = Color(0xFFFDF2F8),
|
|
||||||
lightBorder = Color(0xFFFBCFE8),
|
|
||||||
darkBackground = Color(0x66500724),
|
|
||||||
darkBorder = Color(0xFF831843),
|
|
||||||
lightChipBackground = Color(0xFFFCE7F3),
|
|
||||||
lightChipForeground = Color(0xFFBE185D),
|
|
||||||
darkChipBackground = Color(0x80500724),
|
|
||||||
darkChipForeground = Color(0xFFF9A8D4),
|
|
||||||
),
|
|
||||||
"gray" to
|
|
||||||
NoteTint(
|
|
||||||
label = "Gray",
|
|
||||||
lightBackground = Color(0xFFF5F5F5),
|
|
||||||
lightBorder = Color(0xFFD4D4D4),
|
|
||||||
darkBackground = Color(0xFF262626),
|
|
||||||
darkBorder = Color(0xFF404040),
|
|
||||||
lightChipBackground = Color(0xFFE5E5E5),
|
|
||||||
lightChipForeground = Color(0xFF404040),
|
|
||||||
darkChipBackground = Color(0xFF404040),
|
|
||||||
darkChipForeground = Color(0xFFE5E5E5),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a stored colour key.
|
|
||||||
*
|
|
||||||
* An unknown key falls back to `default` rather than throwing: colours are data
|
|
||||||
* that arrives from a server which may be newer than this client, and a note
|
|
||||||
* whose tint we don't recognise should still be readable.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
@ReadOnlyComposable
|
|
||||||
fun noteTint(key: String): NoteTint = NOTE_TINTS[key] ?: NOTE_TINTS.getValue("default")
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.fabledsword.thoughtsync.AppUpdate
|
|
||||||
import com.fabledsword.thoughtsync.UpdateOutcome
|
|
||||||
import com.fabledsword.thoughtsync.core.ClientUpdate
|
|
||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/** Everything the update card renders from. */
|
|
||||||
data class UpdateState(
|
|
||||||
/** What is running now. Shown even when there is nothing to update to. */
|
|
||||||
val installedVersion: Long = 0,
|
|
||||||
val checking: Boolean = false,
|
|
||||||
/** Only ever set to something NEWER — the core does that comparison. */
|
|
||||||
val available: ClientUpdate? = null,
|
|
||||||
/** A check completed and found nothing. Distinct from "not checked yet". */
|
|
||||||
val upToDate: Boolean = false,
|
|
||||||
val working: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
) {
|
|
||||||
val busy: Boolean get() = checking || working
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updating the app from the server it syncs with.
|
|
||||||
*
|
|
||||||
* **Linked-only, and said out loud.** The app is local-first and completely usable
|
|
||||||
* having never touched a server, so an unlinked install has no update path at all.
|
|
||||||
* The card says that rather than offering a Check button that silently finds
|
|
||||||
* nothing — the same lesson as the desktop's unlink copy (issue 2110).
|
|
||||||
*
|
|
||||||
* The core does the network work, not this class: the device token lives in the
|
|
||||||
* Rust store and pulling it into Kotlin to make an HTTP call would spread the one
|
|
||||||
* secret this app holds across two languages for no gain.
|
|
||||||
*/
|
|
||||||
class UpdateViewModel(
|
|
||||||
private val core: ThoughtSync,
|
|
||||||
/**
|
|
||||||
* MUST be the application context — it outlives this view model, and holding an
|
|
||||||
* Activity here is the textbook way to leak a window.
|
|
||||||
*/
|
|
||||||
private val context: Context,
|
|
||||||
) : ViewModel() {
|
|
||||||
var state by mutableStateOf(UpdateState(installedVersion = AppUpdate.installedVersionCode(context)))
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** Ask the linked server what it has. */
|
|
||||||
fun check() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
state = state.copy(checking = true, error = null, upToDate = false)
|
|
||||||
state =
|
|
||||||
try {
|
|
||||||
val found = core.clientUpdate(state.installedVersion)
|
|
||||||
state.copy(checking = false, available = found, upToDate = found == null)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Broad by intent, as everywhere the core is called: it reports
|
|
||||||
// every failure as one error type carrying a message written to
|
|
||||||
// be read, and a failed check must not take the screen down.
|
|
||||||
state.copy(checking = false, error = e.message ?: FALLBACK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download the update and hand it to the system installer.
|
|
||||||
*
|
|
||||||
* One action rather than two buttons: nobody wants a downloaded APK sitting
|
|
||||||
* around as an intermediate state they have to think about.
|
|
||||||
*/
|
|
||||||
fun downloadAndInstall() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
state = state.copy(working = true, error = null)
|
|
||||||
UpdateOutcome.clear()
|
|
||||||
val failure =
|
|
||||||
try {
|
|
||||||
val target = AppUpdate.downloadTarget(context)
|
|
||||||
core.downloadClientUpdate(target.absolutePath)
|
|
||||||
// Off the main thread: this streams ~55 MiB into the session.
|
|
||||||
withContext(Dispatchers.IO) { AppUpdate.install(context, target) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.message ?: FALLBACK
|
|
||||||
}
|
|
||||||
// `working` stays TRUE on success: the install is still in flight, and
|
|
||||||
// on a silent update this process is about to be replaced. Clearing it
|
|
||||||
// here would flash "ready" a moment before the app disappears.
|
|
||||||
state =
|
|
||||||
if (failure == null) state else state.copy(working = false, error = failure)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Take whatever the system finally said about the install.
|
|
||||||
*
|
|
||||||
* Called from the composition, because the answer arrives at a BroadcastReceiver
|
|
||||||
* the system owns and there is no other way back into this class.
|
|
||||||
*/
|
|
||||||
fun consumeInstallOutcome(result: UpdateOutcome.Result) {
|
|
||||||
UpdateOutcome.clear()
|
|
||||||
state = state.copy(working = false, error = result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissError() {
|
|
||||||
state = state.copy(error = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun factory(
|
|
||||||
core: ThoughtSync,
|
|
||||||
context: Context,
|
|
||||||
): ViewModelProvider.Factory =
|
|
||||||
object : ViewModelProvider.Factory {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
|
||||||
UpdateViewModel(core, context.applicationContext) as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val FALLBACK = "The update couldn't be checked."
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
The Material paperclip ("attach_file"), for the editor's Attach button and the
|
||||||
|
file rows under a note.
|
||||||
|
|
||||||
|
A drawable rather than an Icons.* constant because material-icons-core does not
|
||||||
|
carry it, and the extended set is a multi-megabyte dependency for one glyph.
|
||||||
|
Tinted by whatever draws it, like every other icon in the toolbar.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z" />
|
||||||
|
</vector>
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
Adaptive icon. minSdk is 26, so this is the ONLY icon Android will ask for —
|
||||||
no legacy raster fallback is needed.
|
no legacy raster fallback is needed.
|
||||||
|
|
||||||
The foreground is the shared maskable asset the web app already ships
|
The foreground is the inkwell mark alone on transparency, inside the 66dp safe
|
||||||
(frontend/public/icon-maskable-512.png), which is drawn with the safe-zone
|
circle; the yellow is the background colour. It is rendered by
|
||||||
padding adaptive icons require. Reusing it means the phone, the web app and the
|
packaging/icons.py from the same drawing as the web and desktop icons, so all
|
||||||
desktop all wear the same face rather than three near-misses.
|
three wear one face. Transparent rather than a full-bleed tile because the
|
||||||
|
monochrome layer below reuses it: a themed icon draws its alpha as a silhouette,
|
||||||
|
and an opaque foreground would come out as a solid square.
|
||||||
-->
|
-->
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -2,6 +2,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<!-- The product's brand colour, same value the web app's manifest and
|
<!-- The product's brand colour, same value the web app's manifest and
|
||||||
<meta name="theme-color"> already use. One source of truth for "what
|
<meta name="theme-color"> already use. One source of truth for "what
|
||||||
colour is ThoughtSync" across the three surfaces. -->
|
colour is Inkwell" across the three surfaces. -->
|
||||||
<color name="ic_launcher_background">#F5C518</color>
|
<color name="ic_launcher_background">#F5C518</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ThoughtSync</string>
|
<string name="app_name">Inkwell</string>
|
||||||
|
|
||||||
<!-- Search bar -->
|
<!-- Search bar -->
|
||||||
<string name="search_hint">Search your notes</string>
|
<string name="search_hint">Search your notes</string>
|
||||||
<string name="search_clear">Clear search</string>
|
<string name="search_clear">Clear search</string>
|
||||||
<string name="nav_open">Open navigation</string>
|
<string name="nav_open">Open navigation</string>
|
||||||
<string name="nav_labels">Labels</string>
|
<string name="nav_labels">Tags</string>
|
||||||
|
|
||||||
<!-- Compose sheet -->
|
<!-- Compose sheet -->
|
||||||
<string name="compose_open">New note</string>
|
<string name="compose_open">New note</string>
|
||||||
@@ -14,12 +14,29 @@
|
|||||||
<!-- Board -->
|
<!-- Board -->
|
||||||
<string name="board_empty_note">Empty note</string>
|
<string name="board_empty_note">Empty note</string>
|
||||||
|
|
||||||
|
<!-- The long-press menu. Its ITEMS are the editor_* strings, deliberately: a
|
||||||
|
note has one vocabulary of things you can do to it, and a board that said
|
||||||
|
"Delete" where the editor says "Move to trash" would be describing two
|
||||||
|
different apps. Only the wrapper and the undo need words of their own. -->
|
||||||
|
<string name="board_note_actions">Note actions</string>
|
||||||
|
<string name="board_trashed">Moved to trash</string>
|
||||||
|
<string name="board_undo">Undo</string>
|
||||||
|
|
||||||
<!-- Empty states. Each destination says something true of ITSELF; a single
|
<!-- Empty states. Each destination says something true of ITSELF; a single
|
||||||
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
||||||
<string name="board_empty_title">Nothing here yet</string>
|
<string name="board_empty_title">Nothing here yet</string>
|
||||||
<string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string>
|
<string name="board_empty_body">Tap + to start a note or a list. Everything stays on this device until you connect a server.</string>
|
||||||
<string name="empty_search_title">No matches</string>
|
<string name="empty_search_title">No matches</string>
|
||||||
<string name="empty_search_body">Nothing matched “%1$s”.</string>
|
<string name="empty_search_body">Nothing matched “%1$s”.</string>
|
||||||
|
<string name="empty_filtered_title">No matches</string>
|
||||||
|
<string name="empty_filtered_body">No notes match these filters.</string>
|
||||||
|
<string name="filters">Filters</string>
|
||||||
|
<string name="filters_count">Filters · %1$d</string>
|
||||||
|
<string name="filters_clear">Clear</string>
|
||||||
|
<string name="filters_show">Show only</string>
|
||||||
|
<string name="filters_has_attachment">Has attachment</string>
|
||||||
|
<string name="filters_shared_with_me">Shared with me</string>
|
||||||
|
<string name="filters_tags">Tagged with all of</string>
|
||||||
<string name="empty_trash_title">Trash is empty</string>
|
<string name="empty_trash_title">Trash is empty</string>
|
||||||
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string>
|
<string name="empty_trash_body">Deleted notes wait here before they are removed for good.</string>
|
||||||
<string name="empty_archive_title">Nothing archived</string>
|
<string name="empty_archive_title">Nothing archived</string>
|
||||||
@@ -32,9 +49,8 @@
|
|||||||
<string name="editor_back">Back to notes</string>
|
<string name="editor_back">Back to notes</string>
|
||||||
<string name="editor_add_checklist">Add a checklist</string>
|
<string name="editor_add_checklist">Add a checklist</string>
|
||||||
<string name="editor_body_hint">Take a note…</string>
|
<string name="editor_body_hint">Take a note…</string>
|
||||||
<string name="editor_add_item">Add item</string>
|
|
||||||
<string name="editor_remove_item">Remove item</string>
|
<string name="editor_remove_item">Remove item</string>
|
||||||
<string name="editor_remove_label">Remove label</string>
|
<string name="editor_remove_label">Remove tag</string>
|
||||||
<string name="editor_reminder">Set a reminder</string>
|
<string name="editor_reminder">Set a reminder</string>
|
||||||
<string name="editor_more">More actions</string>
|
<string name="editor_more">More actions</string>
|
||||||
<string name="editor_saving">Saving…</string>
|
<string name="editor_saving">Saving…</string>
|
||||||
@@ -44,12 +60,49 @@
|
|||||||
<string name="editor_done">Done</string>
|
<string name="editor_done">Done</string>
|
||||||
<string name="editor_pin">Pin</string>
|
<string name="editor_pin">Pin</string>
|
||||||
<string name="editor_unpin">Unpin</string>
|
<string name="editor_unpin">Unpin</string>
|
||||||
<string name="editor_labels">Labels…</string>
|
<string name="editor_labels">Tags…</string>
|
||||||
<string name="editor_archive">Archive</string>
|
<string name="editor_archive">Archive</string>
|
||||||
<string name="editor_unarchive">Unarchive</string>
|
<string name="editor_unarchive">Unarchive</string>
|
||||||
<string name="editor_trash">Move to trash</string>
|
<string name="editor_trash">Move to trash</string>
|
||||||
<string name="editor_restore">Restore</string>
|
<string name="editor_restore">Restore</string>
|
||||||
<string name="editor_cancel">Cancel</string>
|
<string name="editor_cancel">Cancel</string>
|
||||||
|
<string name="editor_share">Share…</string>
|
||||||
|
|
||||||
|
<!-- Sharing (#5175). Shares live on the server, so the sheet says so when this
|
||||||
|
phone isn't linked to one. -->
|
||||||
|
<string name="share_title">Share</string>
|
||||||
|
<string name="share_loading">Loading…</string>
|
||||||
|
<string name="share_needs_server">Sharing is between people on a server. Link this phone in Sync to share notes.</string>
|
||||||
|
<string name="share_none">Not shared with anyone yet.</string>
|
||||||
|
<string name="share_everyone">Shared with everyone on this server.</string>
|
||||||
|
<string name="share_add">Add someone or a group</string>
|
||||||
|
<string name="share_action">Share</string>
|
||||||
|
<string name="share_remove">Stop sharing</string>
|
||||||
|
<string name="share_can_view">Can view</string>
|
||||||
|
<string name="share_can_edit">Can edit</string>
|
||||||
|
<string name="share_someone">someone</string>
|
||||||
|
<string name="share_by_view_only">Shared by %1$s · view only</string>
|
||||||
|
<string name="share_by_can_edit">Shared by %1$s · you can edit the text</string>
|
||||||
|
<string name="share_chip_shared">Shared</string>
|
||||||
|
<string name="share_chip_by">From %1$s</string>
|
||||||
|
<plurals name="share_group_members">
|
||||||
|
<item quantity="one">Group · %d person</item>
|
||||||
|
<item quantity="other">Group · %d people</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
|
<!-- Attachments. A file is stored on the phone at once and uploaded on a later
|
||||||
|
sync, so nothing here talks about the network. -->
|
||||||
|
<string name="editor_attach">Attach a file</string>
|
||||||
|
<string name="attach_remove">Remove attachment</string>
|
||||||
|
<string name="attach_unnamed">Unnamed file</string>
|
||||||
|
<string name="attach_more">+%1$d more</string>
|
||||||
|
<string name="attach_refused">Not uploaded: %1$s</string>
|
||||||
|
<string name="attach_too_large">%1$s is over %2$d MB, too large to attach from the phone.</string>
|
||||||
|
<string name="attach_unreadable">Couldn\'t read %1$s.</string>
|
||||||
|
<string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string>
|
||||||
|
<string name="attach_no_app">No app on this phone opens this kind of file.</string>
|
||||||
|
<string name="attach_open_failed">Couldn\'t open the file: %1$s</string>
|
||||||
|
<string name="preview_remove">Remove link preview</string>
|
||||||
|
|
||||||
<!-- Deleting for good is the only thing in the app that cannot be undone, so
|
<!-- Deleting for good is the only thing in the app that cannot be undone, so
|
||||||
the copy says exactly that rather than asking "Are you sure?". -->
|
the copy says exactly that rather than asking "Are you sure?". -->
|
||||||
@@ -58,12 +111,61 @@
|
|||||||
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
|
<string name="editor_delete_forever_body">It will be removed from this device and from every device you sync with. This cannot be undone.</string>
|
||||||
<string name="editor_delete_forever_confirm">Delete</string>
|
<string name="editor_delete_forever_confirm">Delete</string>
|
||||||
|
|
||||||
|
<!-- Quick capture from outside the app: the share sheet and the text-selection
|
||||||
|
toolbar. "New note" says what happens; the activity's own label would say
|
||||||
|
who it happens in. -->
|
||||||
|
<string name="capture_process_text">New note</string>
|
||||||
|
|
||||||
|
<!-- Tag management. The whole vocabulary is "tag" (see Scribe #2966); the
|
||||||
|
schema still says Label, and no string here needs to know that. -->
|
||||||
|
<string name="tags_manage">Manage tags</string>
|
||||||
|
<string name="tags_title">Tags</string>
|
||||||
|
<string name="tags_back">Back</string>
|
||||||
|
<string name="tags_new_hint">New tag</string>
|
||||||
|
<string name="tags_create">Create</string>
|
||||||
|
<string name="tags_count">%1$d notes</string>
|
||||||
|
<string name="tags_count_one">1 note</string>
|
||||||
|
<string name="tags_count_none">No notes yet</string>
|
||||||
|
<string name="tags_empty_title">No tags yet</string>
|
||||||
|
<string name="tags_empty_body">Create one above, or write a #tag in a note and it becomes one.</string>
|
||||||
|
<string name="tags_actions">More actions</string>
|
||||||
|
<string name="tags_colour">Colour</string>
|
||||||
|
<string name="tags_colour_of">Colour for %1$s</string>
|
||||||
|
|
||||||
|
<string name="tags_rename">Rename</string>
|
||||||
|
<string name="tags_rename_title">Rename %1$s</string>
|
||||||
|
<string name="tags_rename_confirm">Rename</string>
|
||||||
|
<!-- Renaming onto an existing tag merges the two, older survives (Scribe
|
||||||
|
#3324). A merge cannot be undone by repeating it and is reachable here by
|
||||||
|
a typo, so it says so before it happens — same reasoning as #2116. -->
|
||||||
|
<string name="tags_rename_merges_title">Merge with %1$s?</string>
|
||||||
|
<string name="tags_rename_merges_body">A tag called %1$s already exists. Renaming will merge these two into one, carrying every note from both. The notes are kept; one of the two tags stops existing, and that cannot be undone.</string>
|
||||||
|
<string name="tags_rename_merges_confirm">Merge</string>
|
||||||
|
|
||||||
|
<string name="tags_merge">Merge into…</string>
|
||||||
|
<string name="tags_merge_title">Merge %1$s into…</string>
|
||||||
|
<!-- The survivor is named in the button, not just the title: this is the one
|
||||||
|
operation here that repeating does not undo. -->
|
||||||
|
<string name="tags_merge_body">Every note tagged %1$s will be tagged with the one you pick instead, and %1$s will stop existing. The notes are kept.</string>
|
||||||
|
<string name="tags_merge_none">There is no other tag to merge into.</string>
|
||||||
|
|
||||||
|
<string name="tags_delete">Delete</string>
|
||||||
|
<string name="tags_delete_title">Delete %1$s?</string>
|
||||||
|
<string name="tags_delete_body">It will be removed from every note that has it, on every device you sync with. The notes themselves are kept.</string>
|
||||||
|
<string name="tags_delete_body_counted">It is on %1$d notes. It will be removed from all of them, on every device you sync with. The notes themselves are kept.</string>
|
||||||
|
<string name="tags_delete_confirm">Delete</string>
|
||||||
|
<!-- A tag written as #tag in a note's body is owned by that text. Deleting the
|
||||||
|
row cannot un-write the word, so it comes back on that note's next edit —
|
||||||
|
said here rather than left as a surprise. -->
|
||||||
|
<string name="tags_delete_from_text">Tags written as #tag in a note come back when that note is next edited.</string>
|
||||||
|
|
||||||
|
<string name="tags_cancel">Cancel</string>
|
||||||
|
|
||||||
<!-- Pickers -->
|
<!-- Pickers -->
|
||||||
<string name="color_picker_title">Color</string>
|
<string name="label_picker_title">Tags</string>
|
||||||
<string name="label_picker_title">Labels</string>
|
<string name="label_new_hint">Type a tag and press enter</string>
|
||||||
<string name="label_new_hint">Type a label and press enter</string>
|
<string name="label_from_tag">from the text</string>
|
||||||
<string name="label_from_tag">from #tag</string>
|
<string name="label_none_body">No tags yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
||||||
<string name="label_none_body">No labels yet. Type one above, or write a #tag in a note and it becomes one.</string>
|
|
||||||
<string name="picker_next">Next</string>
|
<string name="picker_next">Next</string>
|
||||||
<string name="picker_set">Set</string>
|
<string name="picker_set">Set</string>
|
||||||
<string name="picker_time_title">Pick a time</string>
|
<string name="picker_time_title">Pick a time</string>
|
||||||
@@ -107,7 +209,7 @@
|
|||||||
<string name="reminder_channel">Reminders</string>
|
<string name="reminder_channel">Reminders</string>
|
||||||
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
|
<string name="reminder_channel_description">Notifies you when a note\'s reminder is due.</string>
|
||||||
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
|
<string name="reminder_notifications_blocked_title">Reminders can\'t notify you</string>
|
||||||
<string name="reminder_notifications_blocked_body">Notifications are turned off for ThoughtSync, so reminders will only show here on the board.</string>
|
<string name="reminder_notifications_blocked_body">Notifications are turned off for Inkwell, so reminders will only show here on the board.</string>
|
||||||
<string name="reminder_open_settings">Open settings</string>
|
<string name="reminder_open_settings">Open settings</string>
|
||||||
<string name="reminder_inexact_title">Reminders may arrive late</string>
|
<string name="reminder_inexact_title">Reminders may arrive late</string>
|
||||||
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
|
<string name="reminder_inexact_body">Without permission for exact alarms, Android delivers reminders when it next wakes the phone — usually within a few minutes, sometimes longer.</string>
|
||||||
@@ -120,9 +222,11 @@
|
|||||||
<string name="update_current">You\'re on the newest build this server has.</string>
|
<string name="update_current">You\'re on the newest build this server has.</string>
|
||||||
<string name="update_check">Check for an update</string>
|
<string name="update_check">Check for an update</string>
|
||||||
<string name="update_install">Update</string>
|
<string name="update_install">Update</string>
|
||||||
|
<string name="update_banner_ready">Build %1$s is downloaded and ready.</string>
|
||||||
|
<string name="update_later">Later</string>
|
||||||
<string name="update_failed_title">The update didn\'t install</string>
|
<string name="update_failed_title">The update didn\'t install</string>
|
||||||
<string name="update_permission_title">Android needs your permission</string>
|
<string name="update_permission_title">Android needs your permission</string>
|
||||||
<string name="update_permission_body">ThoughtSync has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
<string name="update_permission_body">Inkwell has to be allowed to install apps before it can update itself. This is a one-time setting.</string>
|
||||||
<string name="update_permission_action">Allow installing</string>
|
<string name="update_permission_action">Allow installing</string>
|
||||||
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
|
<string name="update_needs_server">App updates come from a server you connect. Until then, install new builds yourself.</string>
|
||||||
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
|
<string name="sync_footer">Your notes live on this device either way — syncing just keeps a server copy in step, so your other devices can catch up.</string>
|
||||||
@@ -137,14 +241,14 @@
|
|||||||
|
|
||||||
<!-- Unlinked -->
|
<!-- Unlinked -->
|
||||||
<string name="sync_offline_title">Working offline on this device</string>
|
<string name="sync_offline_title">Working offline on this device</string>
|
||||||
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect a ThoughtSync server if you want them to reach your other devices.</string>
|
<string name="sync_offline_body">Everything works without a server — your notes are stored on this phone. Connect an Inkwell server if you want them to reach your other devices.</string>
|
||||||
<string name="sync_address_label">Server address</string>
|
<string name="sync_address_label">Server address</string>
|
||||||
<string name="sync_address_hint">notes.example.com</string>
|
<string name="sync_address_hint">notes.example.com</string>
|
||||||
<string name="sync_address_help">Uses https unless you type http:// yourself.</string>
|
<string name="sync_address_help">Uses https unless you type http:// yourself.</string>
|
||||||
<string name="sync_check">Check</string>
|
<string name="sync_check">Check</string>
|
||||||
<string name="sync_probe_failed">Couldn\'t reach that server</string>
|
<string name="sync_probe_failed">Couldn\'t reach that server</string>
|
||||||
<string name="sync_link_failed">Couldn\'t connect</string>
|
<string name="sync_link_failed">Couldn\'t connect</string>
|
||||||
<string name="sync_server_generic">ThoughtSync server</string>
|
<string name="sync_server_generic">Inkwell server</string>
|
||||||
<string name="sync_server_version">v%1$s</string>
|
<string name="sync_server_version">v%1$s</string>
|
||||||
<string name="sync_compat_ok">Fully compatible.</string>
|
<string name="sync_compat_ok">Fully compatible.</string>
|
||||||
<string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
|
<string name="sync_compat_degraded">Compatible, but these features aren\'t available on this server: %1$s.</string>
|
||||||
@@ -188,7 +292,20 @@
|
|||||||
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item>
|
<item quantity="one">%d attachment didn\'t download — it\'ll retry on the next sync.</item>
|
||||||
<item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
|
<item quantity="other">%d attachments didn\'t download — they\'ll retry on the next sync.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="sync_summary_uploaded">
|
||||||
|
<item quantity="one">uploaded %d file</item>
|
||||||
|
<item quantity="other">uploaded %d files</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="sync_summary_upload_failed">
|
||||||
|
<item quantity="one">%d file didn\'t upload. If the server refused it, its note says why; otherwise it\'ll retry.</item>
|
||||||
|
<item quantity="other">%d files didn\'t upload. Any the server refused say why on their note; the rest will retry.</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Errors -->
|
<!-- Errors -->
|
||||||
<string name="error_dismiss">Dismiss</string>
|
<string name="error_dismiss">Dismiss</string>
|
||||||
|
|
||||||
|
<!-- The build, at the foot of Sync. Never blank: an APK with no versionName is
|
||||||
|
a real state (a bare `gradlew assembleDebug` with no override) and saying
|
||||||
|
so is better than an empty line that reads as a layout bug. -->
|
||||||
|
<string name="build_unknown">unknown</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
truth in XML — the same reason the desktop reads its live theme rather
|
truth in XML — the same reason the desktop reads its live theme rather
|
||||||
than hardcoding a window colour.
|
than hardcoding a window colour.
|
||||||
-->
|
-->
|
||||||
<style name="Theme.ThoughtSync" parent="android:Theme.Material.NoActionBar" />
|
<style name="Theme.Inkwell" parent="android:Theme.Material.NoActionBar" />
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
What the FileProvider may hand to another app: ONLY the copies made to open an
|
||||||
|
attachment (AttachmentFiles.open), never the store or the blob directory itself.
|
||||||
|
A viewer gets read access to the one file it was asked to show.
|
||||||
|
-->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="open" path="open/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AttachmentRulesTest {
|
||||||
|
@Test
|
||||||
|
fun `raster images draw inline and SVG never does`() {
|
||||||
|
assertTrue(rendersInline("image/png"))
|
||||||
|
assertTrue(rendersInline("image/jpeg"))
|
||||||
|
assertTrue(rendersInline("IMAGE/WEBP; charset=binary"))
|
||||||
|
assertFalse(rendersInline("image/svg+xml"))
|
||||||
|
assertFalse(rendersInline("Image/SVG+XML"))
|
||||||
|
assertFalse(rendersInline("application/pdf"))
|
||||||
|
assertFalse(rendersInline(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a decode is scaled down but never below the size it is drawn at`() {
|
||||||
|
assertEquals(1, sampleSize(width = 600, height = 400, maxPx = 640))
|
||||||
|
assertEquals(1, sampleSize(width = 1279, height = 900, maxPx = 640))
|
||||||
|
assertEquals(2, sampleSize(width = 1280, height = 900, maxPx = 640))
|
||||||
|
// A 12-megapixel portrait photo for a card: the longer side decides.
|
||||||
|
assertEquals(4, sampleSize(width = 3024, height = 4032, maxPx = 640))
|
||||||
|
assertTrue(4032 / sampleSize(3024, 4032, 640) >= 640)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cache copy's name cannot leave its directory and is never empty`() {
|
||||||
|
assertEquals("receipt.pdf", safeName("receipt.pdf"))
|
||||||
|
assertEquals("passwd", safeName("../../etc/passwd"))
|
||||||
|
assertEquals("evil.txt", safeName("C:\\temp\\evil.txt"))
|
||||||
|
assertEquals("file", safeName(".."))
|
||||||
|
assertEquals("file", safeName(" "))
|
||||||
|
assertEquals("file", safeName(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sizes print like the web prints them`() {
|
||||||
|
assertEquals("512 B", sizeLabel(512))
|
||||||
|
assertEquals("2 KB", sizeLabel(1536))
|
||||||
|
assertEquals("3.5 MB", sizeLabel(3_670_016))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BoardFiltersTest {
|
||||||
|
@Test
|
||||||
|
fun `no filters asks the core for no facets at all`() {
|
||||||
|
assertNull(BoardFilters().facets())
|
||||||
|
assertEquals(0, BoardFilters().count)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `each tag counts as one, as on the web's badge`() {
|
||||||
|
val filters = BoardFilters(labelIds = setOf("a", "b"), hasAttachment = true, sharedWithMe = true)
|
||||||
|
assertEquals(4, filters.count)
|
||||||
|
|
||||||
|
val facets = requireNotNull(filters.facets())
|
||||||
|
assertEquals(setOf("a", "b"), facets.label?.toSet())
|
||||||
|
assertEquals(true, facets.hasAttachment)
|
||||||
|
assertEquals("with_me", facets.shared)
|
||||||
|
// Dates are Timeline's and text is the search bar's; the filters never set them.
|
||||||
|
assertNull(facets.q)
|
||||||
|
assertNull(facets.createdAfter)
|
||||||
|
assertNull(facets.createdBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a switch that is off is left out rather than sent as false`() {
|
||||||
|
val facets = requireNotNull(BoardFilters(labelIds = setOf("a")).facets())
|
||||||
|
assertNull(facets.hasAttachment)
|
||||||
|
assertNull(facets.shared)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tapping a tag twice takes it back off`() {
|
||||||
|
val once = BoardFilters().toggleLabel("a")
|
||||||
|
assertEquals(setOf("a"), once.labelIds)
|
||||||
|
assertEquals(BoardFilters(), once.toggleLabel("a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.fabledsword.inkwell.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the derived-colour rule against `frontend/src/notes/colors.ts`.
|
||||||
|
*
|
||||||
|
* These are not tests of Kotlin — they pin the pair. The web runs the same values
|
||||||
|
* from `core/testdata/grammar.json` (`tint`) in `notes/grammar.test.ts`; this file
|
||||||
|
* writes them out by hand, so changing the hash or the key order means changing the
|
||||||
|
* fixture and this file together, or one of the two suites goes red.
|
||||||
|
*
|
||||||
|
* SMALLER SINCE M315. Half of what this file used to pin — the generated card fill, and
|
||||||
|
* the resolution order that chose between a picked colour, a tag's and a generated one
|
||||||
|
* — went with the code it guarded when the card became one neutral. The four UUID
|
||||||
|
* hashes stay because they are what the hash ITSELF is pinned by; nothing derives a
|
||||||
|
* colour from an id any more, only from a tag's name.
|
||||||
|
*/
|
||||||
|
class DerivedTintTest {
|
||||||
|
@Test
|
||||||
|
fun `hashes match the fixture shared with the web`() {
|
||||||
|
// Kotlin's Int is signed, so the two hashes above 0x7FFFFFFF are written as
|
||||||
|
// their negative literal. The unsigned value in the comment is what colors.ts
|
||||||
|
// records and what an implementation of FNV-1a will actually produce.
|
||||||
|
assertEquals(-0x41B8712F, tintHash("00000000-0000-0000-0000-000000000000")) // 0xbe478ed1
|
||||||
|
assertEquals(0x3D75CC01, tintHash("11111111-1111-1111-1111-111111111111"))
|
||||||
|
assertEquals(-0x0EF71AD0, tintHash("6ba7b810-9dad-11d1-80b4-00c04fd430c8")) // 0xf108e530
|
||||||
|
assertEquals(0x5B651540, tintHash("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `colours match the fixture shared with the web`() {
|
||||||
|
assertEquals("purple", derivedTint("00000000-0000-0000-0000-000000000000"))
|
||||||
|
assertEquals("blue", derivedTint("11111111-1111-1111-1111-111111111111"))
|
||||||
|
assertEquals("orange", derivedTint("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
|
||||||
|
assertEquals("orange", derivedTint("f47ac10b-58cc-4372-a567-0e02b2c3d479"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half of all 32-bit hashes are negative as Kotlin Ints; a signed remainder would
|
||||||
|
* index out of the list for those. The bug this catches is a crash, not a wrong
|
||||||
|
* colour, so it is worth more than one name's worth of coverage. */
|
||||||
|
@Test
|
||||||
|
fun `every derived colour is a real palette key, over many names`() {
|
||||||
|
for (n in 0 until 2000) {
|
||||||
|
assertEquals(true, derivedTint("tag-$n") in DERIVED_TINT_KEYS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the derived palette excludes default`() {
|
||||||
|
assertEquals(false, "default" in DERIVED_TINT_KEYS)
|
||||||
|
assertEquals(9, DERIVED_TINT_KEYS.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The order IS the mapping — reordering silently recolours every tag on one
|
||||||
|
* surface only. Written out longhand so a reorder fails here loudly. */
|
||||||
|
@Test
|
||||||
|
fun `key order matches colors ts`() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray"),
|
||||||
|
DERIVED_TINT_KEYS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tag with no colour of its own derives one from its NAME, which is what makes
|
||||||
|
* every `#todo` chip the same colour rather than nine different ones. */
|
||||||
|
@Test
|
||||||
|
fun `a label with no colour derives one from its name`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
val todo = resolvedLabelColor("todo", "default", known)
|
||||||
|
assertEquals(derivedTint("todo"), todo)
|
||||||
|
assertNotEquals("default", todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags dedupe case-insensitively, so `#Todo` and `#todo` are one tag and must not
|
||||||
|
* be two colours. This is the whole reason the name is lowercased first. */
|
||||||
|
@Test
|
||||||
|
fun `label colour ignores case`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
assertEquals(
|
||||||
|
resolvedLabelColor("todo", "default", known),
|
||||||
|
resolvedLabelColor("ToDo", "default", known),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `teal` deliberately, NOT the colour "todo" derives to (pink) — asserting the
|
||||||
|
* derived value here would pass even with the explicit branch deleted. */
|
||||||
|
@Test
|
||||||
|
fun `an explicitly picked label colour still wins`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
assertNotEquals("teal", derivedTint("todo"))
|
||||||
|
assertEquals("teal", resolvedLabelColor("todo", "teal", known))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unreadable key is not a choice — it is data from a server newer than this
|
||||||
|
* client, and the tag should still be drawn as something. */
|
||||||
|
@Test
|
||||||
|
fun `an unknown colour key falls back to the derived colour`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
assertEquals(derivedTint("todo"), resolvedLabelColor("todo", "chartreuse", known))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A label with no name at all has nothing to hash. Neutral, not a random hue. */
|
||||||
|
@Test
|
||||||
|
fun `a nameless label stays default`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
assertEquals("default", resolvedLabelColor("", "", known))
|
||||||
|
assertEquals("default", resolvedLabelColor("", "default", known))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spread is real, and collisions are real too.
|
||||||
|
*
|
||||||
|
* Nine keys means two tags sharing a colour is not a bug and cannot be designed
|
||||||
|
* out — in this very sample `home`/`reading` are both gray and `work`/`ideas` are
|
||||||
|
* both green. Colour is a hint that two chips are distinct, never a claim that two
|
||||||
|
* of one colour are the same tag; the chip's TEXT is what says which tag it is.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `different tag names spread across the palette`() {
|
||||||
|
val known = DERIVED_TINT_KEYS.toSet() + "default"
|
||||||
|
val names = listOf("todo", "grocery", "work", "home", "ideas", "reading", "urgent")
|
||||||
|
val colours = names.map { resolvedLabelColor(it, "default", known) }
|
||||||
|
assertEquals(true, colours.toSet().size >= 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reason the feature exists: two tags on one board should not look identical. */
|
||||||
|
@Test
|
||||||
|
fun `the derived colour spreads across the palette`() {
|
||||||
|
val seen = (0 until 500).map { derivedTint("spread-$it") }.toSet()
|
||||||
|
assertEquals(DERIVED_TINT_KEYS.size, seen.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "thoughtsync-uniffi-bindgen"
|
name = "inkwell-uniffi-bindgen"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Generates the Kotlin bindings for thoughtsync-ffi"
|
description = "Generates the Kotlin bindings for inkwell-ffi"
|
||||||
authors = ["bvandeusen"]
|
authors = ["bvandeusen"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# A crate whose ONLY dependency is uniffi itself.
|
# A crate whose ONLY dependency is uniffi itself.
|
||||||
#
|
#
|
||||||
# This started life as a `[[bin]]` inside thoughtsync-ffi, which failed: building
|
# This started life as a `[[bin]]` inside inkwell-ffi, which failed: building
|
||||||
# it compiled that crate and therefore the core, reqwest, native-tls and
|
# it compiled that crate and therefore the core, reqwest, native-tls and
|
||||||
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
|
# openssl-sys — for the HOST. The vendored-OpenSSL block in core/Cargo.toml is
|
||||||
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
|
# scoped to `cfg(target_os = "android")`, so a host build looks for a system
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
|
//! Invoked by Gradle (see android/app/build.gradle.kts) as:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! cargo run --locked -p thoughtsync-uniffi-bindgen -- \
|
//! cargo run --locked -p inkwell-uniffi-bindgen -- \
|
||||||
//! generate --library <path/to/libthoughtsync_ffi.so> \
|
//! generate --library <path/to/libinkwell_ffi.so> \
|
||||||
//! --language kotlin --out-dir <build/generated/uniffi>
|
//! --language kotlin --out-dir <build/generated/uniffi>
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ exceptions:
|
|||||||
# right and still applies.
|
# right and still applies.
|
||||||
excludes:
|
excludes:
|
||||||
- "**/ui/**"
|
- "**/ui/**"
|
||||||
- "**/ThoughtSyncApplication.kt"
|
- "**/InkwellApplication.kt"
|
||||||
- "**/SyncWorker.kt"
|
- "**/SyncWorker.kt"
|
||||||
- "**/ReminderReceiver.kt"
|
- "**/ReminderReceiver.kt"
|
||||||
- "**/AppUpdate.kt"
|
- "**/AppUpdate.kt"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "thoughtsync-ffi"
|
name = "inkwell-ffi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "uniffi bindings exposing thoughtsync-core to the native Android client"
|
description = "uniffi bindings exposing inkwell-core to the native Android client"
|
||||||
authors = ["bvandeusen"]
|
authors = ["bvandeusen"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@ edition = "2021"
|
|||||||
# bindgen binary below — and this crate's own tests — can use the crate normally;
|
# bindgen binary below — and this crate's own tests — can use the crate normally;
|
||||||
# a cdylib-only crate is unusable from Rust.
|
# a cdylib-only crate is unusable from Rust.
|
||||||
crate-type = ["cdylib", "lib"]
|
crate-type = ["cdylib", "lib"]
|
||||||
name = "thoughtsync_ffi"
|
name = "inkwell_ffi"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
thoughtsync-core = { path = "../../core" }
|
inkwell-core = { path = "../../core" }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
+369
-112
@@ -1,4 +1,4 @@
|
|||||||
//! uniffi bindings: `thoughtsync-core` as seen from Kotlin.
|
//! uniffi bindings: `inkwell-core` as seen from Kotlin.
|
||||||
//!
|
//!
|
||||||
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
|
//! This crate is to Android what `desktop/src-tauri/src/commands/` is to the desktop
|
||||||
//! — a thin shim over the shared core, holding no logic of its own. If something here
|
//! — a thin shim over the shared core, holding no logic of its own. If something here
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Shape
|
//! ## Shape
|
||||||
//!
|
//!
|
||||||
//! One `ThoughtSync` object holds the store and the blob directory, mirroring how
|
//! One `Inkwell` object holds the store and the blob directory, mirroring how
|
||||||
//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
|
//! Tauri manages them as app state. Kotlin constructs it once, keeps it for the
|
||||||
//! process lifetime, and calls methods on it.
|
//! process lifetime, and calls methods on it.
|
||||||
//!
|
//!
|
||||||
@@ -38,13 +38,14 @@ pub mod models;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use thoughtsync_core::local::{self, Db};
|
use inkwell_core::local::{self, Db};
|
||||||
use thoughtsync_core::sync::blobs::BlobStore;
|
use inkwell_core::sync::blobs::BlobStore;
|
||||||
use thoughtsync_core::sync::{client, compat, engine, push, state};
|
use inkwell_core::sync::{client, compat, engine, push, sharing, state};
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
patch_from, BodyItem, ClientUpdate, Identity, Label, Note, NoteDraft, NoteEdit, NoteQuery,
|
patch_from, BodyItem, BodyTag, ClientUpdate, Directory, Identity, Label, Note, NoteDraft,
|
||||||
ProbeResult, RevokeOutcome, SyncOutcome, SyncStatus,
|
NoteEdit, NoteQuery, NoteShare, ProbeResult, RevokeOutcome, ShareTarget, SyncOutcome,
|
||||||
|
SyncStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
@@ -108,30 +109,30 @@ impl CoreError {
|
|||||||
/// behind its mutex, the blob store being a path — which is what lets uniffi share
|
/// behind its mutex, the blob store being a path — which is what lets uniffi share
|
||||||
/// one instance across coroutines.
|
/// one instance across coroutines.
|
||||||
#[derive(uniffi::Object)]
|
#[derive(uniffi::Object)]
|
||||||
pub struct ThoughtSync {
|
pub struct Inkwell {
|
||||||
db: Db,
|
db: Db,
|
||||||
blobs: BlobStore,
|
blobs: BlobStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
impl ThoughtSync {
|
impl Inkwell {
|
||||||
/// Open (creating on first run) the store under `data_dir`, and the attachment
|
/// Open (creating on first run) the store under `data_dir`, and the attachment
|
||||||
/// directory beside it.
|
/// directory beside it.
|
||||||
///
|
///
|
||||||
/// `data_dir` comes from Kotlin because only Android knows where its app-private
|
/// `data_dir` comes from Kotlin because only Android knows where its app-private
|
||||||
/// storage is; the core must not guess at a platform path. The layout inside is
|
/// storage is; the core must not guess at a platform path. The layout inside is
|
||||||
/// the core's business and matches the desktop's exactly — `thoughtsync.db` and
|
/// the core's business and matches the desktop's exactly — `inkwell.db` and
|
||||||
/// `blobs/` — so a store is readable by any client that opens it.
|
/// `blobs/` — so a store is readable by any client that opens it.
|
||||||
#[uniffi::constructor]
|
#[uniffi::constructor]
|
||||||
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
|
pub fn new(data_dir: String) -> Result<Arc<Self>, CoreError> {
|
||||||
let dir = PathBuf::from(data_dir);
|
let dir = PathBuf::from(data_dir);
|
||||||
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
|
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
|
||||||
|
|
||||||
let db = local::open(&dir.join("thoughtsync.db")).map_err(CoreError::store)?;
|
let db = local::open(&dir.join("inkwell.db")).map_err(CoreError::store)?;
|
||||||
log::info!("local store ready — {}", local::summary(&db));
|
log::info!("local store ready — {}", local::summary(&db));
|
||||||
|
|
||||||
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
|
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
|
||||||
Ok(Arc::new(ThoughtSync { db, blobs }))
|
Ok(Arc::new(Inkwell { db, blobs }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A one-line count summary, for the boot log.
|
/// A one-line count summary, for the boot log.
|
||||||
@@ -222,52 +223,38 @@ impl ThoughtSync {
|
|||||||
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
|
local::store::delete_forever(&conn, &id).map_err(CoreError::store)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────── checklist items ────────────────────────────
|
/// Put the board in this order, first id on top: the desktop's `notes_reorder`.
|
||||||
//
|
|
||||||
// Every one of these returns the whole reloaded note rather than the item it
|
|
||||||
// touched. That is the core's shape, and it is the right one for a UI: ticking
|
|
||||||
// a box changes `updated_at` and can change what the board shows, so handing
|
|
||||||
// back only the item would leave Kotlin to guess at the rest.
|
|
||||||
|
|
||||||
pub fn add_item(&self, note_id: String, text: String) -> Result<Note, CoreError> {
|
|
||||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
||||||
local::store::add_item(&conn, ¬e_id, &text)
|
|
||||||
.map(Note::from)
|
|
||||||
.map_err(CoreError::store)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retitle one item.
|
|
||||||
///
|
///
|
||||||
/// Split from `set_item_checked` rather than exposing the core's
|
/// Returns nothing, as the board reloads its list afterwards anyway. The core marks
|
||||||
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an
|
/// each note dirty so the new positions sync, and a note shared with this account
|
||||||
/// optional-field struct cannot say "leave this alone" in Kotlin without
|
/// moves on this board only (#5176).
|
||||||
/// colliding with "set it to null", and two unambiguous calls beat one
|
pub fn reorder_notes(&self, ordered_ids: Vec<String>) -> Result<(), CoreError> {
|
||||||
/// ambiguous one when each is three lines.
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
pub fn set_item_text(
|
local::store::reorder(&conn, &ordered_ids).map_err(CoreError::store)
|
||||||
&self,
|
|
||||||
note_id: String,
|
|
||||||
item_id: String,
|
|
||||||
text: String,
|
|
||||||
) -> Result<Note, CoreError> {
|
|
||||||
self.patch_item(¬e_id, &item_id, serde_json::json!({ "text": text }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── checklist items ────────────────────────────
|
||||||
|
|
||||||
|
/// Tick or untick one item. Adding, rewording and removing items are edits to
|
||||||
|
/// the body, which the editor makes like any other.
|
||||||
|
///
|
||||||
|
/// Returns the whole reloaded note rather than the item it touched. That is the
|
||||||
|
/// core's shape, and it is the right one for a UI: ticking a box changes
|
||||||
|
/// `updated_at` and can change what the board shows, so handing back only the
|
||||||
|
/// item would leave Kotlin to guess at the rest.
|
||||||
pub fn set_item_checked(
|
pub fn set_item_checked(
|
||||||
&self,
|
&self,
|
||||||
note_id: String,
|
note_id: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
checked: bool,
|
checked: bool,
|
||||||
) -> Result<Note, CoreError> {
|
) -> Result<Note, CoreError> {
|
||||||
self.patch_item(
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::update_item(
|
||||||
|
&conn,
|
||||||
¬e_id,
|
¬e_id,
|
||||||
&item_id,
|
&item_id,
|
||||||
serde_json::json!({ "checked": checked }),
|
&serde_json::json!({ "checked": checked }),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
|
|
||||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
|
||||||
local::store::delete_item(&conn, ¬e_id, &item_id)
|
|
||||||
.map(Note::from)
|
.map(Note::from)
|
||||||
.map_err(CoreError::store)
|
.map_err(CoreError::store)
|
||||||
}
|
}
|
||||||
@@ -333,6 +320,115 @@ impl ThoughtSync {
|
|||||||
.map_err(CoreError::store)
|
.map_err(CoreError::store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a label. Every note carrying it follows, because notes reference it
|
||||||
|
/// by id and never by name.
|
||||||
|
///
|
||||||
|
/// Renaming onto a name another tag already holds MERGES the two, and the OLDER
|
||||||
|
/// row is the survivor — it keeps its id and colour and takes the new spelling.
|
||||||
|
/// Matching is case-insensitive, like `find_or_create_label`.
|
||||||
|
///
|
||||||
|
/// So this call can return a label whose id is NOT the one passed in, and it can
|
||||||
|
/// make another label stop existing. A UI over it should say so before calling:
|
||||||
|
/// the merge cannot be undone by repeating it, and here it is reachable by a
|
||||||
|
/// typo in a text field. The web asks first (`stores/labels.ts`); this binding
|
||||||
|
/// deliberately does not, because a confirmation belongs to the surface that has
|
||||||
|
/// a person in front of it, not to the store.
|
||||||
|
///
|
||||||
|
/// `store::rename_label` and the server's PATCH implement the same rule, so the
|
||||||
|
/// phone, the desktop and the web agree on which row survives.
|
||||||
|
pub fn rename_label(&self, id: String, name: String) -> Result<Label, CoreError> {
|
||||||
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::rename_label(&conn, &id, &name)
|
||||||
|
.map(Label::from)
|
||||||
|
.map_err(CoreError::store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recolour a label.
|
||||||
|
///
|
||||||
|
/// `color` is a palette KEY from the shared vocabulary (`NoteTint.kt` on this
|
||||||
|
/// side), not a hex value — the point of the shared palette is that a colour
|
||||||
|
/// picked on the phone resolves to the same swatch on the web and the desktop,
|
||||||
|
/// which a literal colour could not promise across themes.
|
||||||
|
pub fn set_label_color(&self, id: String, color: String) -> Result<Label, CoreError> {
|
||||||
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::set_label_color(&conn, &id, &color)
|
||||||
|
.map(Label::from)
|
||||||
|
.map_err(CoreError::store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a label. The notes that carried it are NOT deleted — they simply stop
|
||||||
|
/// carrying it, which is the thing a confirmation dialog has to say out loud.
|
||||||
|
///
|
||||||
|
/// A `#tag` in a body will re-derive the label on the next edit of that note.
|
||||||
|
/// That is correct rather than a leak: the text mandates it, and deleting the
|
||||||
|
/// row cannot un-write the word.
|
||||||
|
pub fn remove_label(&self, id: String) -> Result<(), CoreError> {
|
||||||
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::remove_label(&conn, &id).map_err(CoreError::store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold `source` into `target` and return the survivor.
|
||||||
|
///
|
||||||
|
/// DIRECTIONAL and NOT reversible by repeating it: source stops existing. Any
|
||||||
|
/// UI over this has to name the survivor before it runs, because afterwards
|
||||||
|
/// there is nothing left to read the direction from.
|
||||||
|
pub fn merge_labels(&self, source_id: String, target_id: String) -> Result<Label, CoreError> {
|
||||||
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::merge_labels(&conn, &source_id, &target_id)
|
||||||
|
.map(Label::from)
|
||||||
|
.map_err(CoreError::store)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────── attachments and previews ──────────────────────────
|
||||||
|
|
||||||
|
/// Attach a file. The bytes go into the blob store now and up to the server on
|
||||||
|
/// the next sync that can reach one, so attaching works with no network.
|
||||||
|
///
|
||||||
|
/// The bytes cross the FFI as one buffer. Kotlin caps what it reads before
|
||||||
|
/// calling, because this copies the whole file into Rust's memory once.
|
||||||
|
pub fn add_attachment(
|
||||||
|
&self,
|
||||||
|
note_id: String,
|
||||||
|
filename: String,
|
||||||
|
mime: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
) -> Result<Note, CoreError> {
|
||||||
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::add_attachment(&conn, &self.blobs, ¬e_id, &filename, &mime, &bytes)
|
||||||
|
.map(Note::from)
|
||||||
|
.map_err(CoreError::store)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_attachment(
|
||||||
|
&self,
|
||||||
|
note_id: String,
|
||||||
|
attachment_id: String,
|
||||||
|
) -> Result<Note, CoreError> {
|
||||||
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::delete_attachment(&conn, ¬e_id, &attachment_id)
|
||||||
|
.map(Note::from)
|
||||||
|
.map_err(CoreError::store)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_preview(&self, note_id: String, preview_id: String) -> Result<Note, CoreError> {
|
||||||
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::delete_preview(&conn, ¬e_id, &preview_id)
|
||||||
|
.map(Note::from)
|
||||||
|
.map_err(CoreError::store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this device holds an attachment's bytes, or None when it doesn't yet
|
||||||
|
/// (still downloading, or the download failed).
|
||||||
|
///
|
||||||
|
/// A path rather than the bytes, so Kotlin can decode an image at the size it
|
||||||
|
/// will be drawn instead of pulling the full file across the FFI for a thumbnail.
|
||||||
|
pub fn blob_path(&self, sha256: String) -> Option<String> {
|
||||||
|
self.blobs
|
||||||
|
.path(&sha256)
|
||||||
|
.filter(|p| p.is_file())
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────── sync ────────────────────────────────
|
// ─────────────────────────────── sync ────────────────────────────────
|
||||||
|
|
||||||
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
pub fn sync_status(&self) -> Result<SyncStatus, CoreError> {
|
||||||
@@ -354,7 +450,7 @@ impl ThoughtSync {
|
|||||||
/// functions. Split into its own impl block so the runtime attribute — and the fact
|
/// functions. Split into its own impl block so the runtime attribute — and the fact
|
||||||
/// that everything in here touches the network — is visible at a glance.
|
/// that everything in here touches the network — is visible at a glance.
|
||||||
#[uniffi::export(async_runtime = "tokio")]
|
#[uniffi::export(async_runtime = "tokio")]
|
||||||
impl ThoughtSync {
|
impl Inkwell {
|
||||||
/// Ask a server who it is, without committing to anything. Called as the user
|
/// Ask a server who it is, without committing to anything. Called as the user
|
||||||
/// finishes typing an address, so they see what answered before handing over
|
/// finishes typing an address, so they see what answered before handing over
|
||||||
/// credentials.
|
/// credentials.
|
||||||
@@ -466,7 +562,7 @@ impl ThoughtSync {
|
|||||||
///
|
///
|
||||||
/// Takes the destination rather than choosing one: only Android knows a
|
/// Takes the destination rather than choosing one: only Android knows a
|
||||||
/// directory its own package installer can read from, and the core has no
|
/// directory its own package installer can read from, and the core has no
|
||||||
/// business guessing at platform paths — the same reason `ThoughtSync::new`
|
/// business guessing at platform paths — the same reason `Inkwell::new`
|
||||||
/// takes a data dir.
|
/// takes a data dir.
|
||||||
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
|
pub async fn download_client_update(&self, dest_path: String) -> Result<(), CoreError> {
|
||||||
let (base_url, token) = self.credentials()?;
|
let (base_url, token) = self.credentials()?;
|
||||||
@@ -497,6 +593,55 @@ impl ThoughtSync {
|
|||||||
.map(SyncOutcome::from)
|
.map(SyncOutcome::from)
|
||||||
.map_err(CoreError::network)
|
.map_err(CoreError::network)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────── sharing ──────────────────────────────
|
||||||
|
//
|
||||||
|
// The Share dialog asks the server directly (#5175). Unlinked, each answers
|
||||||
|
// `NotLinked`, which the dialog turns into "sharing needs a server".
|
||||||
|
|
||||||
|
/// Everyone on the instance a note can be shared with, and every group.
|
||||||
|
pub async fn share_directory(&self) -> Result<Directory, CoreError> {
|
||||||
|
self.credentials()?;
|
||||||
|
let directory = sharing::directory(&self.db)
|
||||||
|
.await
|
||||||
|
.map_err(CoreError::network)?;
|
||||||
|
Ok(directory.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who this note is shared with.
|
||||||
|
pub async fn note_shares(&self, note_id: String) -> Result<Vec<NoteShare>, CoreError> {
|
||||||
|
self.credentials()?;
|
||||||
|
let shares = sharing::list(&self.db, ¬e_id)
|
||||||
|
.await
|
||||||
|
.map_err(CoreError::network)?;
|
||||||
|
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share with a person or a group at "view" or "edit", or change their permission.
|
||||||
|
pub async fn share_note(
|
||||||
|
&self,
|
||||||
|
note_id: String,
|
||||||
|
target: ShareTarget,
|
||||||
|
permission: String,
|
||||||
|
) -> Result<Vec<NoteShare>, CoreError> {
|
||||||
|
self.credentials()?;
|
||||||
|
let shares = sharing::share(&self.db, ¬e_id, &target.into(), &permission)
|
||||||
|
.await
|
||||||
|
.map_err(CoreError::network)?;
|
||||||
|
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unshare_note(
|
||||||
|
&self,
|
||||||
|
note_id: String,
|
||||||
|
share_id: String,
|
||||||
|
) -> Result<Vec<NoteShare>, CoreError> {
|
||||||
|
self.credentials()?;
|
||||||
|
let shares = sharing::unshare(&self.db, ¬e_id, &share_id)
|
||||||
|
.await
|
||||||
|
.map_err(CoreError::network)?;
|
||||||
|
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── checklist text, as pure functions ───────────────────────────────────────
|
// ── checklist text, as pure functions ───────────────────────────────────────
|
||||||
@@ -520,6 +665,20 @@ pub fn checklist_render(text: String, checked: bool) -> String {
|
|||||||
local::derive::render_item(&text, checked)
|
local::derive::render_item(&text, checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the core which app it is running inside, and which build of it.
|
||||||
|
///
|
||||||
|
/// Android has to say so because the core cannot: the same crate is compiled into
|
||||||
|
/// the desktop app, and it used to announce every phone in the field as
|
||||||
|
/// `inkwell-desktop` carrying the CORE crate's version — a number no build
|
||||||
|
/// stamps and nobody has seen. The honest value is the installed package's own
|
||||||
|
/// `versionName`, which is what Kotlin passes here.
|
||||||
|
///
|
||||||
|
/// Called once from `InkwellApplication.onCreate`, before anything can sync.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn set_client_agent(name: String, version: String) {
|
||||||
|
compat::set_client_agent(&name, &version);
|
||||||
|
}
|
||||||
|
|
||||||
/// Every checklist item in a body, with the line each one sits on — so a renderer
|
/// Every checklist item in a body, with the line each one sits on — so a renderer
|
||||||
/// walking the body line by line knows which lines are boxes and what is in them.
|
/// walking the body line by line knows which lines are boxes and what is in them.
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
@@ -530,25 +689,24 @@ pub fn checklist_items(body: String) -> Vec<BodyItem> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
/// Every `#tag` in a body, with the line and the UTF-16 span each one occupies — so
|
||||||
/// block names, so these stay Rust-side.
|
/// a card can colour the tag where it was typed instead of printing it twice.
|
||||||
impl ThoughtSync {
|
|
||||||
/// Apply a `{text}` or `{checked}` patch to one checklist item.
|
|
||||||
///
|
///
|
||||||
/// The two public setters differ only in the key they write, and the lock +
|
/// The same argument as `checklist_items` above, and the same answer: the grammar for
|
||||||
/// convert + map-error dance around it is identical, so it lives once here.
|
/// what a `#tag` is already exists in Rust, Python and TypeScript. Matching it a
|
||||||
fn patch_item(
|
/// fourth time in Compose would be a fourth place for a tag to change shape when it
|
||||||
&self,
|
/// syncs — and this one would fail silently, as the wrong characters tinted.
|
||||||
note_id: &str,
|
#[uniffi::export]
|
||||||
item_id: &str,
|
pub fn body_tags(body: String) -> Vec<BodyTag> {
|
||||||
changes: serde_json::Value,
|
local::derive::extract_tag_spans(&body)
|
||||||
) -> Result<Note, CoreError> {
|
.into_iter()
|
||||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
.map(BodyTag::from)
|
||||||
local::store::update_item(&conn, note_id, item_id, &changes)
|
.collect()
|
||||||
.map(Note::from)
|
|
||||||
.map_err(CoreError::store)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||||
|
/// block names, so these stay Rust-side.
|
||||||
|
impl Inkwell {
|
||||||
/// The server URL + token, or the `NotLinked` state. Every networked call needs
|
/// The server URL + token, or the `NotLinked` state. Every networked call needs
|
||||||
/// exactly this, and none of them may hold the lock past it.
|
/// exactly this, and none of them may hold the lock past it.
|
||||||
fn credentials(&self) -> Result<(String, String), CoreError> {
|
fn credentials(&self) -> Result<(String, String), CoreError> {
|
||||||
@@ -592,7 +750,7 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir = std::env::temp_dir().join(format!(
|
||||||
"thoughtsync-ffi-{}-{}",
|
"inkwell-ffi-{}-{}",
|
||||||
std::process::id(),
|
std::process::id(),
|
||||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||||
));
|
));
|
||||||
@@ -602,7 +760,6 @@ mod tests {
|
|||||||
fn draft(body: &str) -> NoteDraft {
|
fn draft(body: &str) -> NoteDraft {
|
||||||
NoteDraft {
|
NoteDraft {
|
||||||
body: body.to_string(),
|
body: body.to_string(),
|
||||||
color: "default".to_string(),
|
|
||||||
items: None,
|
items: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,7 +771,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn creates_a_store_and_round_trips_a_note() {
|
fn creates_a_store_and_round_trips_a_note() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
let created = app
|
let created = app
|
||||||
.create_note(draft("Groceries\nmilk"))
|
.create_note(draft("Groceries\nmilk"))
|
||||||
@@ -636,7 +793,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_note_is_named_by_its_first_line() {
|
fn a_note_is_named_by_its_first_line() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
let created = app
|
let created = app
|
||||||
.create_note(draft("just a thought"))
|
.create_note(draft("just a thought"))
|
||||||
@@ -651,12 +808,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_note_with_only_items_is_named_by_its_first_item() {
|
fn a_note_with_only_items_is_named_by_its_first_item() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
let created = app
|
let created = app
|
||||||
.create_note(NoteDraft {
|
.create_note(NoteDraft {
|
||||||
body: String::new(),
|
body: String::new(),
|
||||||
color: "default".to_string(),
|
|
||||||
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
items: Some(vec!["milk".to_string(), "eggs".to_string()]),
|
||||||
})
|
})
|
||||||
.expect("create should succeed");
|
.expect("create should succeed");
|
||||||
@@ -671,7 +827,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn syncing_unlinked_reports_not_linked() {
|
fn syncing_unlinked_reports_not_linked() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
let status = app.sync_status().expect("status should read");
|
let status = app.sync_status().expect("status should read");
|
||||||
assert!(!status.linked);
|
assert!(!status.linked);
|
||||||
@@ -682,56 +838,32 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle
|
/// Ticking a box returns the reloaded note, which is what the UI splices back
|
||||||
/// it, drop it. Each call returns the reloaded note, which is what the UI
|
/// into the board rather than re-querying, and leaves the item's text alone.
|
||||||
/// splices back into the board rather than re-querying.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
|
fn ticking_an_item_rewrites_only_its_box() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app
|
let note = app
|
||||||
.create_note(NoteDraft {
|
.create_note(NoteDraft {
|
||||||
body: "Packing".to_string(),
|
body: "Packing".to_string(),
|
||||||
color: "default".to_string(),
|
items: Some(vec!["socks".to_string(), "charger".to_string()]),
|
||||||
items: Some(vec!["socks".to_string()]),
|
|
||||||
})
|
})
|
||||||
.expect("create");
|
.expect("create");
|
||||||
assert_eq!(note.items.len(), 1);
|
assert_eq!(note.items.len(), 2);
|
||||||
|
|
||||||
let with_two = app
|
let item_id = note.items[1].id.clone();
|
||||||
.add_item(note.id.clone(), "charger".to_string())
|
|
||||||
.expect("add");
|
|
||||||
assert_eq!(with_two.items.len(), 2);
|
|
||||||
// Appended, not prepended — a new row belongs at the bottom of the list the
|
|
||||||
// user is looking at.
|
|
||||||
assert_eq!(with_two.items[1].text, "charger");
|
|
||||||
|
|
||||||
let item_id = with_two.items[1].id.clone();
|
|
||||||
let ticked = app
|
let ticked = app
|
||||||
.set_item_checked(note.id.clone(), item_id.clone(), true)
|
.set_item_checked(note.id.clone(), item_id.clone(), true)
|
||||||
.expect("tick");
|
.expect("tick");
|
||||||
assert!(ticked.items[1].checked);
|
assert!(ticked.items[1].checked);
|
||||||
assert_eq!(
|
assert!(!ticked.items[0].checked);
|
||||||
ticked.items[1].text, "charger",
|
assert_eq!(ticked.items[1].text, "charger");
|
||||||
"ticking a box must not disturb its text — both setters rewrite the \
|
|
||||||
same line of the body now, so one clobbering the other is a live risk \
|
|
||||||
rather than a theoretical one"
|
|
||||||
);
|
|
||||||
|
|
||||||
let renamed = app
|
let unticked = app
|
||||||
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string())
|
.set_item_checked(note.id.clone(), item_id, false)
|
||||||
.expect("rename");
|
.expect("untick");
|
||||||
assert_eq!(renamed.items[1].text, "usb-c cable");
|
assert_eq!(unticked.body, note.body);
|
||||||
assert!(
|
|
||||||
renamed.items[1].checked,
|
|
||||||
"and the same in the other direction"
|
|
||||||
);
|
|
||||||
|
|
||||||
let trimmed = app
|
|
||||||
.delete_item(note.id.clone(), item_id)
|
|
||||||
.expect("delete item");
|
|
||||||
assert_eq!(trimmed.items.len(), 1);
|
|
||||||
assert_eq!(trimmed.items[0].text, "socks");
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
@@ -743,7 +875,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn setting_labels_leaves_tag_derived_ones_alone() {
|
fn setting_labels_leaves_tag_derived_ones_alone() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
let note = app
|
let note = app
|
||||||
.create_note(draft("Trip\nbook the ferry #travel"))
|
.create_note(draft("Trip\nbook the ferry #travel"))
|
||||||
@@ -784,7 +916,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn deleting_forever_removes_the_note() {
|
fn deleting_forever_removes_the_note() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
let note = app.create_note(draft("Ephemeral\nbody")).expect("create");
|
||||||
|
|
||||||
app.delete_note_forever(note.id.clone())
|
app.delete_note_forever(note.id.clone())
|
||||||
@@ -797,11 +929,61 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file attached here lands in the blob store, is findable by its hash, and
|
||||||
|
/// leaves both the note and the board's view of it when removed.
|
||||||
|
#[test]
|
||||||
|
fn an_attached_file_is_stored_found_and_removable() {
|
||||||
|
let dir = scratch_dir();
|
||||||
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
let note = app.create_note(draft("Receipt")).expect("create");
|
||||||
|
|
||||||
|
let attached = app
|
||||||
|
.add_attachment(
|
||||||
|
note.id.clone(),
|
||||||
|
"receipt.png".into(),
|
||||||
|
"image/png".into(),
|
||||||
|
b"not really a png".to_vec(),
|
||||||
|
)
|
||||||
|
.expect("attach");
|
||||||
|
assert_eq!(attached.attachments.len(), 1);
|
||||||
|
let att = &attached.attachments[0];
|
||||||
|
assert_eq!(att.filename.as_deref(), Some("receipt.png"));
|
||||||
|
assert_eq!(att.mime, "image/png");
|
||||||
|
assert_eq!(att.upload_error, None);
|
||||||
|
|
||||||
|
let sha = att.sha256.clone().expect("a stored file carries its hash");
|
||||||
|
let path = app.blob_path(sha.clone()).expect("the bytes are on disk");
|
||||||
|
assert_eq!(std::fs::read(path).unwrap(), b"not really a png");
|
||||||
|
assert_eq!(
|
||||||
|
app.blob_path("0".repeat(64)),
|
||||||
|
None,
|
||||||
|
"an unknown hash has no path"
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = app
|
||||||
|
.delete_attachment(note.id.clone(), att.id.clone())
|
||||||
|
.expect("remove");
|
||||||
|
assert!(after.attachments.is_empty());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.add_attachment(
|
||||||
|
"missing".into(),
|
||||||
|
"a.txt".into(),
|
||||||
|
"text/plain".into(),
|
||||||
|
vec![1]
|
||||||
|
)
|
||||||
|
.is_err(),
|
||||||
|
"attaching to a note that doesn't exist is refused"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// Snooze writes a future instant from the CORE's clock; complete clears it.
|
/// Snooze writes a future instant from the CORE's clock; complete clears it.
|
||||||
#[test]
|
#[test]
|
||||||
fn reminders_can_be_snoozed_and_completed() {
|
fn reminders_can_be_snoozed_and_completed() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app.create_note(draft("Call back")).expect("create");
|
let note = app.create_note(draft("Call back")).expect("create");
|
||||||
assert_eq!(note.remind_at, None);
|
assert_eq!(note.remind_at, None);
|
||||||
|
|
||||||
@@ -827,7 +1009,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
fn completing_a_recurring_reminder_moves_it_rather_than_ending_it() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = ThoughtSync::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app.create_note(draft("Water the plants")).expect("create");
|
let note = app.create_note(draft("Water the plants")).expect("create");
|
||||||
|
|
||||||
let armed = app
|
let armed = app
|
||||||
@@ -880,6 +1062,81 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renaming a tag onto a name another tag already holds MERGES the two, and the
|
||||||
|
/// OLDER row is the survivor.
|
||||||
|
///
|
||||||
|
/// Before this, the bare UPDATE met `idx_labels_name` — unique on `lower(name)` —
|
||||||
|
/// and the user got a raw "UNIQUE constraint failed" from SQLite. Merging is what
|
||||||
|
/// a person means by typing an existing tag's name onto this one.
|
||||||
|
#[test]
|
||||||
|
fn renaming_onto_an_existing_tag_merges_into_the_older_one() {
|
||||||
|
let dir = scratch_dir();
|
||||||
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
|
let older = app.create_label("grocery".to_string()).expect("older");
|
||||||
|
// `created_at` is RFC3339 to the MILLISECOND. Without a gap the two rows can
|
||||||
|
// share a timestamp, and then the tie-break is under test instead of the age
|
||||||
|
// rule this test is about.
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
let newer = app.create_label("errands".to_string()).expect("newer");
|
||||||
|
|
||||||
|
let one = app.create_note(draft("milk")).expect("note one");
|
||||||
|
let two = app.create_note(draft("stamps")).expect("note two");
|
||||||
|
app.set_note_labels(one.id.clone(), vec![older.id.clone()])
|
||||||
|
.expect("tag one");
|
||||||
|
app.set_note_labels(two.id.clone(), vec![newer.id.clone()])
|
||||||
|
.expect("tag two");
|
||||||
|
|
||||||
|
// The YOUNGER one is renamed onto the older's name, in a different case —
|
||||||
|
// matching is case-insensitive, and the survivor takes the spelling asked for.
|
||||||
|
let survivor = app
|
||||||
|
.rename_label(newer.id.clone(), "Grocery".to_string())
|
||||||
|
.expect("a rename onto an existing name merges instead of failing");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
survivor.id, older.id,
|
||||||
|
"the older row is the one that survives"
|
||||||
|
);
|
||||||
|
assert_eq!(survivor.name, "Grocery", "spelled the way the caller asked");
|
||||||
|
|
||||||
|
let all = app.list_labels().expect("list");
|
||||||
|
assert_eq!(all.len(), 1, "the two became one");
|
||||||
|
assert_eq!(all[0].id, older.id);
|
||||||
|
assert_eq!(all[0].count, Some(2), "carrying every note from both sides");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mirror of the test above. Renaming the OLDER one onto the younger's name
|
||||||
|
/// still leaves the older row standing — it just changes its name.
|
||||||
|
///
|
||||||
|
/// This is the whole reason age decides rather than "whoever already held the
|
||||||
|
/// name": otherwise the survivor depends on which way round someone typed it,
|
||||||
|
/// and two devices tidying the same pair would disagree about which id exists.
|
||||||
|
#[test]
|
||||||
|
fn the_rename_merge_survivor_does_not_depend_on_the_direction() {
|
||||||
|
let dir = scratch_dir();
|
||||||
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
|
|
||||||
|
let older = app.create_label("grocery".to_string()).expect("older");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||||
|
let newer = app.create_label("errands".to_string()).expect("newer");
|
||||||
|
|
||||||
|
let survivor = app
|
||||||
|
.rename_label(older.id.clone(), "errands".to_string())
|
||||||
|
.expect("rename");
|
||||||
|
|
||||||
|
assert_eq!(survivor.id, older.id, "age wins in this direction too");
|
||||||
|
assert_eq!(survivor.name, "errands");
|
||||||
|
assert_ne!(
|
||||||
|
survivor.id, newer.id,
|
||||||
|
"the younger row is the one that went"
|
||||||
|
);
|
||||||
|
assert_eq!(app.list_labels().expect("list").len(), 1);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
|
/// A crude RFC3339 sanity check that doesn't pull a date crate into this
|
||||||
/// crate's dev-dependencies to assert one field is well-formed.
|
/// crate's dev-dependencies to assert one field is well-formed.
|
||||||
fn chrono_free_parse(raw: &str) -> usize {
|
fn chrono_free_parse(raw: &str) -> usize {
|
||||||
|
|||||||
+192
-29
@@ -1,6 +1,6 @@
|
|||||||
//! The types that cross into Kotlin.
|
//! The types that cross into Kotlin.
|
||||||
//!
|
//!
|
||||||
//! These MIRROR `thoughtsync_core::local::models` rather than reusing it. The core's
|
//! These MIRROR `inkwell_core::local::models` rather than reusing it. The core's
|
||||||
//! shapes are serde structs whose field names and optionality are contracted with the
|
//! shapes are serde structs whose field names and optionality are contracted with the
|
||||||
//! shared Vue frontend; hanging uniffi derives on them would couple two very
|
//! shared Vue frontend; hanging uniffi derives on them would couple two very
|
||||||
//! different consumers to one definition and put a `serde_json::Value` (which has no
|
//! different consumers to one definition and put a `serde_json::Value` (which has no
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
|
//! what to do with it. That is the entire reason for the `let Core { .. } = value`
|
||||||
//! style here; please keep it.
|
//! style here; please keep it.
|
||||||
|
|
||||||
use thoughtsync_core::local::models as core_models;
|
use inkwell_core::local::models as core_models;
|
||||||
use thoughtsync_core::sync::client as core_client;
|
use inkwell_core::sync::client as core_client;
|
||||||
use thoughtsync_core::sync::compat as core_compat;
|
use inkwell_core::sync::compat as core_compat;
|
||||||
use thoughtsync_core::sync::engine as core_engine;
|
use inkwell_core::sync::engine as core_engine;
|
||||||
use thoughtsync_core::sync::pull as core_pull;
|
use inkwell_core::sync::pull as core_pull;
|
||||||
use thoughtsync_core::sync::push as core_push;
|
use inkwell_core::sync::push as core_push;
|
||||||
use thoughtsync_core::sync::state as core_state;
|
use inkwell_core::sync::state as core_state;
|
||||||
|
|
||||||
/// A note, with everything needed to render a card or open the editor.
|
/// A note, with everything needed to render a card or open the editor.
|
||||||
///
|
///
|
||||||
@@ -33,7 +33,6 @@ pub struct Note {
|
|||||||
/// Always present. Derived by the core, never stored.
|
/// Always present. Derived by the core, never stored.
|
||||||
pub display_title: String,
|
pub display_title: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub color: String,
|
|
||||||
pub position: i64,
|
pub position: i64,
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
pub archived: bool,
|
pub archived: bool,
|
||||||
@@ -47,6 +46,20 @@ pub struct Note {
|
|||||||
pub previews: Vec<LinkPreview>,
|
pub previews: Vec<LinkPreview>,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
|
/// How this account holds the note (#5175): "owner", or "edit"/"view" for one
|
||||||
|
/// someone shared with it.
|
||||||
|
pub permission: String,
|
||||||
|
/// Whether the owner has shared it with anyone.
|
||||||
|
pub shared: bool,
|
||||||
|
/// Who shared it with us; absent on our own notes.
|
||||||
|
pub shared_by: Option<SharedBy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner of a note shared with this account.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct SharedBy {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A checklist item as it sits in a note's body.
|
/// A checklist item as it sits in a note's body.
|
||||||
@@ -61,9 +74,9 @@ pub struct BodyItem {
|
|||||||
pub checked: bool,
|
pub checked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
|
impl From<inkwell_core::local::derive::DerivedItem> for BodyItem {
|
||||||
fn from(i: thoughtsync_core::local::derive::DerivedItem) -> Self {
|
fn from(i: inkwell_core::local::derive::DerivedItem) -> Self {
|
||||||
let thoughtsync_core::local::derive::DerivedItem {
|
let inkwell_core::local::derive::DerivedItem {
|
||||||
text,
|
text,
|
||||||
checked,
|
checked,
|
||||||
line,
|
line,
|
||||||
@@ -76,6 +89,36 @@ impl From<thoughtsync_core::local::derive::DerivedItem> for BodyItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `#tag` and where it sits in a note's body.
|
||||||
|
///
|
||||||
|
/// Mirrors `derive::DerivedTag`. The card colours the tag where it was typed rather
|
||||||
|
/// than repeating it as a chip, so it needs the SPAN — and the offsets are UTF-16
|
||||||
|
/// code units precisely because Kotlin's `AnnotatedString` counts that way.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct BodyTag {
|
||||||
|
pub line: u32,
|
||||||
|
pub start: u32,
|
||||||
|
pub end: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<inkwell_core::local::derive::DerivedTag> for BodyTag {
|
||||||
|
fn from(t: inkwell_core::local::derive::DerivedTag) -> Self {
|
||||||
|
let inkwell_core::local::derive::DerivedTag {
|
||||||
|
line,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
name,
|
||||||
|
} = t;
|
||||||
|
BodyTag {
|
||||||
|
line,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An Android build the linked server is offering, already judged to be newer.
|
/// An Android build the linked server is offering, already judged to be newer.
|
||||||
///
|
///
|
||||||
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
/// A mirror rather than a re-export of `client::ClientRelease`, for the same
|
||||||
@@ -92,12 +135,12 @@ pub struct ClientUpdate {
|
|||||||
pub size: i64,
|
pub size: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<thoughtsync_core::sync::client::ClientRelease> for ClientUpdate {
|
impl From<inkwell_core::sync::client::ClientRelease> for ClientUpdate {
|
||||||
fn from(r: thoughtsync_core::sync::client::ClientRelease) -> Self {
|
fn from(r: inkwell_core::sync::client::ClientRelease) -> Self {
|
||||||
// Destructured exhaustively, like every other conversion in this file: a
|
// Destructured exhaustively, like every other conversion in this file: a
|
||||||
// field added upstream stops this compiling until Android is told what to
|
// field added upstream stops this compiling until Android is told what to
|
||||||
// do with it, which turns silent drift into a build error.
|
// do with it, which turns silent drift into a build error.
|
||||||
let thoughtsync_core::sync::client::ClientRelease {
|
let inkwell_core::sync::client::ClientRelease {
|
||||||
version,
|
version,
|
||||||
version_code,
|
version_code,
|
||||||
size,
|
size,
|
||||||
@@ -138,6 +181,8 @@ pub struct Attachment {
|
|||||||
pub mime: String,
|
pub mime: String,
|
||||||
pub size: Option<i64>,
|
pub size: Option<i64>,
|
||||||
pub sha256: Option<String>,
|
pub sha256: Option<String>,
|
||||||
|
/// Why the server refused a file attached on this device. None otherwise.
|
||||||
|
pub upload_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
@@ -157,7 +202,6 @@ impl From<core_models::Note> for Note {
|
|||||||
id,
|
id,
|
||||||
display_title,
|
display_title,
|
||||||
body,
|
body,
|
||||||
color,
|
|
||||||
position,
|
position,
|
||||||
pinned,
|
pinned,
|
||||||
archived,
|
archived,
|
||||||
@@ -171,12 +215,14 @@ impl From<core_models::Note> for Note {
|
|||||||
previews,
|
previews,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
|
permission,
|
||||||
|
shared,
|
||||||
|
shared_by,
|
||||||
} = value;
|
} = value;
|
||||||
Note {
|
Note {
|
||||||
id,
|
id,
|
||||||
display_title,
|
display_title,
|
||||||
body,
|
body,
|
||||||
color,
|
|
||||||
position,
|
position,
|
||||||
pinned,
|
pinned,
|
||||||
archived,
|
archived,
|
||||||
@@ -190,6 +236,12 @@ impl From<core_models::Note> for Note {
|
|||||||
previews: previews.into_iter().map(LinkPreview::from).collect(),
|
previews: previews.into_iter().map(LinkPreview::from).collect(),
|
||||||
created_at,
|
created_at,
|
||||||
updated_at,
|
updated_at,
|
||||||
|
permission,
|
||||||
|
shared,
|
||||||
|
shared_by: shared_by.map(|by| SharedBy {
|
||||||
|
id: by.id,
|
||||||
|
display_name: by.display_name,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,6 +289,7 @@ impl From<core_models::Attachment> for Attachment {
|
|||||||
mime,
|
mime,
|
||||||
size,
|
size,
|
||||||
sha256,
|
sha256,
|
||||||
|
upload_error,
|
||||||
} = value;
|
} = value;
|
||||||
Attachment {
|
Attachment {
|
||||||
id,
|
id,
|
||||||
@@ -245,6 +298,7 @@ impl From<core_models::Attachment> for Attachment {
|
|||||||
mime,
|
mime,
|
||||||
size,
|
size,
|
||||||
sha256,
|
sha256,
|
||||||
|
upload_error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,12 +368,12 @@ pub struct NoteQuery {
|
|||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct NoteFacets {
|
pub struct NoteFacets {
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
pub color: Option<String>,
|
|
||||||
pub label: Option<Vec<String>>,
|
pub label: Option<Vec<String>>,
|
||||||
pub has_reminder: Option<bool>,
|
|
||||||
pub has_attachment: Option<bool>,
|
pub has_attachment: Option<bool>,
|
||||||
pub created_after: Option<String>,
|
pub created_after: Option<String>,
|
||||||
pub created_before: Option<String>,
|
pub created_before: Option<String>,
|
||||||
|
/// "with_me": only notes someone else shared with this account.
|
||||||
|
pub shared: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<NoteQuery> for core_models::ListQuery {
|
impl From<NoteQuery> for core_models::ListQuery {
|
||||||
@@ -343,21 +397,19 @@ impl From<NoteFacets> for core_models::Facets {
|
|||||||
fn from(value: NoteFacets) -> Self {
|
fn from(value: NoteFacets) -> Self {
|
||||||
let NoteFacets {
|
let NoteFacets {
|
||||||
q,
|
q,
|
||||||
color,
|
|
||||||
label,
|
label,
|
||||||
has_reminder,
|
|
||||||
has_attachment,
|
has_attachment,
|
||||||
created_after,
|
created_after,
|
||||||
created_before,
|
created_before,
|
||||||
|
shared,
|
||||||
} = value;
|
} = value;
|
||||||
core_models::Facets {
|
core_models::Facets {
|
||||||
q,
|
q,
|
||||||
color,
|
|
||||||
label,
|
label,
|
||||||
has_reminder,
|
|
||||||
has_attachment,
|
has_attachment,
|
||||||
created_after,
|
created_after,
|
||||||
created_before,
|
created_before,
|
||||||
|
shared,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,8 +418,6 @@ impl From<NoteFacets> for core_models::Facets {
|
|||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
pub struct NoteDraft {
|
pub struct NoteDraft {
|
||||||
pub body: String,
|
pub body: String,
|
||||||
/// "default" unless the user picked a colour.
|
|
||||||
pub color: String,
|
|
||||||
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
|
/// Checklist lines. A note can carry both a body and items (M13 step 2), so this
|
||||||
/// is not an alternative to `body` — it is an addition to it.
|
/// is not an alternative to `body` — it is an addition to it.
|
||||||
pub items: Option<Vec<String>>,
|
pub items: Option<Vec<String>>,
|
||||||
@@ -375,8 +425,8 @@ pub struct NoteDraft {
|
|||||||
|
|
||||||
impl From<NoteDraft> for core_models::NoteCreateInput {
|
impl From<NoteDraft> for core_models::NoteCreateInput {
|
||||||
fn from(value: NoteDraft) -> Self {
|
fn from(value: NoteDraft) -> Self {
|
||||||
let NoteDraft { body, color, items } = value;
|
let NoteDraft { body, items } = value;
|
||||||
core_models::NoteCreateInput { body, color, items }
|
core_models::NoteCreateInput { body, items }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +441,6 @@ impl From<NoteDraft> for core_models::NoteCreateInput {
|
|||||||
#[derive(Debug, Clone, uniffi::Enum)]
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
pub enum NoteEdit {
|
pub enum NoteEdit {
|
||||||
Body { value: String },
|
Body { value: String },
|
||||||
Color { value: String },
|
|
||||||
Pinned { value: bool },
|
Pinned { value: bool },
|
||||||
Archived { value: bool },
|
Archived { value: bool },
|
||||||
RemindAt { value: String },
|
RemindAt { value: String },
|
||||||
@@ -411,7 +460,6 @@ impl NoteEdit {
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
match self {
|
match self {
|
||||||
NoteEdit::Body { value } => ("body", Value::String(value)),
|
NoteEdit::Body { value } => ("body", Value::String(value)),
|
||||||
NoteEdit::Color { value } => ("color", Value::String(value)),
|
|
||||||
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
NoteEdit::Pinned { value } => ("pinned", Value::Bool(value)),
|
||||||
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
NoteEdit::Archived { value } => ("archived", Value::Bool(value)),
|
||||||
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
NoteEdit::RemindAt { value } => ("remind_at", Value::String(value)),
|
||||||
@@ -562,6 +610,113 @@ impl From<core_client::Identity> for Identity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Someone on the instance a note can be shared with (#5175).
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Member {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::Member> for Member {
|
||||||
|
fn from(value: core_client::Member) -> Self {
|
||||||
|
let core_client::Member {
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
email,
|
||||||
|
} = value;
|
||||||
|
Member {
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A group the admin made, which a note can be shared with (#5177).
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct ShareGroup {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub member_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::ShareGroup> for ShareGroup {
|
||||||
|
fn from(value: core_client::ShareGroup) -> Self {
|
||||||
|
let core_client::ShareGroup {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
member_count,
|
||||||
|
} = value;
|
||||||
|
ShareGroup {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
member_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everyone a note can be shared with: the other people, and every group.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct Directory {
|
||||||
|
pub members: Vec<Member>,
|
||||||
|
pub groups: Vec<ShareGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::Directory> for Directory {
|
||||||
|
fn from(value: core_client::Directory) -> Self {
|
||||||
|
let core_client::Directory { members, groups } = value;
|
||||||
|
Directory {
|
||||||
|
members: members.into_iter().map(Member::from).collect(),
|
||||||
|
groups: groups.into_iter().map(ShareGroup::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who a new share goes to: one person, or a group.
|
||||||
|
#[derive(Debug, Clone, uniffi::Enum)]
|
||||||
|
pub enum ShareTarget {
|
||||||
|
Member { id: String },
|
||||||
|
Group { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ShareTarget> for core_client::ShareTarget {
|
||||||
|
fn from(value: ShareTarget) -> Self {
|
||||||
|
match value {
|
||||||
|
ShareTarget::Member { id } => core_client::ShareTarget::Member(id),
|
||||||
|
ShareTarget::Group { id } => core_client::ShareTarget::Group(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One share of a note, at "view" or "edit": to a person or to a group, never both.
|
||||||
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
|
pub struct NoteShare {
|
||||||
|
pub id: String,
|
||||||
|
pub member: Option<Member>,
|
||||||
|
pub group: Option<ShareGroup>,
|
||||||
|
pub permission: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<core_client::NoteShare> for NoteShare {
|
||||||
|
fn from(value: core_client::NoteShare) -> Self {
|
||||||
|
// `created_at` stays behind: nothing on the phone orders or shows by it.
|
||||||
|
let core_client::NoteShare {
|
||||||
|
id,
|
||||||
|
member,
|
||||||
|
group,
|
||||||
|
permission,
|
||||||
|
created_at: _,
|
||||||
|
} = value;
|
||||||
|
NoteShare {
|
||||||
|
id,
|
||||||
|
member: member.map(Member::from),
|
||||||
|
group: group.map(ShareGroup::from),
|
||||||
|
permission,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What became of this device's token on the server during an unlink.
|
/// What became of this device's token on the server during an unlink.
|
||||||
///
|
///
|
||||||
/// Separate from the local result because the local half always succeeds and the
|
/// Separate from the local result because the local half always succeeds and the
|
||||||
@@ -617,6 +772,10 @@ pub struct PushSummary {
|
|||||||
/// realistic case). Silently retrying forever would be the wrong shape.
|
/// realistic case). Silently retrying forever would be the wrong shape.
|
||||||
pub rejected: u64,
|
pub rejected: u64,
|
||||||
pub errors: Vec<String>,
|
pub errors: Vec<String>,
|
||||||
|
/// Files attached on this device that reached the server this cycle.
|
||||||
|
pub uploaded: u64,
|
||||||
|
/// Files that didn't; their reasons are in `errors`.
|
||||||
|
pub upload_failed: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, uniffi::Record)]
|
#[derive(Debug, Clone, uniffi::Record)]
|
||||||
@@ -648,6 +807,8 @@ impl From<core_push::PushSummary> for PushSummary {
|
|||||||
noop,
|
noop,
|
||||||
rejected,
|
rejected,
|
||||||
errors,
|
errors,
|
||||||
|
uploaded,
|
||||||
|
upload_failed,
|
||||||
} = value;
|
} = value;
|
||||||
PushSummary {
|
PushSummary {
|
||||||
batches: batches as u64,
|
batches: batches as u64,
|
||||||
@@ -658,6 +819,8 @@ impl From<core_push::PushSummary> for PushSummary {
|
|||||||
noop: noop as u64,
|
noop: noop as u64,
|
||||||
rejected: rejected as u64,
|
rejected: rejected as u64,
|
||||||
errors,
|
errors,
|
||||||
|
uploaded: uploaded as u64,
|
||||||
|
upload_failed: upload_failed as u64,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Where the generated Kotlin lands. Matches the app's package so the bindings are
|
# Where the generated Kotlin lands. Matches the app's package so the bindings are
|
||||||
# `com.fabledsword.thoughtsync.core.*` rather than something the app has to alias.
|
# `com.fabledsword.inkwell.core.*` rather than something the app has to alias.
|
||||||
[bindings.kotlin]
|
[bindings.kotlin]
|
||||||
package_name = "com.fabledsword.thoughtsync.core"
|
package_name = "com.fabledsword.inkwell.core"
|
||||||
cdylib_name = "thoughtsync_ffi"
|
cdylib_name = "inkwell_ffi"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ dependencyResolutionManagement {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rootProject.name = "ThoughtSync"
|
rootProject.name = "Inkwell"
|
||||||
|
|
||||||
// `ffi/` sits beside `app/` but is deliberately NOT a Gradle module: it is a Rust
|
// `ffi/` sits beside `app/` but is deliberately NOT a Gradle module: it is a Rust
|
||||||
// crate belonging to the Cargo workspace at the repo root. Gradle reaches it by
|
// crate belonging to the Cargo workspace at the repo root. Gradle reaches it by
|
||||||
|
|||||||
+35
-29
@@ -1,4 +1,4 @@
|
|||||||
# CI Requirements — ThoughtSync
|
# CI Requirements — Inkwell
|
||||||
|
|
||||||
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
|
||||||
> in the CI-Runner repo.
|
> in the CI-Runner repo.
|
||||||
@@ -39,28 +39,26 @@ entirely on `ci-python:3.14`.
|
|||||||
install` cold cost is a non-blocker.
|
install` cold cost is a non-blocker.
|
||||||
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for
|
- Build gates on `typecheck` + `lint` only. The `test` job runs in parallel for
|
||||||
visibility but does not block the dev image push. DB-backed / integration tests
|
visibility but does not block the dev image push. DB-backed / integration tests
|
||||||
run against the dev image manually — ThoughtSync's unit tests are DB-free (no
|
run against the dev image manually — Inkwell's unit tests are DB-free (no
|
||||||
Postgres service lane in CI yet).
|
Postgres service lane in CI yet).
|
||||||
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>`
|
- `dev` push -> `:dev` + `:<sha>`; `v*` tag -> `:latest` + `:<version>` + `:<sha>`
|
||||||
(family rule 46).
|
(family rule 46).
|
||||||
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
|
- The production runtime `Dockerfile` tracks python:3.12 so test results stay
|
||||||
representative of the deployed image.
|
representative of the deployed image.
|
||||||
- **Artifacts — use the mirrored upload action, never `actions/upload-artifact`.**
|
- **Artifacts — stock `actions/upload-artifact@v7`, never `@v3`.**
|
||||||
```yaml
|
```yaml
|
||||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
uses: actions/upload-artifact@v7
|
||||||
```
|
```
|
||||||
Upstream's `actions/upload-artifact@v4` cannot work against this instance and
|
Stock works on this forge since the runner moved to gitea/runner 3.x, which
|
||||||
no server-side change will help: its `isGhes()` rejects any hostname that isn't
|
edits the action's client-side `isGhes()` refusal out of its bundle. Proven on
|
||||||
`github.com` / `*.ghe.com` / `*.localhost` and throws before it opens a
|
2026-09-10 for upload-artifact v4–v7 and download-artifact v4–v8 (Scribe spike
|
||||||
connection, so the server is never asked what it supports. `@v3` is worse — it
|
#3843). Until then this repo pinned a SHA mirror of the Forgejo project's
|
||||||
reports success, and Gitea then serves artifacts back only through the v4 API
|
fork, because upstream threw on the hostname before it opened a connection.
|
||||||
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
|
||||||
to every retrieval path. A green job producing nothing retrievable.
|
|
||||||
|
|
||||||
`bvandeusen/upload-artifact` is our pull mirror of `forgejo/upload-artifact`
|
`@v3` is still broken: it reports success, and Gitea serves artifacts back only
|
||||||
(the Forgejo project's fork, one commit on upstream v5.0.0 disabling that
|
through the v4 API (`content_encoding = application/zip`), so a v3 upload is
|
||||||
check). Mirrored so CI depends on a commit we hold; pinned by SHA because the
|
stored but invisible to every retrieval path. A green job producing nothing
|
||||||
mirror auto-syncs and a moved upstream tag would otherwise change what runs.
|
retrievable.
|
||||||
|
|
||||||
Both desktop upload steps also set `if-no-files-found: error` and carry **no**
|
Both desktop upload steps also set `if-no-files-found: error` and carry **no**
|
||||||
`continue-on-error`. They previously had both defaults inverted, which is how
|
`continue-on-error`. They previously had both defaults inverted, which is how
|
||||||
@@ -89,7 +87,7 @@ parts. Three of them are family rules for a reason:
|
|||||||
`docker ps` by it. A spaced or underscored name breaks the filter.
|
`docker ps` by it. A spaced or underscored name breaks the filter.
|
||||||
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
|
- **Service hostnames are not routable** on this runner (rule 79), so the step resolves
|
||||||
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
|
the Postgres container's bridge IP with `docker ps --filter` + `docker inspect` and
|
||||||
builds `THOUGHTSYNC_DATABASE_URL` from it. `postgres:5432` will not connect.
|
builds `INKWELL_DATABASE_URL` from it. `postgres:5432` will not connect.
|
||||||
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
|
- **`run:` is busybox sh** (rule 81) — no `/dev/tcp` — so the readiness wait is a small
|
||||||
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
|
Python heredoc. Its terminator must dedent to column 0 after YAML strips the block
|
||||||
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
|
indent; check with `yaml.safe_load` and print the `run` string if you edit it.
|
||||||
@@ -207,7 +205,7 @@ backend/frontend push.
|
|||||||
## Android lane — being rebuilt (M12)
|
## Android lane — being rebuilt (M12)
|
||||||
|
|
||||||
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
|
The Tauri-mobile Android lane is gone. Android is a native Kotlin/Compose client
|
||||||
over the shared `thoughtsync-core` crate instead — see Scribe note 2730 for the
|
over the shared `inkwell-core` crate instead — see Scribe note 2730 for the
|
||||||
decision and milestone M12 for the arc.
|
decision and milestone M12 for the arc.
|
||||||
|
|
||||||
The image it will run on already exists: **`ci-rust-android:1.97`**, repurposed
|
The image it will run on already exists: **`ci-rust-android:1.97`**, repurposed
|
||||||
@@ -220,7 +218,7 @@ second image, and JDK 25 (which requires **Gradle 9.1+** in this repo's wrapper
|
|||||||
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
|
the old JDK 17 pin existed only because Tauri generated a Gradle 8.x project).
|
||||||
|
|
||||||
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
|
The Rust pin is in LOCKSTEP with `ci-tauri` and `ci-tauri-win`. All three build
|
||||||
`thoughtsync-core` from one workspace `Cargo.lock` under `--locked`, so a
|
`inkwell-core` from one workspace `Cargo.lock` under `--locked`, so a
|
||||||
mismatched Rust minor across the lanes would mean divergent resolution for no
|
mismatched Rust minor across the lanes would mean divergent resolution for no
|
||||||
reason. Bump the three together or not at all.
|
reason. Bump the three together or not at all.
|
||||||
|
|
||||||
@@ -335,8 +333,9 @@ differs from CI is worse than none.
|
|||||||
|
|
||||||
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
|
**This reproduces CI exactly, not approximately.** On the 2026-08-18 run the
|
||||||
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
|
local test binary hashes (`thoughtsync_core-bbaae79723888ad1`,
|
||||||
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`)
|
`thoughtsync_desktop_lib-9d162263f8d0aca3`, `thoughtsync_ffi-fc557b96dc795e27`,
|
||||||
matched CI run 3931's byte for byte. Same image, same lockfile, same units.
|
named for the crates as they were before the rename to Inkwell) matched CI run
|
||||||
|
3931's byte for byte. Same image, same lockfile, same units.
|
||||||
|
|
||||||
`target/` persists on the host between runs, so after the first cold build these
|
`target/` persists on the host between runs, so after the first cold build these
|
||||||
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
|
take seconds (~30s for clippy). It is gitignored and reaches ~1.4 GB; delete it
|
||||||
@@ -411,18 +410,25 @@ the lockfile format and the picked versions identical to what CI would have
|
|||||||
chosen. Commit the result in the same change as the `Cargo.toml` edit — a
|
chosen. Commit the result in the same change as the `Cargo.toml` edit — a
|
||||||
manifest change pushed without it fails the gate.
|
manifest change pushed without it fails the gate.
|
||||||
|
|
||||||
## Pushing: `dev` is both a branch and a tag
|
## Channel releases: `dev-rolling` and `stable`
|
||||||
|
|
||||||
`git push origin dev` fails in this repo:
|
The two update channels are releases on fixed tags, because Fabled-Git has no
|
||||||
|
`/releases/latest/download/<asset>` route and the updater needs a permanent URL.
|
||||||
|
The **channel** is still called `dev` everywhere a person sees it; its **release
|
||||||
|
tag** is `dev-rolling`. `packaging/channel-tag.sh` is the one mapping CI reads.
|
||||||
|
`desktop/src-tauri/src/update.rs` and `desktop/packaging/install.sh` carry their
|
||||||
|
own copy because neither can run it — change all three together.
|
||||||
|
|
||||||
```
|
The tag used to be `dev`, which shadowed the branch of the same name: once a clone
|
||||||
error: src refspec dev matches more than one
|
had fetched it, `git push origin dev` failed with
|
||||||
```
|
`error: src refspec dev matches more than one` (Scribe #2184). Never name a channel
|
||||||
|
tag after a branch; `tests/test_channel_tag.py` and the `update.rs` tests fail if
|
||||||
|
one is.
|
||||||
|
|
||||||
The rolling update channel is a release on a **fixed tag named `dev`** (the tag
|
**Transitional, from 2026-09-10:** the old `dev` release still exists so desktop
|
||||||
never moves — Fabled-Git has no `/releases/latest/download/<asset>` route, so the
|
apps installed from it can update across — the manifest job writes `latest.json`
|
||||||
updater needs a permanent URL). Once that tag is fetched locally, the short name
|
to it too (`BRIDGE_TAG=dev` in `desktop.yml`). Until that release and its tag are
|
||||||
`dev` resolves to both `refs/heads/dev` and `refs/tags/dev`. Fully qualify it:
|
deleted, fully qualify pushes:
|
||||||
|
|
||||||
```
|
```
|
||||||
git push origin refs/heads/dev:refs/heads/dev
|
git push origin refs/heads/dev:refs/heads/dev
|
||||||
|
|||||||
+11
-2
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "thoughtsync-core"
|
name = "inkwell-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "ThoughtSync client core — local-first SQLite store and opt-in sync engine"
|
description = "Inkwell client core — local-first SQLite store and opt-in sync engine"
|
||||||
authors = ["bvandeusen"]
|
authors = ["bvandeusen"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
@@ -26,6 +26,15 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] }
|
||||||
# Verifying downloaded attachment bytes against the sha256 the server advertised.
|
# Verifying downloaded attachment bytes against the sha256 the server advertised.
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
# Export and import with no server (local/portable.rs): the same zip the server
|
||||||
|
# writes and reads, so a backup crosses between surfaces. Deflate only — every
|
||||||
|
# other method (bzip2, zstd, lzma, AES) is off, since neither the server's exports
|
||||||
|
# nor Google Takeout use them and several pull in C code.
|
||||||
|
zip = { version = "4", default-features = false, features = ["deflate-flate2"] }
|
||||||
|
# zip's deflate goes through flate2, which needs a backend chosen. miniz_oxide
|
||||||
|
# (`rust_backend`) is pure Rust, so the Windows and Android cross-compiles stay
|
||||||
|
# free of C; both crates were already in the lockfile, via the updater and png.
|
||||||
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||||
|
|
||||||
# Android has no system OpenSSL to link against, and `native-tls` resolves to
|
# Android has no system OpenSSL to link against, and `native-tls` resolves to
|
||||||
# OpenSSL there — unlike Windows, where it lands on schannel and costs nothing.
|
# OpenSSL there — unlike Windows, where it lands on schannel and costs nothing.
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
//! ThoughtSync's client core: the on-device SQLite store and the sync engine.
|
//! Inkwell's client core: the on-device SQLite store and the sync engine.
|
||||||
//!
|
//!
|
||||||
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
|
//! Deliberately free of any UI framework. The desktop wraps it in Tauri commands;
|
||||||
//! the Android client binds it through uniffi. Neither owns it, and a change to
|
//! the Android client binds it through uniffi. Neither owns it, and a change to
|
||||||
|
|||||||
+388
-86
@@ -2,8 +2,10 @@
|
|||||||
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
|
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
|
||||||
//! with the frontend's inline rules (see frontend notes/markdown.ts):
|
//! with the frontend's inline rules (see frontend notes/markdown.ts):
|
||||||
//!
|
//!
|
||||||
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
//! - `#tag`: `#` at the start of a line or after whitespace, then a letter, then
|
||||||
//! On save these become labels attached with `via_tag = true`.
|
//! letters, digits, `_` and `-`. On save these become labels attached with
|
||||||
|
//! `via_tag = true`. The server and the web run the same cases
|
||||||
|
//! (core/testdata/grammar.json).
|
||||||
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
|
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
|
||||||
//! table of items beside it, so a list can sit between two paragraphs instead of
|
//! table of items beside it, so a list can sit between two paragraphs instead of
|
||||||
//! only after them.
|
//! only after them.
|
||||||
@@ -19,22 +21,28 @@
|
|||||||
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
||||||
//! capture-and-recall surface, and a linking system is organization.
|
//! capture-and-recall surface, and a linking system is organization.
|
||||||
|
|
||||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
/// Every `#tag` in ONE line, as `(start, end, name)` in char indices.
|
||||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
///
|
||||||
let chars: Vec<char> = body.chars().collect();
|
/// Char indices rather than byte offsets so the spans can be used to cut the tags
|
||||||
let mut out: Vec<String> = Vec::new();
|
/// back out of the line without ever landing mid-codepoint — see
|
||||||
|
/// [`lift_standalone_tags`], which is the only reason the spans exist.
|
||||||
|
fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
||||||
|
let mut out: Vec<(usize, usize, String)> = Vec::new();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < chars.len() {
|
while i < chars.len() {
|
||||||
if chars[i] == '#' {
|
if chars[i] == '#' {
|
||||||
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
|
// Start of line or after whitespace, and nothing else. Any non-tag
|
||||||
|
// character used to count, which made `(#todo)` a tag here and plain
|
||||||
|
// text on the server, and made the `/#section` of a pasted URL a label.
|
||||||
|
// Whitespace is the rule all three implementations now share (#5166).
|
||||||
|
let boundary = i == 0 || chars[i - 1].is_whitespace();
|
||||||
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
||||||
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
||||||
let mut j = i + 1;
|
let mut j = i + 1;
|
||||||
while j < chars.len() && is_tag_char(chars[j]) {
|
while j < chars.len() && is_tag_char(chars[j]) {
|
||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
let tag: String = chars[i + 1..j].iter().collect();
|
out.push((i, j, chars[i + 1..j].iter().collect()));
|
||||||
push_unique(&mut out, &tag);
|
|
||||||
i = j;
|
i = j;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -44,6 +52,167 @@ pub fn extract_tags(body: &str) -> Vec<String> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
|
||||||
|
///
|
||||||
|
/// The card no longer prints a chip for a tag whose text is still in the note — it
|
||||||
|
/// colours the token where it was typed instead. To do that a renderer needs the
|
||||||
|
/// SPAN, not just the name, and asking it to find the name again would be a second
|
||||||
|
/// grammar quietly disagreeing with this one about what `##a` or `#1` is.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DerivedTag {
|
||||||
|
/// Which body line it sits on, like [`DerivedItem::line`].
|
||||||
|
pub line: u32,
|
||||||
|
/// Offsets into that line, in UTF-16 code units — INCLUDING the leading `#`.
|
||||||
|
///
|
||||||
|
/// UTF-16 rather than chars or bytes because the two languages that consume this
|
||||||
|
/// both index strings that way: Kotlin's `AnnotatedString` and JavaScript. A char
|
||||||
|
/// index is right up until somebody puts an emoji before a tag, and then it lands
|
||||||
|
/// mid-token with no error anywhere.
|
||||||
|
pub start: u32,
|
||||||
|
pub end: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `#tag` in `body` with its position — the scan [`lift_standalone_tags`] does,
|
||||||
|
/// keeping the spans instead of throwing them away.
|
||||||
|
///
|
||||||
|
/// Not deduped: two mentions of `#todo` are two pieces of text to colour. Fences are
|
||||||
|
/// not skipped either, and that is deliberate — a `#tag` inside a code block stays an
|
||||||
|
/// inline label on the note, and a renderer that left it plain would be the only
|
||||||
|
/// surface disagreeing.
|
||||||
|
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (n, line) in body.split('\n').enumerate() {
|
||||||
|
let chars: Vec<char> = line.chars().collect();
|
||||||
|
let spans = line_tags(&chars);
|
||||||
|
if spans.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Prefix sums, built once per tagged line: char index -> UTF-16 offset.
|
||||||
|
let mut units: Vec<u32> = Vec::with_capacity(chars.len() + 1);
|
||||||
|
let mut total: u32 = 0;
|
||||||
|
units.push(0);
|
||||||
|
for c in &chars {
|
||||||
|
total += c.len_utf16() as u32;
|
||||||
|
units.push(total);
|
||||||
|
}
|
||||||
|
for (start, end, name) in spans {
|
||||||
|
out.push(DerivedTag {
|
||||||
|
line: n as u32,
|
||||||
|
start: units[start],
|
||||||
|
end: units[end],
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a line opens or closes a fenced code block.
|
||||||
|
fn is_fence(line: &str) -> bool {
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
trimmed.starts_with("```") || trimmed.starts_with("~~~")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs of three or more newlines become two, and the ends are trimmed.
|
||||||
|
///
|
||||||
|
/// Removing a line must not leave a hole where it was.
|
||||||
|
fn collapse_blank_runs(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut run = 0;
|
||||||
|
for c in text.chars() {
|
||||||
|
if c == '\n' {
|
||||||
|
run += 1;
|
||||||
|
if run <= 2 {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run = 0;
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.trim_matches('\n').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a body's tags by whether the text around them can be taken away.
|
||||||
|
///
|
||||||
|
/// Returns `(standalone, inline, lifted_body)`.
|
||||||
|
///
|
||||||
|
/// THE RULE: a line containing nothing but tags and whitespace is removed. Anything
|
||||||
|
/// else is left exactly as written.
|
||||||
|
///
|
||||||
|
/// The MIRROR of `split_body_tags` in the server's `notes/tags.py`, and it has to stay
|
||||||
|
/// one: a note lifted differently here than there would change under the operator the
|
||||||
|
/// moment it synced. Same discipline, and the same reason, as `DerivedTint`.
|
||||||
|
///
|
||||||
|
/// The conservative reading of "standalone" is deliberate. A trailing tag is
|
||||||
|
/// ambiguous and the text does not say which it is — `buy milk #grocery` is filing,
|
||||||
|
/// `remember to call #mom` is the sentence's object, and lifting the second leaves
|
||||||
|
/// "remember to call". A tag sharing a line with words keeps its words.
|
||||||
|
///
|
||||||
|
/// `standalone` tags become ORDINARY labels (`via_tag = 0`): nothing is left to derive
|
||||||
|
/// them from, so the row becomes the record and the chip's × becomes the way to remove
|
||||||
|
/// one. `inline` tags stay derived exactly as before. That is what `via_tag` means from
|
||||||
|
/// here on — backed by text still in the body.
|
||||||
|
pub fn lift_standalone_tags(body: &str) -> (Vec<String>, Vec<String>, String) {
|
||||||
|
let mut standalone: Vec<String> = Vec::new();
|
||||||
|
let mut inline: Vec<String> = Vec::new();
|
||||||
|
let mut kept: Vec<&str> = Vec::new();
|
||||||
|
let mut in_fence = false;
|
||||||
|
|
||||||
|
for line in body.split('\n') {
|
||||||
|
if is_fence(line) {
|
||||||
|
in_fence = !in_fence;
|
||||||
|
kept.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let chars: Vec<char> = line.chars().collect();
|
||||||
|
let spans = line_tags(&chars);
|
||||||
|
// Cut the tags out and see whether anything is left. That is what
|
||||||
|
// "standalone" means, and it is the whole rule.
|
||||||
|
let mut remainder = String::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
for (start, end, _) in &spans {
|
||||||
|
remainder.extend(chars[pos..*start].iter());
|
||||||
|
pos = *end;
|
||||||
|
}
|
||||||
|
remainder.extend(chars[pos..].iter());
|
||||||
|
|
||||||
|
// A fence's contents are CODE: a `#tag` there is a shell comment in somebody's
|
||||||
|
// snippet, and deleting the line would eat part of their example.
|
||||||
|
if in_fence || spans.is_empty() || !remainder.trim().is_empty() {
|
||||||
|
for (_, _, name) in &spans {
|
||||||
|
push_unique(&mut inline, name);
|
||||||
|
}
|
||||||
|
kept.push(line);
|
||||||
|
} else {
|
||||||
|
for (_, _, name) in &spans {
|
||||||
|
push_unique(&mut standalone, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lifted = collapse_blank_runs(&kept.join("\n"));
|
||||||
|
if !body.trim().is_empty() && lifted.trim().is_empty() {
|
||||||
|
// The note was NOTHING but tags. Lifting would leave a blank card, which is a
|
||||||
|
// worse outcome than a duplicated chip — so leave it alone.
|
||||||
|
let mut all = standalone;
|
||||||
|
for name in &inline {
|
||||||
|
push_unique(&mut all, name);
|
||||||
|
}
|
||||||
|
return (Vec::new(), all, body.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tag that ALSO appears in prose stays derived: the prose copy still backs it,
|
||||||
|
// so deleting that copy should still detach the label.
|
||||||
|
let inline_lower: Vec<String> = inline.iter().map(|n| n.to_lowercase()).collect();
|
||||||
|
let standalone = standalone
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| !inline_lower.contains(&n.to_lowercase()))
|
||||||
|
.collect();
|
||||||
|
(standalone, inline, lifted)
|
||||||
|
}
|
||||||
|
|
||||||
fn is_tag_char(c: char) -> bool {
|
fn is_tag_char(c: char) -> bool {
|
||||||
c.is_alphanumeric() || c == '_' || c == '-'
|
c.is_alphanumeric() || c == '_' || c == '-'
|
||||||
}
|
}
|
||||||
@@ -198,65 +367,29 @@ pub fn extract_items(body: &str) -> Vec<DerivedItem> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite the `index`-th task line, or drop it when `f` returns None.
|
/// Tick or untick the `index`-th item, keeping its indent, bullet and text.
|
||||||
|
///
|
||||||
|
/// The only edit to an item that isn't typing in the body: adding, rewording and
|
||||||
|
/// deleting one are all text edits, which every client makes in its editor.
|
||||||
///
|
///
|
||||||
/// A body with fewer task lines than that is returned UNCHANGED rather than
|
/// A body with fewer task lines than that is returned UNCHANGED rather than
|
||||||
/// panicking: the index comes from a UI that may be a moment behind the store, and
|
/// panicking: the index comes from a UI that may be a moment behind the store, and
|
||||||
/// a stale tap should do nothing rather than take the app down.
|
/// a stale tap should do nothing rather than take the app down.
|
||||||
fn map_task_line<F>(body: &str, index: usize, f: F) -> String
|
|
||||||
where
|
|
||||||
F: FnOnce(&TaskLine<'_>) -> Option<String>,
|
|
||||||
{
|
|
||||||
let lines: Vec<&str> = body.split('\n').collect();
|
|
||||||
let mut target: Option<usize> = None;
|
|
||||||
let mut seen = 0usize;
|
|
||||||
for (n, line) in lines.iter().enumerate() {
|
|
||||||
if parse_task_line(line).is_some() {
|
|
||||||
if seen == index {
|
|
||||||
target = Some(n);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
seen += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let target = match target {
|
|
||||||
Some(n) => n,
|
|
||||||
None => return body.to_string(),
|
|
||||||
};
|
|
||||||
let replacement = match parse_task_line(lines[target]) {
|
|
||||||
Some(parsed) => f(&parsed),
|
|
||||||
None => return body.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut out: Vec<String> = Vec::with_capacity(lines.len());
|
|
||||||
for (n, line) in lines.iter().enumerate() {
|
|
||||||
if n != target {
|
|
||||||
out.push((*line).to_string());
|
|
||||||
} else if let Some(new_line) = &replacement {
|
|
||||||
out.push(new_line.clone());
|
|
||||||
}
|
|
||||||
// None at the target line drops it, which is `remove_item`.
|
|
||||||
}
|
|
||||||
out.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tick or untick the `index`-th item.
|
|
||||||
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
|
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
|
||||||
map_task_line(body, index, |t| {
|
let mut lines: Vec<String> = body.split('\n').map(str::to_string).collect();
|
||||||
Some(render_task_line(t.indent, t.bullet, checked, t.text))
|
let Some(line) = lines
|
||||||
})
|
.iter_mut()
|
||||||
}
|
.filter(|l| parse_task_line(l.as_str()).is_some())
|
||||||
|
.nth(index)
|
||||||
/// Replace the text of the `index`-th item, keeping its state and its bullet.
|
else {
|
||||||
pub fn set_item_text(body: &str, index: usize, text: &str) -> String {
|
return body.to_string();
|
||||||
map_task_line(body, index, |t| {
|
};
|
||||||
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim()))
|
let Some(t) = parse_task_line(line.as_str()) else {
|
||||||
})
|
return body.to_string();
|
||||||
}
|
};
|
||||||
|
let ticked = render_task_line(t.indent, t.bullet, checked, t.text);
|
||||||
/// Delete the `index`-th item, line and all.
|
*line = ticked;
|
||||||
pub fn remove_item(body: &str, index: usize) -> String {
|
lines.join("\n")
|
||||||
map_task_line(body, index, |_| None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an item at the end of the body.
|
/// Add an item at the end of the body.
|
||||||
@@ -291,10 +424,19 @@ pub fn append_item(body: &str, text: &str, checked: bool) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The tag names in `body`, once each — what the shared fixture lists.
|
||||||
|
fn tag_names(body: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for t in extract_tag_spans(body) {
|
||||||
|
push_unique(&mut out, &t.name);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tags_basic() {
|
fn tags_basic() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_tags("a #todo and #Work-item_2 here"),
|
tag_names("a #todo and #Work-item_2 here"),
|
||||||
vec!["todo", "Work-item_2"]
|
vec!["todo", "Work-item_2"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,17 +444,118 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tags_require_letter_start_and_boundary() {
|
fn tags_require_letter_start_and_boundary() {
|
||||||
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
||||||
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
|
assert_eq!(tag_names("#1 nope a#b no but #Yes ##no"), vec!["Yes"]);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tags_dedupe_case_insensitive() {
|
|
||||||
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_body() {
|
fn empty_body() {
|
||||||
assert!(extract_tags("").is_empty());
|
assert!(extract_tag_spans("").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tag spans, for the renderer that draws them in place ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_spans_carry_the_hash_and_the_line() {
|
||||||
|
let spans = extract_tag_spans("buy milk #grocery\nand call #mom about #mom");
|
||||||
|
assert_eq!(spans.len(), 3);
|
||||||
|
assert_eq!((spans[0].line, spans[0].start, spans[0].end), (0, 9, 17));
|
||||||
|
assert_eq!(spans[0].name, "grocery");
|
||||||
|
// Not deduped: two mentions are two pieces of text to colour.
|
||||||
|
assert_eq!(spans[1].line, 1);
|
||||||
|
assert_eq!(spans[2].name, "mom");
|
||||||
|
assert_eq!((spans[2].start, spans[2].end), (20, 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_spans_are_utf16_offsets_not_char_indices() {
|
||||||
|
// The emoji is ONE char and TWO UTF-16 code units. Kotlin and JS both index
|
||||||
|
// the second way, so a char index would highlight one character too early.
|
||||||
|
let spans = extract_tag_spans("🎁 #gift");
|
||||||
|
assert_eq!(spans.len(), 1);
|
||||||
|
assert_eq!((spans[0].start, spans[0].end), (3, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── lifting standalone tags ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
||||||
|
// A note lifted differently here than there would change under the operator the
|
||||||
|
// moment it synced, so these are the cases that file agrees to.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifts_a_line_that_is_nothing_but_tags() {
|
||||||
|
let (standalone, inline, body) = lift_standalone_tags("#todo\nreorganize the homepage");
|
||||||
|
assert_eq!(standalone, vec!["todo"]);
|
||||||
|
assert!(inline.is_empty());
|
||||||
|
assert_eq!(body, "reorganize the homepage");
|
||||||
|
|
||||||
|
let (standalone, _, body) = lift_standalone_tags("needs a tauri app\n#todo");
|
||||||
|
assert_eq!(standalone, vec!["todo"]);
|
||||||
|
assert_eq!(body, "needs a tauri app");
|
||||||
|
|
||||||
|
let (standalone, _, body) = lift_standalone_tags("#todo #work\nreal text");
|
||||||
|
assert_eq!(standalone, vec!["todo", "work"]);
|
||||||
|
assert_eq!(body, "real text");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cases that must come back byte-identical. Getting any of these wrong
|
||||||
|
/// destroys somebody's words, which is why the rule is the conservative one:
|
||||||
|
/// a trailing tag is ambiguous and the text does not say which kind it is.
|
||||||
|
#[test]
|
||||||
|
fn leaves_a_tag_that_shares_its_line_with_words() {
|
||||||
|
for prose in [
|
||||||
|
"remember to call #mom tomorrow",
|
||||||
|
"buy milk #grocery",
|
||||||
|
"#2024\nreal",
|
||||||
|
] {
|
||||||
|
let (standalone, _, body) = lift_standalone_tags(prose);
|
||||||
|
assert!(standalone.is_empty(), "{prose}");
|
||||||
|
assert_eq!(body, prose, "{prose}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removing_a_line_leaves_no_hole() {
|
||||||
|
let (_, _, body) = lift_standalone_tags("foo\n\n#todo\n\nbar");
|
||||||
|
assert_eq!(body, "foo\n\nbar");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `#tag` in a fence is a shell comment in somebody's snippet. It still becomes
|
||||||
|
/// a label — it always has — but the line is never touched.
|
||||||
|
#[test]
|
||||||
|
fn never_touches_a_fenced_line() {
|
||||||
|
let fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone";
|
||||||
|
let (standalone, inline, body) = lift_standalone_tags(fenced);
|
||||||
|
assert!(standalone.is_empty());
|
||||||
|
assert_eq!(inline, vec!["deploy"]);
|
||||||
|
assert_eq!(body, fenced);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lifting would leave a blank card, which is worse than the duplication this
|
||||||
|
/// removes. So the note keeps its text and its tags stay derived.
|
||||||
|
#[test]
|
||||||
|
fn will_not_blank_a_note_that_is_only_tags() {
|
||||||
|
let (standalone, inline, body) = lift_standalone_tags("#todo");
|
||||||
|
assert!(standalone.is_empty());
|
||||||
|
assert_eq!(inline, vec!["todo"]);
|
||||||
|
assert_eq!(body, "#todo");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appearing on its own line does NOT lift a tag also written in a sentence — the
|
||||||
|
/// sentence still backs it, so deleting the sentence should still detach it.
|
||||||
|
#[test]
|
||||||
|
fn a_tag_still_in_prose_stays_derived() {
|
||||||
|
let (standalone, inline, body) = lift_standalone_tags("#todo\nremember the #todo list");
|
||||||
|
assert!(standalone.is_empty());
|
||||||
|
assert_eq!(inline, vec!["todo"]);
|
||||||
|
assert_eq!(body, "remember the #todo list");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifting_an_empty_body_is_a_no_op() {
|
||||||
|
let (standalone, inline, body) = lift_standalone_tags("");
|
||||||
|
assert!(standalone.is_empty());
|
||||||
|
assert!(inline.is_empty());
|
||||||
|
assert_eq!(body, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── checklist items ─────────────────────────────────────────────────────
|
// ── checklist items ─────────────────────────────────────────────────────
|
||||||
@@ -396,17 +639,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_text_keeps_state() {
|
|
||||||
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_takes_the_whole_line() {
|
|
||||||
let body = "keep\n- [ ] drop\n- [ ] stay";
|
|
||||||
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn append_spaces_like_the_exporter() {
|
fn append_spaces_like_the_exporter() {
|
||||||
// Prose then a blank line then the list — byte-for-byte what
|
// Prose then a blank line then the list — byte-for-byte what
|
||||||
@@ -454,16 +686,12 @@ mod tests {
|
|||||||
// that arrives late should be inert, not fatal.
|
// that arrives late should be inert, not fatal.
|
||||||
let body = "- [ ] only";
|
let body = "- [ ] only";
|
||||||
assert_eq!(set_item_checked(body, 7, true), body);
|
assert_eq!(set_item_checked(body, 7, true), body);
|
||||||
assert_eq!(remove_item(body, 7), body);
|
|
||||||
assert_eq!(set_item_text(body, 7, "x"), body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_plain_body_is_returned_byte_identical() {
|
fn a_plain_body_is_returned_byte_identical() {
|
||||||
let body = "just prose\nwith two lines";
|
let body = "just prose\nwith two lines";
|
||||||
assert_eq!(set_item_checked(body, 0, true), body);
|
assert_eq!(set_item_checked(body, 0, true), body);
|
||||||
assert_eq!(set_item_text(body, 0, "x"), body);
|
|
||||||
assert_eq!(remove_item(body, 0), body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -475,4 +703,78 @@ mod tests {
|
|||||||
assert_eq!(touched, body);
|
assert_eq!(touched, body);
|
||||||
assert_eq!(extract_items(&touched), items);
|
assert_eq!(extract_items(&touched), items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── the shared fixture ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// core/testdata/grammar.json is the one set of cases the server (pytest), the web
|
||||||
|
// (vitest) and this file all run. The cases above stay as this file's own
|
||||||
|
// reasoning; these are the ones the other languages have agreed to.
|
||||||
|
|
||||||
|
fn fixture() -> serde_json::Value {
|
||||||
|
serde_json::from_str(include_str!("../../testdata/grammar.json"))
|
||||||
|
.expect("grammar.json parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strings(v: &serde_json::Value) -> Vec<String> {
|
||||||
|
v.as_array()
|
||||||
|
.expect("an array")
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str().expect("a string").to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_task_lines() {
|
||||||
|
for case in fixture()["task_lines"].as_array().unwrap() {
|
||||||
|
let line = case["line"].as_str().unwrap();
|
||||||
|
let got: Vec<(String, bool)> = extract_items(line)
|
||||||
|
.into_iter()
|
||||||
|
.map(|i| (i.text, i.checked))
|
||||||
|
.collect();
|
||||||
|
let want: Vec<(String, bool)> = match &case["item"] {
|
||||||
|
serde_json::Value::Null => Vec::new(),
|
||||||
|
item => vec![(
|
||||||
|
item["text"].as_str().unwrap().to_string(),
|
||||||
|
item["checked"].as_bool().unwrap(),
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
assert_eq!(got, want, "line {line:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_rendered_items() {
|
||||||
|
for case in fixture()["rendered_items"].as_array().unwrap() {
|
||||||
|
let text = case["text"].as_str().unwrap();
|
||||||
|
let checked = case["checked"].as_bool().unwrap();
|
||||||
|
assert_eq!(render_item(text, checked), case["line"].as_str().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_tags() {
|
||||||
|
for case in fixture()["tags"].as_array().unwrap() {
|
||||||
|
let body = case["body"].as_str().unwrap();
|
||||||
|
assert_eq!(tag_names(body), strings(&case["tags"]), "body {body:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_lifts() {
|
||||||
|
for case in fixture()["lifts"].as_array().unwrap() {
|
||||||
|
let body = case["body"].as_str().unwrap();
|
||||||
|
let (standalone, inline, lifted) = lift_standalone_tags(body);
|
||||||
|
assert_eq!(
|
||||||
|
standalone,
|
||||||
|
strings(&case["standalone"]),
|
||||||
|
"standalone, body {body:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(inline, strings(&case["inline"]), "inline, body {body:?}");
|
||||||
|
assert_eq!(
|
||||||
|
lifted,
|
||||||
|
case["lifted"].as_str().unwrap(),
|
||||||
|
"lifted, body {body:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
pub mod derive;
|
pub mod derive;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod portable;
|
||||||
pub mod recur;
|
pub mod recur;
|
||||||
pub mod retention;
|
pub mod retention;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
@@ -25,12 +26,9 @@ pub struct Db(pub Mutex<Connection>);
|
|||||||
impl Db {
|
impl Db {
|
||||||
/// Lock the store, reporting a poisoned lock as a message rather than a panic.
|
/// Lock the store, reporting a poisoned lock as a message rather than a panic.
|
||||||
///
|
///
|
||||||
/// Every consumer was writing `db.0.lock().map_err(|e| e.to_string())?` at each
|
/// The one way every caller takes the lock. Returning the guard from here lets a
|
||||||
/// call site. Beyond the repetition, that spelling forces the caller to NAME
|
/// caller bind it by inference without naming `rusqlite::Connection`, so layers
|
||||||
/// `rusqlite::Connection` in any helper that returns the guard — which would make
|
/// above the store need not depend on what it is made of.
|
||||||
/// rusqlite a dependency of a layer whose whole point is not to know what the
|
|
||||||
/// store is made of. Returning it from here means callers can bind the guard by
|
|
||||||
/// inference and never name the type.
|
|
||||||
///
|
///
|
||||||
/// A poisoned lock means some earlier call panicked while holding it. The store
|
/// A poisoned lock means some earlier call panicked while holding it. The store
|
||||||
/// is not necessarily corrupt, but this connection can't be trusted blind, so it
|
/// is not necessarily corrupt, but this connection can't be trusted blind, so it
|
||||||
|
|||||||
+31
-18
@@ -13,7 +13,6 @@ pub struct Note {
|
|||||||
/// never stored.
|
/// never stored.
|
||||||
pub display_title: String,
|
pub display_title: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub color: String,
|
|
||||||
pub position: i64,
|
pub position: i64,
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
pub archived: bool,
|
pub archived: bool,
|
||||||
@@ -29,6 +28,20 @@ pub struct Note {
|
|||||||
pub previews: Vec<LinkPreview>,
|
pub previews: Vec<LinkPreview>,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
|
/// How this account holds the note (#5175): `owner`, or `edit`/`view` for one
|
||||||
|
/// someone shared with it. Named and valued as the server's, so the shared
|
||||||
|
/// frontend gates the editor identically either way.
|
||||||
|
pub permission: String,
|
||||||
|
/// Whether the owner has shared it with anyone.
|
||||||
|
pub shared: bool,
|
||||||
|
/// Who shared it with us; null on our own notes.
|
||||||
|
pub shared_by: Option<SharedBy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SharedBy {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -56,6 +69,10 @@ pub struct Attachment {
|
|||||||
pub mime: String,
|
pub mime: String,
|
||||||
pub size: Option<i64>,
|
pub size: Option<i64>,
|
||||||
pub sha256: Option<String>,
|
pub sha256: Option<String>,
|
||||||
|
/// Why the server refused a file attached on this device, in words to show on it.
|
||||||
|
/// Absent for everything else, including a file still waiting to upload.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub upload_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -92,13 +109,17 @@ pub struct TitleEntry {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
/// A reminder that has come due, as the desktop's reminder worker needs it.
|
||||||
pub struct SavedFilter {
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct DueReminder {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub title: String,
|
||||||
/// Mirrors NoteFacets — stored as a JSON blob, round-tripped opaquely.
|
/// `remind_at` as stored. It names the occurrence: completing or snoozing a
|
||||||
pub params: serde_json::Value,
|
/// reminder changes it, and that is how an announcer tells a new occurrence
|
||||||
pub position: i64,
|
/// from one it has already announced.
|
||||||
|
pub remind_at: String,
|
||||||
|
/// The same instant, in milliseconds since the epoch.
|
||||||
|
pub due_ms: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -117,20 +138,13 @@ pub struct User {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub email_verified: bool,
|
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_color() -> String {
|
|
||||||
"default".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct NoteCreateInput {
|
pub struct NoteCreateInput {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub body: String,
|
pub body: String,
|
||||||
#[serde(default = "default_color")]
|
|
||||||
pub color: String,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub items: Option<Vec<String>>,
|
pub items: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
@@ -154,15 +168,14 @@ pub struct Facets {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub q: Option<String>,
|
pub q: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub color: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub label: Option<Vec<String>>,
|
pub label: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_reminder: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub has_attachment: Option<bool>,
|
pub has_attachment: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_after: Option<String>,
|
pub created_after: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_before: Option<String>,
|
pub created_before: Option<String>,
|
||||||
|
/// `with_me`: only notes someone else shared with this account.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shared: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user