From 45ab27fabf46743a8de7cea1fca694c4f10a86e3 Mon Sep 17 00:00:00 2001 From: JSdotNet Date: Sun, 27 Sep 2026 00:32:49 +0200 Subject: [PATCH 1/2] Add the talk-note screen with pictures and file attachments A note taken during a conference talk (what was said, the slide photo, the handout) now lands in the desktop Inbox as one ordinary capture. It is called a talk note throughout, never a session note: Sessions is its own bounded context. - /note in Backlog.Mobile.UI has a title, a Markdown body that dictation appends to, the speaker as one @name, tags, and an attachment strip. The draft lives in a scoped TalkNoteDraft, so it survives tab switches and backgrounding. - IAttachmentPicker mirrors ISpeechTranscriber. Android uses MediaPicker and FilePicker (CAMERA and the IMAGE_CAPTURE query are in the manifest). The harness uses hidden elements. - A file over the 25 MB cap or off the ADR 0014 allowlist is refused on its tile with the reason, and the note is not sent until it is removed. The cap and the allowlist move to SyncAttachmentLimits in Sync.Abstractions, so the phone and the service share one list. - Pictures are downscaled with SkiaSharp before they queue: 1600 px longest edge, JPEG, EXIF reduced to the orientation. A per-note "Send originals" switch keeps them as taken. - A sent note is one outbox entry of kind talk-note. It uploads each file (PUT /api/sync/attachments/{id}) and checkpoints it through the new IStagedOutboxKind, so a retry never re-sends an uploaded id and each file gets its own attempt budget. It then posts the capture naming them. DeviceOutbox.Sending drives "waiting / uploading 2 of 3 / synced". - Pending talk notes show in the phone's Inbox list, marked waiting. - An AttachmentStrip library component with its storybook page. - The button reads "Send to Inbox" rather than "Save". interaction-guidelines.md#auto-save-no-save-buttons forbids a Save button as the means of persistence. - Devbook: a Talk note sub-feature in capture/features.md, a Talk Note Upload sequence in arc42/06, and a SkiaSharp entry in tech/mobile.md. Plan item talk-note-screen of plan mobile-inbox-slice. Co-Authored-By: Claude Opus 5.5 --- .devbook/arc42/06-runtime-view.md | 67 +++- .devbook/domain/capture/features.md | 32 ++ .devbook/tech/mobile.md | 22 ++ .qa-workspacetalk-note-screenpage.html | 1 + .qa-workspacetalk-note-screentool.exe | 1 + Directory.Packages.props | 6 + .../Backlog.Mobile.UI.csproj | 2 + .../Components/Pages/Inbox.razor | 10 +- .../Components/Pages/Note.razor | 298 +++++++++++++++- .../Components/WebAttachmentInputs.razor | 34 ++ .../Backlog.Mobile.UI/Outbox/DeviceOutbox.cs | 38 ++- .../Backlog.Mobile.UI/Outbox/IOutboxKind.cs | 19 ++ .../Outbox/TalkNoteOutboxKind.cs | 121 +++++++ .../Services/CloudSyncClient.cs | 29 ++ .../Services/IAttachmentPicker.cs | 54 +++ .../MobileShellServiceCollectionExtensions.cs | 8 + .../Services/WebAttachmentPicker.cs | 161 +++++++++ .../TalkNotes/AttachmentRules.cs | 112 ++++++ .../TalkNotes/ExifOrientation.cs | 132 ++++++++ .../TalkNotes/PictureDownscaler.cs | 101 ++++++ .../TalkNotes/TalkNoteComposer.cs | 88 +++++ .../TalkNotes/TalkNoteDraft.cs | 96 ++++++ .../TalkNotes/TalkNoteFiles.cs | 92 +++++ .../TalkNotes/TalkNoteTitle.cs | 51 +++ src/App/Backlog.Mobile.UI/wwwroot/app.css | 24 ++ .../Backlog.Mobile.UI/wwwroot/attachments.js | 14 + src/App/Backlog.Mobile/MauiProgram.cs | 4 + .../Platforms/Android/AndroidManifest.xml | 8 + .../Services/AndroidAttachmentPicker.cs | 87 +++++ .../Inputs/AttachmentStrip.razor | 79 +++++ .../Inputs/AttachmentTile.cs | 20 ++ .../wwwroot/components.css | 69 ++++ .../Backlog.Mobile.WebHarness/Program.cs | 5 + .../Pages/AttachmentStripPage.razor | 102 ++++++ .../Components/Shared/StorybookIndex.cs | 6 + .../SyncAttachmentLimits.cs | 43 +++ .../Options/SyncAttachmentOptions.cs | 30 +- .../StorybookOrderTests.cs | 2 +- .../Backlog.Mobile.UI.UnitTests.csproj | 6 + .../Fixtures/handout.pdf | Bin 0 -> 597 bytes .../Fixtures/slide-photo.jpg | Bin 0 -> 79450 bytes .../InMemoryDeviceStore.cs | 5 + .../PictureDownscalerTests.cs | 91 +++++ .../ScriptedInboxService.cs | 26 +- .../Backlog.Mobile.UI.UnitTests/ShellHost.cs | 6 + .../ShellNavigationTests.cs | 2 +- .../TalkNoteOutboxTests.cs | 319 ++++++++++++++++++ .../TalkNoteScreenTests.cs | 153 +++++++++ .../TalkNoteTitleTests.cs | 59 ++++ .../TestAttachmentPicker.cs | 46 +++ .../AttachmentStripTests.cs | 71 ++++ 51 files changed, 2811 insertions(+), 41 deletions(-) create mode 100644 .qa-workspacetalk-note-screenpage.html create mode 100644 .qa-workspacetalk-note-screentool.exe create mode 100644 src/App/Backlog.Mobile.UI/Components/WebAttachmentInputs.razor create mode 100644 src/App/Backlog.Mobile.UI/Outbox/TalkNoteOutboxKind.cs create mode 100644 src/App/Backlog.Mobile.UI/Services/IAttachmentPicker.cs create mode 100644 src/App/Backlog.Mobile.UI/Services/WebAttachmentPicker.cs create mode 100644 src/App/Backlog.Mobile.UI/TalkNotes/AttachmentRules.cs create mode 100644 src/App/Backlog.Mobile.UI/TalkNotes/ExifOrientation.cs create mode 100644 src/App/Backlog.Mobile.UI/TalkNotes/PictureDownscaler.cs create mode 100644 src/App/Backlog.Mobile.UI/TalkNotes/TalkNoteComposer.cs create mode 100644 src/App/Backlog.Mobile.UI/TalkNotes/TalkNoteDraft.cs create mode 100644 src/App/Backlog.Mobile.UI/TalkNotes/TalkNoteFiles.cs create mode 100644 src/App/Backlog.Mobile.UI/TalkNotes/TalkNoteTitle.cs create mode 100644 src/App/Backlog.Mobile.UI/wwwroot/attachments.js create mode 100644 src/App/Backlog.Mobile/Services/AndroidAttachmentPicker.cs create mode 100644 src/Core/Backlog.UI.Components/Inputs/AttachmentStrip.razor create mode 100644 src/Core/Backlog.UI.Components/Inputs/AttachmentTile.cs create mode 100644 src/Harness/Backlog.UI.Storybook/Components/Pages/AttachmentStripPage.razor create mode 100644 src/Modules/Sync/Backlog.Modules.Sync.Abstractions/SyncAttachmentLimits.cs create mode 100644 tests/Backlog.Mobile.UI.UnitTests/Fixtures/handout.pdf create mode 100644 tests/Backlog.Mobile.UI.UnitTests/Fixtures/slide-photo.jpg create mode 100644 tests/Backlog.Mobile.UI.UnitTests/PictureDownscalerTests.cs create mode 100644 tests/Backlog.Mobile.UI.UnitTests/TalkNoteOutboxTests.cs create mode 100644 tests/Backlog.Mobile.UI.UnitTests/TalkNoteScreenTests.cs create mode 100644 tests/Backlog.Mobile.UI.UnitTests/TalkNoteTitleTests.cs create mode 100644 tests/Backlog.Mobile.UI.UnitTests/TestAttachmentPicker.cs create mode 100644 tests/Backlog.UI.Components.UnitTests/AttachmentStripTests.cs diff --git a/.devbook/arc42/06-runtime-view.md b/.devbook/arc42/06-runtime-view.md index 90ec1dee5..064e1f33d 100644 --- a/.devbook/arc42/06-runtime-view.md +++ b/.devbook/arc42/06-runtime-view.md @@ -89,8 +89,9 @@ when the service answers. - **One outbox, many kinds.** Each row is `id, kind, payload, attempts, last_error, created_at`. The `kind` picks the sender; `capture` is the first, - and later kinds (an attachment upload that must go ahead of its capture, a task - pushed to its own endpoint) join the same queue and the same order. + and later kinds join the same queue and the same order: `task`, pushed to its + own endpoint, and `talk-note`, one entry that is several requests — see + **Talk Note Upload**. - **Oldest first, head of line.** A transient failure (no network, a timeout, a 5xx, or a 401 from a service that restarted with a new signing key) stops the flush, so nothing overtakes it. It is retried after 2s, 4s, 8s, 16s. The fifth @@ -144,8 +145,8 @@ related: [".devbook/arc42/adr/0014-attachments-travel-through-a-blob-store-besid A capture's files travel beside it rather than inside it (local ADR 0014). The phone uploads each file first, under an id it mints itself, and posts the capture that names them after; the capture document carries only their -metadata. The service side is built; the phone's upload and the desktop's -intake are later slices. +metadata. The service side and the phone's upload are built — the phone's half +is the talk note, below; the desktop's intake is a later slice. - **Upload, then capture.** `PUT /api/sync/attachments/{id}` carries the bytes, their declared `Content-Type` and their SHA-256 in `X-Attachment-Sha256`. The @@ -202,6 +203,64 @@ sequenceDiagram Sync-->>-Desktop: 200 ``` +## Talk Note Upload + +```meta +related: [".devbook/domain/capture/features.md#talk-note", ".devbook/arc42/06-runtime-view.md#capture-attachments", ".devbook/arc42/06-runtime-view.md#mobile-capture-and-sync"] +``` + +A talk note is the phone's half of **Capture Attachments**: one outbox entry of +kind `talk-note` whose payload is the capture — attachment metadata included — +and the ids of the files already uploaded. The bytes wait beside the database +in `talk-notes/outbox/`, since an outbox row holds JSON. + +- **Prepared once, at Send.** Each picture is downscaled (1600 px longest edge, + JPEG, EXIF reduced to the orientation) unless the note keeps originals; the + size and SHA-256 are taken from the bytes that will actually go. +- **Uploads, checkpointed.** An attempt uploads each file not yet listed as + uploaded, then posts the capture. Every upload that lands is written back + into the entry before the next starts (`IStagedOutboxKind`), and hands the + entry its attempts back — so a retry resumes at the first file still to go, + and each file has the whole backoff budget of its own. +- **One refusal refuses the note.** A 409, 413 or 415 on a file sets the entry + aside naming the file; the capture is never posted without it. +- **Released on delivery.** The files are deleted from the phone once the + capture is taken (201, or 200 for an id the service already holds). +- **Status from the outbox.** *synced* once the entry is gone, *uploading i of + n* while an attempt is sending its files, *waiting* otherwise, and *waiting — + tap to retry* once parked. + +```mermaid +sequenceDiagram + actor ME + participant App as Phone App + participant Outbox as SQLite Outbox + participant Files as talk-notes/outbox + participant Sync as Sync Service + + ME->>App: Send to Inbox (body, speaker, tags, files) + App->>Files: Downscale pictures, move files; size + sha256 + App->>Outbox: INSERT (id v7, kind=talk-note, capture + uploaded=[]) + App-->>ME: Inbox row marked waiting; note says "waiting" + + loop Each file not yet uploaded + Outbox->>+Sync: PUT /api/sync/attachments/{id} (X-Attachment-Sha256) + alt 201, or 200 for the same bytes + Sync-->>Outbox: StoredAttachment + Outbox->>Outbox: Checkpoint: uploaded += id, attempts = 0 + else No network, timeout, 5xx or 401 + Sync-->>Outbox: Transient — attempts+1, back off, stop the flush + else 409, 413 or 415 + Sync-->>-Outbox: Refused — set aside, naming the file + end + end + + Outbox->>+Sync: POST /api/sync/inbox (same id, attachments: metadata) + Sync-->>-Outbox: 201 InboxItem + Outbox->>Outbox: DELETE entry + Outbox->>Files: Delete the note's files + App-->>ME: "synced" + ## Mobile My Day and Task Push ```meta diff --git a/.devbook/domain/capture/features.md b/.devbook/domain/capture/features.md index 0cab1a0d3..851fceed2 100644 --- a/.devbook/domain/capture/features.md +++ b/.devbook/domain/capture/features.md @@ -73,6 +73,38 @@ down, on the refresh button and on returning to the app. When the service cannot answer, the last list the phone saw stays on screen with a line saying why it is not newer. +### Talk note + +```meta +type: sub-feature +related: [".devbook/arc42/06-runtime-view.md#talk-note-upload", ".devbook/arc42/adr/0014-attachments-travel-through-a-blob-store-beside-the-replica.md"] +``` + +A note taken during a conference talk — what was said, the slide photo, the +handout — that lands in the desktop Inbox as one ordinary capture. It is called +a talk note, never a session note: Sessions is a bounded context of its own. + +The Note tab holds a title, a Markdown body that dictation appends to, the +speaker as one `@name`, tags, and the files attached to it: photos taken with +the camera, pictures and files chosen from the phone, several to a note. Each +file shows its name, type and size. A file cloud sync would not take — over the +per-file limit, or not a picture, PDF, text or Office file — stays on the strip +with the reason written under it, and the note is not sent until it is taken +off. The note being written survives a switch to another tab and the app going +to the background. + +Pictures are made smaller before they leave: at most 1600 pixels on the longest +side, as JPEG, with the location, camera and time details removed and only the +way-up kept. A note can keep its pictures as taken instead. Files go as they +are. + +Sending the note keeps it on the phone first, like any capture, and it appears +in the Inbox list marked waiting. Each file is uploaded before the capture that +names it; a file that already went is never sent again, and a failed one is +retried on its own. The note says where it stands — waiting, uploading 2 of 3, +synced. A note sent without a title is named for the first line of its body, +or "Photo · " when a picture is attached, or else the first file's name. + ### Share-sheet and shortcuts ```meta diff --git a/.devbook/tech/mobile.md b/.devbook/tech/mobile.md index 2e022c0fa..9676fa5c8 100644 --- a/.devbook/tech/mobile.md +++ b/.devbook/tech/mobile.md @@ -48,6 +48,28 @@ JSON-backed on-device storage for captures made while offline. (`.devbook/arc42/adr/0003-sqlite-is-the-canonical-local-task-store.md`), so adopting it here would mean sharing an adapter rather than introducing a new dependency. +## SkiaSharp + +```meta +status: adopted +type: library +version: "4.152.1" +related: [".devbook/domain/capture/features.md#talk-note", ".devbook/arc42/adr/0014-attachments-travel-through-a-blob-store-beside-the-replica.md"] +alternatives: ["ImageSharp", "Android Bitmap plus a browser canvas"] +``` + +The image codec the phone downscales a talk note's pictures with, before they +enter the outbox. + +- **Used for** — decoding a slide photo, resizing it to a 1600 px longest edge + and re-encoding it as JPEG; the EXIF orientation is carried over by hand and + everything else in EXIF is dropped. +- **Why** — MIT-licensed, with the Android and Windows natives in the package, + so the MAUI head, the browser harness and the unit tests run the one codec + and the downscaler has one implementation a test can hold. ImageSharp's split + licence and a per-platform pair (Android `Bitmap` beside a browser canvas) + were the alternatives. + ## Android SDK Build Tools ```meta diff --git a/.qa-workspacetalk-note-screenpage.html b/.qa-workspacetalk-note-screenpage.html new file mode 100644 index 000000000..0cfc83780 --- /dev/null +++ b/.qa-workspacetalk-note-screenpage.html @@ -0,0 +1 @@ +not an attachment diff --git a/.qa-workspacetalk-note-screentool.exe b/.qa-workspacetalk-note-screentool.exe new file mode 100644 index 000000000..6bee438f1 --- /dev/null +++ b/.qa-workspacetalk-note-screentool.exe @@ -0,0 +1 @@ +fake exe content diff --git a/Directory.Packages.props b/Directory.Packages.props index 20520ceb1..cc47f79f5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -157,6 +157,12 @@ The version is the one Microsoft.Extensions.Http.Resilience above resolves. --> + + diff --git a/src/App/Backlog.Mobile.UI/Backlog.Mobile.UI.csproj b/src/App/Backlog.Mobile.UI/Backlog.Mobile.UI.csproj index d5d0ea16b..e65824947 100644 --- a/src/App/Backlog.Mobile.UI/Backlog.Mobile.UI.csproj +++ b/src/App/Backlog.Mobile.UI/Backlog.Mobile.UI.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/App/Backlog.Mobile.UI/Components/Pages/Inbox.razor b/src/App/Backlog.Mobile.UI/Components/Pages/Inbox.razor index 6f5f2f840..a51e71194 100644 --- a/src/App/Backlog.Mobile.UI/Components/Pages/Inbox.razor +++ b/src/App/Backlog.Mobile.UI/Components/Pages/Inbox.razor @@ -237,12 +237,16 @@ get { var pending = Outbox.Entries - .Where(entry => entry.Kind == CaptureOutboxKind.Token) + .Where(entry => entry.Kind is CaptureOutboxKind.Token or TalkNoteOutboxKind.Token) .Select(entry => { - var capture = CaptureOutboxKind.Read(entry); + // A talk note is a capture with its uploads in front of it; + // on the list it is the capture it will become. + var capture = entry.Kind == TalkNoteOutboxKind.Token + ? TalkNoteOutboxKind.Read(entry).Capture + : CaptureOutboxKind.Read(entry); return new Row( - new InboxItem(entry.Id, capture.Title, capture.Source, entry.CreatedAt, capture.BodyMd, capture.Tags, capture.Person), + new InboxItem(entry.Id, capture.Title, capture.Source, entry.CreatedAt, capture.BodyMd, capture.Tags, capture.Person, capture.Attachments), entry); }) .ToList(); diff --git a/src/App/Backlog.Mobile.UI/Components/Pages/Note.razor b/src/App/Backlog.Mobile.UI/Components/Pages/Note.razor index aea0be1c4..764f6c18e 100644 --- a/src/App/Backlog.Mobile.UI/Components/Pages/Note.razor +++ b/src/App/Backlog.Mobile.UI/Components/Pages/Note.razor @@ -1,12 +1,300 @@ @page "/note" +@using Backlog.Mobile.UI.Services +@using Backlog.Mobile.UI.TalkNotes +@inject TalkNoteDraft Draft +@inject TalkNoteFiles Files +@inject TalkNoteComposer Composer +@inject DeviceOutbox Outbox +@inject IAttachmentPicker Picker +@inject ISpeechTranscriber Speech +@implements IDisposable @* - A placeholder, so the Note tab has a route to land on. The note screen itself - is a later entry of the mobile-inbox-slice plan. + A talk note: what was said in a talk, the slide photo and the handout, kept + on the phone until cloud sync takes it and delivered to the desktop Inbox as + one ordinary capture. Not a "session note" — Sessions is its own bounded + context here, and one word for two things is what this name avoids. *@ -Note +Talk note -

Note

+

Talk note

- +
+ + +
+