feat(instance): the first start offers instances as tiles

Under the wordmark on a cobalt wash, two tiles as Proton's account page
shows its services: the build's instance, chosen in one tap, and
another, which unfolds the address to type. A ring from cobalt to
copper, a glow and a tagline come in under the pointer, on the
keyboard's focus, while pressed or open; without a mouse the taglines
always show. The launch screen's "Halkyon" over "Learn" becomes
BrandWordmark, which both show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-18 20:33:33 +02:00
commit 9b0f28d7c1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 638 additions and 62 deletions

View file

@ -98,7 +98,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `ConflictVersions` (the versions of a conflict each editor shows, with Keep; its save status beside it is `SaveStatusLabel` in `core/editor/`), `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `ConflictVersions` (the versions of a conflict each editor shows, with Keep; its save status beside it is `SaveStatusLabel` in `core/editor/`), `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn", the `BrandWordmark`, which the first start shows too), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`; how many steps of its learning paths are done and the PDFs not read to their end, begun first, each opening the reader, decided 2026-10-17: `MaterialProgressCubit`; all of it across its topics) and Study plan (the paths finished; Next up: studying for the exam while one is ahead and its pace leaves cards for the day, then the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; at the top the exam the person prepares for, below; `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
@ -161,7 +161,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`: an address checked with `instance.info()`, or the build's own in one tap), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use. **Signed out, the app asks how to start** (decided 2026-10-16, `StartScreen`): on the instance in use, with GitHub, by email or without an account, or on another instance; it makes an anonymous account by itself only at the very first start. A session the server ended leads there too. That the device is signed out is kept in the people's folder (`SignedOutMark`, `signed_out`), so a restart asks again. Settings names the kind of account and its instance ("Anonymous on learn.halkyon.eu").
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`, under the wordmark on a cobalt wash: two tiles as Proton's account page shows its services, decided 2026-10-18, the build's own in one tap, and another instance, which unfolds the address to type, checked with `instance.info()`; `InstanceTile`: a ring from the primary colour to the tertiary, a glow and a tagline come in under the pointer, on the keyboard's focus, while pressed or open, and without a mouse the taglines always show), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use. **Signed out, the app asks how to start** (decided 2026-10-16, `StartScreen`): on the instance in use, with GitHub, by email or without an account, or on another instance; it makes an anonymous account by itself only at the very first start. A session the server ended leads there too. That the device is signed out is kept in the people's folder (`SignedOutMark`, `signed_out`), so a restart asks again. Settings names the kind of account and its instance ("Anonymous on learn.halkyon.eu").
- **Asking an AI assistant or searching the web** (decided 2026-10-10; `feature/assistant/`): a device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home (`AssistantBar`) and a button that unfolds into one (`AssistantButton`): at the lower left of the Library, across from Create studyset, at the left above a studyset's bottom bar (floating alone at the lower left from expanded windows on), and at the lower left while studying, above the buttons that answer the card (decided 2026-10-16, as Vaia places it). A switch at the field's start sends to the AI assistant chosen in Settings, named in the field, or to a web search and is remembered (`assistantTarget`). Above the field, chips offer the parts of what it is about (`AssistantTopic.parts`: a card's front, back, each hint, its explanation, the studyset's name and description), none chosen, so only what the person picks goes along, each cut at 600 characters; a web search takes the question and the chosen text, never the studyset's name or description. Nothing is answered here: `AssistantHandOff` opens the chosen assistant's new conversation with the question and a compact line per chosen part written in, for the person to send (`AiAssistant`, decided 2026-10-14: Proton's Lumo by default, by its guest chat `https://lumo.proton.me/guest#prefill=…`, the question in the fragment, which reaches no server, as Lumo's page reads it, undocumented; its address for one's own account loses the question when nobody is signed in; then ChatGPT, Claude, Mistral, Perplexity, which answers at once, Duck.ai, Grok, which asks first, Kagi Assistant, HuggingChat, or the person's own address with `%s`; only assistants whose address fills a question in, so not Gemini, which has none, nor Copilot, whose stopped working in November 2025; each cut at what it takes, 8,000 to 12,000 characters), and `WebSearch` opens the chosen engine's results (`SearchEngine`: Brave Search by default on desktops, Ecosia, DuckDuckGo, Startpage, Qwant, Google, Bing, or the person's own address with `%s`), or on Android hands the question to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). Halkyon apps use no LLM API keys: their pricing is opaque and open to abuse, and the person decides each time what an LLM sees. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude reads studysets through the instance's connector instead (ADR 0006): `halkyon_learn_server/lib/src/assistant/` serves MCP (`/mcp`, Streamable HTTP, JSON answers, no sessions; five read-only tools in `connector_tools.dart`) and its OAuth (`/.well-known/oauth-*`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) on Serverpod's web server, which production runs on 8082 for these paths alone (Caddy forwards them; `connectorOf` takes the public address from `webServer`). People connect with a code from Settings › Assistant › Connect Claude (`AssistantEndpoint.connectCode`, eight Crockford characters, ten minutes, once), typed on the authorization page; codes and tokens are kept as SHA-256 hashes. The handlers take plain requests (`ConnectorRequest`), so `test/integration/assistant/` drives the whole flow without a web server; the Relic routes only translate.
- **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, beside whether a sync ever went through on this device (`syncedHere`, read as the person's data opens): until one has, an empty library says it is on its way from the instance rather than that there are no studysets (decided 2026-10-17: a first sync brings everything at once, at its end). 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, pinned to a signed commit of branch `halkyon/batch-inserts`, which stacks six branches): `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); `halkyon/inbound-merges` (2026-10-17: `onInboundMerge`, called only when changes from the remote peer were merged, which the app's sync uses; `onMergeSuccess` fires for a space only pushed to, so every repository read everything again after each save's sync); `halkyon/fast-deletes` (2026-10-17: a local delete skips the foreign key projection pass when it can't change it, `canSkipProjectionAfterLocalDelete`; the pass loaded nearly every synced row, 8 s for one delete in a library of 8,000 cards, 30 s for a card; not reported upstream yet, the developer's call); `halkyon/fast-merges` (2026-10-17: a merge only inserting new rows proven unable to change the projection skips its passes, `canLeaveProjectionUnchangedByMergedInserts`, 9.5 s to 60 ms for a rating another device made in such a library; and projections are rebuilt once after the synced tables changed, not on every session and merge, which fixes #150 below); `halkyon/fast-merge-deletes` (2026-10-17: a merge only deleting rows skips the pass when every visible row below them goes with them, `canLeaveProjectionUnchangedByMergedDeletes`: a card deleted on one device took the server 5.4 s to merge and each other device 4 s, now 35 and 99 ms); `halkyon/batch-inserts` (2026-10-17: such a merge writes its new rows up to 1,000 to a statement, and the insert proof takes a parent of the row's own table inserted before it, as nested studysets are: a first push of 8,000 cards took the server 35 to 41 s, now about 5; a device receiving it 60 s, now 44, as Serverpod's SQLite adapter still inserts row by row). Move the fork along when upgrading, and drop each change once a release has it.

View file

@ -586,6 +586,9 @@
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Lernsets liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"instanceAddress": "Adresse der Instanz",
"instanceUseBuiltIn": "{name} verwenden",
"instanceBuiltInTagline": "In der App voreingestellt",
"instanceAnother": "Andere Instanz",
"instanceAnotherTagline": "Gib ihre Adresse ein",
"instanceContinue": "Weiter",
"instanceAddressRequired": "Gib die Adresse einer Instanz ein.",
"instanceAddressInvalid": "Das ist keine Adresse.",

View file

@ -665,6 +665,9 @@
"instanceAddress": "Instance address",
"instanceUseBuiltIn": "Use {name}",
"@instanceUseBuiltIn": {"placeholders": {"name": {"type": "String"}}},
"instanceBuiltInTagline": "Comes with the app",
"instanceAnother": "Another instance",
"instanceAnotherTagline": "Enter its address",
"instanceContinue": "Continue",
"instanceAddressRequired": "Enter the address of an instance.",
"instanceAddressInvalid": "This isn't an address.",

View file

@ -3508,6 +3508,24 @@ abstract class AppLocalizations {
/// **'Use {name}'**
String instanceUseBuiltIn(String name);
/// No description provided for @instanceBuiltInTagline.
///
/// In en, this message translates to:
/// **'Comes with the app'**
String get instanceBuiltInTagline;
/// No description provided for @instanceAnother.
///
/// In en, this message translates to:
/// **'Another instance'**
String get instanceAnother;
/// No description provided for @instanceAnotherTagline.
///
/// In en, this message translates to:
/// **'Enter its address'**
String get instanceAnotherTagline;
/// No description provided for @instanceContinue.
///
/// In en, this message translates to:

View file

@ -2148,6 +2148,15 @@ class AppLocalizationsDe extends AppLocalizations {
return '$name verwenden';
}
@override
String get instanceBuiltInTagline => 'In der App voreingestellt';
@override
String get instanceAnother => 'Andere Instanz';
@override
String get instanceAnotherTagline => 'Gib ihre Adresse ein';
@override
String get instanceContinue => 'Weiter';

View file

@ -2128,6 +2128,15 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Use $name';
}
@override
String get instanceBuiltInTagline => 'Comes with the app';
@override
String get instanceAnother => 'Another instance';
@override
String get instanceAnotherTagline => 'Enter its address';
@override
String get instanceContinue => 'Continue';

View file

@ -0,0 +1,32 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:material_ui/material_ui.dart';
/// "Halkyon" over "Learn" in the app's own font, cobalt and copper as the
/// logo has them. The logo's own typeface (ITC Avant Garde) isn't free to
/// ship with the app. Screen readers don't hear it: the screen showing it
/// names the app its own way.
class const BrandWordmark({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final word = theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w800,
height: 1.05,
letterSpacing: -0.5,
);
return ExcludeSemantics(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.launchBrand, style: word?.copyWith(color: scheme.primary)),
Text(
l10n.launchProduct,
style: word?.copyWith(color: scheme.tertiary),
),
],
),
);
}
}

View file

@ -1,11 +1,10 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/brand_wordmark.dart';
import 'package:material_ui/material_ui.dart';
/// What shows while the app opens: the instance, the session, the
/// person's data (decided 2026-10-11). The kingfisher, and beneath it
/// "Halkyon" over "Learn" in the app's own font, cobalt and copper as the
/// logo has them, then a quiet line of progress. The logo's own typeface
/// (ITC Avant Garde) isn't free to ship with the app.
/// person's data (decided 2026-10-11). The kingfisher, and beneath it the
/// [BrandWordmark], then a quiet line of progress.
class const LaunchScreen({super.key}) extends StatelessWidget {
/// How large the kingfisher shows.
static const iconSize = 112.0;
@ -13,13 +12,6 @@ class const LaunchScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final word = theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w800,
height: 1.05,
letterSpacing: -0.5,
);
return Scaffold(
body: Center(
child: Semantics(
@ -42,20 +34,7 @@ class const LaunchScreen({super.key}) extends StatelessWidget {
),
),
const SizedBox(height: 24),
ExcludeSemantics(
child: Column(
children: [
Text(
l10n.launchBrand,
style: word?.copyWith(color: scheme.primary),
),
Text(
l10n.launchProduct,
style: word?.copyWith(color: scheme.tertiary),
),
],
),
),
const BrandWordmark(),
const SizedBox(height: 32),
const SizedBox(width: 120, child: LinearProgressIndicator()),
],

View file

@ -1,41 +1,121 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/widget/brand_wordmark.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/feature/instance/widget/instance_step.dart';
import 'package:halkyon_learn_flutter/src/feature/instance/widget/instance_tile.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The first screen of a new install: which instance to keep the person's
/// studysets on. Nothing is made anywhere until they choose, not even an
/// anonymous account.
class const FirstStartScreen({super.key}) extends StatelessWidget {
/// anonymous account. Under the [BrandWordmark] on a cobalt wash, two
/// [InstanceTile]s (decided 2026-10-18): the build's instance, chosen in
/// one tap, and another, which unfolds the address to type.
class const FirstStartScreen({super.key}) extends StatefulWidget {
@override
State<FirstStartScreen> createState() => _FirstStartScreenState();
}
class _FirstStartScreenState() extends State<FirstStartScreen> {
/// Whether the address of another instance is asked for.
var _another = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final scope = InstanceScope.maybeOf(context)!;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.instanceWelcome,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
final builtIn = scope.choice.builtIn;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: TopGradient.overlayStyle(context),
child: TopGradient(
color: Brand.primary,
background: theme.colorScheme.surface,
extent: 320,
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Semantics(
header: true,
label: l10n.instanceWelcome,
child: const BrandWordmark(),
),
const SizedBox(height: 16),
Text(
l10n.instanceFirstStartSubtitle,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: InstanceTile(
icon: Image.asset(
'assets/icon/halkyon_icon.png',
width: InstanceTile.iconSize,
height: InstanceTile.iconSize,
// The source is 1024 px square; no more is
// needed in memory, even at three times the
// density.
cacheWidth: (InstanceTile.iconSize * 3).round(),
),
label: builtIn.name,
tagline: l10n.instanceBuiltInTagline,
onTap: () => unawaited(scope.choice.use(builtIn)),
),
),
const SizedBox(width: 16),
Expanded(
child: InstanceTile(
icon: const _BrandIcon(LucideIcons.globe),
label: l10n.instanceAnother,
tagline: l10n.instanceAnotherTagline,
expanded: _another,
onTap: () => setState(() => _another = !_another),
),
),
],
),
AnimatedSize(
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: InstanceTile.duration,
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: _another
? Padding(
padding: const EdgeInsets.only(top: 32),
child: InstanceStep(
builtIn: builtIn,
checker: scope.checker,
offerBuiltIn: false,
autofocus: true,
onChosen: scope.choice.use,
),
)
: const SizedBox(width: double.infinity),
),
],
),
const SizedBox(height: 24),
InstanceStep(
builtIn: scope.choice.builtIn,
checker: scope.checker,
subtitle: l10n.instanceFirstStartSubtitle,
onChosen: scope.choice.use,
),
],
),
),
),
),
@ -44,3 +124,20 @@ class const FirstStartScreen({super.key}) extends StatelessWidget {
);
}
}
/// [icon] in the kingfisher's blues, as the icon has them: cobalt into sky
/// blue. Its orange turns muddy in thin strokes, after either.
class const _BrandIcon(final IconData icon) extends StatelessWidget {
static const _colors = [Color(0xFF3C57E5), Color(0xFF179FD9)];
@override
Widget build(BuildContext context) => ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _colors,
).createShader(bounds),
child: Icon(icon, size: 44, color: Colors.white),
);
}

