feat(profile): the profile saves as it is written, the handle chosen in a dialog that checks it
Changing the profile meant a Save button at the foot of the form, which the floating navigation bar half covered on a phone. A field now saves when it is left or Enter is pressed in it, and a link when it is removed; "Saving…" and "Saved" show beside the title. The form keeps what is being written as saves come back, where it used to start afresh and lose focus after each one. The handle is chosen in a dialog that asks the instance while it is typed whether it is free (`ProfileEndpoint.checkHandle`, which tells nothing `byHandle` doesn't), and choosing the first makes the profile with what was written. Saves go one at a time, the latest of those waiting next, and a handle chosen holds for every save after it, so a field's save can't take it back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
b46dfe22b1
commit
959014b4a1
22 changed files with 1062 additions and 152 deletions
|
|
@ -161,7 +161,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
|
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
|
||||||
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. Its shared part, `serverpod_offline_sync`, comes from our fork (the root `pubspec.yaml`'s `dependency_overrides`, `project-halkyon/serverpod-offline-sync` on code.halkyon.app, branch `halkyon/byte-payloads`, decided 2026-10-12): v0.0.10 compares byte payloads byte by byte through a UUID parse that throws for each byte, so one sync touching a few thousand cards blocked the server for minutes. Move the fork along when upgrading, and drop it once a release has the fix.
|
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. Its shared part, `serverpod_offline_sync`, comes from our fork (the root `pubspec.yaml`'s `dependency_overrides`, `project-halkyon/serverpod-offline-sync` on code.halkyon.app, branch `halkyon/byte-payloads`, decided 2026-10-12): v0.0.10 compares byte payloads byte by byte through a UUID parse that throws for each byte, so one sync touching a few thousand cards blocked the server for minutes. Move the fork along when upgrading, and drop it once a release has the fix.
|
||||||
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
|
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
|
||||||
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity.
|
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own, and says why a handle couldn't be theirs (`checkHandle`, null when it can: whose a handle is, `byHandle` tells anyone already); `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity, that saves as it is written (decided 2026-10-12): a field when it is left or Enter is pressed in it, a link when removed, never twice the same; the handle is chosen in a dialog that asks the instance while it is typed whether it is free, and choosing the first makes the profile with what was written (`PublicProfileBloc` takes saves one at a time, the latest of those waiting next, and a handle chosen holds for every save after it).
|
||||||
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. The library's cards say who made a studyset ("By you", or a copy's author by the handle in its `publishedAt`) and whether it is out on Explore (a lock or a globe, opening the Publish sheet through `showShareSheet`): `published()` lists the person's studysets published and not withdrawn, and `PublishRepository` remembers them in `published.json` in the person's folder (`PublishedStore`), so the library shows them offline too; publishing, withdrawing and looking one up update it.
|
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. The library's cards say who made a studyset ("By you", or a copy's author by the handle in its `publishedAt`) and whether it is out on Explore (a lock or a globe, opening the Publish sheet through `showShareSheet`): `published()` lists the person's studysets published and not withdrawn, and `PublishRepository` remembers them in `published.json` in the person's folder (`PublishedStore`), so the library shows them offline too; publishing, withdrawing and looking one up update it.
|
||||||
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
|
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
|
||||||
- **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/<sha256>), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). There is no export into it yet.
|
- **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/<sha256>), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). There is no export into it yet.
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
|
||||||
as _i26u8vl4;
|
as _i26u8vl4;
|
||||||
import 'package:halkyon_learn_client/src/protocol/profile/profile.dart'
|
import 'package:halkyon_learn_client/src/protocol/profile/profile.dart'
|
||||||
as _ihcmr71y;
|
as _ihcmr71y;
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/profile/profile_refusal.dart'
|
||||||
|
as _ibeo2wv6;
|
||||||
import 'package:halkyon_learn_client/src/protocol/publish/publication.dart'
|
import 'package:halkyon_learn_client/src/protocol/publish/publication.dart'
|
||||||
as _ij0cqo11;
|
as _ij0cqo11;
|
||||||
import 'package:http/http.dart' as _i85jenna;
|
import 'package:http/http.dart' as _i85jenna;
|
||||||
|
|
@ -621,6 +623,19 @@ class EndpointProfile extends _isc.EndpointRef {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Why [handle] couldn't be yours, as saving it would refuse it: not a
|
||||||
|
/// handle, reserved for the instance, someone else's whatever its case,
|
||||||
|
/// or a new one once you have published; null when it can, as your own
|
||||||
|
/// can. For the app to say so while it is typed (decided 2026-10-12);
|
||||||
|
/// saving checks again. Whose a handle is, anyone can find out already
|
||||||
|
/// ([ProfilesEndpoint.byHandle]), so this tells nothing new.
|
||||||
|
_ida.Future<_ibeo2wv6.ProfileRefusal?> checkHandle(String handle) =>
|
||||||
|
caller.callServerEndpoint<_ibeo2wv6.ProfileRefusal?>(
|
||||||
|
'profile',
|
||||||
|
'checkHandle',
|
||||||
|
{'handle': handle},
|
||||||
|
);
|
||||||
|
|
||||||
/// Shows [png] as your avatar, in place of any before: a PNG of at most
|
/// Shows [png] as your avatar, in place of any before: a PNG of at most
|
||||||
/// 1 MB, which the app draws from the picture chosen. Throws
|
/// 1 MB, which the app draws from the picture chosen. Throws
|
||||||
/// [ProfileRefusedException] for anything else, and [NotFoundException]
|
/// [ProfileRefusedException] for anything else, and [NotFoundException]
|
||||||
|
|
|
||||||
|
|
@ -449,7 +449,7 @@
|
||||||
"profileSwitchStay": "Anonym bleiben",
|
"profileSwitchStay": "Anonym bleiben",
|
||||||
"profileSwitchConfirm": "Wechseln",
|
"profileSwitchConfirm": "Wechseln",
|
||||||
"publicProfileTitle": "Öffentliches Profil",
|
"publicProfileTitle": "Öffentliches Profil",
|
||||||
"publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Nichts davon ist sichtbar, bevor du es speicherst.",
|
"publicProfileIntro": "Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.",
|
||||||
"publicProfileAnonymousHint": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
|
"publicProfileAnonymousHint": "Melde dich mit GitHub an, um ein öffentliches Profil zu haben und zu veröffentlichen.",
|
||||||
"publicProfileDisplayName": "Anzeigename",
|
"publicProfileDisplayName": "Anzeigename",
|
||||||
"publicProfileDisplayNameHint": "Der Name neben deinem Handle",
|
"publicProfileDisplayNameHint": "Der Name neben deinem Handle",
|
||||||
|
|
@ -464,7 +464,19 @@
|
||||||
"publicProfileLinkHint": "https://…",
|
"publicProfileLinkHint": "https://…",
|
||||||
"publicProfileAddLink": "Link hinzufügen",
|
"publicProfileAddLink": "Link hinzufügen",
|
||||||
"publicProfileRemoveLink": "Link entfernen",
|
"publicProfileRemoveLink": "Link entfernen",
|
||||||
"publicProfileSaved": "Profil gespeichert.",
|
"publicProfileSaving": "Wird gespeichert …",
|
||||||
|
"publicProfileSavedNow": "Gespeichert",
|
||||||
|
"publicProfileHandleChoose": "Wähle ein Handle",
|
||||||
|
"publicProfileHandleFirst": "Wähle zuerst dein Handle: Damit wird dein Profil angelegt, und ab dann speichert es sich beim Schreiben.",
|
||||||
|
"publicProfileHandleChooseTitle": "Wähle dein Handle",
|
||||||
|
"publicProfileHandleChangeTitle": "Ändere dein Handle",
|
||||||
|
"publicProfileHandleDialogText": "Andere finden dich unter deinem Handle. Du kannst es ändern, bis du zum ersten Mal ein Lernset veröffentlichst.",
|
||||||
|
"publicProfileHandleChecking": "Wird geprüft …",
|
||||||
|
"publicProfileHandleFree": "@{handle} ist frei",
|
||||||
|
"@publicProfileHandleFree": {"placeholders": {"handle": {"type": "String"}}},
|
||||||
|
"publicProfileHandleYours": "Das ist dein Handle",
|
||||||
|
"publicProfileHandleConfirmChoose": "Wählen",
|
||||||
|
"publicProfileHandleConfirmChange": "Ändern",
|
||||||
"publicProfileRemove": "Profil entfernen",
|
"publicProfileRemove": "Profil entfernen",
|
||||||
"publicProfileRemoveTitle": "Dein öffentliches Profil entfernen?",
|
"publicProfileRemoveTitle": "Dein öffentliches Profil entfernen?",
|
||||||
"publicProfileRemoveMessage": "Dein Handle wird für andere frei.",
|
"publicProfileRemoveMessage": "Dein Handle wird für andere frei.",
|
||||||
|
|
@ -485,7 +497,7 @@
|
||||||
"publicProfileChoosePicture": "Bild wählen",
|
"publicProfileChoosePicture": "Bild wählen",
|
||||||
"publicProfileRemovePicture": "Bild entfernen",
|
"publicProfileRemovePicture": "Bild entfernen",
|
||||||
"publicProfilePictureType": "Bilder",
|
"publicProfilePictureType": "Bilder",
|
||||||
"publicProfilePictureLater": "Speichere dein Profil, um ein Bild hinzuzufügen.",
|
"publicProfilePictureLater": "Wähle dein Handle, um ein Bild hinzuzufügen.",
|
||||||
|
|
||||||
"settingsTitle": "Einstellungen",
|
"settingsTitle": "Einstellungen",
|
||||||
"settingsAccount": "Konto",
|
"settingsAccount": "Konto",
|
||||||
|
|
|
||||||
|
|
@ -508,7 +508,7 @@
|
||||||
"profileSwitchStay": "Stay anonymous",
|
"profileSwitchStay": "Stay anonymous",
|
||||||
"profileSwitchConfirm": "Switch",
|
"profileSwitchConfirm": "Switch",
|
||||||
"publicProfileTitle": "Public profile",
|
"publicProfileTitle": "Public profile",
|
||||||
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.",
|
"publicProfileIntro": "How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.",
|
||||||
"publicProfileAnonymousHint": "Sign in with GitHub to have a public profile and publish.",
|
"publicProfileAnonymousHint": "Sign in with GitHub to have a public profile and publish.",
|
||||||
"publicProfileDisplayName": "Display name",
|
"publicProfileDisplayName": "Display name",
|
||||||
"publicProfileDisplayNameHint": "The name shown beside your handle",
|
"publicProfileDisplayNameHint": "The name shown beside your handle",
|
||||||
|
|
@ -523,7 +523,23 @@
|
||||||
"publicProfileLinkHint": "https://…",
|
"publicProfileLinkHint": "https://…",
|
||||||
"publicProfileAddLink": "Add link",
|
"publicProfileAddLink": "Add link",
|
||||||
"publicProfileRemoveLink": "Remove link",
|
"publicProfileRemoveLink": "Remove link",
|
||||||
"publicProfileSaved": "Profile saved.",
|
"publicProfileSaving": "Saving…",
|
||||||
|
"publicProfileSavedNow": "Saved",
|
||||||
|
"publicProfileHandleChoose": "Choose a handle",
|
||||||
|
"publicProfileHandleFirst": "Choose your handle first: your profile is made with it, and saves as you write from then on.",
|
||||||
|
"publicProfileHandleChooseTitle": "Choose your handle",
|
||||||
|
"publicProfileHandleChangeTitle": "Change your handle",
|
||||||
|
"publicProfileHandleDialogText": "Others find you by your handle. You can change it until you first publish a studyset.",
|
||||||
|
"publicProfileHandleChecking": "Checking…",
|
||||||
|
"publicProfileHandleFree": "@{handle} is free",
|
||||||
|
"@publicProfileHandleFree": {
|
||||||
|
"placeholders": {
|
||||||
|
"handle": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"publicProfileHandleYours": "This is your handle",
|
||||||
|
"publicProfileHandleConfirmChoose": "Choose",
|
||||||
|
"publicProfileHandleConfirmChange": "Change",
|
||||||
"publicProfileRemove": "Remove profile",
|
"publicProfileRemove": "Remove profile",
|
||||||
"publicProfileRemoveTitle": "Remove your public profile?",
|
"publicProfileRemoveTitle": "Remove your public profile?",
|
||||||
"publicProfileRemoveMessage": "Your handle becomes free for others to take.",
|
"publicProfileRemoveMessage": "Your handle becomes free for others to take.",
|
||||||
|
|
@ -544,7 +560,7 @@
|
||||||
"publicProfileChoosePicture": "Choose picture",
|
"publicProfileChoosePicture": "Choose picture",
|
||||||
"publicProfileRemovePicture": "Remove picture",
|
"publicProfileRemovePicture": "Remove picture",
|
||||||
"publicProfilePictureType": "Images",
|
"publicProfilePictureType": "Images",
|
||||||
"publicProfilePictureLater": "Save your profile to add a picture.",
|
"publicProfilePictureLater": "Choose your handle to add a picture.",
|
||||||
|
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"settingsAccount": "Account",
|
"settingsAccount": "Account",
|
||||||
|
|
|
||||||
|
|
@ -2707,7 +2707,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @publicProfileIntro.
|
/// No description provided for @publicProfileIntro.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.'**
|
/// **'How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.'**
|
||||||
String get publicProfileIntro;
|
String get publicProfileIntro;
|
||||||
|
|
||||||
/// No description provided for @publicProfileAnonymousHint.
|
/// No description provided for @publicProfileAnonymousHint.
|
||||||
|
|
@ -2788,11 +2788,77 @@ abstract class AppLocalizations {
|
||||||
/// **'Remove link'**
|
/// **'Remove link'**
|
||||||
String get publicProfileRemoveLink;
|
String get publicProfileRemoveLink;
|
||||||
|
|
||||||
/// No description provided for @publicProfileSaved.
|
/// No description provided for @publicProfileSaving.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Profile saved.'**
|
/// **'Saving…'**
|
||||||
String get publicProfileSaved;
|
String get publicProfileSaving;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileSavedNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Saved'**
|
||||||
|
String get publicProfileSavedNow;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleChoose.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose a handle'**
|
||||||
|
String get publicProfileHandleChoose;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleFirst.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose your handle first: your profile is made with it, and saves as you write from then on.'**
|
||||||
|
String get publicProfileHandleFirst;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleChooseTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose your handle'**
|
||||||
|
String get publicProfileHandleChooseTitle;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleChangeTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Change your handle'**
|
||||||
|
String get publicProfileHandleChangeTitle;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleDialogText.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Others find you by your handle. You can change it until you first publish a studyset.'**
|
||||||
|
String get publicProfileHandleDialogText;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleChecking.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Checking…'**
|
||||||
|
String get publicProfileHandleChecking;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleFree.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'@{handle} is free'**
|
||||||
|
String publicProfileHandleFree(String handle);
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleYours.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This is your handle'**
|
||||||
|
String get publicProfileHandleYours;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleConfirmChoose.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose'**
|
||||||
|
String get publicProfileHandleConfirmChoose;
|
||||||
|
|
||||||
|
/// No description provided for @publicProfileHandleConfirmChange.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Change'**
|
||||||
|
String get publicProfileHandleConfirmChange;
|
||||||
|
|
||||||
/// No description provided for @publicProfileRemove.
|
/// No description provided for @publicProfileRemove.
|
||||||
///
|
///
|
||||||
|
|
@ -2917,7 +2983,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @publicProfilePictureLater.
|
/// No description provided for @publicProfilePictureLater.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Save your profile to add a picture.'**
|
/// **'Choose your handle to add a picture.'**
|
||||||
String get publicProfilePictureLater;
|
String get publicProfilePictureLater;
|
||||||
|
|
||||||
/// No description provided for @settingsTitle.
|
/// No description provided for @settingsTitle.
|
||||||
|
|
|
||||||
|
|
@ -1662,7 +1662,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfileIntro =>
|
String get publicProfileIntro =>
|
||||||
'Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Nichts davon ist sichtbar, bevor du es speicherst.';
|
'Wie andere dich sehen, sobald du veröffentlichst: das Handle, unter dem sie dich finden, dein Name, ein paar Worte und Links. Es speichert sich beim Schreiben, sobald du dein Handle gewählt hast.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfileAnonymousHint =>
|
String get publicProfileAnonymousHint =>
|
||||||
|
|
@ -1710,7 +1710,44 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get publicProfileRemoveLink => 'Link entfernen';
|
String get publicProfileRemoveLink => 'Link entfernen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfileSaved => 'Profil gespeichert.';
|
String get publicProfileSaving => 'Wird gespeichert …';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileSavedNow => 'Gespeichert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleChoose => 'Wähle ein Handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleFirst =>
|
||||||
|
'Wähle zuerst dein Handle: Damit wird dein Profil angelegt, und ab dann speichert es sich beim Schreiben.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleChooseTitle => 'Wähle dein Handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleChangeTitle => 'Ändere dein Handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleDialogText =>
|
||||||
|
'Andere finden dich unter deinem Handle. Du kannst es ändern, bis du zum ersten Mal ein Lernset veröffentlichst.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleChecking => 'Wird geprüft …';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String publicProfileHandleFree(String handle) {
|
||||||
|
return '@$handle ist frei';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleYours => 'Das ist dein Handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleConfirmChoose => 'Wählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleConfirmChange => 'Ändern';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfileRemove => 'Profil entfernen';
|
String get publicProfileRemove => 'Profil entfernen';
|
||||||
|
|
@ -1782,7 +1819,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfilePictureLater =>
|
String get publicProfilePictureLater =>
|
||||||
'Speichere dein Profil, um ein Bild hinzuzufügen.';
|
'Wähle dein Handle, um ein Bild hinzuzufügen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Einstellungen';
|
String get settingsTitle => 'Einstellungen';
|
||||||
|
|
|
||||||
|
|
@ -1648,7 +1648,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfileIntro =>
|
String get publicProfileIntro =>
|
||||||
'How others see you once you publish: the handle they find you by, your name, a few words and links. Nothing here shows until you save it.';
|
'How others see you once you publish: the handle they find you by, your name, a few words and links. It saves as you write, once you have chosen your handle.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfileAnonymousHint =>
|
String get publicProfileAnonymousHint =>
|
||||||
|
|
@ -1696,7 +1696,44 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get publicProfileRemoveLink => 'Remove link';
|
String get publicProfileRemoveLink => 'Remove link';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfileSaved => 'Profile saved.';
|
String get publicProfileSaving => 'Saving…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileSavedNow => 'Saved';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleChoose => 'Choose a handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleFirst =>
|
||||||
|
'Choose your handle first: your profile is made with it, and saves as you write from then on.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleChooseTitle => 'Choose your handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleChangeTitle => 'Change your handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleDialogText =>
|
||||||
|
'Others find you by your handle. You can change it until you first publish a studyset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleChecking => 'Checking…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String publicProfileHandleFree(String handle) {
|
||||||
|
return '@$handle is free';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleYours => 'This is your handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleConfirmChoose => 'Choose';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicProfileHandleConfirmChange => 'Change';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfileRemove => 'Remove profile';
|
String get publicProfileRemove => 'Remove profile';
|
||||||
|
|
@ -1766,7 +1803,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get publicProfilePictureType => 'Images';
|
String get publicProfilePictureType => 'Images';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicProfilePictureLater => 'Save your profile to add a picture.';
|
String get publicProfilePictureLater =>
|
||||||
|
'Choose your handle to add a picture.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsTitle => 'Settings';
|
String get settingsTitle => 'Settings';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// A form's field whose value is set on a screen of its own: it shows the
|
/// A form's field whose value is set on a screen of its own, or in a
|
||||||
/// [value], up to [maxLines] lines, or the [placeholder] in italics while
|
/// dialog: it shows the [value], up to [maxLines] lines, or the
|
||||||
/// there is none, and a chevron leads on, as tapping opens that screen.
|
/// [placeholder] in italics while there is none, and a chevron leads on, as
|
||||||
/// So the form never holds what needs a whole window (an editor, a map).
|
/// tapping opens it. So the form never holds what needs a whole window (an
|
||||||
|
/// editor, a map) or a check of its own (a handle). Without [onTap] it only
|
||||||
|
/// shows the value.
|
||||||
class const ChevronField({
|
class const ChevronField({
|
||||||
/// What the field holds, above it.
|
/// What the field holds, above it.
|
||||||
required final String label,
|
required final String label,
|
||||||
|
|
@ -15,9 +17,15 @@ class const ChevronField({
|
||||||
/// What shows while there is no value, inviting to set one.
|
/// What shows while there is no value, inviting to set one.
|
||||||
required final String placeholder,
|
required final String placeholder,
|
||||||
|
|
||||||
/// Opens the screen that sets the value.
|
/// Opens what sets the value; null where it can't change.
|
||||||
required final VoidCallback onTap,
|
required final VoidCallback? onTap,
|
||||||
final int maxLines = 3,
|
final int maxLines = 3,
|
||||||
|
|
||||||
|
/// What is said below the field.
|
||||||
|
final String? helperText,
|
||||||
|
|
||||||
|
/// Why the value was refused, below the field in place of [helperText].
|
||||||
|
final String? errorText,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -29,6 +37,10 @@ class const ChevronField({
|
||||||
labelText: label,
|
labelText: label,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
contentPadding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
|
contentPadding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
|
||||||
|
helperText: helperText,
|
||||||
|
helperMaxLines: 3,
|
||||||
|
errorText: errorText,
|
||||||
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
|
@ -50,12 +62,14 @@ class const ChevronField({
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
if (onTap != null) ...[
|
||||||
Icon(
|
const SizedBox(width: 8),
|
||||||
LucideIcons.chevronRight,
|
Icon(
|
||||||
size: 18,
|
LucideIcons.chevronRight,
|
||||||
color: colors.onSurfaceVariant,
|
size: 18,
|
||||||
),
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||||
|
|
||||||
|
/// Whether the handle being typed could be the person's.
|
||||||
|
enum HandleCheck() {
|
||||||
|
/// Nothing typed, or the person's own handle: nothing to say.
|
||||||
|
none,
|
||||||
|
|
||||||
|
/// Being asked of the instance.
|
||||||
|
checking,
|
||||||
|
|
||||||
|
/// Free.
|
||||||
|
free,
|
||||||
|
|
||||||
|
/// Refused: [HandleCheckState.problem] says why.
|
||||||
|
refused,
|
||||||
|
|
||||||
|
/// The instance couldn't be asked; saving will say.
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The handle typed, and what the instance says of it.
|
||||||
|
final class const HandleCheckState({
|
||||||
|
final String handle = '',
|
||||||
|
final HandleCheck check = HandleCheck.none,
|
||||||
|
final ProfileProblem? problem,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [handle, check, problem];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the instance whether a handle could be the person's while it is
|
||||||
|
/// typed (decided 2026-10-12), once typing pauses, and says only what it
|
||||||
|
/// says of the handle typed last. The person's own handle needs
|
||||||
|
/// no asking.
|
||||||
|
class HandleCheckCubit(
|
||||||
|
final ProfileRepository _profiles, {
|
||||||
|
final String? _current,
|
||||||
|
}) extends Cubit<HandleCheckState> {
|
||||||
|
this : super(HandleCheckState(handle: _current ?? ''));
|
||||||
|
|
||||||
|
/// How long typing must pause before the instance is asked.
|
||||||
|
static const pause = Duration(milliseconds: 300);
|
||||||
|
|
||||||
|
Timer? _pause;
|
||||||
|
|
||||||
|
/// The person typed [handle].
|
||||||
|
void typed(String handle) {
|
||||||
|
_pause?.cancel();
|
||||||
|
final trimmed = handle.trim();
|
||||||
|
if (trimmed.isEmpty || trimmed == _current) {
|
||||||
|
emit(HandleCheckState(handle: trimmed));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(HandleCheckState(handle: trimmed, check: HandleCheck.checking));
|
||||||
|
_pause = Timer(pause, () => unawaited(_ask(trimmed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ask(String handle) async {
|
||||||
|
HandleCheckState answer;
|
||||||
|
try {
|
||||||
|
answer = switch (await _profiles.checkHandle(handle)) {
|
||||||
|
null => HandleCheckState(handle: handle, check: HandleCheck.free),
|
||||||
|
final problem => HandleCheckState(
|
||||||
|
handle: handle,
|
||||||
|
check: HandleCheck.refused,
|
||||||
|
problem: problem,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Checking a handle failed', name: 'Profile', error: error);
|
||||||
|
answer = HandleCheckState(handle: handle, check: HandleCheck.unknown);
|
||||||
|
}
|
||||||
|
// Only what is still typed.
|
||||||
|
if (!isClosed && state.handle == handle) {
|
||||||
|
emit(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_pause?.cancel();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||||
|
|
||||||
/// The person's public profile: reading it, saving it, removing it.
|
/// The person's public profile: reading it, saving it as it is written
|
||||||
|
/// (field by field, decided 2026-10-12), removing it. A save asked for while
|
||||||
|
/// another is on its way follows it, the latest of those waiting, so none
|
||||||
|
/// is lost and none overtakes another. A handle chosen holds for every save
|
||||||
|
/// after it until one with it went through or was refused for it, so a
|
||||||
|
/// field's save can't take a new handle back.
|
||||||
class PublicProfileBloc(final ProfileRepository _profiles)
|
class PublicProfileBloc(final ProfileRepository _profiles)
|
||||||
extends Bloc<PublicProfileEvent, PublicProfileState> {
|
extends Bloc<PublicProfileEvent, PublicProfileState> {
|
||||||
this : super(const PublicProfileStateLoading()) {
|
this : super(const PublicProfileStateLoading()) {
|
||||||
|
|
@ -64,37 +69,75 @@ class PublicProfileBloc(final ProfileRepository _profiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The save asked for while another was on its way, the latest.
|
||||||
|
PublicProfileEventSaved? _waiting;
|
||||||
|
|
||||||
|
/// The handle chosen last, until a save with it went through or was
|
||||||
|
/// refused for it.
|
||||||
|
String? _chosenHandle;
|
||||||
|
|
||||||
Future<void> _onSaved(
|
Future<void> _onSaved(
|
||||||
PublicProfileEventSaved event,
|
PublicProfileEventSaved event,
|
||||||
Emitter<PublicProfileState> emit,
|
Emitter<PublicProfileState> emit,
|
||||||
) async {
|
) async {
|
||||||
if (state case PublicProfileStateReady(:final profile, saving: false)) {
|
if (event.handle case final handle?) {
|
||||||
emit(PublicProfileStateReady(profile: profile, saving: true));
|
_chosenHandle = handle;
|
||||||
try {
|
}
|
||||||
emit(
|
if (state case PublicProfileStateReady(saving: true)) {
|
||||||
PublicProfileStateReady(
|
_waiting = event;
|
||||||
profile: await _profiles.save(
|
return;
|
||||||
handle: event.handle,
|
}
|
||||||
displayName: event.displayName,
|
PublicProfileEventSaved? next = event;
|
||||||
bio: event.bio,
|
while (next != null) {
|
||||||
links: event.links,
|
if (state case PublicProfileStateReady(:final profile, saving: false)) {
|
||||||
),
|
await _save(next, profile, emit);
|
||||||
outcome: PublicProfileOutcome.saved,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} on ProfileRefused catch (refused) {
|
|
||||||
emit(
|
|
||||||
PublicProfileStateReady(profile: profile, problem: refused.problem),
|
|
||||||
);
|
|
||||||
} on Object catch (error) {
|
|
||||||
log('Saving the profile failed', name: 'Profile', error: error);
|
|
||||||
emit(
|
|
||||||
PublicProfileStateReady(
|
|
||||||
profile: profile,
|
|
||||||
outcome: PublicProfileOutcome.failed,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
next = _waiting;
|
||||||
|
_waiting = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save(
|
||||||
|
PublicProfileEventSaved event,
|
||||||
|
PublicProfile? profile,
|
||||||
|
Emitter<PublicProfileState> emit,
|
||||||
|
) async {
|
||||||
|
// No profile is made without a handle.
|
||||||
|
final handle = _chosenHandle ?? profile?.handle;
|
||||||
|
if (handle == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(PublicProfileStateReady(profile: profile, saving: true));
|
||||||
|
try {
|
||||||
|
final saved = await _profiles.save(
|
||||||
|
handle: handle,
|
||||||
|
displayName: event.displayName,
|
||||||
|
bio: event.bio,
|
||||||
|
links: event.links,
|
||||||
|
);
|
||||||
|
if (saved.handle.toLowerCase() == _chosenHandle?.toLowerCase()) {
|
||||||
|
_chosenHandle = null;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
PublicProfileStateReady(
|
||||||
|
profile: saved,
|
||||||
|
outcome: PublicProfileOutcome.saved,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on ProfileRefused catch (refused) {
|
||||||
|
if (handle == _chosenHandle &&
|
||||||
|
ProfileProblem.handleProblems.contains(refused.problem)) {
|
||||||
|
_chosenHandle = null;
|
||||||
|
}
|
||||||
|
emit(PublicProfileStateReady(profile: profile, problem: refused.problem));
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Saving the profile failed', name: 'Profile', error: error);
|
||||||
|
emit(
|
||||||
|
PublicProfileStateReady(
|
||||||
|
profile: profile,
|
||||||
|
outcome: PublicProfileOutcome.failed,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,12 +215,13 @@ sealed class const PublicProfileEvent() with Equatable {
|
||||||
/// The profile shows: read it.
|
/// The profile shows: read it.
|
||||||
final class const PublicProfileEventStarted() extends PublicProfileEvent;
|
final class const PublicProfileEventStarted() extends PublicProfileEvent;
|
||||||
|
|
||||||
/// The person saves their profile as written.
|
/// The person saves their profile as written: a field they left, or a
|
||||||
|
/// [handle] they chose (null keeps the one chosen last, or the profile's).
|
||||||
final class const PublicProfileEventSaved({
|
final class const PublicProfileEventSaved({
|
||||||
required final String handle,
|
|
||||||
required final String displayName,
|
required final String displayName,
|
||||||
required final String bio,
|
required final String bio,
|
||||||
required final List<String> links,
|
required final List<String> links,
|
||||||
|
final String? handle,
|
||||||
}) extends PublicProfileEvent {
|
}) extends PublicProfileEvent {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [handle, displayName, bio, links];
|
List<Object?> get props => [handle, displayName, bio, links];
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ abstract interface class ProfileGateway() {
|
||||||
required List<String> links,
|
required List<String> links,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Why [handle] couldn't be the person's, as saving would refuse it; null
|
||||||
|
/// when it can.
|
||||||
|
Future<ProfileProblem?> checkHandle(String handle);
|
||||||
|
|
||||||
/// Removes the person's public profile. Throws [ProfileRefused] once they
|
/// Removes the person's public profile. Throws [ProfileRefused] once they
|
||||||
/// have published.
|
/// have published.
|
||||||
Future<void> remove();
|
Future<void> remove();
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ class ProfileRepository(
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Why [handle] couldn't be the person's, as saving would refuse it: not
|
||||||
|
/// a handle, reserved, someone else's, or a new one once they have
|
||||||
|
/// published; null when it can, as their own can (decided 2026-10-12, to
|
||||||
|
/// say so while it is typed).
|
||||||
|
Future<ProfileProblem?> checkHandle(String handle) =>
|
||||||
|
_gateway.checkHandle(handle.trim());
|
||||||
|
|
||||||
/// Removes the person's public profile. Throws [ProfileRefused] once they
|
/// Removes the person's public profile. Throws [ProfileRefused] once they
|
||||||
/// have published.
|
/// have published.
|
||||||
Future<void> remove() => _gateway.remove();
|
Future<void> remove() => _gateway.remove();
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,16 @@ class ServerpodProfileGateway(final protocol.Client _client)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProfileProblem?> checkHandle(String handle) async =>
|
||||||
|
switch (await _client.profile.checkHandle(handle)) {
|
||||||
|
null => null,
|
||||||
|
// A reason this app doesn't know reads as an invalid handle.
|
||||||
|
final reason =>
|
||||||
|
ProfileProblem.values.asNameMap()[reason.name] ??
|
||||||
|
ProfileProblem.handleInvalid,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> remove() => _refusing(_client.profile.remove);
|
Future<void> remove() => _refusing(_client.profile.remove);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,15 @@ enum ProfileProblem() {
|
||||||
linkInvalid,
|
linkInvalid,
|
||||||
|
|
||||||
/// The picture can't be read, or isn't a PNG of at most 1 MB.
|
/// The picture can't be read, or isn't a PNG of at most 1 MB.
|
||||||
avatarInvalid,
|
avatarInvalid;
|
||||||
|
|
||||||
|
/// The problems with a handle.
|
||||||
|
static const handleProblems = {
|
||||||
|
handleInvalid,
|
||||||
|
handleReserved,
|
||||||
|
handleTaken,
|
||||||
|
published,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The instance refused to save or remove a profile, for [problem];
|
/// The instance refused to save or remove a profile, for [problem];
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,20 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/handle_check_cubit.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// The person's public profile (ADR 0005, rule 2), in the Profile tab: what
|
/// The person's public profile (ADR 0005, rule 2), in the Profile tab: what
|
||||||
/// it is for, and a form of its display name, handle, bio and links, saved
|
/// it is for, and a form of its handle, display name, bio and links that
|
||||||
/// at once; a way to remove it until the person publishes.
|
/// saves as it is written; a way to remove it until the person publishes.
|
||||||
class const PublicProfileSection({super.key}) extends StatelessWidget {
|
class const PublicProfileSection({super.key}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => BlocProvider(
|
Widget build(BuildContext context) => BlocProvider(
|
||||||
|
|
@ -29,12 +32,13 @@ class const _Section() extends StatelessWidget {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return BlocConsumer<PublicProfileBloc, PublicProfileState>(
|
return BlocConsumer<PublicProfileBloc, PublicProfileState>(
|
||||||
|
// Saving as one writes says so beside the title, not in a message.
|
||||||
listenWhen: (before, now) =>
|
listenWhen: (before, now) =>
|
||||||
now is PublicProfileStateReady && now.outcome != null,
|
now is PublicProfileStateReady &&
|
||||||
|
now.outcome != null &&
|
||||||
|
now.outcome != PublicProfileOutcome.saved,
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
final message = switch (state) {
|
final message = switch (state) {
|
||||||
PublicProfileStateReady(outcome: PublicProfileOutcome.saved) =>
|
|
||||||
l10n.publicProfileSaved,
|
|
||||||
PublicProfileStateReady(outcome: PublicProfileOutcome.removed) =>
|
PublicProfileStateReady(outcome: PublicProfileOutcome.removed) =>
|
||||||
l10n.publicProfileRemoved,
|
l10n.publicProfileRemoved,
|
||||||
_ => l10n.publicProfileFailed,
|
_ => l10n.publicProfileFailed,
|
||||||
|
|
@ -46,7 +50,24 @@ class const _Section() extends StatelessWidget {
|
||||||
builder: (context, state) => Column(
|
builder: (context, state) => Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(l10n.publicProfileTitle, style: theme.textTheme.titleMedium),
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.publicProfileTitle,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state case PublicProfileStateReady(
|
||||||
|
:final saving,
|
||||||
|
:final outcome,
|
||||||
|
))
|
||||||
|
_SaveStatus(
|
||||||
|
saving: saving,
|
||||||
|
saved: outcome == PublicProfileOutcome.saved,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
l10n.publicProfileIntro,
|
l10n.publicProfileIntro,
|
||||||
|
|
@ -78,9 +99,9 @@ class const _Section() extends StatelessWidget {
|
||||||
:final problem,
|
:final problem,
|
||||||
) =>
|
) =>
|
||||||
_ProfileForm(
|
_ProfileForm(
|
||||||
// Each profile the instance answers with starts the form
|
// Kept while the person writes, as saves come back; afresh
|
||||||
// afresh, with what it holds.
|
// once a profile is made or removed.
|
||||||
key: ValueKey(profile),
|
key: ValueKey(profile != null),
|
||||||
profile: profile,
|
profile: profile,
|
||||||
saving: saving,
|
saving: saving,
|
||||||
problem: problem,
|
problem: problem,
|
||||||
|
|
@ -92,6 +113,46 @@ class const _Section() extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether what was written is being saved, or was.
|
||||||
|
class const _SaveStatus({required final bool saving, required final bool saved})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final text = saving
|
||||||
|
? l10n.publicProfileSaving
|
||||||
|
: saved
|
||||||
|
? l10n.publicProfileSavedNow
|
||||||
|
: null;
|
||||||
|
return AnimatedOpacity(
|
||||||
|
opacity: text == null ? 0 : 1,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!saving && saved) ...[
|
||||||
|
Icon(LucideIcons.check, size: 16, color: theme.extras.success),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
text ?? '',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile as a form that saves as it is written (decided 2026-10-12):
|
||||||
|
/// a field when it is left or Enter is pressed in it (the bio, of several
|
||||||
|
/// lines, when it is left), a link when removed. The handle is chosen in a
|
||||||
|
/// dialog that says while typing whether it is free; without a profile,
|
||||||
|
/// choosing it makes the profile with what is written. Once the person has
|
||||||
|
/// published, the handle stays.
|
||||||
class const _ProfileForm({
|
class const _ProfileForm({
|
||||||
required final PublicProfile? profile,
|
required final PublicProfile? profile,
|
||||||
required final bool saving,
|
required final bool saving,
|
||||||
|
|
@ -106,32 +167,102 @@ class _ProfileFormState() extends State<_ProfileForm> {
|
||||||
late final _displayName = TextEditingController(
|
late final _displayName = TextEditingController(
|
||||||
text: widget.profile?.displayName,
|
text: widget.profile?.displayName,
|
||||||
);
|
);
|
||||||
late final _handle = TextEditingController(text: widget.profile?.handle);
|
|
||||||
late final _bio = TextEditingController(text: widget.profile?.bio);
|
late final _bio = TextEditingController(text: widget.profile?.bio);
|
||||||
late final _links = [
|
late final _links = [
|
||||||
for (final link in widget.profile?.links ?? const <String>[])
|
for (final link in widget.profile?.links ?? const <String>[])
|
||||||
TextEditingController(text: link),
|
TextEditingController(text: link),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// The fields that save when left.
|
||||||
|
late final _displayNameFocus = _savingWhenLeft();
|
||||||
|
late final _bioFocus = _savingWhenLeft();
|
||||||
|
final _linkFocus = <FocusNode>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_linkFocus.addAll([for (final _ in _links) _savingWhenLeft()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
FocusNode _savingWhenLeft() {
|
||||||
|
final node = FocusNode();
|
||||||
|
node.addListener(() {
|
||||||
|
if (!node.hasFocus) {
|
||||||
|
_saveIfChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_displayName.dispose();
|
for (final controller in [_displayName, _bio, ..._links]) {
|
||||||
_handle.dispose();
|
controller.dispose();
|
||||||
_bio.dispose();
|
}
|
||||||
for (final link in _links) {
|
for (final node in [_displayNameFocus, _bioFocus, ..._linkFocus]) {
|
||||||
link.dispose();
|
node.dispose();
|
||||||
}
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _save() => context.read<PublicProfileBloc>().add(
|
/// What was last sent to be saved, as [_written] puts it; null before.
|
||||||
PublicProfileEventSaved(
|
String? _sent;
|
||||||
handle: _handle.text,
|
|
||||||
displayName: _displayName.text,
|
/// The name, bio and links written, as one string to compare.
|
||||||
bio: _bio.text,
|
String get _written => [
|
||||||
links: [for (final link in _links) link.text],
|
_displayName.text.trim(),
|
||||||
),
|
_bio.text.trim(),
|
||||||
);
|
for (final link in _links)
|
||||||
|
if (link.text.trim().isNotEmpty) link.text.trim(),
|
||||||
|
].join('\u0000');
|
||||||
|
|
||||||
|
/// Saves what is written, if a profile is there and it differs from what
|
||||||
|
/// was last saved or sent to be: Enter in a field also leaves it.
|
||||||
|
void _saveIfChanged() {
|
||||||
|
final profile = widget.profile;
|
||||||
|
if (profile == null || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final saved =
|
||||||
|
_sent ??
|
||||||
|
[
|
||||||
|
profile.displayName,
|
||||||
|
profile.bio,
|
||||||
|
...profile.links,
|
||||||
|
].join('\u0000');
|
||||||
|
if (_written != saved) {
|
||||||
|
_save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves what is written, with [handle] if one was chosen.
|
||||||
|
void _save({String? handle}) {
|
||||||
|
_sent = _written;
|
||||||
|
context.read<PublicProfileBloc>().add(
|
||||||
|
PublicProfileEventSaved(
|
||||||
|
handle: handle,
|
||||||
|
displayName: _displayName.text,
|
||||||
|
bio: _bio.text,
|
||||||
|
links: [for (final link in _links) link.text],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _chooseHandle() async {
|
||||||
|
final current = widget.profile?.handle;
|
||||||
|
if (await chooseHandle(context, current: current) case final handle?
|
||||||
|
when handle != current && mounted) {
|
||||||
|
_save(handle: handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeLink(int index) {
|
||||||
|
setState(() {
|
||||||
|
_links.removeAt(index).dispose();
|
||||||
|
_linkFocus.removeAt(index).dispose();
|
||||||
|
});
|
||||||
|
_saveIfChanged();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _remove() async {
|
Future<void> _remove() async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
@ -159,6 +290,7 @@ class _ProfileFormState() extends State<_ProfileForm> {
|
||||||
),
|
),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
final handleProblem = said(ProfileProblem.handleProblems);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -168,10 +300,30 @@ class _ProfileFormState() extends State<_ProfileForm> {
|
||||||
problem: said({ProfileProblem.avatarInvalid}),
|
problem: said({ProfileProblem.avatarInvalid}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
ChevronField(
|
||||||
|
label: l10n.publicProfileHandle,
|
||||||
|
value: switch (profile) {
|
||||||
|
final profile? => '@${profile.handle}',
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
|
placeholder: l10n.publicProfileHandleChoose,
|
||||||
|
maxLines: 1,
|
||||||
|
onTap: fixed ? null : _chooseHandle,
|
||||||
|
helperText: switch (profile) {
|
||||||
|
PublicProfile(handleFixed: true) => l10n.publicProfileHandleFixed,
|
||||||
|
final profile? => l10n.publicProfileAddress(profile.address),
|
||||||
|
null => l10n.publicProfileHandleFirst,
|
||||||
|
},
|
||||||
|
errorText: handleProblem,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
maxLength: PublicProfile.maxDisplayNameLength,
|
maxLength: PublicProfile.maxDisplayNameLength,
|
||||||
buildCounter: nearLimitCounter,
|
buildCounter: nearLimitCounter,
|
||||||
controller: _displayName,
|
controller: _displayName,
|
||||||
|
focusNode: _displayNameFocus,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onSubmitted: (_) => _saveIfChanged(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l10n.publicProfileDisplayName,
|
labelText: l10n.publicProfileDisplayName,
|
||||||
hintText: l10n.publicProfileDisplayNameHint,
|
hintText: l10n.publicProfileDisplayNameHint,
|
||||||
|
|
@ -179,35 +331,11 @@ class _ProfileFormState() extends State<_ProfileForm> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
|
||||||
maxLength: PublicProfile.maxHandleLength,
|
|
||||||
buildCounter: nearLimitCounter,
|
|
||||||
controller: _handle,
|
|
||||||
readOnly: fixed,
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: l10n.publicProfileHandle,
|
|
||||||
hintText: l10n.publicProfileHandleHint,
|
|
||||||
prefixText: '@',
|
|
||||||
helperText: switch (profile) {
|
|
||||||
PublicProfile(handleFixed: true) => l10n.publicProfileHandleFixed,
|
|
||||||
final profile? => l10n.publicProfileAddress(profile.address),
|
|
||||||
null => null,
|
|
||||||
},
|
|
||||||
errorText: said({
|
|
||||||
ProfileProblem.handleInvalid,
|
|
||||||
ProfileProblem.handleReserved,
|
|
||||||
ProfileProblem.handleTaken,
|
|
||||||
ProfileProblem.published,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
TextField(
|
||||||
maxLength: PublicProfile.maxBioLength,
|
maxLength: PublicProfile.maxBioLength,
|
||||||
buildCounter: nearLimitCounter,
|
buildCounter: nearLimitCounter,
|
||||||
controller: _bio,
|
controller: _bio,
|
||||||
|
focusNode: _bioFocus,
|
||||||
minLines: 3,
|
minLines: 3,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
|
@ -239,8 +367,11 @@ class _ProfileFormState() extends State<_ProfileForm> {
|
||||||
maxLength: PublicProfile.maxLinkLength,
|
maxLength: PublicProfile.maxLinkLength,
|
||||||
buildCounter: nearLimitCounter,
|
buildCounter: nearLimitCounter,
|
||||||
controller: link,
|
controller: link,
|
||||||
|
focusNode: _linkFocus[i],
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
|
onSubmitted: (_) => _saveIfChanged(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: l10n.publicProfileLinkHint,
|
hintText: l10n.publicProfileLinkHint,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
|
|
@ -250,7 +381,7 @@ class _ProfileFormState() extends State<_ProfileForm> {
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.publicProfileRemoveLink,
|
tooltip: l10n.publicProfileRemoveLink,
|
||||||
icon: const Icon(LucideIcons.x, size: 18),
|
icon: const Icon(LucideIcons.x, size: 18),
|
||||||
onPressed: () => setState(() => _links.removeAt(i).dispose()),
|
onPressed: () => _removeLink(i),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -259,27 +390,146 @@ class _ProfileFormState() extends State<_ProfileForm> {
|
||||||
Align(
|
Align(
|
||||||
alignment: AlignmentDirectional.centerStart,
|
alignment: AlignmentDirectional.centerStart,
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: () =>
|
onPressed: () {
|
||||||
setState(() => _links.add(TextEditingController())),
|
final focus = _savingWhenLeft();
|
||||||
|
setState(() {
|
||||||
|
_links.add(TextEditingController());
|
||||||
|
_linkFocus.add(focus);
|
||||||
|
});
|
||||||
|
// Into the new field, once it is there.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => focus.requestFocus(),
|
||||||
|
);
|
||||||
|
},
|
||||||
icon: const Icon(LucideIcons.plus, size: 18),
|
icon: const Icon(LucideIcons.plus, size: 18),
|
||||||
label: Text(l10n.publicProfileAddLink),
|
label: Text(l10n.publicProfileAddLink),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
if (profile != null && !fixed) ...[
|
||||||
Row(
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: widget.saving ? null : _remove,
|
||||||
|
child: Text(l10n.publicProfileRemove),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for a handle in a dialog, saying while it is typed whether it
|
||||||
|
/// could be the person's ([HandleCheckCubit]); completes with the one
|
||||||
|
/// chosen, or null when the person cancels. [current] is the person's own
|
||||||
|
/// handle, if they have one.
|
||||||
|
Future<String?> chooseHandle(BuildContext context, {required String? current}) {
|
||||||
|
final profiles = PersonScope.of(context).profile;
|
||||||
|
return showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => BlocProvider(
|
||||||
|
create: (context) => HandleCheckCubit(profiles, current: current),
|
||||||
|
child: _HandleDialog(current: current),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _HandleDialog({required final String? current})
|
||||||
|
extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_HandleDialog> createState() => _HandleDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HandleDialogState() extends State<_HandleDialog> {
|
||||||
|
late final _handle = TextEditingController(text: widget.current);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_handle.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final state = context.watch<HandleCheckCubit>().state;
|
||||||
|
// The instance decides when it couldn't be asked.
|
||||||
|
final choosable =
|
||||||
|
state.handle.isNotEmpty &&
|
||||||
|
state.handle != widget.current &&
|
||||||
|
(state.check == HandleCheck.free || state.check == HandleCheck.unknown);
|
||||||
|
void choose() => Navigator.of(context).pop(state.handle);
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
widget.current == null
|
||||||
|
? l10n.publicProfileHandleChooseTitle
|
||||||
|
: l10n.publicProfileHandleChangeTitle,
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (profile != null && !fixed)
|
Text(l10n.publicProfileHandleDialogText),
|
||||||
TextButton(
|
const SizedBox(height: 16),
|
||||||
onPressed: widget.saving ? null : _remove,
|
TextField(
|
||||||
child: Text(l10n.publicProfileRemove),
|
controller: _handle,
|
||||||
|
autofocus: true,
|
||||||
|
maxLength: PublicProfile.maxHandleLength,
|
||||||
|
buildCounter: nearLimitCounter,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onChanged: (handle) =>
|
||||||
|
context.read<HandleCheckCubit>().typed(handle),
|
||||||
|
onSubmitted: (_) => choosable ? choose() : null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.publicProfileHandle,
|
||||||
|
hintText: l10n.publicProfileHandleHint,
|
||||||
|
prefixText: '@',
|
||||||
|
errorText: switch (state) {
|
||||||
|
HandleCheckState(
|
||||||
|
check: HandleCheck.refused,
|
||||||
|
:final problem?,
|
||||||
|
) =>
|
||||||
|
_problemText(l10n, problem),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
helperText: switch (state.check) {
|
||||||
|
HandleCheck.checking => l10n.publicProfileHandleChecking,
|
||||||
|
HandleCheck.free => l10n.publicProfileHandleFree(
|
||||||
|
state.handle,
|
||||||
|
),
|
||||||
|
HandleCheck.none when state.handle.isNotEmpty =>
|
||||||
|
l10n.publicProfileHandleYours,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
helperStyle: state.check == HandleCheck.free
|
||||||
|
? theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.extras.success,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: widget.saving ? null : _save,
|
|
||||||
child: Text(l10n.commonSave),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.commonCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: choosable ? choose : null,
|
||||||
|
child: Text(
|
||||||
|
widget.current == null
|
||||||
|
? l10n.publicProfileHandleConfirmChoose
|
||||||
|
: l10n.publicProfileHandleConfirmChange,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/chevron_field.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/profile/bloc/public_profile_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
@ -44,43 +47,124 @@ void main() {
|
||||||
|
|
||||||
Finder field(String label) => find.widgetWithText(TextField, label);
|
Finder field(String label) => find.widgetWithText(TextField, label);
|
||||||
|
|
||||||
testWidgets('a person with an identity writes a public profile and saves '
|
/// The dialog's field for the handle.
|
||||||
'it, then sees the address others find them by', (tester) async {
|
Finder handleInDialog() => find.descendant(
|
||||||
final profiles = FakeProfileGateway();
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.byType(TextField),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Types [handle] into the handle's dialog and waits for the instance to
|
||||||
|
/// say whether it is free.
|
||||||
|
Future<void> typeHandle(WidgetTester tester, String handle) async {
|
||||||
|
await tester.enterText(handleInDialog(), handle);
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('a person with an identity writes first, then chooses a '
|
||||||
|
'handle in a dialog saying whether it is free; that makes the profile '
|
||||||
|
'with what was written', (tester) async {
|
||||||
|
final profiles = FakeProfileGateway(taken: {'Tizian'});
|
||||||
await showProfile(tester, profiles);
|
await showProfile(tester, profiles);
|
||||||
|
|
||||||
expect(find.text('Public profile'), findsOneWidget);
|
expect(find.text('Public profile'), findsOneWidget);
|
||||||
|
expect(find.text('Save'), findsNothing);
|
||||||
await tester.enterText(field('Display name'), 'Tizian');
|
await tester.enterText(field('Display name'), 'Tizian');
|
||||||
await tester.enterText(field('Handle'), 'raisondetredev');
|
|
||||||
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
||||||
await tester.tap(find.text('Add link'));
|
await tester.tap(find.text('Display name'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.enterText(
|
// Nothing to save without a handle.
|
||||||
find.widgetWithText(TextField, 'https://…'),
|
expect(profiles.saves, isEmpty);
|
||||||
'https://halkyon.app',
|
|
||||||
|
await tester.tap(find.text('Choose a handle'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Choose your handle'), findsOneWidget);
|
||||||
|
await typeHandle(tester, 'tizian');
|
||||||
|
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Choose'))
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
);
|
);
|
||||||
await tester.tap(find.text('Save'));
|
await typeHandle(tester, 'raisondetredev');
|
||||||
|
expect(find.text('@raisondetredev is free'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Choose'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Profile saved.'), findsOneWidget);
|
|
||||||
expect(
|
expect(
|
||||||
find.text('Others find you as @raisondetredev@halkyon.eu'),
|
find.text('Others find you as @raisondetredev@halkyon.eu'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(profiles.profile!.links, ['https://halkyon.app']);
|
expect(find.text('@raisondetredev'), findsOneWidget);
|
||||||
expect(profiles.profile!.displayName, 'Tizian');
|
expect(profiles.profile!.displayName, 'Tizian');
|
||||||
|
expect(profiles.profile!.bio, 'Learning Japanese.');
|
||||||
|
expect(find.text('Saved'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('a handle someone else has is refused, said under it', (
|
testWidgets('a profile saves a field when it is left or Enter is pressed, '
|
||||||
|
'only what changed, without losing what is being written', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await showProfile(tester, FakeProfileGateway(taken: {'Tizian'}));
|
final profiles = FakeProfileGateway(
|
||||||
|
profile: const PublicProfile(
|
||||||
|
handle: 'raisondetredev',
|
||||||
|
address: 'raisondetredev@halkyon.eu',
|
||||||
|
displayName: 'Tizian',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await showProfile(tester, profiles);
|
||||||
|
|
||||||
await tester.enterText(field('Handle'), 'tizian');
|
await tester.enterText(field('Display name'), 'Tizian K.');
|
||||||
await tester.tap(find.text('Save'));
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(profiles.saves.single.displayName, 'Tizian K.');
|
||||||
|
|
||||||
|
await tester.enterText(field('Bio'), 'Learning Japanese.');
|
||||||
|
// Into the next field: the bio is left, and saves.
|
||||||
|
await tester.tap(find.text('Add link'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(profiles.saves.last.bio, 'Learning Japanese.');
|
||||||
|
expect(profiles.saves, hasLength(2));
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'https://…'),
|
||||||
|
'https://halkyon.app',
|
||||||
|
);
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(profiles.profile!.links, ['https://halkyon.app']);
|
||||||
|
// What was written stays as it was written.
|
||||||
|
expect(find.text('Tizian K.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Remove link'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(profiles.profile!.links, isEmpty);
|
||||||
|
expect(profiles.saves, hasLength(4));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the handle changes through the dialog, which says one is '
|
||||||
|
'yours', (tester) async {
|
||||||
|
final profiles = FakeProfileGateway(
|
||||||
|
profile: const PublicProfile(
|
||||||
|
handle: 'raisondetredev',
|
||||||
|
address: 'raisondetredev@halkyon.eu',
|
||||||
|
displayName: 'Tizian',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await showProfile(tester, profiles);
|
||||||
|
|
||||||
|
await tester.tap(find.text('@raisondetredev'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Change your handle'), findsOneWidget);
|
||||||
|
expect(find.text('This is your handle'), findsOneWidget);
|
||||||
|
await typeHandle(tester, 'tizian_k');
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Change'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Someone else has this handle.'), findsOneWidget);
|
expect(profiles.profile!.handle, 'tizian_k');
|
||||||
|
expect(profiles.profile!.displayName, 'Tizian');
|
||||||
|
expect(find.text('@tizian_k'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('once its person has published, the handle is fixed and the '
|
testWidgets('once its person has published, the handle is fixed and the '
|
||||||
|
|
@ -101,8 +185,8 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester.widget<TextField>(field('Handle')).readOnly,
|
tester.widget<ChevronField>(find.byType(ChevronField)).onTap,
|
||||||
isTrue,
|
isNull,
|
||||||
);
|
);
|
||||||
expect(find.text('Remove profile'), findsNothing);
|
expect(find.text('Remove profile'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
@ -145,7 +229,7 @@ void main() {
|
||||||
|
|
||||||
expect(profiles.avatars.single, [1, 2, 3]);
|
expect(profiles.avatars.single, [1, 2, 3]);
|
||||||
expect(files.asked.single, contains('jpg'));
|
expect(files.asked.single, contains('jpg'));
|
||||||
expect(find.text('Profile saved.'), findsOneWidget);
|
expect(find.text('Saved'), findsOneWidget);
|
||||||
await tester.tap(find.text('Remove picture'));
|
await tester.tap(find.text('Remove picture'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(profiles.profile!.avatarUrl, isNull);
|
expect(profiles.profile!.avatarUrl, isNull);
|
||||||
|
|
@ -157,7 +241,7 @@ void main() {
|
||||||
) async {
|
) async {
|
||||||
await showProfile(tester, FakeProfileGateway());
|
await showProfile(tester, FakeProfileGateway());
|
||||||
|
|
||||||
expect(find.text('Save your profile to add a picture.'), findsOneWidget);
|
expect(find.text('Choose your handle to add a picture.'), findsOneWidget);
|
||||||
expect(find.text('Choose picture'), findsNothing);
|
expect(find.text('Choose picture'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,7 +268,48 @@ void main() {
|
||||||
profiles.fail = null;
|
profiles.fail = null;
|
||||||
await tester.tap(find.text('Retry'));
|
await tester.tap(find.text('Retry'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(field('Handle'), findsOneWidget);
|
expect(find.byType(ChevronField), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a save asked for while another is on its way follows it, the '
|
||||||
|
'latest of those waiting; a handle chosen holds for the saves after '
|
||||||
|
'it', () async {
|
||||||
|
final gateway = _HeldProfileGateway(
|
||||||
|
profile: const PublicProfile(
|
||||||
|
handle: 'raisondetredev',
|
||||||
|
address: 'raisondetredev@halkyon.eu',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final bloc = PublicProfileBloc(ProfileRepository(gateway))
|
||||||
|
..add(const PublicProfileEventStarted());
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
await bloc.stream.firstWhere((state) => state is PublicProfileStateReady);
|
||||||
|
|
||||||
|
PublicProfileEventSaved saved(String name, {String? handle}) =>
|
||||||
|
PublicProfileEventSaved(
|
||||||
|
displayName: name,
|
||||||
|
bio: '',
|
||||||
|
links: const [],
|
||||||
|
handle: handle,
|
||||||
|
);
|
||||||
|
bloc
|
||||||
|
..add(saved('A'))
|
||||||
|
..add(saved('A', handle: 'tizian'))
|
||||||
|
..add(saved('B'));
|
||||||
|
await pumpEventQueue();
|
||||||
|
gateway.release();
|
||||||
|
await pumpEventQueue();
|
||||||
|
gateway.release();
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
// The second gave way to the third, which kept its handle.
|
||||||
|
expect(
|
||||||
|
[for (final s in gateway.saves) (s.handle, s.displayName)],
|
||||||
|
[
|
||||||
|
('raisondetredev', 'A'),
|
||||||
|
('tizian', 'B'),
|
||||||
|
],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
|
|
@ -203,3 +328,29 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saves only once [release] lets each through.
|
||||||
|
class _HeldProfileGateway({super.profile}) extends FakeProfileGateway {
|
||||||
|
final _held = <Completer<void>>[];
|
||||||
|
|
||||||
|
/// Lets the save waiting longest through.
|
||||||
|
void release() => _held.removeAt(0).complete();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PublicProfile> save({
|
||||||
|
required String handle,
|
||||||
|
required String displayName,
|
||||||
|
required String bio,
|
||||||
|
required List<String> links,
|
||||||
|
}) async {
|
||||||
|
final held = Completer<void>();
|
||||||
|
_held.add(held);
|
||||||
|
await held.future;
|
||||||
|
return await super.save(
|
||||||
|
handle: handle,
|
||||||
|
displayName: displayName,
|
||||||
|
bio: bio,
|
||||||
|
links: links,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,40 @@ class FakeProfileGateway({
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The handles checked, in order.
|
||||||
|
final checked = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ProfileProblem?> checkHandle(String handle) async {
|
||||||
|
if (fail case final error?) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
checked.add(handle);
|
||||||
|
return _problemOf(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileProblem? _problemOf(String handle) {
|
||||||
|
final current = profile;
|
||||||
|
if (!RegExp(r'^[A-Za-z0-9_]{1,30}$').hasMatch(handle)) {
|
||||||
|
return ProfileProblem.handleInvalid;
|
||||||
|
}
|
||||||
|
if (handle.toLowerCase() == 'admin') {
|
||||||
|
return ProfileProblem.handleReserved;
|
||||||
|
}
|
||||||
|
if (taken.any((other) => other.toLowerCase() == handle.toLowerCase())) {
|
||||||
|
return ProfileProblem.handleTaken;
|
||||||
|
}
|
||||||
|
if (current != null &&
|
||||||
|
current.handleFixed &&
|
||||||
|
current.handle.toLowerCase() != handle.toLowerCase()) {
|
||||||
|
return ProfileProblem.published;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every profile saved, in order.
|
||||||
|
final saves = <PublicProfile>[];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PublicProfile> save({
|
Future<PublicProfile> save({
|
||||||
required String handle,
|
required String handle,
|
||||||
|
|
@ -35,18 +69,10 @@ class FakeProfileGateway({
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
final current = profile;
|
final current = profile;
|
||||||
if (!RegExp(r'^[A-Za-z0-9_]{1,30}$').hasMatch(handle)) {
|
if (_problemOf(handle) case final problem?) {
|
||||||
throw ProfileRefused(ProfileProblem.handleInvalid);
|
throw ProfileRefused(problem);
|
||||||
}
|
}
|
||||||
if (taken.any((other) => other.toLowerCase() == handle.toLowerCase())) {
|
final saved = profile = PublicProfile(
|
||||||
throw ProfileRefused(ProfileProblem.handleTaken);
|
|
||||||
}
|
|
||||||
if (current != null &&
|
|
||||||
current.handleFixed &&
|
|
||||||
current.handle.toLowerCase() != handle.toLowerCase()) {
|
|
||||||
throw ProfileRefused(ProfileProblem.published);
|
|
||||||
}
|
|
||||||
return profile = PublicProfile(
|
|
||||||
handle: handle,
|
handle: handle,
|
||||||
address: '$handle@halkyon.eu',
|
address: '$handle@halkyon.eu',
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
|
|
@ -54,6 +80,8 @@ class FakeProfileGateway({
|
||||||
links: links,
|
links: links,
|
||||||
handleFixed: current?.handleFixed ?? false,
|
handleFixed: current?.handleFixed ?? false,
|
||||||
);
|
);
|
||||||
|
saves.add(saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The avatars set, in order.
|
/// The avatars set, in order.
|
||||||
|
|
|
||||||
|
|
@ -916,6 +916,25 @@ class Endpoints extends _is.EndpointDispatch {
|
||||||
params['links'],
|
params['links'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
'checkHandle': _is.MethodConnector(
|
||||||
|
name: 'checkHandle',
|
||||||
|
params: {
|
||||||
|
'handle': _is.ParameterDescription(
|
||||||
|
name: 'handle',
|
||||||
|
type: _is.getType<String>(),
|
||||||
|
nullable: false,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
call:
|
||||||
|
(
|
||||||
|
_is.Session session,
|
||||||
|
Map<String, dynamic> params,
|
||||||
|
) async => (endpoints['profile'] as _i6ky944g.ProfileEndpoint)
|
||||||
|
.checkHandle(
|
||||||
|
session,
|
||||||
|
params['handle'],
|
||||||
|
),
|
||||||
|
),
|
||||||
'setAvatar': _is.MethodConnector(
|
'setAvatar': _is.MethodConnector(
|
||||||
name: 'setAvatar',
|
name: 'setAvatar',
|
||||||
params: {
|
params: {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ instance:
|
||||||
profile:
|
profile:
|
||||||
- mine:
|
- mine:
|
||||||
- save:
|
- save:
|
||||||
|
- checkHandle:
|
||||||
- setAvatar:
|
- setAvatar:
|
||||||
- removeAvatar:
|
- removeAvatar:
|
||||||
- remove:
|
- remove:
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,43 @@ class ProfileEndpoint extends Endpoint {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why [handle] couldn't be yours, as saving it would refuse it: not a
|
||||||
|
/// handle, reserved for the instance, someone else's whatever its case,
|
||||||
|
/// or a new one once you have published; null when it can, as your own
|
||||||
|
/// can. For the app to say so while it is typed (decided 2026-10-12);
|
||||||
|
/// saving checks again. Whose a handle is, anyone can find out already
|
||||||
|
/// ([ProfilesEndpoint.byHandle]), so this tells nothing new.
|
||||||
|
Future<ProfileRefusal?> checkHandle(Session session, String handle) async {
|
||||||
|
if (profileRefusal(
|
||||||
|
handle: handle,
|
||||||
|
displayName: '',
|
||||||
|
bio: '',
|
||||||
|
links: const [],
|
||||||
|
)
|
||||||
|
case final reason?) {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
final person = session.authenticated!.authUserId;
|
||||||
|
final key = handle.toLowerCase();
|
||||||
|
// SELECT … FROM "public_profile" WHERE "authUserId" = $1
|
||||||
|
final mine = await PublicProfile.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.authUserId.equals(person),
|
||||||
|
);
|
||||||
|
if (mine != null &&
|
||||||
|
mine.firstPublishedAt != null &&
|
||||||
|
mine.handleKey != key) {
|
||||||
|
return ProfileRefusal.published;
|
||||||
|
}
|
||||||
|
// SELECT … FROM "public_profile"
|
||||||
|
// WHERE "handleKey" = $1 AND "authUserId" <> $2
|
||||||
|
final theirs = await PublicProfile.db.findFirstRow(
|
||||||
|
session,
|
||||||
|
where: (t) => t.handleKey.equals(key) & t.authUserId.notEquals(person),
|
||||||
|
);
|
||||||
|
return theirs == null ? null : ProfileRefusal.handleTaken;
|
||||||
|
}
|
||||||
|
|
||||||
/// Shows [png] as your avatar, in place of any before: a PNG of at most
|
/// Shows [png] as your avatar, in place of any before: a PNG of at most
|
||||||
/// 1 MB, which the app draws from the picture chosen. Throws
|
/// 1 MB, which the app draws from the picture chosen. Throws
|
||||||
/// [ProfileRefusedException] for anything else, and [NotFoundException]
|
/// [ProfileRefusedException] for anything else, and [NotFoundException]
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue