diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03181ed..82f33c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,16 @@ jobs: steps: - uses: actions/checkout@v7 + # The example reads its Gemini key from lib/env.g.dart, which is + # gitignored so a real key can never land in a commit — meaning a + # fresh checkout doesn't have it. Stub it with an empty key, the + # same state a fresh clone runs with (see example/README.md). + - name: Stub the example's key file + if: matrix.app == 'example' + run: | + printf '// CI stub — the real file is gitignored.\nconst String apiKey = %s;\n' "''" \ + > example/lib/env.g.dart + - uses: subosito/flutter-action@v2 with: channel: stable diff --git a/.vscode/launch.json b/.vscode/launch.json index 0c72507..8edc41a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,6 +9,12 @@ "cwd": "playground", "request": "launch", "type": "dart" + }, + { + "name": "example", + "cwd": "example", + "request": "launch", + "type": "dart" } ] } diff --git a/AGENTS.md b/AGENTS.md index 6c33f00..085bb6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,11 +89,12 @@ Status legend: ⬜ Todo · ✅ Done | 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ | | 23 | Shimmer | text only; sweeping highlight, static when settled | ✅ | | 24 | Pill | removable tool/mode pill for the composer's action row; label auto-drops on phones | ✅ | +| 25 | Markdown | built-in parser + renderer; assistant text parts render it by default; fences compose Code block; tables, links, streaming reveal | ✅ | ### Surfaces | # | Component | Variants / notes | Status | |---|-----------|------------------|--------| -| 25 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ | -| 26 | SidePanel | | ⬜ | -| 27 | Modal | | ⬜ | +| 26 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ | +| 27 | SidePanel | | ⬜ | +| 28 | Modal | | ⬜ | diff --git a/CHANGELOG.md b/CHANGELOG.md index 94ae984..bc55d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,53 @@ retry pill) and a `FlowErrorPart` message part, with `onRetry`/`errorTitle`/`retryLabel` threaded through `FlowMessage` and `FlowThread`. +- **Markdown** — `FlowMarkdown`, assistant prose typeset by a built-in + parser (no new dependency): headings on the existing type ramp, + emphasis, inline code on the `codeInline` role as a rounded chip + painted under the glyphs — wrapping keeps only the outer corners + rounded — links reporting intent through `onLinkTap` (bare + `https://`, `http://` and `www.` URLs autolink with GFM's trimming + rules), nested lists, quotes, rules, tables with alignment and + overflow scroll, and fenced code rendered by `FlowCodeBlock` — + sharing the `FlowCodePart` copy contract. Streaming input may end + mid-construct and renders gracefully, with the trailing paragraph + revealing character by character. +- **Streaming motion** — markdown reveals through one sequenced + frontier: nothing renders below the animating block, fences, tables + and rules ease in with height and opacity when the frontier reaches + them, and pacing keeps the frontier within the reveal's lag bound on + fast streams. Growth is eased in layout — the revealing paragraph + gains each wrapped line over a beat — so a thread pinned to the + newest message moves continuously instead of stepping a line-height + at a time. Reduced-motion settings render statically. +- **Breaking**: assistant text parts now render as markdown by default. + Hosts whose assistant text is literal pass `markdown: false` on + `FlowThread` or `FlowMessage`; user bubbles and system notices are + unaffected. +- **Keyboard** — taps landing on the chat surface itself (dead space, + the thread, a settled message) now dismiss the keyboard, and scrolling + the thread dismisses it too (`FlowThread.keyboardDismissBehavior`, + default on-drag) — the chat conventions. Interactive children keep + their taps. +- **Thread** — a conversation that still fits its viewport now reads from + the top, the AI-app convention, instead of hugging the composer with + empty space above. Once it outgrows the viewport the thread anchors to + the newest message as before. A new `messageFooter` builder fills each + default message's footer slot (an actions row, a timestamp) without + replacing the whole message the way `messageBuilder` does. - **Pill** — `FlowPill`, a removable pill showing an enabled tool or mode in the composer's action row: host-passed icon, label and tooltips, removal intent on `onRemove`, and a label that auto-drops to the icon-only form on phones (`showLabel` forces either). +- **Breaking**: `FlowComposer.placeholder` now defaults to + 'How can I help you today?' — the one string the package ships. Hosts + that want an empty field must pass an explicit `placeholder: null`; + localized hosts keep passing their own copy. +- **Breaking**: `FlowChatView.composer` is now required — still nullable, + so a read-only surface passes an explicit `composer: null` instead of + omitting it. Building the view with nothing to show at all (no thread, + composer, header, or zero state) now asserts in debug builds rather + than rendering a blank screen. - **Breaking**: `FlowChatScreen` is renamed to `FlowChatView`. The widget was never a screen — it is body-only and embeddable, and upcoming surfaces (side panel, modal) will host it — so the name now follows diff --git a/CLAUDE.md b/CLAUDE.md index 83fc7fd..a54478b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,11 +91,12 @@ Values come from the Flow UI Figma file. Role names follow Material 3's `ColorSc | 22 | Thinking indicator | turning, breathing asterisk + shimmer label; active & settled | ✅ | | 23 | Shimmer | text only; sweeping highlight, static when settled | ✅ | | 24 | Pill | removable tool/mode pill for the composer's action row; label auto-drops on phones | ✅ | +| 25 | Markdown | built-in parser + renderer; assistant text parts render it by default; fences compose Code block; tables, links, streaming reveal | ✅ | ### Surfaces | # | Component | Variants / notes | Status | |---|-----------|------------------|--------| -| 25 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ | -| 26 | SidePanel | | ⬜ | -| 27 | Modal | | ⬜ | +| 26 | Chat View | centred 760 rail; zero state (greeting, lifted composer, starters); jump to latest | ✅ | +| 27 | SidePanel | | ⬜ | +| 28 | Modal | | ⬜ | diff --git a/README.md b/README.md index c82b3f5..f8ad26d 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,13 @@ | Component | What it does | |---|---| | [`FlowChatView`](https://flowui.stac.dev/components/chat-view) | The full chat surface: bounded thread over a composer, centred at a readable width, with a zero state (greeting, lifted composer, starters) and a jump-to-latest button | -| [`FlowThread`](https://flowui.stac.dev/components/message-thread) | Scrollable conversation anchored to the newest message | +| [`FlowThread`](https://flowui.stac.dev/components/message-thread) | Scrollable conversation — reads from the top, anchoring to the newest message once it outgrows the viewport | | [`FlowMessage`](https://flowui.stac.dev/components/message-thread) | One turn — ink-wash user bubble, plain assistant, error bubble, typed content parts | | [`FlowStreamingText`](https://flowui.stac.dev/components/streaming-text) | Animated text reveal while a reply arrives | | [`FlowThinkingIndicator`](https://flowui.stac.dev/components/thinking-indicator) | Turning, breathing asterisk with a shimmering label | | [`FlowShimmerText`](https://flowui.stac.dev/components/shimmer-text) | Sweeping text highlight, static once settled | | [`FlowCodeBlock`](https://flowui.stac.dev/components/code-block) | Fenced code with built-in synchronous highlighting, a header label, and a copy affordance — languages host-extensible | +| [`FlowMarkdown`](https://flowui.stac.dev/components/markdown) | Assistant prose typeset from a built-in parser — headings, emphasis, lists, quotes, tables, links, and fences composing the code block; assistant turns render it by default and it streams gracefully | | [`FlowErrorState`](https://flowui.stac.dev/components/error-state) | Failure card with a host-written message and retry pill — failed turns render it automatically | | [`FlowMessageActions`](https://flowui.stac.dev/components/message-actions) | Copy / regenerate / edit / feedback row under a message | | [`FlowComposer`](https://flowui.stac.dev/components/composer) | Multiline input with send/stop, attachments strip, and leading/trailing action slots | diff --git a/docs/src/content/docs/components/chat-view.mdx b/docs/src/content/docs/components/chat-view.mdx index 73ca2c6..00a9489 100644 --- a/docs/src/content/docs/components/chat-view.mdx +++ b/docs/src/content/docs/components/chat-view.mdx @@ -44,6 +44,10 @@ host keeps the chrome, the background, and the keyboard inset. Pass `composer: null` for a read-only surface — an archived thread, a shared transcript. +Taps that land on the surface itself — dead space, the thread, a settled +message — dismiss the keyboard, the chat convention; the composer, links +and buttons keep their taps. + ## Jump to latest Pass the **same** `ScrollController` to the thread and to @@ -55,7 +59,8 @@ reach in and attach its own. Null leaves the button out entirely. - `thread` — usually a `FlowThread`, given the bounded height it needs. Null renders an empty thread: a conversation nobody has spoken in yet is a real state of a chat, so the surface stands up on its own. -- `composer` — null renders no input: a read-only surface. +- `composer` — required, but nullable: an explicit `composer: null` + renders no input, a read-only surface. - `empty` + `greeting` + `suggestions` — the zero state; the host flips `empty` (typically `messages.isEmpty`). - `header` — optional full-bleed bar above the thread; `aboveComposer` diff --git a/docs/src/content/docs/components/composer.mdx b/docs/src/content/docs/components/composer.mdx index c4341eb..5ccfd60 100644 --- a/docs/src/content/docs/components/composer.mdx +++ b/docs/src/content/docs/components/composer.mdx @@ -96,5 +96,8 @@ page shows the full flow, from the "+" menu to the sent message. preview; `previewCloseTooltip` labels the preview's close button. - `controller`, `focusNode`, `placeholder` — the field itself; pass your own `TextEditingController` to prefill or clear the draft. + `placeholder` defaults to 'How can I help you today?' — the one string + the package ships — so localized hosts pass their own copy, and an + explicit null renders no hint. - `padding`, `borderRadius` — the card's metric overrides (the design's 24px corner by default). diff --git a/docs/src/content/docs/components/markdown.mdx b/docs/src/content/docs/components/markdown.mdx new file mode 100644 index 0000000..046857e --- /dev/null +++ b/docs/src/content/docs/components/markdown.mdx @@ -0,0 +1,103 @@ +--- +title: Markdown +description: Assistant prose, typeset — headings, emphasis, lists, quotes, tables, links, and fenced code, streaming gracefully. +sidebar: + order: 16 +--- + +import FlowDemo from '../../../components/FlowDemo.astro'; + +`FlowMarkdown` typesets what the assistant says. Assistant text parts +render through it **by default** — a thread wired yesterday shows rich +replies today, and `markdown: false` on `FlowThread` or `FlowMessage` +opts a literal-text host back out. User bubbles and system notices always +render plain: what the user typed is a transcription, not prose to +typeset. + +The parser is built in — no dependency, synchronous, and written for the +dialect assistants actually emit. Deliberately deferred syntax (images, +task lists, footnotes, inline HTML, setext headings) renders as the +literal text it is. + +## The document + +Headings sit on the existing type ramp, inline code takes the mono face +on a rounded chip of the faint wash — painted under the glyphs, so a +wrapped chip keeps only its outer corners rounded and the streaming +reveal runs straight through it — quotes step down to the secondary ink +behind the hairline bar, and fenced code renders through +`FlowCodeBlock` — highlighting, header, and copy intent included: + + + +```dart title="Standalone, outside a thread" +FlowMarkdown( + text: reply, + isStreaming: generating, + onLinkTap: (href) => openInBrowser(href), + onCodeCopy: copyPart, + codeCopyTooltip: 'Copy code', +) +``` + +## Streaming + +Streaming is data, as everywhere: rebuild with a longer `text` and the +trailing paragraph reveals with the same per-character fade plain text +gets, while fences, tables and rules render whole. The parser tolerates +input that ends mid-construct — unclosed emphasis stays literal until its +closer arrives (and restyles without re-fading), a half-typed link shows +its label and hides the URL, an unterminated fence is a code block still +in progress, and a table only appears once its delimiter row lands: + + + +## Tables + +Column alignment comes from the delimiter row; a table wider than its +column scrolls horizontally inside the message rather than wrapping the +page: + + + +## Links + +Links report intent: the label styles in the accent ink with an +underline, and tapping hands the host the href — the package never +launches URLs. With no `onLinkTap` wired, links render as plain prose +rather than a styled-but-dead affordance. In a thread the callback +carries the message too: `onLinkTap: (message, href) => ...`. + +Bare URLs autolink with GFM's rules: `https://`, `http://` and `www.` +(handed to the host with `https://` prepended), trailing punctuation +trimmed, a closing parenthesis kept when the URL's parens balance, and +emails deliberately left literal. Mid-stream, a URL still being typed +links early and its href grows with the text — links are inert while +the reveal runs, so nothing mis-taps. + + + +## Fenced code and copy + +Every fence synthesizes a `FlowCodePart`, so copy flows through the same +contract code parts use — one `onCodeCopy` handler and one +`copiedCodePart` confirmation serve both. While a fence is still +streaming its copy affordance stays hidden, exactly like a streaming +`FlowCodePart`. + +## Key API + +- `text` — the markdown source received so far. +- `isStreaming` — animates the trailing text block; tolerant parsing + either way. +- `style` — merged over `bodyLarge` + `onSurface`; headings keep their + scale but follow this style's color. +- `onLinkTap`, `onCodeCopy`, `copiedCodePart`, `codeCopyTooltip` — + intent out, host-localized strings in. +- On `FlowThread` / `FlowMessage`: `markdown` (default true) gates the + assistant-text rendering; `onLinkTap` threads through with the + message-arity shape on the thread. + +Prose renders non-selectable `Text.rich`, matching plain assistant text — +wrap the thread in a `SelectionArea` for selection. Fenced code keeps +`FlowCodeBlock`'s own selectable body. diff --git a/docs/src/content/docs/components/message-thread.mdx b/docs/src/content/docs/components/message-thread.mdx index aa7dd43..0ae9872 100644 --- a/docs/src/content/docs/components/message-thread.mdx +++ b/docs/src/content/docs/components/message-thread.mdx @@ -9,8 +9,14 @@ import FlowDemo from '../../../components/FlowDemo.astro'; `FlowThread` renders a conversation from plain message data — user, assistant, and system roles — and `FlowMessage` renders a single turn: an -ink-wash bubble for the user, plain text on the page for the assistant. -Neither knows where the messages came from. +ink-wash bubble for the user, typeset prose on the page for the assistant +(assistant text renders as [markdown](/components/markdown/) by default; +`markdown: false` opts a literal-text host out). Neither knows where the +messages came from. + +A conversation that still fits its viewport reads from the top, the +AI-app convention; once it outgrows the viewport the thread anchors to +the newest message. ## A conversation @@ -100,13 +106,20 @@ card, a chart) without the library knowing what it is. - `FlowThread` — the scrolling conversation; give it a `ScrollController` to pair with `FlowChatView`'s jump-to-latest. `padding` (the design's - 16) and `itemSpacing` (32) override the metrics; `messageBuilder` + 16 at the sides, 40 vertically) and `itemSpacing` (32) override the + metrics; `messageBuilder` swaps the default `FlowMessage` per turn; `thinkingLabel`, `charactersPerSecond`, and `previewCloseTooltip` forward to every - message. + message; `markdown` (default true) and `onLinkTap: (message, href)` + gate and wire the assistant markdown; `keyboardDismissBehavior` + (default on-drag) dismisses the keyboard as the thread scrolls; + `messageFooter` builds each message's footer slot (an actions row, a + timestamp) without replacing the default message the way + `messageBuilder` does. - `FlowMessage` — one turn; accepts a `FlowCustomPartBuilder`, an `onAttachmentTap` override, `leading` and `footer` slots (an avatar, - the action row), a `textStyle`, and the user bubble's overrides — + the action row), a `textStyle`, the same `markdown` gate and a + single-arity `onLinkTap`, and the user bubble's overrides — `maxBubbleWidthFraction` (0.75), `bubbleRadius`, `bubblePadding`. - `FlowMessageData` — `id`, `role`, `parts`, `status`, and an optional `timestamp`; the `.text` constructor covers the plain case. diff --git a/docs/src/content/docs/roadmap.md b/docs/src/content/docs/roadmap.md index 4e9e693..b54396f 100644 --- a/docs/src/content/docs/roadmap.md +++ b/docs/src/content/docs/roadmap.md @@ -41,6 +41,7 @@ elements and the remaining AI states are on the way. | Confirmation | Planned | | Error state | Shipped | | Code block | Shipped | +| Markdown | Shipped | | Thinking indicator | Shipped | | Shimmer | Shipped | | Pill | Shipped | diff --git a/example/.gitignore b/example/.gitignore index 61608ef..bc3656c 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,3 +1,51 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Your Gemini API key lives here — created by hand, never committed. +lib/env.g.dart + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id .dart_tool/ -build/ -pubspec.lock +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..f194874 --- /dev/null +++ b/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "4cf24164269a5ebf0c16a028a00727d0e77bbb05" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: android + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: ios + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: linux + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: macos + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: web + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: windows + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/README.md b/example/README.md index eac136b..914dc6e 100644 --- a/example/README.md +++ b/example/README.md @@ -1,16 +1,27 @@ # flow_ui example -A single-file chat screen built with flow_ui: zero state with greeting and -starters, a streaming reply behind the thinking indicator, and a composer -with an add-menu and model selector. +A live chat screen built with flow_ui: a zero state with a greeting, a +streaming Gemini reply behind the thinking indicator, stop, and retry on +the error card. flow_ui renders the state; `gemini_api.dart` is the +host-side transport it never sees. -To run it, generate platform runners once, then launch: +Set up a key once — grab one from [Google AI Studio](https://aistudio.google.com/apikey) +and create `lib/env.g.dart` with it (the file is gitignored, so your key +stays on your machine): + +```dart +const String apiKey = 'AIza...'; +``` + +Platform runners are checked in, so it launches directly: ```bash -flutter create . --platforms=web -flutter run -d chrome +flutter run ``` +With an empty key the app still runs — sending just answers with the +error card explaining what's missing. + For a live tour of every component — with variants and code snippets — open the hosted [playground](https://flowui.stac.dev/playground), or run it from [the repo](https://github.com/StacDev/flow_ui/tree/main/playground): diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..cedcc10 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,38 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..74caf01 --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8b7dfea --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 0000000..ac81bae --- /dev/null +++ b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/example/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e867712 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,647 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..cd88d65 --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/example/ios/Runner/SceneDelegate.swift b/example/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/example/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/example/lib/gemini_api.dart b/example/lib/gemini_api.dart new file mode 100644 index 0000000..edaedf9 --- /dev/null +++ b/example/lib/gemini_api.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flow_ui/flow_ui.dart'; +import 'package:http/http.dart' as http; + +/// Minimal Gemini client for the example app — the host-side transport. +/// +/// flow_ui never sees it: the conversation goes in as the screen's +/// [FlowMessageData] list, and the reply comes back as a stream of text +/// deltas the screen folds into its own state. Everything model-facing +/// stays on this side of the boundary. +class GeminiApi { + GeminiApi({required this.apiKey, this.model = 'gemini-3.6-flash'}); + + final String apiKey; + final String model; + + static const String _host = 'generativelanguage.googleapis.com'; + + /// Streams the reply to [history] as text deltas. + /// + /// [history] is the conversation so far, oldest first; user turns are + /// sent as `user`, assistant turns as `model`, and everything that isn't + /// a successful turn with text (system turns, failed turns) is skipped. + /// Throws a + /// [GeminiApiException] with the API's own message when the request is + /// refused. + Stream streamReply(List history) async* { + if (apiKey.isEmpty) { + throw GeminiApiException( + 'No API key. Paste yours into example/lib/env.g.dart — grab one ' + 'from Google AI Studio.', + ); + } + + final client = http.Client(); + try { + final request = + http.Request( + 'POST', + Uri.https(_host, '/v1beta/models/$model:streamGenerateContent', { + 'alt': 'sse', + }), + ) + ..headers['x-goog-api-key'] = apiKey + ..headers['content-type'] = 'application/json' + ..body = jsonEncode({'contents': _contentsFrom(history)}); + + final response = await client.send(request); + if (response.statusCode != 200) { + throw GeminiApiException( + _errorMessage( + await response.stream.bytesToString(), + response.statusCode, + ), + ); + } + + // The SSE stream: one `data: {json}` line per chunk. + final lines = response.stream + .transform(utf8.decoder) + .transform(const LineSplitter()); + await for (final line in lines) { + if (!line.startsWith('data: ')) continue; + final delta = _textFrom(line.substring(6)); + if (delta.isNotEmpty) yield delta; + } + } finally { + client.close(); + } + } + + static List> _contentsFrom( + List history, + ) { + return [ + for (final message in history) + // A failed turn can carry the partial text streamed before the + // error; replaying it as a successful `model` message would make + // Gemini continue from its own aborted reply. + if (message.role != FlowMessageRole.system && + message.status != FlowMessageStatus.error) + if (_textOf(message) case final text when text.isNotEmpty) + { + 'role': message.role == FlowMessageRole.user ? 'user' : 'model', + 'parts': [ + {'text': text}, + ], + }, + ]; + } + + static String _textOf(FlowMessageData message) => [ + for (final part in message.parts) + if (part is FlowTextPart) part.text, + ].join('\n'); + + static String _textFrom(String data) { + try { + final json = jsonDecode(data) as Map; + final candidates = json['candidates'] as List? ?? const []; + if (candidates.isEmpty) return ''; + final content = candidates.first['content'] as Map?; + final parts = content?['parts'] as List? ?? const []; + return [ + for (final part in parts) + if (part case {'text': final String text}) text, + ].join(); + } on FormatException { + return ''; + } + } + + static String _errorMessage(String body, int statusCode) { + try { + final json = jsonDecode(body) as Map; + if (json['error'] case {'message': final String message}) { + return message; + } + } on FormatException { + // Fall through to the generic message. + } + return 'Gemini returned HTTP $statusCode.'; + } +} + +/// A refused request, carrying the API's message for the error card. +class GeminiApiException implements Exception { + GeminiApiException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 8dae4ed..bc55d93 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,113 +1,278 @@ import 'dart:async'; import 'package:flow_ui/flow_ui.dart'; +import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; -void main() => runApp(const ExampleApp()); +// The API key lives in env.g.dart — paste yours there. +import 'env.g.dart'; +import 'gemini_api.dart'; -class ExampleApp extends StatelessWidget { - const ExampleApp({super.key}); +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); @override Widget build(BuildContext context) { + // FlowTheme installs the design tokens as theme extensions — the + // canonical host wiring, in both brightnesses so the app follows the + // device setting. return MaterialApp( - title: 'flow_ui example', + title: 'Flow UI Example', debugShowCheckedModeBanner: false, theme: ThemeData(extensions: [FlowTheme.light()]), darkTheme: ThemeData( brightness: Brightness.dark, extensions: [FlowTheme.dark()], ), - home: const ChatPage(), + home: const ChatScreen(), ); } } -/// A complete chat surface: zero state with greeting and starters, a -/// thread that streams a canned reply, and a composer with menus. -/// -/// flow_ui renders state and reports intent — there is no model behind -/// this page. Replace [_reply] with chunks from your backend. -class ChatPage extends StatefulWidget { - const ChatPage({super.key}); +/// The minimal live host: flow_ui renders the state, [GeminiApi] is the +/// transport, and this screen is the fold between them — messages are +/// pure view models, and streaming is data: each delta rebuilds the +/// reply's message with the grown text. +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key}); @override - State createState() => _ChatPageState(); + State createState() => _ChatScreenState(); } -const String _reply = - 'I can assist you with most tasks across this app — drafting an essay, ' - 'putting together a briefing, or sketching out a new venture. What else ' - 'can I do for you today?'; +class _ChatScreenState extends State { + /// The models offered in the composer's selector. The selection is host + /// state: flow_ui reports the picked id and this screen hands it to the + /// transport on the next turn. + static const List _models = [ + FlowModelOption( + id: 'gemini-3.6-flash', + label: 'Gemini 3.6 Flash', + description: 'Fast, general-purpose replies', + ), + FlowModelOption( + id: 'gemini-3.5-flash-lite', + label: 'Gemini 3.5 Flash Lite', + description: 'Lightest and quickest', + ), + ]; -class _ChatPageState extends State { final ScrollController _scroll = ScrollController(); - final TextEditingController _input = TextEditingController(); + List _messages = const []; - bool _generating = false; - String _modelId = 'smart'; - Timer? _timer; + StreamSubscription? _reply; int _nextId = 0; + String _model = 'gemini-3.6-flash'; + + /// Feedback per message id — true thumbed up, false down. Host state, + /// like everything else: the actions row only reports the taps. + final Map _feedback = {}; + + /// The code part whose copy confirmation is showing; cleared after a + /// beat. Copying is intent out: the block reports the part, the host + /// writes the clipboard. + FlowCodePart? _copiedCode; + Timer? _copiedReset; + + bool get _generating => _reply != null; @override void dispose() { - _timer?.cancel(); - _input.dispose(); + _copiedReset?.cancel(); + _reply?.cancel(); _scroll.dispose(); super.dispose(); } - /// Appends the user turn plus a pending reply, then streams the canned - /// text word by word — rebuild with [FlowMessageData.copyWith] as chunks - /// arrive and the thread animates the reveal. void _send(String text) { - _timer?.cancel(); - final id = 'msg${_nextId++}'; + if (_generating) return; setState(() { _messages = [ ..._messages, - FlowMessageData.text(id: id, role: FlowMessageRole.user, text: text), + FlowMessageData.text( + id: 'u${_nextId++}', + role: FlowMessageRole.user, + text: text, + ), + ]; + }); + _generate(); + } + + void _generate() { + final id = 'a${_nextId++}'; + final history = List.of(_messages); + setState(() { + _messages = [ + ..._messages, + // Pending renders the thinking indicator; the first delta flips + // the turn to streaming and the text reveal takes over. FlowMessageData( - id: '$id-reply', + id: id, role: FlowMessageRole.assistant, status: FlowMessageStatus.pending, ), ]; - _generating = true; }); - // A thinking beat, then the reply streams in. - _timer = Timer(const Duration(milliseconds: 1200), () { - final words = _reply.split(' '); - var index = 0; - _timer = Timer.periodic(const Duration(milliseconds: 80), (timer) { - if (index >= words.length) { - timer.cancel(); - _finish(); - return; - } - index++; - setState(() { - _messages = [ - ..._messages.sublist(0, _messages.length - 1), - _messages.last.copyWith( - parts: [FlowTextPart(words.take(index).join(' '))], + var reply = ''; + _reply = GeminiApi(apiKey: apiKey, model: _model) + .streamReply(history) + .listen( + (delta) { + reply += delta; + _update( + id, + parts: [FlowTextPart(reply)], status: FlowMessageStatus.streaming, - ), - ]; - }); + ); + }, + onError: (Object error) { + _reply = null; + _update( + id, + status: FlowMessageStatus.error, + parts: [ + if (reply.isNotEmpty) FlowTextPart(reply), + FlowErrorPart( + message: error is GeminiApiException + ? error.message + : 'Something went wrong. Check your connection and ' + 'try again.', + ), + ], + ); + }, + onDone: () { + _reply = null; + // A stream can close without ever emitting text (an empty or + // filtered response); completing then would leave a blank + // assistant row — drop the turn instead, like _stop does. + if (reply.isEmpty) { + if (!mounted) return; + setState(() { + _messages = [ + for (final m in _messages) + if (m.id != id) m, + ]; + }); + } else { + _update(id, status: FlowMessageStatus.complete); + } + }, + cancelOnError: true, + ); + } + + /// Stop keeps whatever streamed in and closes the turn — unless nothing + /// arrived yet, where completing would leave an empty turn: the still + /// pending reply is removed instead. + void _stop() { + _reply?.cancel(); + _reply = null; + final last = _messages.last; + if (last.parts.isEmpty) { + setState(() { + _messages = [ + for (final m in _messages) + if (m.id != last.id) m, + ]; }); + } else { + _update(last.id, status: FlowMessageStatus.complete); + } + } + + void _copyCode(FlowCodePart part) { + Clipboard.setData(ClipboardData(text: part.code)); + _copiedReset?.cancel(); + setState(() => _copiedCode = part); + _copiedReset = Timer(const Duration(seconds: 2), () { + _copiedReset = null; + if (mounted) setState(() => _copiedCode = null); }); } - void _finish() { - _timer?.cancel(); + void _copy(FlowMessageData message) { + final text = [ + for (final part in message.parts) + if (part is FlowTextPart) part.text, + ].join('\n'); + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Copied'))); + } + + /// The actions row under a settled assistant reply: copy, feedback, and + /// — on the latest reply only, where re-running makes sense — regenerate. + Widget? _actionsFor(FlowMessageData message) { + if (message.role != FlowMessageRole.assistant || + message.status != FlowMessageStatus.complete) { + return null; + } + final feedback = _feedback[message.id]; + final isLatest = message.id == _messages.last.id; + return FlowMessageActions( + actions: [ + FlowMessageAction.copy( + tooltip: 'Copy', + onPressed: () => _copy(message), + ), + FlowMessageAction.thumbUp( + tooltip: 'Good response', + selected: feedback == true, + onPressed: () => setState(() { + feedback == true + ? _feedback.remove(message.id) + : _feedback[message.id] = true; + }), + ), + FlowMessageAction.thumbDown( + tooltip: 'Bad response', + selected: feedback == false, + onPressed: () => setState(() { + feedback == false + ? _feedback.remove(message.id) + : _feedback[message.id] = false; + }), + ), + if (isLatest) + FlowMessageAction.regenerate( + tooltip: 'Regenerate', + onPressed: _generating ? null : () => _retry(message), + ), + ], + ); + } + + /// Retry from the thread's error card: drop the failed reply, re-run. + void _retry(FlowMessageData message) { + if (_generating) return; + setState(() { + _messages = [ + for (final m in _messages) + if (m.id != message.id) m, + ]; + }); + _generate(); + } + + void _update( + String id, { + List? parts, + FlowMessageStatus? status, + }) { + if (!mounted) return; setState(() { _messages = [ - ..._messages.sublist(0, _messages.length - 1), - _messages.last.copyWith(status: FlowMessageStatus.complete), + for (final m in _messages) + if (m.id == id) m.copyWith(parts: parts, status: status) else m, ]; - _generating = false; }); } @@ -121,73 +286,36 @@ class _ChatPageState extends State { icon: Icons.wb_twilight, text: 'Good afternoon', ), - suggestions: FlowSuggestionGroup( - layout: FlowSuggestionLayout.column, - suggestions: [ - for (final (icon, prompt) in const [ - (Icons.edit_note, 'Write an essay about life and enjoyment'), - (Icons.event_available, 'Create a Monday briefing from my tasks'), - (Icons.search, 'Suggest a new venture for me'), - ]) - FlowSuggestion( - label: prompt, - icon: icon, - onTap: () => _send(prompt), - ), - ], - ), thread: FlowThread( messages: _messages, controller: _scroll, thinkingLabel: 'Thinking…', + errorTitle: 'Reply failed', + retryLabel: 'Retry', + onRetry: _retry, + // Intent out: the host decides what opening a link means. Here, + // a snackbar showing the href stands in for a browser launch. + onLinkTap: (message, href) => ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(href))), + onCodeCopy: _copyCode, + copiedCodePart: _copiedCode, + codeCopyTooltip: 'Copy code', + // The footer slot keeps the default message and its wiring; + // only the actions row is the host's. + messageFooter: _actionsFor, ), threadController: _scroll, jumpToLatestTooltip: 'Jump to latest', composer: FlowComposer( - controller: _input, - placeholder: 'How can I help you today?', isStreaming: _generating, onSend: _send, - onStop: _finish, - leadingActions: [ - FlowMenu( - icon: Icons.add, - tooltip: 'Add to chat', - sheetTitle: 'Add to chat', - entries: const [ - FlowMenuOption( - id: 'files', - icon: Icons.attach_file, - label: 'Add files or photos', - ), - FlowMenuDivider(), - FlowMenuOption( - id: 'web', - icon: Icons.public, - label: 'Web search', - ), - ], - onSelected: (_) {}, - ), - ], + onStop: _stop, trailingActions: [ FlowModelSelector( - tooltip: 'Choose model', - sheetTitle: 'Select model', - models: const [ - FlowModelOption( - id: 'fast', - label: 'Fast', - description: 'Quick answers', - ), - FlowModelOption( - id: 'smart', - label: 'Smart', - description: 'For hard problems', - ), - ], - selectedId: _modelId, - onSelected: (id) => setState(() => _modelId = id), + models: _models, + selectedId: _model, + onSelected: (id) => setState(() => _model = id), ), ], ), diff --git a/example/linux/.gitignore b/example/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/example/linux/CMakeLists.txt b/example/linux/CMakeLists.txt new file mode 100644 index 0000000..7a9a314 --- /dev/null +++ b/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/example/linux/flutter/CMakeLists.txt b/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/example/linux/flutter/generated_plugin_registrant.h b/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/linux/runner/CMakeLists.txt b/example/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/example/linux/runner/main.cc b/example/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/example/linux/runner/my_application.cc b/example/linux/runner/my_application.cc new file mode 100644 index 0000000..27b4f86 --- /dev/null +++ b/example/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "example"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/example/linux/runner/my_application.h b/example/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/example/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/example/macos/.gitignore b/example/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/example/macos/Flutter/Flutter-Debug.xcconfig b/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/Flutter-Release.xcconfig b/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..cccf817 --- /dev/null +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..deddb0f --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ee5d00f --- /dev/null +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/example/macos/Runner/Base.lproj/MainMenu.xib b/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..f67a84b --- /dev/null +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/example/macos/Runner/Configs/Debug.xcconfig b/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Release.xcconfig b/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Warnings.xcconfig b/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..08c3ab1 --- /dev/null +++ b/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/example/macos/Runner/Info.plist b/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/example/macos/Runner/MainFlutterWindow.swift b/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/example/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/example/macos/RunnerTests/RunnerTests.swift b/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/example/pubspec.lock b/example/pubspec.lock new file mode 100644 index 0000000..445ce6d --- /dev/null +++ b/example/pubspec.lock @@ -0,0 +1,273 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: "7ed8ce4159d342eec4c65f4ea6eec57adaf9365404378541f38efc1da20a5b3d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + flow_ui: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + material_ui: + dependency: "direct main" + description: + name: material_ui + sha256: "4f3f38b9953df0a87d6bf5f21880029f77c47048487d5339410c39936be4683b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6398c66..d1599eb 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,7 +1,7 @@ name: flow_ui_example description: A minimal chat screen built with flow_ui. -publish_to: none -version: 0.1.0 +publish_to: 'none' +version: 1.0.0+1 environment: sdk: ^3.12.2 @@ -10,11 +10,15 @@ environment: dependencies: flutter: sdk: flutter - material_ui: ^1.0.0 + flow_ui: path: ../ + material_ui: ^1.0.1 + http: ^1.2.0 dev_dependencies: + flutter_test: + sdk: flutter flutter_lints: ^6.0.0 flutter: diff --git a/example/web/favicon.png b/example/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/example/web/favicon.png differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/example/web/icons/Icon-192.png differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/example/web/icons/Icon-512.png differ diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/example/web/icons/Icon-maskable-192.png differ diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/example/web/icons/Icon-maskable-512.png differ diff --git a/example/web/index.html b/example/web/index.html new file mode 100644 index 0000000..badaed3 --- /dev/null +++ b/example/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + example + + + + + + + diff --git a/example/web/manifest.json b/example/web/manifest.json new file mode 100644 index 0000000..096edf8 --- /dev/null +++ b/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "example", + "short_name": "example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/example/windows/.gitignore b/example/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/example/windows/CMakeLists.txt b/example/windows/CMakeLists.txt new file mode 100644 index 0000000..d960948 --- /dev/null +++ b/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/example/windows/flutter/CMakeLists.txt b/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/example/windows/flutter/generated_plugin_registrant.h b/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b93c4c3 --- /dev/null +++ b/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/windows/runner/CMakeLists.txt b/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc new file mode 100644 index 0000000..0e3e27b --- /dev/null +++ b/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "example.exe" "\0" + VALUE "ProductName", "example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/example/windows/runner/flutter_window.cpp b/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/example/windows/runner/flutter_window.h b/example/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/example/windows/runner/main.cpp b/example/windows/runner/main.cpp new file mode 100644 index 0000000..a61bf80 --- /dev/null +++ b/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/example/windows/runner/resource.h b/example/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/example/windows/runner/resources/app_icon.ico b/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/example/windows/runner/resources/app_icon.ico differ diff --git a/example/windows/runner/runner.exe.manifest b/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/example/windows/runner/utils.cpp b/example/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/example/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/example/windows/runner/utils.h b/example/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/example/windows/runner/win32_window.cpp b/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/example/windows/runner/win32_window.h b/example/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/lib/flow_ui.dart b/lib/flow_ui.dart index 7fc8765..541fe9e 100644 --- a/lib/flow_ui.dart +++ b/lib/flow_ui.dart @@ -20,11 +20,12 @@ export 'src/widgets/flow_code_block.dart'; export 'src/widgets/flow_composer.dart'; export 'src/widgets/flow_error_state.dart'; export 'src/widgets/flow_greeting.dart'; +export 'src/widgets/flow_markdown.dart'; export 'src/widgets/flow_menu.dart'; export 'src/widgets/flow_menu_style.dart'; export 'src/widgets/flow_message.dart'; -export 'src/widgets/flow_model_selector.dart'; export 'src/widgets/flow_message_actions.dart'; +export 'src/widgets/flow_model_selector.dart'; export 'src/widgets/flow_pill.dart'; export 'src/widgets/flow_shimmer_text.dart'; export 'src/widgets/flow_streaming_text.dart'; diff --git a/lib/src/utils/flow_chip_text.dart b/lib/src/utils/flow_chip_text.dart new file mode 100644 index 0000000..a9566d7 --- /dev/null +++ b/lib/src/utils/flow_chip_text.dart @@ -0,0 +1,258 @@ +import 'package:flutter/rendering.dart'; +import 'package:material_ui/material_ui.dart'; + +/// Internal — not exported from the package barrel. +/// +/// The inline-code chip, painted rather than composed: Flutter gives a +/// text span exactly one decoration slot (`TextStyle.background`, a +/// single Paint — no radius, no padding), and the usual `WidgetSpan` +/// chip can't wrap, breaks the streaming reveal, and sits off the +/// baseline. Instead the span stays plain text tagged as a chip, and a +/// paragraph render object paints rounded rects behind the glyph boxes — +/// so chips wrap across lines, ride the reveal, and stay selectable. + +/// The chip's metrics — provisional, pending a Figma frame for the +/// inline-code chip. +const double _chipRadius = 4; +const double _chipHPad = 3; + +/// A text span whose glyph boxes get a chip painted behind them by +/// [FlowChipText]'s render object. Construct with [text] only — the +/// offset walk counts the span's own text. +class FlowChipSpan extends TextSpan { + const FlowChipSpan({ + required String super.text, + super.style, + super.recognizer, + required this.fill, + }); + + final Color fill; + + // TextSpan's own equality and comparison don't know about [fill]; a + // fill-only change must still compare unequal and repaint, or + // RenderParagraph's text setter short-circuits and keeps the old wash. + @override + RenderComparison compareTo(InlineSpan other) { + final result = super.compareTo(other); + if (result == RenderComparison.identical && + !identical(this, other) && + (other as FlowChipSpan).fill != fill) { + return RenderComparison.paint; + } + return result; + } + + @override + bool operator ==(Object other) => + super == other && other is FlowChipSpan && other.fill == fill; + + @override + int get hashCode => Object.hash(super.hashCode, fill); +} + +/// `Text.rich`, chip-aware: renders [span] through a paragraph that +/// paints [FlowChipSpan] chips before the glyphs. Mirrors `Text.rich`'s +/// ambient wiring — default style, bold text, text scaling, selection +/// registration — so it is a drop-in swap. +class FlowChipText extends StatelessWidget { + const FlowChipText(this.span, {super.key}); + + final InlineSpan span; + + @override + Widget build(BuildContext context) { + final defaults = DefaultTextStyle.of(context); + var style = defaults.style; + if (MediaQuery.boldTextOf(context)) { + style = style.merge(const TextStyle(fontWeight: FontWeight.bold)); + } + final registrar = SelectionContainer.maybeOf(context); + final selectionStyle = DefaultSelectionStyle.of(context); + Widget result = _ChipRichText( + text: TextSpan(style: style, children: [span]), + textAlign: defaults.textAlign ?? TextAlign.start, + softWrap: defaults.softWrap, + overflow: defaults.overflow, + maxLines: defaults.maxLines, + textWidthBasis: defaults.textWidthBasis, + textHeightBehavior: + defaults.textHeightBehavior ?? + DefaultTextHeightBehavior.maybeOf(context), + textScaler: MediaQuery.textScalerOf(context), + locale: Localizations.maybeLocaleOf(context), + selectionRegistrar: registrar, + selectionColor: + selectionStyle.selectionColor ?? DefaultSelectionStyle.defaultColor, + ); + if (registrar != null) { + result = MouseRegion( + cursor: + DefaultSelectionStyle.of(context).mouseCursor ?? + SystemMouseCursors.text, + child: result, + ); + } + return result; + } +} + +class _ChipRichText extends RichText { + _ChipRichText({ + required super.text, + required super.textAlign, + required super.softWrap, + required super.overflow, + super.maxLines, + required super.textWidthBasis, + super.textHeightBehavior, + required super.textScaler, + super.locale, + super.selectionRegistrar, + super.selectionColor, + }); + + @override + RenderParagraph createRenderObject(BuildContext context) { + return _ChipRenderParagraph( + text, + textAlign: textAlign, + textDirection: textDirection ?? Directionality.of(context), + softWrap: softWrap, + overflow: overflow, + textScaler: textScaler, + maxLines: maxLines, + strutStyle: strutStyle, + textWidthBasis: textWidthBasis, + textHeightBehavior: textHeightBehavior, + locale: locale ?? Localizations.maybeLocaleOf(context), + registrar: selectionRegistrar, + selectionColor: selectionColor, + ); + } +} + +class _ChipRenderParagraph extends RenderParagraph { + _ChipRenderParagraph( + super.text, { + required super.textAlign, + required super.textDirection, + required super.softWrap, + required super.overflow, + required super.textScaler, + super.maxLines, + super.strutStyle, + required super.textWidthBasis, + super.textHeightBehavior, + super.locale, + super.registrar, + super.selectionColor, + }); + + @override + void paint(PaintingContext context, Offset offset) { + _paintChips(context.canvas, offset); + // Painting first keeps the fill under the glyphs and under the + // selection highlight. + super.paint(context, offset); + } + + void _paintChips(Canvas canvas, Offset offset) { + // Walk the span tree with a plain-text cursor, collecting the tagged + // ranges. Adjacent same-fill ranges coalesce, which folds the + // reveal's per-character spans of one code run back into one chip. + final ranges = <_ChipRange>[]; + var cursor = 0; + void walk(InlineSpan span) { + if (span is TextSpan) { + final text = span.text; + if (text != null && text.isNotEmpty) { + if (span is FlowChipSpan) { + final last = ranges.isEmpty ? null : ranges.last; + if (last != null && last.end == cursor && last.fill == span.fill) { + last.end = cursor + text.length; + } else { + ranges.add(_ChipRange(cursor, cursor + text.length, span.fill)); + } + } + cursor += text.length; + } + final children = span.children; + if (children != null) { + children.forEach(walk); + } + } else { + // A placeholder occupies one object-replacement code unit. None + // exist in markdown spans today; the rule keeps the offsets + // honest the day one does. + cursor += 1; + } + } + + walk(text); + if (ranges.isEmpty) return; + + for (final range in ranges) { + final boxes = getBoxesForSelection( + TextSelection(baseOffset: range.start, extentOffset: range.end), + ); + if (boxes.isEmpty) continue; + + // Fold boxes into line fragments by vertical overlap — boxes come + // one per style run and in text order, so a wrap starts a new + // fragment and grouping stays order-agnostic within a line (RTL + // safe). + final fragments = []; + for (final box in boxes) { + final rect = box.toRect(); + if (rect.width <= 0) continue; + if (fragments.isNotEmpty && + rect.top < fragments.last.bottom && + rect.bottom > fragments.last.top) { + fragments[fragments.length - 1] = fragments.last.expandToInclude( + rect, + ); + } else { + fragments.add(rect); + } + } + if (fragments.isEmpty) continue; + + final rtl = boxes.first.direction == TextDirection.rtl; + final paint = Paint()..color = range.fill; + const radius = Radius.circular(_chipRadius); + for (var i = 0; i < fragments.length; i++) { + final rect = fragments[i].shift(offset).inflateHorizontally(_chipHPad); + // Only the outer corners of a wrapped run round, so a chip + // broken across lines reads as one run. + final startRounded = i == 0; + final endRounded = i == fragments.length - 1; + final leftRounded = rtl ? endRounded : startRounded; + final rightRounded = rtl ? startRounded : endRounded; + canvas.drawRRect( + RRect.fromRectAndCorners( + rect, + topLeft: leftRounded ? radius : Radius.zero, + bottomLeft: leftRounded ? radius : Radius.zero, + topRight: rightRounded ? radius : Radius.zero, + bottomRight: rightRounded ? radius : Radius.zero, + ), + paint, + ); + } + } + } +} + +class _ChipRange { + _ChipRange(this.start, this.end, this.fill); + + final int start; + int end; + final Color fill; +} + +extension on Rect { + Rect inflateHorizontally(double delta) => + Rect.fromLTRB(left - delta, top, right + delta, bottom); +} diff --git a/lib/src/utils/flow_markdown_parser.dart b/lib/src/utils/flow_markdown_parser.dart new file mode 100644 index 0000000..c5a9f34 --- /dev/null +++ b/lib/src/utils/flow_markdown_parser.dart @@ -0,0 +1,1367 @@ +import 'package:flutter/foundation.dart'; + +import '../models/flow_message_part.dart'; + +/// The markdown engine behind `FlowMarkdown`: pure and synchronous, string +/// in and plain Dart values out, with no theme or widget knowledge — the +/// syntax highlighter's shape, applied to prose. No assets, no setup call, +/// safe to run in `build`. +/// +/// The dialect is the pragmatic subset assistants actually emit: ATX +/// headings, fenced code, `>` quotes, nested lists, rules, pipe tables, +/// emphasis, strikethrough, inline code, `[label](href)` links, and +/// bare-URL autolinks (`https://`, `http://` and `www.` with GFM's +/// trimming rules; emails deliberately not). Setext headings, images, +/// task lists, footnotes and inline HTML are deliberately out — they +/// render as the literal text they are. +/// +/// Built for streaming: input may end mid-construct at any character. +/// Unclosed delimiters stay literal until their closer arrives, an +/// unterminated fence is a code block still in progress, and a table only +/// becomes a table once its delimiter row lands — nothing renders broken, +/// and nothing renders wrong early. +abstract final class FlowMarkdownParser { + /// Parses [source] into its block structure. + /// + /// Offsets on the returned blocks index into [source] after newline + /// normalization, and are the identity a caller can use to recognize + /// unchanged blocks between two parses of a growing source. + static List parseBlocks(String source) { + final normalized = source.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + return _parseBlocks(_linesOf(normalized), 0); + } + + /// Parses one leaf's stripped text into styled runs. Exposed for the + /// leaf blocks' lazy caches; callers normally read `runs` on the block. + static List parseInlines(String source) => + _parseInlines(source, null); + + // ---------------------------------------------------------------- blocks + + /// Containers stop nesting here; deeper markers render as the literal + /// text they are. Assistants rarely exceed three levels. + static const int _maxDepth = 6; + + static List<_Line> _linesOf(String source) { + final lines = <_Line>[]; + var start = 0; + while (start <= source.length) { + final newline = source.indexOf('\n', start); + if (newline == -1) { + lines.add(_Line(start, source.substring(start))); + break; + } + lines.add(_Line(start, source.substring(start, newline))); + start = newline + 1; + } + return lines; + } + + static List _parseBlocks(List<_Line> lines, int depth) { + final blocks = []; + var i = 0; + while (i < lines.length) { + final line = lines[i]; + if (_isBlank(line.text)) { + i++; + continue; + } + + final marker = _dropIndent(line.text); + + final fence = _fenceOpen(marker); + if (fence != null) { + i = _readFence(lines, i, fence, blocks); + continue; + } + + final heading = _headingOf(line, marker); + if (heading != null) { + blocks.add(heading); + i++; + continue; + } + + if (depth < _maxDepth && marker.startsWith('>')) { + i = _readQuote(lines, i, depth, blocks); + continue; + } + + if (_containsPipe(marker) && + i + 1 < lines.length && + _isTableDelimiter(lines[i + 1].text)) { + i = _readTable(lines, i, blocks); + continue; + } + + if (_isRule(marker)) { + blocks.add(FlowMarkdownRuleBlock(line.start, _endOf(line))); + i++; + continue; + } + + if (depth < _maxDepth) { + final item = _listMarkerOf(line.text); + if (item != null) { + i = _readList(lines, i, item, depth, blocks); + continue; + } + } + + i = _readParagraph(lines, i, depth, blocks); + } + return blocks; + } + + static bool _isBlank(String text) => text.trim().isEmpty; + + static int _endOf(_Line line) => line.start + line.text.length; + + /// Up to three leading spaces are tolerated on any block marker. + static String _dropIndent(String text) { + var i = 0; + while (i < text.length && i < 3 && text.codeUnitAt(i) == 0x20) { + i++; + } + return text.substring(i); + } + + // Fences --------------------------------------------------------------- + + static _FenceOpen? _fenceOpen(String text) { + if (text.isEmpty) return null; + final char = text[0]; + if (char != '`' && char != '~') return null; + var length = 0; + while (length < text.length && text[length] == char) { + length++; + } + if (length < 3) return null; + final info = text.substring(length).trim(); + // An info string with a backtick would be ambiguous with inline code. + if (char == '`' && info.contains('`')) return null; + final language = info.isEmpty ? null : info.split(_whitespaceRun).first; + return _FenceOpen(char, length, language); + } + + static int _readFence( + List<_Line> lines, + int index, + _FenceOpen open, + List blocks, + ) { + final start = lines[index].start; + final body = []; + var i = index + 1; + var closed = false; + var end = _endOf(lines[index]); + while (i < lines.length) { + final text = _dropIndent(lines[i].text); + var run = 0; + while (run < text.length && text[run] == open.char) { + run++; + } + if (run >= open.length && text.substring(run).trim().isEmpty) { + closed = true; + end = _endOf(lines[i]); + i++; + break; + } + body.add(lines[i].text); + end = _endOf(lines[i]); + i++; + } + blocks.add( + FlowMarkdownFence( + start, + end, + code: body.join('\n'), + language: open.language, + closed: closed, + ), + ); + return i; + } + + // Headings ------------------------------------------------------------- + + /// Hoisted patterns — the parse re-runs over the whole document on + /// every streaming delta, so per-call RegExp construction compounds. + static final RegExp _whitespaceRun = RegExp(r'\s+'); + static final RegExp _closingHashes = RegExp(r'(^|\s)#+$'); + + static FlowMarkdownHeading? _headingOf(_Line line, String marker) { + var level = 0; + while (level < marker.length && level < 6 && marker[level] == '#') { + level++; + } + if (level == 0) return null; + final rest = marker.substring(level); + // "#hashtag" is prose, "# heading" and a bare "#" mid-stream are not. + if (rest.isNotEmpty && !rest.startsWith(' ')) return null; + var text = rest.trim(); + // A trailing run of #s is decoration, per ATX. + final closing = _closingHashes.firstMatch(text); + if (closing != null) text = text.substring(0, closing.start).trimRight(); + return FlowMarkdownHeading(line.start, _endOf(line), text, level: level); + } + + // Rules ---------------------------------------------------------------- + + static bool _isRule(String text) { + final compact = text.replaceAll(' ', '').replaceAll('\t', ''); + if (compact.length < 3) return false; + final char = compact[0]; + if (char != '-' && char != '*' && char != '_') return false; + for (var i = 1; i < compact.length; i++) { + if (compact[i] != char) return false; + } + return true; + } + + // Quotes --------------------------------------------------------------- + + static int _readQuote( + List<_Line> lines, + int index, + int depth, + List blocks, + ) { + final start = lines[index].start; + final interior = <_Line>[]; + var i = index; + var end = start; + var offset = 0; + while (i < lines.length) { + final text = _dropIndent(lines[i].text); + if (!text.startsWith('>')) break; + var stripped = text.substring(1); + if (stripped.startsWith(' ')) stripped = stripped.substring(1); + interior.add(_Line(offset, stripped)); + offset += stripped.length + 1; + end = _endOf(lines[i]); + i++; + } + blocks.add( + FlowMarkdownQuote(start, end, _parseBlocks(interior, depth + 1)), + ); + return i; + } + + // Tables --------------------------------------------------------------- + + static bool _containsPipe(String text) { + for (var i = 0; i < text.length; i++) { + if (text[i] == '|' && (i == 0 || text[i - 1] != r'\')) return true; + } + return false; + } + + static final RegExp _delimiterCell = RegExp(r'^\s*:?-+:?\s*$'); + + static bool _isTableDelimiter(String text) { + final body = _dropIndent(text); + // Dashes make it a delimiter; the pipe keeps a bare `---` an hr. + if (!body.contains('-') || !body.contains('|')) return false; + final cells = _splitRow(body); + if (cells.isEmpty) return false; + for (final cell in cells) { + if (!_delimiterCell.hasMatch(cell)) return false; + } + return true; + } + + static List _splitRow(String text) { + var body = text.trim(); + if (body.startsWith('|')) body = body.substring(1); + if (body.endsWith('|') && + (body.length < 2 || body[body.length - 2] != r'\')) { + body = body.substring(0, body.length - 1); + } + final cells = []; + final buffer = StringBuffer(); + for (var i = 0; i < body.length; i++) { + final char = body[i]; + if (char == r'\' && i + 1 < body.length && body[i + 1] == '|') { + buffer.write('|'); + i++; + } else if (char == '|') { + cells.add(buffer.toString().trim()); + buffer.clear(); + } else { + buffer.write(char); + } + } + cells.add(buffer.toString().trim()); + return cells; + } + + static int _readTable( + List<_Line> lines, + int index, + List blocks, + ) { + final start = lines[index].start; + final header = [ + for (final cell in _splitRow(lines[index].text)) + FlowMarkdownTableCell(cell), + ]; + final alignments = [ + for (final cell in _splitRow(_dropIndent(lines[index + 1].text))) + _alignOf(cell), + ]; + // The delimiter row is the table's shape; clamp alignments to it. + while (alignments.length < header.length) { + alignments.add(null); + } + if (alignments.length > header.length) { + alignments.removeRange(header.length, alignments.length); + } + + final rows = >[]; + var end = _endOf(lines[index + 1]); + var i = index + 2; + while (i < lines.length && + !_isBlank(lines[i].text) && + _containsPipe(lines[i].text)) { + final cells = _splitRow(lines[i].text); + // Rows keep the header's width: extra cells drop, missing render + // empty — a partial trailing row mid-stream grows cell by cell. + rows.add([ + for (var c = 0; c < header.length; c++) + FlowMarkdownTableCell(c < cells.length ? cells[c] : ''), + ]); + end = _endOf(lines[i]); + i++; + } + blocks.add( + FlowMarkdownTable( + start, + end, + header: header, + alignments: alignments, + rows: rows, + ), + ); + return i; + } + + static FlowMarkdownAlign? _alignOf(String cell) { + final body = cell.trim(); + final left = body.startsWith(':'); + final right = body.endsWith(':'); + if (left && right) return FlowMarkdownAlign.center; + if (right) return FlowMarkdownAlign.right; + if (left) return FlowMarkdownAlign.left; + return null; + } + + // Lists ---------------------------------------------------------------- + + static final RegExp _bulletMarker = RegExp(r'^( {0,3})([-*+])( +|$)'); + static final RegExp _orderedMarker = RegExp( + r'^( {0,3})(\d{1,9})([.)])( +|$)', + ); + + static _ListMarker? _listMarkerOf(String text) { + final bullet = _bulletMarker.firstMatch(text); + if (bullet != null) { + // A rule ("- - -", "***") is never a list. + if (_isRule(_dropIndent(text))) return null; + final spaces = bullet.group(3)!; + return _ListMarker( + ordered: false, + number: 1, + contentColumn: + bullet.group(1)!.length + 1 + (spaces.isEmpty ? 1 : spaces.length), + ); + } + final ordered = _orderedMarker.firstMatch(text); + if (ordered != null) { + final digits = ordered.group(2)!; + final spaces = ordered.group(4)!; + return _ListMarker( + ordered: true, + number: int.parse(digits), + contentColumn: + ordered.group(1)!.length + + digits.length + + 1 + + (spaces.isEmpty ? 1 : spaces.length), + ); + } + return null; + } + + static int _readList( + List<_Line> lines, + int index, + _ListMarker first, + int depth, + List blocks, + ) { + final start = lines[index].start; + final items = []; + var end = start; + var i = index; + List<_Line>? current; + var contentColumn = first.contentColumn; + // Interior offsets accumulate per item, like _readQuote's, so blocks + // parsed inside the item keep the source-offset invariant + // FlowMarkdownBlock documents (offsets index the item's normalized + // source, not everything zero). + var itemOffset = 0; + + void closeItem() { + if (current != null) { + items.add(FlowMarkdownListItem(_parseBlocks(current!, depth + 1))); + } + current = null; + } + + while (i < lines.length) { + final line = lines[i]; + if (_isBlank(line.text)) { + // A blank ends the list unless the next content line is another + // item of the same list, or a continuation of this item. + var peek = i + 1; + while (peek < lines.length && _isBlank(lines[peek].text)) { + peek++; + } + if (peek >= lines.length) break; + final next = lines[peek]; + final nextMarker = _listMarkerOf(next.text); + final continuation = _indentOf(next.text) >= contentColumn; + if ((nextMarker != null && nextMarker.ordered == first.ordered) || + continuation) { + if (continuation && current != null) { + current!.add(_Line(itemOffset, '')); + itemOffset += 1; + } + i = peek; + continue; + } + break; + } + + final marker = _listMarkerOf(line.text); + if (marker != null && + marker.ordered == first.ordered && + _indentOf(line.text) < contentColumn) { + closeItem(); + contentColumn = marker.contentColumn; + final text = line.text.length > contentColumn + ? line.text.substring(contentColumn) + : ''; + current = <_Line>[_Line(0, text)]; + itemOffset = text.length + 1; + end = _endOf(line); + i++; + continue; + } + + if (_indentOf(line.text) >= contentColumn && current != null) { + final text = _stripColumns(line.text, contentColumn); + current!.add(_Line(itemOffset, text)); + itemOffset += text.length + 1; + end = _endOf(line); + i++; + continue; + } + + break; + } + closeItem(); + + blocks.add( + FlowMarkdownList( + start, + end, + ordered: first.ordered, + startNumber: first.number, + items: items, + ), + ); + return i; + } + + static int _indentOf(String text) { + var column = 0; + for (var i = 0; i < text.length; i++) { + final unit = text.codeUnitAt(i); + if (unit == 0x20) { + column++; + } else if (unit == 0x09) { + column += 4 - column % 4; + } else { + return column; + } + } + // All-whitespace lines count as arbitrarily indented continuations. + return 1 << 20; + } + + static String _stripColumns(String text, int columns) { + var column = 0; + var i = 0; + while (i < text.length && column < columns) { + final unit = text.codeUnitAt(i); + if (unit == 0x20) { + column++; + } else if (unit == 0x09) { + column += 4 - column % 4; + } else { + break; + } + i++; + } + return text.substring(i); + } + + // Paragraphs ----------------------------------------------------------- + + static int _readParagraph( + List<_Line> lines, + int index, + int depth, + List blocks, + ) { + final start = lines[index].start; + final body = [lines[index].text.trim()]; + var end = _endOf(lines[index]); + var i = index + 1; + while (i < lines.length) { + final text = lines[i].text; + if (_isBlank(text)) break; + final marker = _dropIndent(text); + if (_fenceOpen(marker) != null) break; + if (_headingOf(lines[i], marker) != null) break; + if (marker.startsWith('>')) break; + if (_isRule(marker)) break; + if (depth < _maxDepth && _listMarkerOf(text) != null) break; + if (_containsPipe(marker) && + i + 1 < lines.length && + _isTableDelimiter(lines[i + 1].text)) { + break; + } + body.add(text.trim()); + end = _endOf(lines[i]); + i++; + } + // A single newline renders as a line break — assistants use it as one, + // and CommonMark's space-join would mangle their poems and addresses. + blocks.add(FlowMarkdownParagraph(start, end, body.join('\n'))); + return i; + } + + // ---------------------------------------------------------------- inlines + + static final RegExp _autolink = RegExp(r'^<(https?://[^\s<>]+)>'); + static const String _escapable = r'''!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~'''; + + static List _parseInlines( + String source, + String? linkHref, { + bool linkify = true, + }) { + final tokens = _tokenize(source); + if (linkify) _linkifyTokens(tokens, source); + _matchDelimiters(tokens); + return _emitRuns(tokens, linkHref); + } + + static List<_InlineToken> _tokenize(String source) { + final tokens = <_InlineToken>[]; + final text = StringBuffer(); + var textStart = 0; + + void flush(int at) { + if (text.isNotEmpty) { + tokens.add(_TextToken(text.toString(), textStart)); + text.clear(); + } + textStart = at; + } + + var i = 0; + while (i < source.length) { + final char = source[i]; + + if (char == r'\' && + i + 1 < source.length && + _escapable.contains(source[i + 1])) { + // The escaped character starts its own run so the source mapping + // stays 1:1 — the backslash is the dropped character. + flush(i); + tokens.add(_TextToken(source[i + 1], i + 1)); + i += 2; + textStart = i; + continue; + } + + if (char == '`') { + var open = 0; + while (i + open < source.length && source[i + open] == '`') { + open++; + } + final closer = _findBacktickRun(source, i + open, open); + if (closer != -1) { + flush(i); + var span = source.substring(i + open, closer); + var spanStart = i + open; + // One space of padding strips when both sides carry it. + if (span.length >= 2 && span.startsWith(' ') && span.endsWith(' ')) { + span = span.substring(1, span.length - 1); + spanStart++; + } + tokens.add(_CodeToken(span, spanStart)); + i = closer + open; + textStart = i; + continue; + } + text.write(source.substring(i, i + open)); + i += open; + continue; + } + + if (char == '<') { + final match = _autolink.firstMatch(source.substring(i)); + if (match != null) { + flush(i); + final href = match.group(1)!; + tokens.add(_LinkToken(href, i + 1, href: href, literalLabel: true)); + i += match.end; + textStart = i; + continue; + } + } + + if (char == '[') { + final link = _readLink(source, i); + if (link != null) { + flush(i); + tokens.add(link); + i = link.consumed; + textStart = i; + continue; + } + } + + if (char == '*' || char == '_' || char == '~') { + var length = 0; + while (i + length < source.length && source[i + length] == char) { + length++; + } + if (char == '~' && length < 2) { + text.write(source.substring(i, i + length)); + i += length; + continue; + } + final before = i == 0 ? null : source[i - 1]; + final after = i + length >= source.length ? null : source[i + length]; + var canOpen = after != null && after.trim().isNotEmpty; + var canClose = before != null && before.trim().isNotEmpty; + if (char == '_') { + // Intraword underscores stay literal — snake_case survives. + canOpen = canOpen && (before == null || !_isWordChar(before)); + canClose = canClose && (after == null || !_isWordChar(after)); + } + if (!canOpen && !canClose) { + text.write(source.substring(i, i + length)); + i += length; + continue; + } + flush(i); + tokens.add( + _DelimiterToken( + char, + length, + i, + canOpen: canOpen, + canClose: canClose, + ), + ); + i += length; + textStart = i; + continue; + } + + text.write(char); + i++; + } + flush(source.length); + return tokens; + } + + static int _findBacktickRun(String source, int from, int length) { + var i = from; + while (i < source.length) { + if (source[i] != '`') { + i++; + continue; + } + var run = 0; + while (i + run < source.length && source[i + run] == '`') { + run++; + } + if (run == length) return i; + i += run; + } + return -1; + } + + /// `[label](href "title")` with one level of bracket nesting in the + /// label and one level of parentheses in the href. A label whose `](` + /// has arrived but whose `)` hasn't yet is the streaming case: the + /// label renders (plain), the href stays hidden until it is whole. + static _LinkToken? _readLink(String source, int from) { + var depth = 0; + var i = from + 1; + while (i < source.length) { + final char = source[i]; + if (char == r'\' && i + 1 < source.length) { + i += 2; + continue; + } + if (char == '[') { + if (depth == 1) return null; + depth++; + } else if (char == ']') { + if (depth == 0) break; + depth--; + } + i++; + } + if (i >= source.length) return null; + final label = source.substring(from + 1, i); + if (i + 1 >= source.length || source[i + 1] != '(') return null; + + var j = i + 2; + var parens = 0; + while (j < source.length) { + final char = source[j]; + if (char == r'\' && j + 1 < source.length) { + j += 2; + continue; + } + if (char == '(') { + if (parens == 1) return null; + parens++; + } else if (char == ')') { + if (parens == 0) { + final target = source.substring(i + 2, j).trim(); + // A quoted title after the href is tolerated and dropped. + final href = target.split(_whitespaceRun).first; + return _LinkToken(label, from + 1, href: href, consumed: j + 1); + } + parens--; + } + j++; + } + // Unterminated: the label is content, the half-typed href is not. + return _LinkToken(label, from + 1, href: null, consumed: source.length); + } + + // Bare-URL autolinks --------------------------------------------------- + + /// GFM's trailing trim set for bare URLs; `;` doubles as the entity + /// trigger. + static const String _urlTrailingPunctuation = '?!.,:*_~;\'"'; + + /// Splits bare `https://`, `http://` and `www.` URLs out of the token + /// stream. Runs after tokenization, deliberately: emphasis markers are + /// already delimiter tokens, so `**https://x.com**` presents a clean + /// URL start — a pre-processing rewrite could never tell where the + /// markdown ends and the URL begins. The URL's *extent* is measured in + /// source space, and delimiter tokens falling wholly inside it are + /// absorbed back into the link, so `wiki/Dart_(programming_language)` + /// survives its underscore. Code spans and explicit links cap the + /// extent; emails deliberately stay literal. + static void _linkifyTokens(List<_InlineToken> tokens, String source) { + for (var t = 0; t < tokens.length; t++) { + final token = tokens[t]; + if (token is! _TextToken) continue; + final text = token.text; + + // A candidate must start inside a text token. + var i = 0; + var found = -1; + var prefix = 0; + while (i < text.length) { + final unit = text.codeUnitAt(i) | 0x20; + if (unit == 0x68 || unit == 0x77) { + prefix = _urlPrefixLength(text, i); + if (prefix > 0 && + _canPrecedeAutolink(source, token.sourceStart + i)) { + found = i; + break; + } + i += prefix > 0 ? prefix : 1; + continue; + } + i++; + } + if (found == -1) continue; + + final start = token.sourceStart + found; + + // Extent in source space, capped where the next atomic token + // begins — a code span or an explicit link is never swallowed. + var cap = source.length; + for (var n = t + 1; n < tokens.length; n++) { + final next = tokens[n]; + if (next is _TextToken || next is _DelimiterToken) continue; + cap = switch (next) { + _CodeToken(:final sourceStart) => sourceStart, + _LinkToken(:final sourceStart) => sourceStart, + _ => cap, + }; + break; + } + var end = start + prefix; + while (end < cap && !_isUrlStop(source.codeUnitAt(end))) { + end++; + } + end = _trimmedUrlEnd(source, start, end); + final www = prefix == 4; + var valid = end > start + prefix; + if (valid && www) { + valid = _isValidWwwDomain(_domainOf(source, start, end)); + } + if (!valid) continue; + + // Splice every token the URL range overlaps: text before the URL + // stays, wholly-covered tokens are absorbed into the link, and a + // partially-covered trailing text token keeps its tail. + final pieces = <_InlineToken>[]; + if (found > 0) { + pieces.add(_TextToken(text.substring(0, found), token.sourceStart)); + } + final url = source.substring(start, end); + pieces.add( + _LinkToken( + url, + start, + href: www ? 'https://$url' : url, + literalLabel: true, + ), + ); + var last = t; + while (last < tokens.length) { + final covered = tokens[last]; + final coveredStart = switch (covered) { + _TextToken(:final sourceStart) => sourceStart, + _CodeToken(:final sourceStart) => sourceStart, + _LinkToken(:final sourceStart) => sourceStart, + _DelimiterToken(:final sourceStart) => sourceStart, + }; + if (coveredStart >= end) break; + if (covered is _TextToken && coveredStart + covered.text.length > end) { + // The URL ends inside this text token; keep its tail. + pieces.add( + _TextToken(covered.text.substring(end - coveredStart), end), + ); + last++; + break; + } + last++; + } + tokens.replaceRange(t, last, pieces); + // Land on the link piece, so the loop's increment continues at the + // trailing text — which may hold another URL. + t += found > 0 ? 1 : 0; + } + } + + static bool _isUrlStop(int unit) => + unit == 0x20 || unit == 0x09 || unit == 0x0A || unit == 0x3C; + + /// 8 for `https://`, 7 for `http://`, 4 for `www.` at [i]; 0 + /// otherwise. Case-insensitive. + static int _urlPrefixLength(String text, int i) { + bool match(String prefix) { + if (i + prefix.length > text.length) return false; + for (var k = 0; k < prefix.length; k++) { + var unit = text.codeUnitAt(i + k); + if (unit >= 0x41 && unit <= 0x5A) unit |= 0x20; + if (unit != prefix.codeUnitAt(k)) return false; + } + return true; + } + + if (match('https://')) return 8; + if (match('http://')) return 7; + if (match('www.')) return 4; + return 0; + } + + /// GFM's left flank: the start of input, or whitespace / `(` / `*` / + /// `_` / `~` before the URL. Checked against the full source, so a URL + /// at a token boundary (the `*` of `**https://…`) resolves correctly — + /// and a preceding backtick or letter rejects. + static bool _canPrecedeAutolink(String source, int sourceIndex) { + if (sourceIndex <= 0) return true; + final before = source[sourceIndex - 1]; + return before == ' ' || + before == '\t' || + before == '\n' || + before == '(' || + before == '*' || + before == '_' || + before == '~'; + } + + /// GFM's tail trim over `text[start, end)`: trailing punctuation, a + /// `)` only while the URL holds more `)` than `(` — so a balanced + /// wiki-style `(…)` stays — and a trailing `&entity;`. + static int _trimmedUrlEnd(String text, int start, int end) { + var open = 0; + var close = 0; + for (var i = start; i < end; i++) { + final c = text[i]; + if (c == '(') open++; + if (c == ')') close++; + } + while (end > start) { + final c = text[end - 1]; + if (c == ')') { + if (close > open) { + end--; + close--; + continue; + } + break; + } + if (c == ';') { + // `&` and friends: alphanumerics between `&` and `;`. + var k = end - 2; + while (k > start && _isAlphanumeric(text.codeUnitAt(k))) { + k--; + } + if (k >= start && text[k] == '&' && k < end - 2) { + end = k; + } else { + end--; + } + continue; + } + if (_urlTrailingPunctuation.contains(c)) { + end--; + continue; + } + break; + } + return end; + } + + static bool _isAlphanumeric(int unit) => + (unit >= 0x30 && unit <= 0x39) || + (unit >= 0x41 && unit <= 0x5A) || + (unit >= 0x61 && unit <= 0x7A); + + static String _domainOf(String text, int start, int end) { + var i = start; + while (i < end && _isDomainChar(text.codeUnitAt(i))) { + i++; + } + return text.substring(start, i); + } + + static bool _isDomainChar(int unit) => + _isAlphanumeric(unit) || unit == 0x2D || unit == 0x2E || unit == 0x5F; + + /// GFM's `www.` domain rule: two or more non-empty dot-separated + /// segments, no underscore in the final two. + static bool _isValidWwwDomain(String domain) { + final segments = domain.split('.'); + if (segments.length < 2) return false; + for (final segment in segments) { + if (segment.isEmpty) return false; + } + if (segments[segments.length - 1].contains('_')) return false; + if (segments[segments.length - 2].contains('_')) return false; + return true; + } + + static bool _isWordChar(String char) { + final unit = char.codeUnitAt(0); + return (unit >= 0x30 && unit <= 0x39) || + (unit >= 0x41 && unit <= 0x5A) || + (unit >= 0x61 && unit <= 0x7A) || + unit == 0x5F || + unit > 0x7F; + } + + static void _matchDelimiters(List<_InlineToken> tokens) { + final stack = <_DelimiterToken>[]; + for (final token in tokens) { + if (token is! _DelimiterToken) continue; + if (token.canClose) { + while (token.remaining > 0) { + _DelimiterToken? opener; + for (var s = stack.length - 1; s >= 0; s--) { + if (stack[s].char == token.char && stack[s].remaining > 0) { + opener = stack[s]; + break; + } + } + if (opener == null) break; + final used = token.char == '~' + ? 2 + : (opener.remaining >= 2 && token.remaining >= 2 ? 2 : 1); + if (opener.remaining < used || token.remaining < used) break; + final style = token.char == '~' + ? _InlineStyle.strike + : used == 2 + ? _InlineStyle.bold + : _InlineStyle.italic; + opener.opens.add(style); + token.closes.add(style); + opener.remaining -= used; + token.remaining -= used; + while (stack.isNotEmpty && stack.last.remaining == 0) { + stack.removeLast(); + } + } + } + if (token.canOpen && token.remaining > 0) stack.add(token); + } + } + + static List _emitRuns( + List<_InlineToken> tokens, + String? linkHref, + ) { + final runs = []; + var bold = 0; + var italic = 0; + var strike = 0; + + void emit(String text, int sourceStart, {bool code = false, String? href}) { + if (text.isEmpty) return; + runs.add( + FlowMarkdownRun( + text: text, + sourceStart: sourceStart, + bold: bold > 0, + italic: italic > 0, + strike: strike > 0, + code: code, + linkHref: href ?? linkHref, + ), + ); + } + + for (final token in tokens) { + switch (token) { + case _TextToken(:final text, :final sourceStart): + emit(text, sourceStart); + case _CodeToken(:final text, :final sourceStart): + emit(text, sourceStart, code: true); + case _LinkToken( + :final text, + :final sourceStart, + :final href, + literalLabel: true, + ): + emit(text, sourceStart, href: href); + case _LinkToken(:final text, :final sourceStart, :final href): + // The label parses on its own, isolated from outer emphasis + // pairing and never re-linkified — a bare URL inside a label + // must not shadow the label's own href. Runs inherit the href, + // or plainness mid-stream. + for (final run in _parseInlines(text, href, linkify: false)) { + runs.add( + FlowMarkdownRun( + text: run.text, + sourceStart: sourceStart + run.sourceStart, + bold: bold > 0 || run.bold, + italic: italic > 0 || run.italic, + strike: strike > 0 || run.strike, + code: run.code, + linkHref: run.linkHref, + ), + ); + } + case _DelimiterToken(): + for (final style in token.closes) { + switch (style) { + case _InlineStyle.bold: + bold--; + case _InlineStyle.italic: + italic--; + case _InlineStyle.strike: + strike--; + } + } + if (token.remaining > 0) { + emit(token.char * token.remaining, token.sourceStart); + } + for (final style in token.opens) { + switch (style) { + case _InlineStyle.bold: + bold++; + case _InlineStyle.italic: + italic++; + case _InlineStyle.strike: + strike++; + } + } + } + } + return runs; + } +} + +// ------------------------------------------------------------------- model + +/// A block-level markdown node. [start] and [end] are offsets into the +/// normalized source the block was parsed from — equal offsets across two +/// parses of a growing source identify an unchanged block, which is how +/// the renderer reuses parsed instances (and their caches) mid-stream. +sealed class FlowMarkdownBlock { + FlowMarkdownBlock(this.start, this.end); + + final int start; + final int end; +} + +/// A block carrying inline content. [source] is the stripped text — +/// markers removed, newlines kept — and [runs] parse lazily, cached on +/// the instance so reused blocks never re-parse. +sealed class FlowMarkdownLeafBlock extends FlowMarkdownBlock { + FlowMarkdownLeafBlock(super.start, super.end, this.source); + + final String source; + List? _runs; + + List get runs => + _runs ??= FlowMarkdownParser.parseInlines(source); +} + +class FlowMarkdownParagraph extends FlowMarkdownLeafBlock { + FlowMarkdownParagraph(super.start, super.end, super.source); +} + +class FlowMarkdownHeading extends FlowMarkdownLeafBlock { + FlowMarkdownHeading( + super.start, + super.end, + super.source, { + required this.level, + }); + + /// 1–6, from the ATX marker. + final int level; +} + +/// A fenced code block. [part] is synthesized eagerly so copy intent can +/// flow through the same `FlowCodePart` contract code parts use — and +/// because parsed instances are reused across deltas, `identical()` +/// copied-state matching keeps working for settled fences. +class FlowMarkdownFence extends FlowMarkdownBlock { + FlowMarkdownFence( + super.start, + super.end, { + required this.code, + required this.language, + required this.closed, + }) : part = FlowCodePart(code, language: language); + + final String code; + final String? language; + + /// False while the closing fence hasn't arrived — the streaming tail. + final bool closed; + + final FlowCodePart part; +} + +class FlowMarkdownQuote extends FlowMarkdownBlock { + FlowMarkdownQuote(super.start, super.end, this.children); + + final List children; +} + +class FlowMarkdownList extends FlowMarkdownBlock { + FlowMarkdownList( + super.start, + super.end, { + required this.ordered, + required this.startNumber, + required this.items, + }); + + final bool ordered; + + /// The first marker's number; items then count up from it. + final int startNumber; + + final List items; +} + +/// One list item — a container of blocks, not itself a block. +class FlowMarkdownListItem { + FlowMarkdownListItem(this.children); + + final List children; +} + +class FlowMarkdownRuleBlock extends FlowMarkdownBlock { + FlowMarkdownRuleBlock(super.start, super.end); +} + +/// Column alignment from a table's delimiter row. Kept theme- and +/// Flutter-free, like everything the parser emits. +enum FlowMarkdownAlign { left, center, right } + +class FlowMarkdownTable extends FlowMarkdownBlock { + FlowMarkdownTable( + super.start, + super.end, { + required this.header, + required this.alignments, + required this.rows, + }); + + final List header; + + /// Per column, from the delimiter row; null is the writer's default. + final List alignments; + + final List> rows; +} + +/// One table cell, with the leaf blocks' lazy run cache. +class FlowMarkdownTableCell { + FlowMarkdownTableCell(this.source); + + final String source; + List? _runs; + + List get runs => + _runs ??= FlowMarkdownParser.parseInlines(source); +} + +/// One styled stretch of output text. +/// +/// [text] maps 1:1 onto consecutive source characters starting at +/// [sourceStart] — the parser splits a run at every delimiter, escape and +/// dropped character so the mapping never skips inside a run. The +/// streaming reveal stamps characters by source offset, which is what +/// lets text restyle when a delimiter closes without re-fading. +@immutable +class FlowMarkdownRun { + const FlowMarkdownRun({ + required this.text, + required this.sourceStart, + this.bold = false, + this.italic = false, + this.strike = false, + this.code = false, + this.linkHref, + }); + + final String text; + final int sourceStart; + final bool bold; + final bool italic; + final bool strike; + + /// An inline code span — mono face on the faint wash, emphasis-free. + final bool code; + + /// Non-null inside a completed `[label](href)` link or a bare-URL + /// autolink. + final String? linkHref; +} + +// ---------------------------------------------------------------- privates + +class _Line { + _Line(this.start, this.text); + + final int start; + final String text; +} + +class _FenceOpen { + _FenceOpen(this.char, this.length, this.language); + + final String char; + final int length; + final String? language; +} + +class _ListMarker { + _ListMarker({ + required this.ordered, + required this.number, + required this.contentColumn, + }); + + final bool ordered; + final int number; + final int contentColumn; +} + +enum _InlineStyle { bold, italic, strike } + +sealed class _InlineToken {} + +class _TextToken extends _InlineToken { + _TextToken(this.text, this.sourceStart); + + final String text; + final int sourceStart; +} + +class _CodeToken extends _InlineToken { + _CodeToken(this.text, this.sourceStart); + + final String text; + final int sourceStart; +} + +class _LinkToken extends _InlineToken { + _LinkToken( + this.text, + this.sourceStart, { + required this.href, + int? consumed, + this.literalLabel = false, + }) : consumed = consumed ?? 0; + + /// True when [text] is the link itself (an autolink): emitted as one + /// run without label re-parsing, so URL punctuation stays literal. + final bool literalLabel; + + /// The label source, inline-parsed on emission. + final String text; + final int sourceStart; + + /// Null while the `)` hasn't streamed in — label renders plain. + final String? href; + + /// Offset just past the construct, for the tokenizer's cursor. + final int consumed; +} + +class _DelimiterToken extends _InlineToken { + _DelimiterToken( + this.char, + int length, + this.sourceStart, { + required this.canOpen, + required this.canClose, + }) : remaining = length; + + final String char; + final int sourceStart; + final bool canOpen; + final bool canClose; + + int remaining; + final List<_InlineStyle> opens = []; + final List<_InlineStyle> closes = []; +} diff --git a/lib/src/utils/flow_reveal_engine.dart b/lib/src/utils/flow_reveal_engine.dart new file mode 100644 index 0000000..c70a85f --- /dev/null +++ b/lib/src/utils/flow_reveal_engine.dart @@ -0,0 +1,115 @@ +import 'dart:math' as math; + +/// The streaming reveal's arithmetic, extracted from `FlowStreamingText` +/// so the markdown renderer's styled leaves share the exact machinery: +/// a fractional reveal count, per-character fade stamps, lag adaptation, +/// and the end-of-stream fast-forward. Widgets own the Ticker — they are +/// the TickerProviders and the ones calling setState — the engine owns +/// the counters, so both span-builders animate identically. +class FlowRevealEngine { + /// How long a revealed character takes to fade to full opacity. + static const double fadeSeconds = 0.25; + + /// Maximum time the reveal may lag behind the incoming text. + static const double maxLagSeconds = 0.4; + + /// Catch-up window once streaming has completed. + static const double fastForwardSeconds = 0.15; + + /// Upper bound on individually faded spans per frame. + static const int maxFadeSpans = 60; + + /// Characters revealed so far (fractional between characters). + double _revealed = 0; + + /// Monotonic clock in seconds, accumulated across ticker runs. + double _clock = 0; + + /// Reveal timestamps: entry `i` is for character `_stampBase + i`. + /// Characters below [_stampBase] were revealed without animation. + final List _revealedAt = []; + int _stampBase = 0; + + bool _fastForwarding = false; + + double get revealed => _revealed; + + int get revealedFloor => _revealed.floor(); + + bool get tailStillFading => + _revealedAt.isNotEmpty && _clock - _revealedAt.last < fadeSeconds; + + /// Restarts the reveal from nothing — a replacement text. + void reset() { + _revealed = 0; + _stampBase = 0; + _revealedAt.clear(); + _fastForwarding = false; + } + + /// Clamps the reveal back to [length] when the source shrank to a + /// prefix of itself — a markdown block handing part of its tail to a + /// newly recognized construct. Stamps are indexed by source offset, so + /// the surviving prefix keeps its in-flight fades. + void truncateTo(int length) { + if (_revealed <= length) return; + _revealed = length.toDouble(); + if (_stampBase > length) _stampBase = length; + final keep = length - _stampBase; + if (_revealedAt.length > keep) { + _revealedAt.removeRange(keep, _revealedAt.length); + } + } + + /// Marks [length] characters revealed without animation. + void snapToEnd(int length) { + _revealed = length.toDouble(); + _revealedAt.clear(); + _stampBase = length; + _fastForwarding = false; + } + + /// Enters the completed-stream catch-up: the remainder reveals within + /// [fastForwardSeconds] instead of trickling at the baseline speed. + void beginFastForward() => _fastForwarding = true; + + void clearFastForward() => _fastForwarding = false; + + /// Advances the reveal by [dt] seconds toward [target] characters at + /// [charactersPerSecond], adapting above it whenever the backlog would + /// otherwise fall more than a beat behind. Returns false once the + /// reveal has caught up and the tail has finished fading — the + /// caller's cue to stop its ticker. + bool tick(double dt, int target, double charactersPerSecond) { + _clock += dt; + + var speed = charactersPerSecond; + final backlog = target - _revealed; + if (backlog > 0) { + final window = _fastForwarding ? fastForwardSeconds : maxLagSeconds; + speed = math.max(speed, backlog / window); + } + + final before = _revealed.floor(); + _revealed = math.min(_revealed + speed * dt, target.toDouble()); + final count = _revealed.floor() - before; + // Stamp newly revealed characters, spread across this frame's time. + for (var i = 0; i < count; i++) { + _revealedAt.add(_clock - dt + dt * (i + 1) / count); + } + + if (_revealed >= target && !tailStillFading) { + _fastForwarding = false; + return false; + } + return true; + } + + /// Fade-in progress (0–1) for the character at index [index]. + double progressFor(int index) { + if (index < _stampBase) return 1; + final i = index - _stampBase; + if (i >= _revealedAt.length) return 0; + return ((_clock - _revealedAt[i]) / fadeSeconds).clamp(0.0, 1.0).toDouble(); + } +} diff --git a/lib/src/widgets/flow_chat_view.dart b/lib/src/widgets/flow_chat_view.dart index 801cd7d..b2002c9 100644 --- a/lib/src/widgets/flow_chat_view.dart +++ b/lib/src/widgets/flow_chat_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:material_ui/material_ui.dart'; @@ -42,8 +43,8 @@ const Duration _jumpScroll = Duration(milliseconds: 240); class FlowChatView extends StatefulWidget { const FlowChatView({ super.key, + required this.composer, this.thread, - this.composer, this.header, this.aboveComposer, this.empty = false, @@ -55,7 +56,19 @@ class FlowChatView extends StatefulWidget { this.emptyComposerWidth = 640, this.emptySuggestionsWidth = 480, this.padding, - }) : assert(maxContentWidth > 0, 'maxContentWidth must be positive'), + }) : assert( + thread != null || + composer != null || + header != null || + aboveComposer != null || + greeting != null || + suggestions != null, + 'FlowChatView was built with nothing to show, which renders a ' + 'blank surface. Pass a thread, a composer, or the zero state ' + 'pieces (greeting, suggestions) — see the class doc for the ' + 'minimal usage.', + ), + assert(maxContentWidth > 0, 'maxContentWidth must be positive'), assert(emptyComposerWidth > 0, 'emptyComposerWidth must be positive'), assert( emptySuggestionsWidth > 0, @@ -69,12 +82,13 @@ class FlowChatView extends StatefulWidget { /// yet is a real state of a chat, so the surface stands up on its own. final Widget? thread; - /// The input, usually a `FlowComposer`. + /// The input, usually a `FlowComposer`. Required so a surface without an + /// input is a decision rather than an omission: pass an explicit null for + /// a read-only surface — an archived thread, a shared transcript. /// - /// Null renders no input at all, leaving a read-only surface — an archived - /// thread, a shared transcript. There is deliberately no default: a - /// composer needs somewhere to send to, which is why `FlowComposer` makes - /// `onSend` required, and a stand-in would swallow what the user typed. + /// There is deliberately no default: a composer needs somewhere to send + /// to, which is why `FlowComposer` makes `onSend` required, and a + /// stand-in would swallow what the user typed. final Widget? composer; /// Optional bar above the thread, full-bleed — the design's mobile nav @@ -150,6 +164,12 @@ class _FlowChatViewState extends State { static const double _jumpInset = 12; static const double _composerGap = 8; + /// The jump button's lift, as an alpha over the ink — the composer's + /// idiom, stronger on the small floating disc so it separates from the + /// content scrolling beneath it. + static const double _jumpShadowOpacity = 0.08; + static const double _jumpShadowBlur = 12; + /// The zero state's rhythm: greeting 32 above the composer; suggestions /// 48 below it on wide layouts, and on compact ones 16 above the docked /// composer, stepped in a further 8 — the design's 24 with the default @@ -160,6 +180,7 @@ class _FlowChatViewState extends State { static const double _suggestionsExtraInset = 8; bool _showJump = false; + Timer? _jumpDebounce; @override void initState() { @@ -183,6 +204,7 @@ class _FlowChatViewState extends State { @override void dispose() { + _jumpDebounce?.cancel(); widget.threadController?.removeListener(_handleScroll); super.dispose(); } @@ -191,11 +213,30 @@ class _FlowChatViewState extends State { final controller = widget.threadController; // hasClients guards the frames before the thread has attached, and the // ones after it detaches. - final show = + final over = controller != null && controller.hasClients && controller.offset > _jumpThreshold; - if (show != _showJump) setState(() => _showJump = show); + if (!over) { + _jumpDebounce?.cancel(); + _jumpDebounce = null; + if (_showJump) setState(() => _showJump = false); + return; + } + if (_showJump || _jumpDebounce != null) return; + // The thread's follow glide can carry the offset past the threshold + // for a beat while a block eases in — only a held position earns the + // button, so it isn't mounted and torn down by every entrance. + _jumpDebounce = Timer(const Duration(milliseconds: 250), () { + _jumpDebounce = null; + if (!mounted) return; + final controller = widget.threadController; + if (controller != null && + controller.hasClients && + controller.offset > _jumpThreshold) { + setState(() => _showJump = true); + } + }); } void _jumpToLatest() { @@ -209,36 +250,54 @@ class _FlowChatViewState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final compact = constraints.maxWidth < _compactBreakpoint; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.header != null) widget.header!, - if (widget.empty && !compact) - // The wide zero state: the composer leaves the bottom edge - // and the whole cluster centres itself instead. - Expanded(child: _emptyCentre(constraints.maxWidth)) - else ...[ - Expanded( - child: widget.empty - ? Center( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: _sideInset, + return GestureDetector( + // Taps that reach the surface itself — dead space, the thread, a + // settled message — dismiss the keyboard, the chat convention. + // Interactive children (the composer, links, buttons) win the + // gesture arena first, so their taps behave as before. Touch + // platforms only — on desktop clicking a page's background doesn't + // blur the input, and a global unfocus would even reach fields + // outside this view. The theme's platform, like the composer's and + // the menus' resolution, so hosts and tests can steer it. + behavior: HitTestBehavior.translucent, + onTap: () { + final platform = Theme.of(context).platform; + if (platform == TargetPlatform.iOS || + platform == TargetPlatform.android) { + FocusManager.instance.primaryFocus?.unfocus(); + } + }, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < _compactBreakpoint; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.header != null) widget.header!, + if (widget.empty && !compact) + // The wide zero state: the composer leaves the bottom edge + // and the whole cluster centres itself instead. + Expanded(child: _emptyCentre(constraints.maxWidth)) + else ...[ + Expanded( + child: widget.empty + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: _sideInset, + ), + child: widget.greeting, ), - child: widget.greeting, - ), - ) - : _threadArea(context), - ), - ..._composerZone(compact), + ) + : _threadArea(context), + ), + ..._composerZone(compact), + ], ], - ], - ); - }, + ); + }, + ), ), ); } @@ -256,11 +315,24 @@ class _FlowChatViewState extends State { child: widget.thread ?? const FlowThread(messages: []), ), ); - if (widget.threadController == null) return thread; + // The scrollable lives inside the centred rail, so left alone the + // platform scrollbar hugs the rail's edge, floating mid-window on + // wide layouts. Suppressing it and painting one out here instead — + // fed by the thread's own notifications — puts the thumb at the + // surface's edge, where readers expect it. Depth 0 keeps the + // scrollers nested in messages (tables, code blocks) off it. + final scrollArea = Scrollbar( + controller: widget.threadController, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: thread, + ), + ); + if (widget.threadController == null) return scrollArea; return Stack( children: [ - Positioned.fill(child: thread), + Positioned.fill(child: scrollArea), Positioned( bottom: _jumpInset, left: 0, @@ -275,12 +347,34 @@ class _FlowChatViewState extends State { duration: MediaQuery.disableAnimationsOf(context) ? Duration.zero : _jumpReveal, - child: FlowCircleButton( - icon: Icons.arrow_downward, - background: colors.surfaceContainerHigh, - foreground: colors.onSurface, - tooltip: widget.jumpToLatestTooltip, - onTap: _jumpToLatest, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: colors.onSurface.withValues( + alpha: _jumpShadowOpacity, + ), + blurRadius: _jumpShadowBlur, + ), + ], + ), + // The hairline rides in front of the disc — behind it, + // the opaque circle would paint over the stroke. + foregroundDecoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: colors.outlineVariant), + ), + child: FlowCircleButton( + icon: Icons.arrow_downward, + // The opaque ground, not a translucent container wash — + // the button floats over the thread, and messages + // scrolling beneath must not read through it. + background: colors.surface, + foreground: colors.onSurface, + tooltip: widget.jumpToLatestTooltip, + onTap: _jumpToLatest, + ), ), ), ), diff --git a/lib/src/widgets/flow_composer.dart b/lib/src/widgets/flow_composer.dart index 114f2e1..eec4833 100644 --- a/lib/src/widgets/flow_composer.dart +++ b/lib/src/widgets/flow_composer.dart @@ -33,7 +33,7 @@ class FlowComposer extends StatefulWidget { this.isStreaming = false, this.controller, this.focusNode, - this.placeholder, + this.placeholder = 'How can I help you today?', this.enabled = true, this.clearOnSend = true, this.submitOnEnter = true, @@ -64,7 +64,9 @@ class FlowComposer extends StatefulWidget { /// Optional external focus node; an internal one is used when null. final FocusNode? focusNode; - /// Host-localized hint text. + /// Hint text in the empty field. Defaults to the design's greeting — + /// the one string the package ships, so localized hosts should pass + /// their own copy. An explicit null renders no hint at all. final String? placeholder; final bool enabled; diff --git a/lib/src/widgets/flow_markdown.dart b/lib/src/widgets/flow_markdown.dart new file mode 100644 index 0000000..4f68699 --- /dev/null +++ b/lib/src/widgets/flow_markdown.dart @@ -0,0 +1,1138 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart' show TapGestureRecognizer; +import 'package:flutter/scheduler.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../models/flow_message_part.dart'; +import '../theme/flow_colors.dart'; +import '../theme/flow_theme.dart'; +import '../theme/flow_typography.dart'; +import '../utils/flow_chip_text.dart'; +import '../utils/flow_markdown_parser.dart'; +import '../utils/flow_reveal_engine.dart'; +import 'flow_code_block.dart'; + +/// Markdown rendered in the package's own voice: assistant prose, typeset. +/// +/// ```dart +/// FlowMarkdown( +/// text: reply, +/// isStreaming: generating, +/// onLinkTap: (href) => openInBrowser(href), +/// ) +/// ``` +/// +/// The dialect is what assistants emit — headings, emphasis, inline code, +/// fenced code (rendered by `FlowCodeBlock`, highlighting and copy intent +/// included), links, nested lists, quotes, rules, and tables. Deferred +/// syntax (images, task lists, footnotes, HTML) renders as the literal +/// text it is. +/// +/// Streaming is data, as everywhere: rebuild with a longer [text] and the +/// trailing paragraph reveals with the same per-character fade plain text +/// gets. The parser tolerates input that ends mid-construct — unclosed +/// emphasis stays literal until its closer arrives (and restyles without +/// re-fading), a half-typed link shows its label and hides the URL, an +/// unterminated fence is a code block still in progress, and a table only +/// appears once its delimiter row lands. +/// +/// Everything reports intent out and ships no strings: [onLinkTap] hands +/// the host the tapped href (null renders links as plain prose — never a +/// dead affordance), and fences flow through the same `FlowCodePart` +/// copy contract code parts use. Fills the width it's given, so it needs +/// a bounded width. +class FlowMarkdown extends StatefulWidget { + const FlowMarkdown({ + super.key, + required this.text, + this.isStreaming = false, + this.style, + this.charactersPerSecond = 300, + this.onLinkTap, + this.onCodeCopy, + this.copiedCodePart, + this.codeCopyTooltip, + }) : assert(charactersPerSecond > 0, 'charactersPerSecond must be positive'); + + /// The markdown source received so far. + final String text; + + /// Whether more text may still arrive. While true the trailing text + /// block animates its reveal; fences, tables and rules render whole. + final bool isStreaming; + + /// Merged over the default `bodyLarge` + `onSurface` prose style. + /// Headings keep their own scale but follow this style's color. + final TextStyle? style; + + /// Baseline reveal speed while streaming. + final double charactersPerSecond; + + /// Link intent, handed the tapped href. Null renders links as plain + /// prose — a styled-but-dead link would look tappable and do nothing. + /// The package never launches URLs itself. + final ValueChanged? onLinkTap; + + /// Copy intent from fenced code, handed a `FlowCodePart` synthesized + /// for the fence — the same contract `FlowCodePart` parts use, so one + /// host handler serves both. Null hides every fence's copy affordance. + final ValueChanged? onCodeCopy; + + /// The part whose fence shows the copied check — the instance received + /// from [onCodeCopy], passed back while the host's confirmation lasts. + final FlowCodePart? copiedCodePart; + + /// Host-localized label for the fences' copy affordance. + final String? codeCopyTooltip; + + @override + State createState() => _FlowMarkdownState(); +} + +class _FlowMarkdownState extends State { + /// The markdown rhythm, inside the message world's 8px part gap and + /// 32px turn gap: blocks sit a part gap apart, headings breathe a + /// little more above, list rows half a gap, and a quote's bar insets + /// its content the design's 16. + static const double _blockGap = 8; + static const double _headingExtraLarge = 8; + static const double _headingExtraSmall = 4; + static const double _listItemGap = 4; + static const double _listIndent = 24; + static const double _quoteBarWidth = 3; + static const double _quoteGap = 13; + static const double _ruleThickness = 1; + static const EdgeInsetsGeometry _tableCellPadding = + EdgeInsetsDirectional.symmetric(horizontal: 12, vertical: 8); + + /// The inline-code wash — the code block's and user bubble's 4% ink. + static const double _inlineCodeWash = 0.04; + + String? _parsedText; + List _blocks = const []; + + /// The reveal queue: every leaf, fence, table and rule, in document + /// order. Rebuilt each parse — ordinal stability comes from the unit + /// list being append-only while the source grows, not from the maps + /// (the growing tail's inner instances churn every delta). + List _units = const []; + Map _ordinalOf = Map.identity(); + Map _firstOrdinalOf = Map.identity(); + + /// The frontier: the unit currently revealing. While streaming, + /// nothing beyond it is built, so the document physically ends where + /// the animation is — content never pops in below the reading + /// position. + int _cursor = 0; + bool _cursorDone = false; + int _cursorDoneLength = 0; + + /// The streaming semantics label's folded settled prefix: the joined + /// text of the first [_labelUnits] units, all below the cursor and so + /// immutable while the source only grows. + String _labelPrefix = ''; + int _labelUnits = 0; + + /// The cursor at the moment streaming ended — units beyond it mount + /// with one soft group fade instead of a terminal pop. + int? _flipOrdinal; + late bool _wasStreaming = widget.isStreaming; + + /// Settled spans cached per block/cell instance, so a delta's build + /// cost follows the frontier rather than the whole document. Spans, + /// deliberately not widgets: reusing a widget instance across frames + /// invites the semantics tree to re-adopt attached nodes, which + /// asserts — spans rebuild their paragraphs cheaply and safely. + final Map> _settledCache = Map.identity(); + final Set _liveSettled = Set.identity(); + FlowColors? _cacheColors; + FlowTypography? _cacheTypography; + TextStyle? _cacheStyle; + ValueChanged? _cacheOnLinkTap; + + /// Link recognizers, keyed by their owning leaf/cell instance and run + /// index. Instance reuse across deltas keeps settled keys stable, so + /// the sweep only ever churns the streaming tail. + final Map<(Object, int), TapGestureRecognizer> _recognizers = {}; + final Set<(Object, int)> _liveRecognizers = {}; + + @override + void dispose() { + for (final recognizer in _recognizers.values) { + recognizer.dispose(); + } + super.dispose(); + } + + /// Reparse on new text, reusing parsed block instances (and their run + /// caches, fade stamps and synthesized code parts) for the unchanged + /// prefix of a growing source, then re-derive the reveal queue and + /// clamp the cursor into it. + void _ensureParsed() { + if (_parsedText == widget.text) return; + final fresh = FlowMarkdownParser.parseBlocks(widget.text); + final previous = _parsedText; + final isExtension = previous != null && widget.text.startsWith(previous); + if (isExtension) { + for (var i = 0; i < fresh.length && i < _blocks.length; i++) { + final old = _blocks[i]; + final neu = fresh[i]; + if (old.runtimeType == neu.runtimeType && + old.start == neu.start && + old.end == neu.end) { + fresh[i] = old; + } else { + break; + } + } + } + _blocks = fresh; + _parsedText = widget.text; + _rebuildQueue(); + + if (previous != null && !isExtension) { + // Replacement: regenerate / branch switch — the reveal restarts. + _cursor = 0; + _cursorDone = false; + _cursorDoneLength = 0; + _flipOrdinal = null; + } else if (_units.isNotEmpty) { + _cursor = math.min(_cursor, _units.length - 1); + if (_cursorDone) { + final unit = _units[_cursor]; + // Growth (or a type change) re-arms the cursor unit; instance + // churn alone must not — the tail's instances churn on every + // delta, and resetting on churn would deadlock the queue. + if (unit is! FlowMarkdownLeafBlock || + unit.source.length > _cursorDoneLength) { + _cursorDone = false; + } + } + } else { + _cursor = 0; + _cursorDone = false; + } + _syncCursor(); + } + + /// Walks the block tree into the linear reveal order: leaves and + /// atomic blocks become units; containers record where they begin so + /// their chrome (quote bar, list marker) appears with their first + /// unit. + void _rebuildQueue() { + final units = []; + final ordinalOf = Map.identity(); + final firstOrdinalOf = Map.identity(); + + void walk(List blocks) { + for (final block in blocks) { + switch (block) { + case FlowMarkdownLeafBlock(): + case FlowMarkdownFence(): + case FlowMarkdownRuleBlock(): + case FlowMarkdownTable(): + ordinalOf[block] = units.length; + units.add(block); + case FlowMarkdownQuote(:final children): + firstOrdinalOf[block] = units.length; + walk(children); + case FlowMarkdownList(:final items): + firstOrdinalOf[block] = units.length; + for (final item in items) { + firstOrdinalOf[item] = units.length; + walk(item.children); + } + } + } + } + + walk(_blocks); + _units = units; + _ordinalOf = ordinalOf; + _firstOrdinalOf = firstOrdinalOf; + } + + /// Advances the cursor past finished units. Atomic units mount fading + /// and the cursor moves on in the same build, so the following text + /// reveals while they fade. The cursor holds on: the last unit (the + /// armed tail, re-armed by extension) and a still-growing fence. + void _syncCursor() { + if (!widget.isStreaming || _units.isEmpty) return; + while (true) { + final unit = _units[_cursor]; + final last = _cursor == _units.length - 1; + if (unit is FlowMarkdownLeafBlock) { + if (!_cursorDone || last) return; + } else { + final growing = unit is FlowMarkdownFence && !unit.closed; + if (growing || last) return; + } + _cursor++; + _cursorDone = false; + } + } + + /// The frontier leaf reports itself fully revealed — from its ticker's + /// phase, so advancing the queue here renders the same frame. + void _onUnitRevealed(int ordinal) { + if (!mounted || ordinal != _cursor || _cursor >= _units.length) return; + final unit = _units[_cursor]; + if (unit is! FlowMarkdownLeafBlock) return; + setState(() { + _cursorDone = true; + _cursorDoneLength = unit.source.length; + _syncCursor(); + }); + } + + /// Text characters queued beyond the cursor — the global backlog the + /// frontier leaf folds into its pacing. Atomic units reveal in + /// constant time and count nothing, so a large fence in the queue + /// doesn't rush the paragraph before it. + int _pendingBeyondCursor() { + var pending = 0; + for (var i = _cursor + 1; i < _units.length; i++) { + final unit = _units[i]; + if (unit is FlowMarkdownLeafBlock) pending += unit.source.length; + } + return pending; + } + + @override + Widget build(BuildContext context) { + final colors = context.flowColors; + final typography = context.flowTypography; + // The settled cache is valid for exactly one theme/style epoch. The + // link callback's *identity* is deliberately not part of the epoch — + // hosts (FlowThread included) build a fresh closure every build, and + // keying on it would clear the cache on every delta. Only the + // null/non-null flip restyles spans; a changed closure is rebound + // onto the cached recognizers in [_markLive]. + if (!identical(colors, _cacheColors) || + !identical(typography, _cacheTypography) || + widget.style != _cacheStyle || + (widget.onLinkTap == null) != (_cacheOnLinkTap == null)) { + _settledCache.clear(); + _cacheColors = colors; + _cacheTypography = typography; + _cacheStyle = widget.style; + } + _cacheOnLinkTap = widget.onLinkTap; + final base = typography.bodyLarge + .copyWith(color: colors.onSurface) + .merge(widget.style); + + _ensureParsed(); + + if (_wasStreaming && !widget.isStreaming) { + // Stream ended: whatever the frontier hadn't reached mounts under + // one soft group fade — bounded by the pacing to ≲0.4s of content + // — instead of a terminal pop. + _flipOrdinal = _cursor; + } else if (!_wasStreaming && widget.isStreaming) { + // Resuming on a live surface: nothing re-hides or replays; the + // tail arms and new appends reveal from the end. + _flipOrdinal = null; + _cursor = _units.isEmpty ? 0 : _units.length - 1; + final unit = _units.isEmpty ? null : _units.last; + _cursorDone = unit is FlowMarkdownLeafBlock; + _cursorDoneLength = unit is FlowMarkdownLeafBlock + ? unit.source.length + : 0; + _syncCursor(); + } + _wasStreaming = widget.isStreaming; + + _liveRecognizers.clear(); + _liveSettled.clear(); + final children = _blockColumn( + context, + _blocks, + base: base, + depth: 0, + itemGap: _blockGap, + ); + _sweepCaches(); + + final column = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ); + if (!widget.isStreaming) return column; + // While streaming, the whole document is one quiet semantics block: + // assistive tech reads the text received so far as a single label, + // and the semantics tree stays structurally still — no per-frame + // node churn from the reveal, the gating mounts, or the entrances — + // until the stream settles and the real tree mounts once. Links are + // inert during the reveal anyway, so nothing interactive is hidden. + return Semantics( + container: true, + // Concatenating the document on every delta is O(n²) over a + // stream, so the label is only built while an accessibility + // service is actually reading it. + label: MediaQuery.accessibleNavigationOf(context) + ? _streamingLabel() + : null, + child: ExcludeSemantics(child: column), + ); + } + + /// The visible text so far, marker-free — leaf runs joined, fences as + /// their code. Units below the cursor are settled, so their text folds + /// into a cached prefix and a delta re-joins only the frontier unit. + String _streamingLabel() { + if (_labelUnits > _cursor) { + // The cursor moved backwards — a replacement reset — so the folded + // prefix no longer matches. + _labelPrefix = ''; + _labelUnits = 0; + } + while (_labelUnits < _cursor && _labelUnits < _units.length) { + _labelPrefix = _labelJoin(_labelPrefix, _units[_labelUnits]); + _labelUnits++; + } + return _cursor < _units.length + ? _labelJoin(_labelPrefix, _units[_cursor]) + : _labelPrefix; + } + + static String _labelJoin(String prefix, FlowMarkdownBlock unit) { + final text = switch (unit) { + FlowMarkdownLeafBlock() => [for (final run in unit.runs) run.text].join(), + FlowMarkdownFence() => unit.code, + _ => null, + }; + if (text == null) return prefix; + return prefix.isEmpty ? text : '$prefix\n$text'; + } + + void _sweepCaches() { + _recognizers.removeWhere((key, recognizer) { + if (_liveRecognizers.contains(key)) return false; + recognizer.dispose(); + return true; + }); + _settledCache.removeWhere((owner, _) => !_liveSettled.contains(owner)); + } + + // ---------------------------------------------------------------- gating + + bool get _gated => widget.isStreaming; + + bool _blockVisible(FlowMarkdownBlock block) { + if (!_gated) return true; + return switch (block) { + FlowMarkdownQuote() || + FlowMarkdownList() => (_firstOrdinalOf[block] ?? 0) <= _cursor, + _ => (_ordinalOf[block] ?? 0) <= _cursor, + }; + } + + bool _itemVisible(FlowMarkdownListItem item) => + !_gated || (_firstOrdinalOf[item] ?? 0) <= _cursor; + + // ---------------------------------------------------------------- blocks + + List _blockColumn( + BuildContext context, + List blocks, { + required TextStyle base, + required int depth, + required double itemGap, + }) { + final children = []; + for (final block in blocks) { + // Beyond the frontier: not built at all — the gap mounts with the + // block when its turn comes, so growth happens at the frontier. + if (!_blockVisible(block)) continue; + if (children.isNotEmpty) { + var gap = itemGap; + if (block is FlowMarkdownHeading) { + gap += block.level <= 2 ? _headingExtraLarge : _headingExtraSmall; + } + children.add(SizedBox(height: gap)); + } + children.add(_buildBlock(context, block, base: base, depth: depth)); + } + return children; + } + + Widget _buildBlock( + BuildContext context, + FlowMarkdownBlock block, { + required TextStyle base, + required int depth, + }) { + final colors = context.flowColors; + final typography = context.flowTypography; + + switch (block) { + case FlowMarkdownParagraph(): + return _leaf(context, block, base); + + case FlowMarkdownHeading(:final level): + final scale = switch (level) { + 1 => typography.titleLarge, + 2 => typography.titleMedium, + 3 => typography.titleSmall, + 4 => typography.bodyLargeDark, + _ => typography.bodyMediumDark, + }; + final style = scale.copyWith( + color: level == 6 ? colors.onSurfaceVariant : base.color, + ); + return _leaf(context, block, style); + + case FlowMarkdownFence(): + final onCodeCopy = widget.onCodeCopy; + return _atomic( + block, + FlowCodeBlock( + code: block.code, + language: block.language, + isStreaming: widget.isStreaming && !block.closed, + onCopy: onCodeCopy == null ? null : () => onCodeCopy(block.part), + copied: identical(block.part, widget.copiedCodePart), + copyTooltip: widget.codeCopyTooltip, + ), + ); + + case FlowMarkdownQuote(:final children): + return Container( + decoration: BoxDecoration( + border: BorderDirectional( + start: BorderSide(color: colors.outline, width: _quoteBarWidth), + ), + ), + padding: const EdgeInsetsDirectional.only(start: _quoteGap), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: _blockColumn( + context, + children, + // Quoted material steps down to the secondary ink. + base: base.copyWith(color: colors.onSurfaceVariant), + depth: depth, + itemGap: _blockGap, + ), + ), + ); + + case FlowMarkdownList(:final ordered, :final startNumber, :final items): + final rows = []; + for (var i = 0; i < items.length; i++) { + // The whole row — marker included — waits for its first unit. + if (!_itemVisible(items[i])) continue; + if (rows.isNotEmpty) rows.add(const SizedBox(height: _listItemGap)); + final marker = ordered + ? '${startNumber + i}.' + : switch (depth % 3) { + 0 => '•', + 1 => '◦', + _ => '▪', + }; + rows.add( + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: _listIndent, + child: Text(marker, style: base), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: _blockColumn( + context, + items[i].children, + base: base, + depth: depth + 1, + itemGap: _listItemGap, + ), + ), + ), + ], + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: rows, + ); + + case FlowMarkdownRuleBlock(): + return _atomic( + block, + Container(height: _ruleThickness, color: colors.outlineVariant), + ); + + case FlowMarkdownTable(): + return _atomic(block, _table(context, block, base)); + } + } + + /// Growth eases instead of stepping: the revealing paragraph gains + /// each wrapped line over a beat, a growing fence its rows — so a + /// thread pinned to the newest message moves continuously rather than + /// jumping a line-height at a time. Layout-space smoothing, on + /// purpose: scroll-offset tricks fight the viewport's own + /// corrections. + Widget _smoothGrowth(BuildContext context, Widget child) { + return AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 140), + curve: Curves.easeOut, + alignment: AlignmentDirectional.topStart, + child: child, + ); + } + + /// Atomic units enter with a short fade when the frontier reaches them + /// — and the post-flip stragglers do the same. The wrapper is + /// permanent (a finished fade is free), so the child's element and its + /// caches survive settling. + Widget _atomic(FlowMarkdownBlock block, Widget child) { + final ordinal = _ordinalOf[block] ?? 0; + final flip = _flipOrdinal; + return _FlowMarkdownBlockFadeIn( + key: ValueKey(ordinal), + animate: widget.isStreaming || (flip != null && ordinal > flip), + child: _smoothGrowth(context, child), + ); + } + + Widget _leaf( + BuildContext context, + FlowMarkdownLeafBlock leaf, + TextStyle style, + ) { + final ordinal = _ordinalOf[leaf] ?? 0; + final isCursor = _gated && ordinal == _cursor; + // The fade wrapper is permanent, like _atomic's: a leaf the frontier + // never reached before the stream ended enters with the settle group + // instead of popping, and one that did animates its own reveal (the + // entrance decision is made once, on mount). Wrapping conditionally + // would change the child's type when the flip clears — remounting + // the reveal element and replaying the whole paragraph. + final flip = _flipOrdinal; + return _FlowMarkdownBlockFadeIn( + key: ValueKey(ordinal), + animate: flip != null && ordinal > flip, + child: _smoothGrowth( + context, + _FlowMarkdownRevealText( + source: leaf.source, + runs: leaf.runs, + baseStyle: style, + styleFor: (run) => _runStyle(context, run, style), + chipFill: context.flowColors.onSurface.withValues( + alpha: _inlineCodeWash, + ), + isStreaming: isCursor, + charactersPerSecond: widget.charactersPerSecond, + extraBacklog: isCursor ? _pendingBeyondCursor().toDouble() : 0, + onRevealed: isCursor ? () => _onUnitRevealed(ordinal) : null, + settled: _settledLeaf(leaf, style), + ), + ), + ); + } + + /// The settled form of a leaf, its spans cached per instance. + Widget _settledLeaf(FlowMarkdownLeafBlock leaf, TextStyle style) { + return FlowChipText( + TextSpan(style: style, children: _settledSpans(leaf, leaf.runs, style)), + ); + } + + List _settledSpans( + Object owner, + List runs, + TextStyle style, + ) { + final cached = _settledCache[owner]; + if (cached != null) { + _markLive(owner, runs); + _liveSettled.add(owner); + return cached; + } + final built = _spansFor(runs, style, owner: owner, tappable: true); + _settledCache[owner] = built; + _liveSettled.add(owner); + return built; + } + + /// Marks a cached subtree's recognizers live without rebuilding its + /// spans — the sweep must never dispose a recognizer a cached span + /// still holds — and rebinds them to this build's callback, since the + /// callback's identity is not part of the cache epoch. + void _markLive(Object owner, List runs) { + final onLinkTap = widget.onLinkTap; + if (onLinkTap == null) return; + for (var i = 0; i < runs.length; i++) { + final href = runs[i].linkHref; + if (href != null) { + final key = (owner, i); + _liveRecognizers.add(key); + _recognizers[key]?.onTap = () => onLinkTap(href); + } + } + } + + Widget _table(BuildContext context, FlowMarkdownTable table, TextStyle base) { + final colors = context.flowColors; + final typography = context.flowTypography; + final headerStyle = typography.bodyMediumDark.copyWith(color: base.color); + // Table material reads a step under the prose, like code does. + final cellStyle = typography.bodyMedium.copyWith(color: base.color); + + Widget cell(FlowMarkdownTableCell cell, TextStyle style, int column) { + final alignment = switch (column < table.alignments.length + ? table.alignments[column] + : null) { + FlowMarkdownAlign.center => AlignmentDirectional.topCenter, + FlowMarkdownAlign.right => AlignmentDirectional.topEnd, + _ => AlignmentDirectional.topStart, + }; + return Container( + padding: _tableCellPadding, + alignment: alignment, + child: FlowChipText( + TextSpan( + style: style, + children: _settledSpans(cell, cell.runs, style), + ), + ), + ); + } + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + defaultVerticalAlignment: TableCellVerticalAlignment.top, + children: [ + TableRow( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.outline)), + ), + children: [ + for (var c = 0; c < table.header.length; c++) + cell(table.header[c], headerStyle, c), + ], + ), + for (var r = 0; r < table.rows.length; r++) + TableRow( + decoration: r == table.rows.length - 1 + ? null + : BoxDecoration( + border: Border( + bottom: BorderSide(color: colors.outlineVariant), + ), + ), + children: [ + for (var c = 0; c < table.header.length; c++) + cell(table.rows[r][c], cellStyle, c), + ], + ), + ], + ), + ); + } + + // ---------------------------------------------------------------- inlines + + TextStyle _runStyle( + BuildContext context, + FlowMarkdownRun run, + TextStyle base, + ) { + final colors = context.flowColors; + final typography = context.flowTypography; + + var style = base; + if (run.code) { + // The mono face at prose size. The wash paints as a rounded chip + // in FlowChipText — the span stays plain text, so wrapping and + // the reveal keep working. + style = typography.codeInline.copyWith(color: base.color); + } + if (run.bold) style = style.copyWith(fontWeight: FontWeight.w600); + if (run.italic) style = style.copyWith(fontStyle: FontStyle.italic); + + final linked = run.linkHref != null && widget.onLinkTap != null; + final decorations = [ + if (run.strike) TextDecoration.lineThrough, + if (linked) TextDecoration.underline, + ]; + if (linked) { + style = style.copyWith( + color: colors.tertiary, + decorationColor: colors.tertiary, + ); + } + if (decorations.isNotEmpty) { + style = style.copyWith(decoration: TextDecoration.combine(decorations)); + } + return style; + } + + List _spansFor( + List runs, + TextStyle base, { + required Object owner, + required bool tappable, + }) { + final onLinkTap = widget.onLinkTap; + final chipFill = context.flowColors.onSurface.withValues( + alpha: _inlineCodeWash, + ); + final spans = []; + for (var i = 0; i < runs.length; i++) { + final run = runs[i]; + TapGestureRecognizer? recognizer; + final href = run.linkHref; + if (href != null && onLinkTap != null && tappable) { + final key = (owner, i); + recognizer = _recognizers.putIfAbsent(key, TapGestureRecognizer.new) + ..onTap = () => onLinkTap(href); + _liveRecognizers.add(key); + } + final style = _runStyle(context, run, base); + spans.add( + run.code + ? FlowChipSpan( + text: run.text, + style: style, + recognizer: recognizer, + fill: chipFill, + ) + : TextSpan(text: run.text, style: style, recognizer: recognizer), + ); + } + return spans; + } +} + +/// One-shot entrance for atomic blocks: a short ease of height and +/// opacity together as the unit reaches the frontier, so fences, tables +/// and rules arrive rather than landing at full height in one frame — +/// the layout grows as smoothly as the text does. The decision is made +/// once, on first dependencies — later prop changes never replay the +/// entrance, and disabled animations render statically. +class _FlowMarkdownBlockFadeIn extends StatefulWidget { + const _FlowMarkdownBlockFadeIn({ + super.key, + required this.animate, + required this.child, + }); + + final bool animate; + final Widget child; + + @override + State<_FlowMarkdownBlockFadeIn> createState() => + _FlowMarkdownBlockFadeInState(); +} + +class _FlowMarkdownBlockFadeInState extends State<_FlowMarkdownBlockFadeIn> + with SingleTickerProviderStateMixin { + static const Duration _duration = Duration(milliseconds: 200); + + late final AnimationController _controller = AnimationController( + vsync: this, + duration: _duration, + value: 1, + ); + late final CurvedAnimation _ease = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + bool _decided = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_decided) return; + _decided = true; + if (widget.animate && !MediaQuery.disableAnimationsOf(context)) { + _controller.forward(from: 0); + } + } + + @override + void dispose() { + _ease.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizeTransition( + sizeFactor: _ease, + // topStart, not topCenter: the transition's Align spans the rail, + // so its cross-axis alignment is the *document's* — anything else + // centers every block narrower than the line. + alignment: AlignmentDirectional.topStart, + child: FadeTransition(opacity: _controller, child: widget.child), + ); + } +} + +/// The rich half of the streaming reveal: `FlowStreamingText`'s clock over +/// styled runs. Every text leaf renders through this widget — settled ones +/// snap to the end and short-circuit to [settled], the frontier leaf +/// fades characters in, and a leaf that stops being the frontier fast- +/// forwards its remainder, which is what hands the reveal from one block +/// to the next without a pop. +/// +/// The reveal is stamped by *source* offset (the run mapping's invariant), +/// so text that restyles when its closing delimiter arrives keeps its +/// stamps and never re-fades. +class _FlowMarkdownRevealText extends StatefulWidget { + const _FlowMarkdownRevealText({ + required this.source, + required this.runs, + required this.baseStyle, + required this.styleFor, + required this.chipFill, + required this.isStreaming, + required this.charactersPerSecond, + required this.settled, + this.onRevealed, + this.extraBacklog = 0, + }); + + final String source; + final List runs; + final TextStyle baseStyle; + final TextStyle Function(FlowMarkdownRun run) styleFor; + + /// The inline-code chip fill, for the code runs this leaf reveals. + final Color chipFill; + final bool isStreaming; + final double charactersPerSecond; + + /// The parent-built settled form — spans with live link recognizers. + final Widget settled; + + /// Fired once every character is revealed (the tail may still be + /// fading) — the queue's cue to advance. Always called from the + /// ticker's phase, never during a build. + final VoidCallback? onRevealed; + + /// Source characters queued beyond this leaf. Folded into the pacing + /// so the whole document honors the reveal's lag bound, not just the + /// leaf at the frontier. + final double extraBacklog; + + @override + State<_FlowMarkdownRevealText> createState() => + _FlowMarkdownRevealTextState(); +} + +class _FlowMarkdownRevealTextState extends State<_FlowMarkdownRevealText> + with SingleTickerProviderStateMixin { + final FlowRevealEngine _engine = FlowRevealEngine(); + + late final Ticker _ticker; + Duration _lastElapsed = Duration.zero; + + /// Whether [_FlowMarkdownRevealText.onRevealed] has fired for the + /// current source; extension re-arms it. Notification only ever runs + /// from [_onTick] — a build-phase caller schedules a tick instead. + bool _notifiedRevealed = false; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_onTick); + if (widget.isStreaming) { + _ensureTicking(); + } else { + _engine.snapToEnd(widget.source.length); + } + } + + @override + void didUpdateWidget(_FlowMarkdownRevealText oldWidget) { + super.didUpdateWidget(oldWidget); + + final extended = + widget.source.length >= oldWidget.source.length && + widget.source.startsWith(oldWidget.source); + if (!extended) { + // A shrunken-to-prefix source is the tail handing characters to a + // newly recognized construct (a table's delimiter row landing) — + // clamp, keeping the surviving prefix's fades, instead of the + // full re-fade a true replacement gets. + final truncated = oldWidget.source.startsWith(widget.source); + if (truncated && widget.isStreaming) { + _engine.truncateTo(widget.source.length); + _notifiedRevealed = false; + _ensureTicking(); + return; + } + if (widget.isStreaming) { + _engine.reset(); + _notifiedRevealed = false; + _ensureTicking(); + } else { + _snapToEnd(); + } + return; + } + + if (widget.isStreaming) { + _engine.clearFastForward(); + if (_engine.revealed < widget.source.length) { + _notifiedRevealed = false; + _ensureTicking(); + } else if (!_notifiedRevealed && widget.onRevealed != null) { + // Already caught up but the parent hasn't heard — deliver from + // the ticker's phase, never from this build. + _ensureTicking(); + } + } else if (oldWidget.isStreaming) { + if (_engine.revealed < widget.source.length || _engine.tailStillFading) { + _engine.beginFastForward(); + _ensureTicking(); + } else { + _snapToEnd(); + } + } else if (widget.source != oldWidget.source) { + _snapToEnd(); + } + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + void _ensureTicking() { + if (_ticker.isActive) return; + _lastElapsed = Duration.zero; + _ticker.start(); + } + + void _snapToEnd() { + _ticker.stop(); + _engine.snapToEnd(widget.source.length); + } + + void _onTick(Duration elapsed) { + final dt = (elapsed - _lastElapsed).inMicroseconds / 1e6; + _lastElapsed = elapsed; + if (dt <= 0) return; + // The document's remaining characters, not just this leaf's, set the + // pace — one frontier moving at the global lag bound. + final cps = math.max( + widget.charactersPerSecond, + (widget.source.length - _engine.revealed + widget.extraBacklog) / + FlowRevealEngine.maxLagSeconds, + ); + setState(() { + if (!_engine.tick(dt, widget.source.length, cps)) { + _ticker.stop(); + } + }); + _maybeNotify(); + } + + void _maybeNotify() { + final onRevealed = widget.onRevealed; + if (!widget.isStreaming || onRevealed == null) return; + if (_engine.revealed >= widget.source.length && !_notifiedRevealed) { + _notifiedRevealed = true; + onRevealed(); + } + } + + static bool _isHighSurrogate(int codeUnit) => (codeUnit & 0xFC00) == 0xD800; + static bool _isLowSurrogate(int codeUnit) => (codeUnit & 0xFC00) == 0xDC00; + + @override + Widget build(BuildContext context) { + // One persistent semantics container spans the animating and settled + // forms, so the swap replaces the children of a stable node instead + // of restructuring the semantics boundary mid-stream. + if (!_ticker.isActive && _engine.revealed >= widget.source.length) { + return Semantics(container: true, child: widget.settled); + } + + final source = widget.source; + var shown = math.min(_engine.revealedFloor, source.length); + if (shown > 0 && + shown < source.length && + _isLowSurrogate(source.codeUnitAt(shown))) { + shown--; + } + + var fadeStart = shown; + while (fadeStart > 0 && + shown - fadeStart < FlowRevealEngine.maxFadeSpans && + _engine.progressFor(fadeStart - 1) < 1) { + fadeStart--; + } + if (fadeStart > 0 && _isLowSurrogate(source.codeUnitAt(fadeStart))) { + fadeStart--; + } + + final spans = []; + for (final run in widget.runs) { + if (run.sourceStart >= shown) break; + final visible = math.min(run.text.length, shown - run.sourceStart); + final style = widget.styleFor(run); + final color = style.color ?? widget.baseStyle.color; + + // Code runs stay tagged through the fade, so the chip paints + // under exactly the revealed characters. + InlineSpan spanOf(String text, TextStyle style) => run.code + ? FlowChipSpan(text: text, style: style, fill: widget.chipFill) + : TextSpan(text: text, style: style); + + final solid = (fadeStart - run.sourceStart).clamp(0, visible); + if (solid > 0) { + spans.add(spanOf(run.text.substring(0, solid), style)); + } + var i = solid; + while (i < visible) { + final end = _isHighSurrogate(run.text.codeUnitAt(i)) && i + 1 < visible + ? i + 2 + : i + 1; + spans.add( + spanOf( + run.text.substring(i, end), + color == null + ? style + : style.copyWith( + color: color.withValues( + alpha: color.a * _engine.progressFor(run.sourceStart + i), + ), + ), + ), + ); + i = end; + } + } + + // Expose the full text once so screen readers aren't re-announced + // on every animation frame. + final label = [for (final run in widget.runs) run.text].join(); + return Semantics( + container: true, + label: label, + excludeSemantics: true, + child: FlowChipText(TextSpan(style: widget.baseStyle, children: spans)), + ); + } +} diff --git a/lib/src/widgets/flow_message.dart b/lib/src/widgets/flow_message.dart index d62416d..a1ffcd6 100644 --- a/lib/src/widgets/flow_message.dart +++ b/lib/src/widgets/flow_message.dart @@ -6,6 +6,7 @@ import '../theme/flow_theme.dart'; import 'flow_attachment_group.dart'; import 'flow_code_block.dart'; import 'flow_error_state.dart'; +import 'flow_markdown.dart'; import 'flow_thinking_indicator.dart'; import 'flow_streaming_text.dart'; @@ -42,6 +43,8 @@ class FlowMessage extends StatelessWidget { this.onCodeCopy, this.copiedCodePart, this.codeCopyTooltip, + this.markdown = true, + this.onLinkTap, this.onRetry, this.errorTitle, this.retryLabel, @@ -92,6 +95,16 @@ class FlowMessage extends StatelessWidget { /// says `retryable: false`). Null hides every retry affordance. final VoidCallback? onRetry; + /// Whether assistant text parts render as markdown (`FlowMarkdown`). + /// User bubbles and system notices always render plain — what the user + /// typed is a transcription, not prose to typeset. Pass false for + /// hosts whose assistant text is literal. + final bool markdown; + + /// Link intent from markdown content, handed the tapped href. Null + /// renders links as plain prose; the package never launches URLs. + final ValueChanged? onLinkTap; + /// Host-localized headline for the error cards, e.g. 'Connection /// error'. Null lets each card's message take the glyph row. final String? errorTitle; @@ -327,6 +340,22 @@ class FlowMessage extends StatelessWidget { for (var i = 0; i < message.parts.length; i++) { final part = message.parts[i]; final child = switch (part) { + // Assistant prose typesets as markdown by default; the user's + // words render exactly as typed. + FlowTextPart(:final text) + when markdown && message.role == FlowMessageRole.assistant => + FlowMarkdown( + text: text, + isStreaming: + message.status == FlowMessageStatus.streaming && + i == lastTextIndex, + style: style, + charactersPerSecond: charactersPerSecond, + onLinkTap: onLinkTap, + onCodeCopy: onCodeCopy, + copiedCodePart: copiedCodePart, + codeCopyTooltip: codeCopyTooltip, + ), FlowTextPart(:final text) => FlowStreamingText( text: text, isStreaming: diff --git a/lib/src/widgets/flow_streaming_text.dart b/lib/src/widgets/flow_streaming_text.dart index 5e7edc9..06b7bf5 100644 --- a/lib/src/widgets/flow_streaming_text.dart +++ b/lib/src/widgets/flow_streaming_text.dart @@ -4,6 +4,7 @@ import 'package:flutter/scheduler.dart'; import 'package:material_ui/material_ui.dart'; import '../theme/flow_theme.dart'; +import '../utils/flow_reveal_engine.dart'; /// Animated reveal for text that arrives incrementally. /// @@ -50,33 +51,12 @@ class FlowStreamingText extends StatefulWidget { class _FlowStreamingTextState extends State with SingleTickerProviderStateMixin { - /// How long a revealed character takes to fade to full opacity. - static const double _fadeSeconds = 0.25; - - /// Maximum time the reveal may lag behind the incoming text. - static const double _maxLagSeconds = 0.4; - - /// Catch-up window once streaming has completed. - static const double _fastForwardSeconds = 0.15; - - /// Upper bound on individually faded spans per frame. - static const int _maxFadeSpans = 60; + /// The counters live in the shared engine; this state owns the Ticker + /// and the flat-string span construction. + final FlowRevealEngine _engine = FlowRevealEngine(); late final Ticker _ticker; - - /// Characters revealed so far (fractional between characters). - double _revealed = 0; - - /// Monotonic clock in seconds, accumulated across ticker runs. - double _clock = 0; - - /// Reveal timestamps: entry `i` is for character `_stampBase + i`. - /// Characters below [_stampBase] were revealed without animation. - final List _revealedAt = []; - int _stampBase = 0; - Duration _lastElapsed = Duration.zero; - bool _fastForwarding = false; @override void initState() { @@ -85,8 +65,7 @@ class _FlowStreamingTextState extends State if (widget.isStreaming) { _ensureTicking(); } else { - _revealed = widget.text.length.toDouble(); - _stampBase = widget.text.length; + _engine.snapToEnd(widget.text.length); } } @@ -99,11 +78,8 @@ class _FlowStreamingTextState extends State widget.text.startsWith(oldWidget.text); if (!extended) { // Replacement: regenerate / branch switch. - _revealedAt.clear(); - _fastForwarding = false; if (widget.isStreaming) { - _revealed = 0; - _stampBase = 0; + _engine.reset(); _ensureTicking(); } else { _snapToEnd(); @@ -112,12 +88,12 @@ class _FlowStreamingTextState extends State } if (widget.isStreaming) { - _fastForwarding = false; - if (_revealed < widget.text.length) _ensureTicking(); + _engine.clearFastForward(); + if (_engine.revealed < widget.text.length) _ensureTicking(); } else if (oldWidget.isStreaming) { // Stream completed: fast-forward whatever is left. - if (_revealed < widget.text.length || _tailStillFading) { - _fastForwarding = true; + if (_engine.revealed < widget.text.length || _engine.tailStillFading) { + _engine.beginFastForward(); _ensureTicking(); } } else if (widget.text != oldWidget.text) { @@ -132,9 +108,6 @@ class _FlowStreamingTextState extends State super.dispose(); } - bool get _tailStillFading => - _revealedAt.isNotEmpty && _clock - _revealedAt.last < _fadeSeconds; - void _ensureTicking() { if (_ticker.isActive) return; _lastElapsed = Duration.zero; @@ -143,52 +116,20 @@ class _FlowStreamingTextState extends State void _snapToEnd() { _ticker.stop(); - _revealed = widget.text.length.toDouble(); - _revealedAt.clear(); - _stampBase = widget.text.length; - _fastForwarding = false; + _engine.snapToEnd(widget.text.length); } void _onTick(Duration elapsed) { final dt = (elapsed - _lastElapsed).inMicroseconds / 1e6; _lastElapsed = elapsed; if (dt <= 0) return; - _clock += dt; - - final target = widget.text.length.toDouble(); - var speed = widget.charactersPerSecond; - final backlog = target - _revealed; - if (backlog > 0) { - // Adapt so the backlog clears within the lag window. - final window = _fastForwarding ? _fastForwardSeconds : _maxLagSeconds; - speed = math.max(speed, backlog / window); - } - setState(() { - final before = _revealed.floor(); - _revealed = math.min(_revealed + speed * dt, target); - final count = _revealed.floor() - before; - // Stamp newly revealed characters, spread across this frame's time. - for (var i = 0; i < count; i++) { - _revealedAt.add(_clock - dt + dt * (i + 1) / count); - } - if (_revealed >= target && !_tailStillFading) { + if (!_engine.tick(dt, widget.text.length, widget.charactersPerSecond)) { _ticker.stop(); - _fastForwarding = false; } }); } - /// Fade-in progress (0–1) for the character at index [index]. - double _fadeProgress(int index) { - if (index < _stampBase) return 1; - final i = index - _stampBase; - if (i >= _revealedAt.length) return 0; - return ((_clock - _revealedAt[i]) / _fadeSeconds) - .clamp(0.0, 1.0) - .toDouble(); - } - static bool _isHighSurrogate(int codeUnit) => (codeUnit & 0xFC00) == 0xD800; static bool _isLowSurrogate(int codeUnit) => (codeUnit & 0xFC00) == 0xDC00; @@ -200,12 +141,12 @@ class _FlowStreamingTextState extends State .merge(widget.style); // Settled: history messages and completed streams render statically. - if (!_ticker.isActive && _revealed >= widget.text.length) { + if (!_ticker.isActive && _engine.revealed >= widget.text.length) { return Text(widget.text, style: style, textAlign: widget.textAlign); } final text = widget.text; - var shown = math.min(_revealed.floor(), text.length); + var shown = math.min(_engine.revealedFloor, text.length); // Never split a surrogate pair at the reveal head. if (shown > 0 && shown < text.length && @@ -215,8 +156,8 @@ class _FlowStreamingTextState extends State var fadeStart = shown; while (fadeStart > 0 && - shown - fadeStart < _maxFadeSpans && - _fadeProgress(fadeStart - 1) < 1) { + shown - fadeStart < FlowRevealEngine.maxFadeSpans && + _engine.progressFor(fadeStart - 1) < 1) { fadeStart--; } if (fadeStart > 0 && _isLowSurrogate(text.codeUnitAt(fadeStart))) { @@ -236,7 +177,9 @@ class _FlowStreamingTextState extends State TextSpan( text: text.substring(i, end), style: TextStyle( - color: baseColor.withValues(alpha: baseColor.a * _fadeProgress(i)), + color: baseColor.withValues( + alpha: baseColor.a * _engine.progressFor(i), + ), ), ), ); diff --git a/lib/src/widgets/flow_thread.dart b/lib/src/widgets/flow_thread.dart index f006587..c999849 100644 --- a/lib/src/widgets/flow_thread.dart +++ b/lib/src/widgets/flow_thread.dart @@ -1,16 +1,21 @@ +import 'dart:async'; + +import 'package:flutter/rendering.dart' show ScrollCacheExtent; import 'package:material_ui/material_ui.dart'; import '../models/flow_message_data.dart'; import '../models/flow_message_part.dart'; import 'flow_message.dart'; -/// The scrollable conversation: a bottom-anchored list of [FlowMessageData]s. +/// The scrollable conversation, a list of [FlowMessageData]s. /// -/// Uses a reversed [ListView] so the thread naturally sticks to the newest -/// message while a reply streams in, and holds position when the user -/// scrolls up to read history. Needs a bounded height (an [Expanded] in a -/// column, or a sized parent). -class FlowThread extends StatelessWidget { +/// A conversation that still fits its viewport reads from the top, the AI +/// apps' convention; once it grows past the viewport it anchors to the +/// bottom instead. Uses a reversed [ListView] underneath so the thread +/// naturally sticks to the newest message while a reply streams in, and +/// holds position when the user scrolls up to read history. Needs a +/// bounded height (an [Expanded] in a column, or a sized parent). +class FlowThread extends StatefulWidget { const FlowThread({ super.key, required this.messages, @@ -20,18 +25,23 @@ class FlowThread extends StatelessWidget { this.onCodeCopy, this.copiedCodePart, this.codeCopyTooltip, + this.markdown = true, + this.onLinkTap, this.onRetry, this.errorTitle, this.retryLabel, this.controller, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag, this.padding, this.itemSpacing, this.messageBuilder, + this.messageFooter, this.charactersPerSecond = 300, this.thinkingLabel, }); - /// Oldest → newest; the thread anchors to the newest. + /// Oldest → newest; once the conversation outgrows the viewport, the + /// thread anchors to the newest. final List messages; /// Forwarded to each [FlowMessage]. @@ -60,6 +70,14 @@ class FlowThread extends StatelessWidget { /// Host-localized label for the code blocks' copy affordance. final String? codeCopyTooltip; + /// Whether assistant text parts render as markdown. Forwarded to each + /// [FlowMessage]; pass false for hosts whose assistant text is literal. + final bool markdown; + + /// Link intent from any markdown content in the thread, handed the + /// message and the tapped href. Null renders links as plain prose. + final void Function(FlowMessageData message, String href)? onLinkTap; + /// Retry intent from a failed turn's error card, handed the message so /// the host can re-run it. Forwarded to each [FlowMessage]. final void Function(FlowMessageData message)? onRetry; @@ -75,7 +93,13 @@ class FlowThread extends StatelessWidget { /// Optional external scroll controller. final ScrollController? controller; - /// Defaults to the design's 16 on every side. + /// How scrolling the thread treats an open keyboard. Defaults to + /// dismissing it on drag — reading history and typing are different + /// modes, the chat convention. + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// Around the whole conversation. Defaults to the design's 16 at the + /// sides and 40 above and below. final EdgeInsetsGeometry? padding; /// Gap between messages; defaults to the design's 32. @@ -85,6 +109,13 @@ class FlowThread extends StatelessWidget { final Widget Function(BuildContext context, FlowMessageData message)? messageBuilder; + /// Builds each default message's footer slot — an actions row, a + /// timestamp — without replacing the message the way [messageBuilder] + /// does, so the thread's per-message wiring stays intact. Return null + /// for no footer on that turn. Ignored when [messageBuilder] is set: + /// the builder owns the whole message. + final Widget? Function(FlowMessageData message)? messageFooter; + /// Forwarded to [FlowMessage] for streaming text parts. final double charactersPerSecond; @@ -92,50 +123,191 @@ class FlowThread extends StatelessWidget { /// on a pending message. final String? thinkingLabel; + @override + State createState() => _FlowThreadState(); +} + +class _FlowThreadState extends State { /// The design's thread metrics: edge padding and the gap between turns. - /// The edge 16 is mirrored by `FlowChatView`'s composer-block padding, - /// which promises its edges line up with the thread's. - static const EdgeInsetsGeometry _defaultPadding = EdgeInsets.all(16); + /// The side 16 is mirrored by `FlowChatView`'s composer-block padding, + /// which promises its edges line up with the thread's; the vertical 40 + /// keeps the opening turn off the viewport edge and the last one + /// breathing against the composer. + static const EdgeInsetsGeometry _defaultPadding = + EdgeInsetsDirectional.fromSTEB(16, 40, 16, 40); static const double _defaultGap = 32; + /// How far beyond the viewport messages get real layouts. A lazy list + /// estimates its total extent from the items laid out so far, and chat + /// turns vary wildly in height — a one-line bubble to a whole document + /// — so a small cache makes the scrollbar thumb resize and jump as the + /// estimate swings with every scroll. A few viewports of cache gives a + /// typical conversation exact extents (a steady thumb) while a long + /// history still lays out lazily. + static const ScrollCacheExtent _cacheExtent = ScrollCacheExtent.viewport(3); + + /// Whether the conversation still fits its viewport. A fitting thread + /// lays out shrink-wrapped so the top alignment can take effect; once + /// content overflows, the lazy bottom-anchored form takes over. The flip + /// is read off the scroll metrics, and at the frame it happens content + /// equals the viewport, so nothing visibly moves. Shrink-wrapping lays + /// out every message, so the mount starts from the lazy form — a + /// restored long conversation must never pay a full layout on open — + /// and the first metrics reading flips a short thread to the top read + /// a frame later. + bool _fits = false; + Timer? _fitsHold; + + /// Whether any message is mid-turn, read each build. The fit flip is + /// frozen while streaming: swapping the list's viewport type remounts + /// the whole subtree, resetting every reveal — mid-stream that reset + /// shrank the content back under the viewport and flipped the fit + /// again, a grow-collapse flicker loop until the stream settled. A + /// shrink-wrapped list whose content overflows still clamps to its + /// constraints and scrolls, so deferring the flip only defers the + /// laziness, not correctness. + bool _streaming = false; + + /// The latest observed fit, applied when the freeze lifts. + bool? _metricsFits; + + @override + void dispose() { + _fitsHold?.cancel(); + super.dispose(); + } + + void _handleMetrics(ScrollMetrics metrics) { + final fits = metrics.maxScrollExtent <= 0; + final first = _metricsFits == null; + _metricsFits = fits; + // The first reading escapes the streaming freeze: a conversation + // opened by sending a message mounts mid-stream, and its opening turn + // must read from the top from the start, not after the reply settles. + // One flip this early cannot oscillate — the freeze guards the + // grow-shrink loop of a reveal already underway. + if (_streaming && !first) return; + if (fits == _fits) { + _fitsHold?.cancel(); + _fitsHold = null; + return; + } + if (first) { + // The mount starts lazy; the very first reading that fits flips + // right away — the hold below guards later shrink-backs, not the + // initial settle. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_fits && _metricsFits == true) { + setState(() => _fits = true); + } + }); + return; + } + if (!fits) { + // Overflow takes the lazy form immediately. + _fitsHold?.cancel(); + _fitsHold = null; + // Metrics arrive during layout; flipping shrinkWrap there would + // rebuild the tree mid-layout, so the flip waits for the frame's + // end. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _fits && !_streaming) setState(() => _fits = false); + }); + return; + } + // Shrinking back to fitting must hold before the flip, so transient + // zero-extent readings — a block still easing in — don't bounce the + // viewport type. + _fitsHold ??= Timer(const Duration(milliseconds: 250), () { + _fitsHold = null; + if (mounted && !_fits && !_streaming) setState(() => _fits = true); + }); + } + + /// Called from build: freezes the flip during a stream and applies the + /// deferred state once the stream ends. + void _syncStreaming(List messages) { + final streamingNow = messages.any( + (message) => + message.status == FlowMessageStatus.streaming || + message.status == FlowMessageStatus.pending, + ); + if (_streaming && !streamingNow) { + final fits = _metricsFits; + if (fits != null && fits != _fits) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final target = _metricsFits; + if (mounted && !_streaming && target != null && target != _fits) { + setState(() => _fits = target); + } + }); + } + } + _streaming = streamingNow; + } + @override Widget build(BuildContext context) { - final gap = itemSpacing ?? _defaultGap; - final onAttachmentTap = this.onAttachmentTap; - final onRetry = this.onRetry; - - return ListView.builder( - controller: controller, - reverse: true, - padding: padding ?? _defaultPadding, - itemCount: messages.length, - itemBuilder: (context, index) { - // Reversed list: index 0 is the newest (bottom) message. - final message = messages[messages.length - 1 - index]; - final isOldest = index == messages.length - 1; - return Padding( - key: ValueKey(message.id), - padding: EdgeInsets.only(top: isOldest ? 0 : gap), - child: - messageBuilder?.call(context, message) ?? - FlowMessage( - message, - customPartBuilder: customPartBuilder, - onAttachmentTap: onAttachmentTap == null - ? null - : (attachmentId) => onAttachmentTap(message, attachmentId), - previewCloseTooltip: previewCloseTooltip, - onCodeCopy: onCodeCopy, - copiedCodePart: copiedCodePart, - codeCopyTooltip: codeCopyTooltip, - onRetry: onRetry == null ? null : () => onRetry(message), - errorTitle: errorTitle, - retryLabel: retryLabel, - charactersPerSecond: charactersPerSecond, - thinkingLabel: thinkingLabel, - ), - ); + final gap = widget.itemSpacing ?? _defaultGap; + final onAttachmentTap = widget.onAttachmentTap; + final onRetry = widget.onRetry; + final onLinkTap = widget.onLinkTap; + final messages = widget.messages; + _syncStreaming(messages); + + return NotificationListener( + onNotification: (notification) { + // Depth 0 is the thread's own list — scrollers nested inside + // messages (attachment strips, code blocks) report deeper and + // must not steer the fit. + if (notification.depth == 0) _handleMetrics(notification.metrics); + return false; }, + child: Align( + alignment: AlignmentDirectional.topCenter, + child: ListView.builder( + controller: widget.controller, + reverse: true, + shrinkWrap: _fits, + scrollCacheExtent: _cacheExtent, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + padding: widget.padding ?? _defaultPadding, + itemCount: messages.length, + itemBuilder: (context, index) { + // Reversed list: index 0 is the newest (bottom) message. + final message = messages[messages.length - 1 - index]; + final isOldest = index == messages.length - 1; + return Padding( + key: ValueKey(message.id), + padding: EdgeInsets.only(top: isOldest ? 0 : gap), + child: + widget.messageBuilder?.call(context, message) ?? + FlowMessage( + message, + customPartBuilder: widget.customPartBuilder, + onAttachmentTap: onAttachmentTap == null + ? null + : (attachmentId) => + onAttachmentTap(message, attachmentId), + previewCloseTooltip: widget.previewCloseTooltip, + onCodeCopy: widget.onCodeCopy, + copiedCodePart: widget.copiedCodePart, + codeCopyTooltip: widget.codeCopyTooltip, + markdown: widget.markdown, + onLinkTap: onLinkTap == null + ? null + : (href) => onLinkTap(message, href), + onRetry: onRetry == null ? null : () => onRetry(message), + errorTitle: widget.errorTitle, + retryLabel: widget.retryLabel, + charactersPerSecond: widget.charactersPerSecond, + thinkingLabel: widget.thinkingLabel, + footer: widget.messageFooter?.call(message), + ), + ); + }, + ), + ), ); } } diff --git a/playground/lib/src/demo_registry.dart b/playground/lib/src/demo_registry.dart index 7eceaff..650dd06 100644 --- a/playground/lib/src/demo_registry.dart +++ b/playground/lib/src/demo_registry.dart @@ -7,6 +7,7 @@ import 'demos/composer_demo.dart'; import 'demos/error_state_demo.dart'; import 'demos/full_chat_demo.dart'; import 'demos/greeting_demo.dart'; +import 'demos/markdown_demo.dart'; import 'demos/message_actions_demo.dart'; import 'demos/message_demo.dart'; import 'demos/model_selector_demo.dart'; @@ -34,6 +35,7 @@ Widget demoFor(PlaygroundItem item, {String? variant}) { variant: variant, ), PlaygroundItem.codeBlock => CodeBlockDemo(key: key, variant: variant), + PlaygroundItem.markdown => MarkdownDemo(key: key, variant: variant), PlaygroundItem.errorState => ErrorStateDemo(key: key, variant: variant), PlaygroundItem.addToChat => AddToChatDemo(key: key), PlaygroundItem.pill => PillDemo(key: key, variant: variant), @@ -81,6 +83,12 @@ List<(String, String)> variantsFor(PlaygroundItem item) { ('plain', 'Plain'), ('streaming', 'Streaming'), ], + PlaygroundItem.markdown => const [ + ('document', 'Document'), + ('streaming', 'Streaming'), + ('tables', 'Tables'), + ('links', 'Links'), + ], PlaygroundItem.errorState => const [ ('card', 'Card'), ('minimal', 'Minimal'), @@ -99,6 +107,7 @@ List<(String, String)> variantsFor(PlaygroundItem item) { PlaygroundItem.thread => const [ ('default', 'Default'), ('streaming', 'Streaming'), + ('short', 'Short'), ], PlaygroundItem.streamingText => const [ ('animated', 'Animated'), @@ -139,6 +148,7 @@ String snippetFor(PlaygroundItem item) { PlaygroundItem.message => messageSnippet, PlaygroundItem.streamingMessage => streamingMessageSnippet, PlaygroundItem.codeBlock => codeBlockSnippet, + PlaygroundItem.markdown => markdownSnippet, PlaygroundItem.errorState => errorStateSnippet, PlaygroundItem.addToChat => addToChatSnippet, PlaygroundItem.pill => pillSnippet, diff --git a/playground/lib/src/demos/markdown_demo.dart b/playground/lib/src/demos/markdown_demo.dart new file mode 100644 index 0000000..ca4ef14 --- /dev/null +++ b/playground/lib/src/demos/markdown_demo.dart @@ -0,0 +1,259 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flow_ui/flow_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; + +const String markdownSnippet = ''' +// Assistant text parts render markdown by default — pass +// markdown: false on FlowThread/FlowMessage for literal text. +FlowThread( + messages: messages, + onLinkTap: (message, href) => openInBrowser(href), + codeCopyTooltip: 'Copy code', + onCodeCopy: copyPart, +) + +// Standalone, outside a thread: +FlowMarkdown( + text: reply, + isStreaming: generating, + onLinkTap: openInBrowser, // null renders links as plain prose + onCodeCopy: copyPart, // fences share FlowCodePart's contract +)'''; + +const String _document = ''' +## Flow UI in one reply + +flow_ui renders **state in** and reports *intent out* — nothing model-facing, no strings shipped, and now the assistant's prose is typeset instead of printed raw. + +### What renders + +1. Headings on the existing type ramp +2. Emphasis — **bold**, *italic*, ~~struck~~ — and `inline code` +3. Fenced code, through the code block you already have: + +```dart +FlowMarkdown( + text: reply, + onLinkTap: (href) => open(href), +) +``` + +> A quote steps down to the secondary ink, behind the hairline bar. + +Inline code wraps as one chip: `FlowMarkdown(text: reply, isStreaming: generating, onLinkTap: open)` keeps its rounded ends across the line break. + +- Unordered lists nest + - two levels in + - and markers cycle by depth +- Back out again + +--- + +Everything else — the [docs](https://flowui.stac.dev) have the full dialect, including what stays deliberately literal. +'''; + +const String _tables = ''' +### Model lineup + +| Model | Context | Strength | +|:------|--------:|:---------| +| Fable 5 | 1M | Deep reasoning | +| Opus 5.1 | 500K | Balanced daily driver | +| Haiku 4.5 | 200K | Fast and light | + +Alignment comes from the delimiter row — left, right, left. Cells carry inline styling: **bold**, `code`, *italics*. + +### Wide tables scroll + +| Stage | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday | +|---|---|---|---|---|---|---|---| +| Draft | done | done | — | done | — | done | — | +| Review | — | done | done | — | done | — | done | +| Publish | — | — | done | — | — | done | — | +'''; + +const String _links = ''' +Links report **intent**: try [the docs](https://flowui.stac.dev), the [GitHub repo](https://github.com/StacDev/flow_ui), or the autolink — the package styles the label, hands the host the href, and launches nothing itself. + +Bare URLs autolink with GFM's trimming: https://stac.dev/docs, the parenthesized https://en.wikipedia.org/wiki/Dart_(programming_language), and www.example.com; a trailing period stays prose: https://flutter.dev. + +Without an `onLinkTap`, links render as plain prose — never a styled-but-dead affordance: +'''; + +const String _linksPlain = ''' +The same [docs](https://flowui.stac.dev) link and the same [repo](https://github.com/StacDev/flow_ui) link, with no handler wired. +'''; + +/// Stage demo for `FlowMarkdown` — the typeset document, the streaming +/// reveal over styled text, tables with alignment and overflow scroll, +/// and link intent made visible. The demo owns the clipboard write and +/// the tapped-href readout, the way a host would. +class MarkdownDemo extends StatefulWidget { + const MarkdownDemo({super.key, this.variant}); + + final String? variant; + + @override + State createState() => _MarkdownDemoState(); +} + +class _MarkdownDemoState extends State { + /// The real assistant cadence: bursts of 40–80 characters every 150ms + /// (deterministic run to run), not a per-character trickle — this is + /// the traffic shape the reveal queue exists to smooth. + static const Duration _feedTick = Duration(milliseconds: 150); + static const Duration _restartDelay = Duration(milliseconds: 1600); + final math.Random _chunks = math.Random(42); + + FlowCodePart? _copiedPart; + Timer? _copyReset; + String? _tappedHref; + + /// Streaming variant: how much of [_document] has "arrived". + int _fed = 0; + bool _settled = false; + Timer? _feed; + + @override + void initState() { + super.initState(); + if (widget.variant == 'streaming') _startFeed(); + } + + @override + void dispose() { + _feed?.cancel(); + _copyReset?.cancel(); + super.dispose(); + } + + void _startFeed() { + _feed?.cancel(); + setState(() { + _fed = 0; + _settled = false; + }); + _feed = Timer.periodic(_feedTick, (timer) { + setState(() { + _fed = (_fed + 40 + _chunks.nextInt(41)).clamp(0, _document.length); + if (_fed == _document.length) { + _settled = true; + timer.cancel(); + _feed = Timer(_restartDelay, _startFeed); + } + }); + }); + } + + Future _copy(FlowCodePart part) async { + await Clipboard.setData(ClipboardData(text: part.code)); + if (!mounted) return; + setState(() => _copiedPart = part); + _copyReset?.cancel(); + _copyReset = Timer(const Duration(milliseconds: 1500), () { + if (mounted) setState(() => _copiedPart = null); + }); + } + + void _openLink(String href) => setState(() => _tappedHref = href); + + /// The tapped-href readout under the interactive variants: the intent, + /// made visible. + Widget _linkReadout(BuildContext context) { + final href = _tappedHref; + if (href == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + 'onLinkTap → $href', + style: context.flowTypography.bodyMedium.copyWith( + color: context.flowColors.onSurfaceMuted, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final child = switch (widget.variant) { + 'streaming' => _StreamingTurn( + text: _document.substring(0, _fed), + settled: _settled, + onCodeCopy: _copy, + copiedCodePart: _copiedPart, + onLinkTap: _openLink, + ), + 'tables' => FlowMarkdown(text: _tables), + 'links' => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowMarkdown(text: _links, onLinkTap: _openLink), + const SizedBox(height: 8), + const FlowMarkdown(text: _linksPlain), + _linkReadout(context), + ], + ), + _ => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FlowMarkdown( + text: _document, + onLinkTap: _openLink, + onCodeCopy: _copy, + copiedCodePart: _copiedPart, + codeCopyTooltip: 'Copy code', + ), + _linkReadout(context), + ], + ), + }; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: child, + ), + ); + } +} + +/// The streaming document, fed through the real message path so the +/// markdown reveal runs exactly as a host's thread would run it. +class _StreamingTurn extends StatelessWidget { + const _StreamingTurn({ + required this.text, + required this.settled, + required this.onCodeCopy, + required this.copiedCodePart, + required this.onLinkTap, + }); + + final String text; + final bool settled; + final ValueChanged onCodeCopy; + final FlowCodePart? copiedCodePart; + final ValueChanged onLinkTap; + + @override + Widget build(BuildContext context) { + return FlowMessage( + FlowMessageData( + id: 'markdown-stream', + role: FlowMessageRole.assistant, + status: settled + ? FlowMessageStatus.complete + : FlowMessageStatus.streaming, + parts: [FlowTextPart(text)], + ), + onCodeCopy: onCodeCopy, + copiedCodePart: copiedCodePart, + codeCopyTooltip: 'Copy code', + onLinkTap: onLinkTap, + ); + } +} diff --git a/playground/lib/src/demos/thread_demo.dart b/playground/lib/src/demos/thread_demo.dart index 2330969..c59d05b 100644 --- a/playground/lib/src/demos/thread_demo.dart +++ b/playground/lib/src/demos/thread_demo.dart @@ -5,7 +5,8 @@ import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; const String threadSnippet = ''' -// A reversed, scrollable conversation — newest at the bottom. Give it +// A scrollable conversation — reads from the top, anchoring to the +// newest message once it outgrows the viewport. Give it // bounded height; inside FlowChatView that comes for free. SizedBox( height: 480, @@ -22,10 +23,11 @@ SizedBox( )'''; const String _reply = - 'FlowThread lays the conversation out as a reversed list, so the ' - 'newest message sits at the bottom and history loads upward. Messages ' - 'keep their identity by id, which is what makes streaming updates ' - 'cheap. Give it bounded height and it does the rest:'; + 'FlowThread reads from the top while the conversation fits, then ' + 'anchors to the newest message once it outgrows the viewport — ' + 'history loads upward. Messages keep their identity by id, which is ' + 'what makes streaming updates cheap. Give it bounded height and it ' + 'does the rest:'; const String _replyCode = ''' SizedBox( @@ -71,9 +73,10 @@ List _seed(bool streaming) => [ /// The conversation list on its own, at a bounded height, closing on a /// reply that carries a code part. The Streaming variant mounts that -/// reply mid-stream, so the text reveal plays above the code block. The -/// demo owns the clipboard write and the copied confirmation, the way a -/// host would. +/// reply mid-stream, so the text reveal plays above the code block; the +/// Short variant fits its viewport, showing the conversation reading +/// from the top. The demo owns the clipboard write and the copied +/// confirmation, the way a host would. class ThreadDemo extends StatefulWidget { const ThreadDemo({super.key, this.variant}); @@ -111,7 +114,9 @@ class _ThreadDemoState extends State { child: SizedBox( height: 480, child: FlowThread( - messages: _seed(widget.variant == 'streaming'), + messages: widget.variant == 'short' + ? _seed(false).take(2).toList() + : _seed(widget.variant == 'streaming'), codeCopyTooltip: 'Copy code', copiedCodePart: _copiedPart, onCodeCopy: _copy, diff --git a/playground/lib/src/playground_item.dart b/playground/lib/src/playground_item.dart index 82f19d7..45d0d0b 100644 --- a/playground/lib/src/playground_item.dart +++ b/playground/lib/src/playground_item.dart @@ -29,6 +29,7 @@ enum PlaygroundItem { 'flow_streaming_message.dart', ), codeBlock('Code Block', PhosphorIconsRegular.code, 'flow_code_block.dart'), + markdown('Markdown', PhosphorIconsRegular.markdownLogo, 'flow_markdown.dart'), errorState( 'Error State', PhosphorIconsRegular.warningCircle,