View file

@ -8,7 +8,8 @@ import 'package:material_ui/material_ui.dart';
/// Choosing an instance, as Mastodon's apps do: its address typed, checked
/// to answer as an instance of Halkyon Learn, or the one the build names in
/// one tap. [onChosen] gets the instance.
/// one tap (unless the screen offers it otherwise, [offerBuiltIn]).
/// [onChosen] gets the instance.
class const InstanceStep({
/// The instance the build names, offered in one tap.
required final Instance builtIn,
@ -21,6 +22,13 @@ class const InstanceStep({
/// A line above the address: what the choice is for.
final String? subtitle,
/// Whether a button offers the build's instance; the first start offers
/// it as a tile of its own.
final bool offerBuiltIn = true,
/// Whether the address has the focus at once.
final bool autofocus = false,
super.key,
}) extends StatefulWidget {
@override
@ -100,6 +108,7 @@ class _InstanceStepState() extends State<InstanceStep> {
maxLength: TextLimits.address,
buildCounter: nearLimitCounter,
controller: _address,
autofocus: widget.autofocus,
enabled: !_checking,
autocorrect: false,
enableSuggestions: false,
@ -116,14 +125,18 @@ class _InstanceStepState() extends State<InstanceStep> {
),
onSubmitted: (_) => _checking ? null : _continue(),
),
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: _checking ? null : () => widget.onChosen(widget.builtIn),
child: Text(l10n.instanceUseBuiltIn(widget.builtIn.name)),
if (widget.offerBuiltIn) ...[
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: _checking
? null
: () => widget.onChosen(widget.builtIn),
child: Text(l10n.instanceUseBuiltIn(widget.builtIn.name)),
),
),
),
],
if (error != null) ...[
const SizedBox(height: 8),
Text(error, style: TextStyle(color: theme.colorScheme.error)),

View file

@ -0,0 +1,239 @@
import 'package:flutter/rendering.dart' show MouseTracker, RendererBinding;
import 'package:material_ui/material_ui.dart';
/// A choice shown as a tile, as Proton's account page lists its services
/// (decided 2026-10-18): [icon] on a white rounded square above [label].
/// While the pointer is over it, the keyboard's focus is on it, it is
/// pressed or what it opens is [expanded], a ring running from the primary
/// colour to the tertiary (cobalt to copper, as the kingfisher's wings)
/// comes in around the square with a glow of both beneath it, and [tagline]
/// under the label. Without a mouse, which can't hover, the tagline shows
/// from the start. A [badge] sits over the square's corner, as Proton's
/// "New" does, fading in when it comes.
class const InstanceTile({
/// What the square shows, at most [iconSize] across.
required final Widget icon,
/// The choice's name.
required final String label,
/// A few words on it.
required final String tagline,
/// What choosing it does.
required final VoidCallback onTap,
/// Whether what it opens is open; null where it opens nothing.
final bool? expanded,
/// What sits over the square's corner, such as the flag of where an
/// instance keeps its data.
final Widget? badge,
super.key,
}) extends StatefulWidget {
/// How large the square is.
static const size = 88.0;
/// How large what it shows may be.
static const iconSize = size - 2 * ringWidth;
/// How wide the ring is.
static const ringWidth = 2.5;
/// How wide the tile is at most, its label and tagline included.
static const maxWidth = 176.0;
/// How long the ring and the tagline take to come in or go.
static const duration = Duration(milliseconds: 200);
/// The ring with its glow.
@visibleForTesting
static const ringKey = Key('instanceTileRing');
@override
State<InstanceTile> createState() => _InstanceTileState();
}
class _InstanceTileState() extends State<InstanceTile> {
final MouseTracker _mice = RendererBinding.instance.mouseTracker;
var _hovered = false;
var _focused = false;
var _pressed = false;
@override
void initState() {
super.initState();
_mice.addListener(_miceChanged);
}
@override
void dispose() {
_mice.removeListener(_miceChanged);
super.dispose();
}
// A mouse plugged in or out: the tagline waits for the pointer, or not.
void _miceChanged() => setState(() {});
void _press(bool pressed) => setState(() => _pressed = pressed);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final lit = _hovered || _focused || _pressed || (widget.expanded ?? false);
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: InstanceTile.duration;
final outer = BorderRadius.circular(InstanceTile.size * 0.22);
final inner = BorderRadius.circular(
InstanceTile.size * 0.22 - InstanceTile.ringWidth,
);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: InstanceTile.maxWidth),
child: MergeSemantics(
child: Semantics(
button: true,
expanded: widget.expanded,
// The pointer over it, as Proton's page has it: a hover
// highlight would wait for a click on a phone with a mouse.
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: FocusableActionDetector(
onShowFocusHighlight: (focused) =>
setState(() => _focused = focused),
actions: {
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
widget.onTap();
return null;
},
),
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _press(true),
onTapUp: (_) => _press(false),
onTapCancel: () => _press(false),
onTap: widget.onTap,
child: TweenAnimationBuilder<double>(
tween: Tween(end: lit ? 1 : 0),
duration: duration,
curve: Curves.easeOut,
builder: (context, glow, _) => Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: InstanceTile.size,
child: Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
Opacity(
key: InstanceTile.ringKey,
opacity: glow,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: outer,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [scheme.primary, scheme.tertiary],
),
boxShadow: [
BoxShadow(
color: scheme.primary.withValues(
alpha: 0.35,
),
blurRadius: 20,
offset: const Offset(-4, 4),
),
BoxShadow(
color: scheme.tertiary.withValues(
alpha: 0.3,
),
blurRadius: 20,
offset: const Offset(4, 8),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(
InstanceTile.ringWidth,
),
child: DecoratedBox(
decoration: BoxDecoration(
// White in both themes, as the kingfisher's
// own background is.
color: Colors.white,
borderRadius: inner,
// At rest a faint shadow of the primary
// colour, giving way to the glow.
boxShadow: [
BoxShadow(
color: scheme.primary.withValues(
alpha: 0.12 * (1 - glow),
),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: inner,
child: Center(child: widget.icon),
),
),
),
PositionedDirectional(
top: -6,
end: -6,
child: AnimatedSwitcher(
duration: duration,
child:
widget.badge ?? const SizedBox.shrink(),
),
),
],
),
),
const SizedBox(height: 12),
Text(
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Opacity(
opacity: _mice.mouseIsConnected ? glow : 1,
// Heard whether it shows or not.
alwaysIncludeSemantics: true,
child: Text(
widget.tagline,
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
),
],
),
),
),
),
),
),
),
),
);
}
}

View file

@ -85,10 +85,12 @@ void main() {
"build's in one tap", (tester) async {
await start(tester);
expect(find.text('Welcome to Halkyon Learn'), findsOneWidget);
// The wordmark says it to screen readers.
expect(find.bySemanticsLabel('Welcome to Halkyon Learn'), findsOneWidget);
expect(opened, isEmpty, reason: 'nothing made anywhere yet');
expect(find.byType(TextField), findsNothing, reason: 'no address asked');
await tester.tap(find.text('Use learn.halkyon.eu'));
await tester.tap(find.text('learn.halkyon.eu'));
await tester.pumpAndSettle();
expect(opened, [builtIn]);
@ -100,6 +102,15 @@ void main() {
) async {
await start(tester, answering: {other});
await tester.tap(find.text('Another instance'));
await tester.pumpAndSettle();
expect(
FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<TextField>(),
isNotNull,
reason: 'the address is typed at once',
);
await tester.enterText(find.byType(TextField), 'nowhere.example.org');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();

View file

@ -0,0 +1,163 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/instance/widget/instance_tile.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
void main() {
late List<String> tapped;
setUp(() => tapped = []);
/// Shows a tile beside something else to move the pointer or focus to.
Future<void> show(WidgetTester tester, {bool? expanded}) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
TextButton(onPressed: () {}, child: const Text('Elsewhere')),
InstanceTile(
icon: const Icon(LucideIcons.globe),
label: 'learn.halkyon.eu',
tagline: 'Comes with the app',
expanded: expanded,
onTap: () => tapped.add('tile'),
),
],
),
),
),
);
}
double ring(WidgetTester tester) =>
tester.widget<Opacity>(find.byKey(InstanceTile.ringKey)).opacity;
double tagline(WidgetTester tester) => tester
.widget<Opacity>(
find.ancestor(
of: find.text('Comes with the app'),
matching: find.byType(Opacity),
),
)
.opacity;
testWidgets('without a mouse, the tagline shows from the start', (
tester,
) async {
await show(tester);
expect(tagline(tester), 1);
expect(ring(tester), 0);
});
testWidgets('with a mouse, the ring and the tagline come in under the '
'pointer and go once it leaves', (tester) async {
await show(tester);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(mouse.removePointer);
await mouse.addPointer(location: tester.getCenter(find.text('Elsewhere')));
await tester.pumpAndSettle();
expect(tagline(tester), 0);
expect(ring(tester), 0);
await mouse.moveTo(tester.getCenter(find.byType(Icon)));
// The frame that starts it, then halfway.
await tester.pump();
await tester.pump(InstanceTile.duration ~/ 2);
expect(ring(tester), inExclusiveRange(0, 1), reason: 'it fades in');
await tester.pumpAndSettle();
expect(tagline(tester), 1);
expect(ring(tester), 1);
await mouse.moveTo(tester.getCenter(find.text('Elsewhere')));
await tester.pumpAndSettle();
expect(tagline(tester), 0);
expect(ring(tester), 0);
});
testWidgets("the keyboard's focus rings it, and Enter chooses it", (
tester,
) async {
await show(tester);
// From Elsewhere to the tile.
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(ring(tester), 1);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
expect(tapped, ['tile']);
});
testWidgets('it rings while pressed', (tester) async {
await show(tester);
final finger = await tester.startGesture(
tester.getCenter(find.byType(Icon)),
);
await tester.pumpAndSettle();
expect(ring(tester), 1);
await finger.up();
await tester.pumpAndSettle();
expect(ring(tester), 0);
expect(tapped, ['tile']);
});
testWidgets('it stays ringed while what it opens is open', (tester) async {
await show(tester, expanded: true);
expect(ring(tester), 1);
await show(tester, expanded: false);
await tester.pumpAndSettle();
expect(ring(tester), 0);
});
testWidgets('a screen reader hears one button, its tagline included', (
tester,
) async {
await show(tester, expanded: false);
expect(
// The label's node is merged into the tile's.
tester.getSemantics(find.text('learn.halkyon.eu')),
matchesSemantics(
label: 'learn.halkyon.eu\nComes with the app',
isButton: true,
hasTapAction: true,
hasFocusAction: true,
isFocusable: true,
hasExpandedState: true,
),
);
});
testWidgets('with animations off, it rings at once', (tester) async {
await tester.pumpWidget(
MaterialApp(
// The app's own MediaQuery would hide one above it.
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(disableAnimations: true),
child: child!,
),
home: Scaffold(
body: InstanceTile(
icon: const Icon(LucideIcons.globe),
label: 'learn.halkyon.eu',
tagline: 'Comes with the app',
expanded: false,
onTap: () {},
),
),
),
);
await tester.startGesture(tester.getCenter(find.byType(Icon)));
await tester.pump();
expect(ring(tester), 1);
});
}