From fc5d25d2539d72133f27c4b567975b3f5bb0fbe6 Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Wed, 14 Oct 2026 14:58:37 +0200 Subject: [PATCH 1/3] :sparkles: Add splash to the showcase kit --- CHANGELOG.md | 3 ++ example/pubspec.lock | 2 +- lib/showcase.dart | 1 + lib/showcase/showcase_config.dart | 11 ++++- lib/showcase/showcase_splash.dart | 20 +++++++++ lib/showcase/splash_dom.dart | 8 ++++ lib/showcase/splash_dom_web.dart | 46 +++++++++++++++++++++ pubspec.yaml | 2 +- skills/dcc-toolkit-create-showcase/SKILL.md | 26 ++++++++++-- test/showcase/showcase_state_test.dart | 14 +++++-- 10 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 lib/showcase/showcase_splash.dart create mode 100644 lib/showcase/splash_dom.dart create mode 100644 lib/showcase/splash_dom_web.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f9cdd0..3e28999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.1.2 +* Added `removeShowcaseSplash` to show the splash of the app from `web/index.html` while the showcase loads, for at least a fixed duration, and the `?splash=off` url parameter + ## 0.1.1 * Added the showcase kit, building blocks for a clickable web demo of an app made from its real screens. Import it with `package:dcc_toolkit/showcase.dart`, see [doc/showcase.md](doc/showcase.md) * Added the `dcc-toolkit-create-showcase` agent skill for setting up a showcase app diff --git a/example/pubspec.lock b/example/pubspec.lock index e5e6acc..b8feb2f 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -215,7 +215,7 @@ packages: path: ".." relative: true source: path - version: "0.1.0" + version: "0.1.1" equatable: dependency: transitive description: diff --git a/lib/showcase.dart b/lib/showcase.dart index 2109463..1e8cb1c 100644 --- a/lib/showcase.dart +++ b/lib/showcase.dart @@ -9,5 +9,6 @@ export 'showcase/showcase_config.dart'; export 'showcase/showcase_navigator.dart'; export 'showcase/showcase_scope.dart'; export 'showcase/showcase_shell.dart'; +export 'showcase/showcase_splash.dart'; export 'showcase/showcase_state.dart'; export 'showcase/showcase_strings.dart'; diff --git a/lib/showcase/showcase_config.dart b/lib/showcase/showcase_config.dart index 891dc3c..78174ad 100644 --- a/lib/showcase/showcase_config.dart +++ b/lib/showcase/showcase_config.dart @@ -3,8 +3,14 @@ import 'package:material_ui/material_ui.dart'; /// Safe areas of an iPhone 16, for embedding in a website without a status bar of its own. const iosInsets = EdgeInsets.only(top: 59, bottom: 34); -/// How the host page configures the demo, read from the url: `?highlights=off&insets=ios`. -class const ShowcaseConfig({final bool highlightsEnabled = true, final EdgeInsets insets = EdgeInsets.zero}) { +/// How the host page configures the demo, read from the url: `?highlights=off&insets=ios&splash=off`. +class const ShowcaseConfig({ + final bool highlightsEnabled = true, + final EdgeInsets insets = EdgeInsets.zero, + + /// Whether the splash stays for its minimum duration. When off, it goes as soon as the app is drawn. + final bool splashEnabled = true, +}) { /// Creates a [ShowcaseConfig]. this; @@ -15,6 +21,7 @@ class const ShowcaseConfig({final bool highlightsEnabled = true, final EdgeInset return ShowcaseConfig( highlightsEnabled: query['highlights'] != 'off', insets: query['insets'] == 'ios' ? iosInsets : EdgeInsets.zero, + splashEnabled: query['splash'] != 'off', ); } } diff --git a/lib/showcase/showcase_splash.dart b/lib/showcase/showcase_splash.dart new file mode 100644 index 0000000..5b3d6e6 --- /dev/null +++ b/lib/showcase/showcase_splash.dart @@ -0,0 +1,20 @@ +import 'package:dcc_toolkit/showcase/splash_dom.dart' + if (dart.library.js_interop) 'package:dcc_toolkit/showcase/splash_dom_web.dart'; +import 'package:flutter/widgets.dart'; + +/// Removes the splash of `web/index.html`, the element with id `splash`, like the native splash of the app on a +/// phone. Call it right after `runApp`. +/// +/// The splash stays until the first frame is drawn and at least [minimum] has passed since the page started +/// loading, so a slow connection never waits longer. It fades out by adding the class `splash--hidden` and is +/// removed a second later. Outside the browser this does nothing. +Future removeShowcaseSplash({Duration minimum = const Duration(seconds: 2)}) async { + if (!hasSplashDom) return; + + // Not waitUntilFirstFrameRasterized: it relies on frame timings, which the web does not report for a single frame. + await WidgetsBinding.instance.endOfFrame; + final remaining = minimum - timeSincePageLoad(); + if (remaining > Duration.zero) await Future.delayed(remaining); + + hideSplashDom(); +} diff --git a/lib/showcase/splash_dom.dart b/lib/showcase/splash_dom.dart new file mode 100644 index 0000000..a37de66 --- /dev/null +++ b/lib/showcase/splash_dom.dart @@ -0,0 +1,8 @@ +/// Outside the browser there is no splash in the page. +const bool hasSplashDom = false; + +/// How long the page has been loading. +Duration timeSincePageLoad() => Duration.zero; + +/// Fades the splash out and removes it from the page. +void hideSplashDom() {} diff --git a/lib/showcase/splash_dom_web.dart b/lib/showcase/splash_dom_web.dart new file mode 100644 index 0000000..cfbd40d --- /dev/null +++ b/lib/showcase/splash_dom_web.dart @@ -0,0 +1,46 @@ +import 'dart:async'; +import 'dart:js_interop'; + +const _splashId = 'splash'; +const _hiddenClass = 'splash--hidden'; +const _removeDelay = Duration(seconds: 1); + +/// In the browser the splash of `web/index.html` can be removed. +const bool hasSplashDom = true; + +/// How long the page has been loading. +Duration timeSincePageLoad() => Duration(microseconds: (_performance.now() * 1000).round()); + +/// Fades the splash out and removes it from the page. +void hideSplashDom() { + final splash = _document.getElementById(_splashId); + if (splash == null) return; + + splash.classList.add(_hiddenClass); + // Interop members can not be torn off. + // ignore: unnecessary_lambdas + Timer(_removeDelay, () => splash.remove()); +} + +@JS('document') +external _Document get _document; + +@JS('performance') +external _Performance get _performance; + +extension type _Document._(JSObject _) implements JSObject { + external _Element? getElementById(String id); +} + +extension type _Performance._(JSObject _) implements JSObject { + external double now(); +} + +extension type _Element._(JSObject _) implements JSObject { + external _ClassList get classList; + external void remove(); +} + +extension type _ClassList._(JSObject _) implements JSObject { + external void add(String token); +} diff --git a/pubspec.yaml b/pubspec.yaml index d9c4b47..9554740 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: dcc_toolkit description: "Internal toolkit package used by the DCC team." -version: 0.1.1 +version: 0.1.2 homepage: https://dutchcodingcompany.com repository: https://github.com/DutchCodingCompany/dcc_toolkit diff --git a/skills/dcc-toolkit-create-showcase/SKILL.md b/skills/dcc-toolkit-create-showcase/SKILL.md index a39a299..20b6b8a 100644 --- a/skills/dcc-toolkit-create-showcase/SKILL.md +++ b/skills/dcc-toolkit-create-showcase/SKILL.md @@ -35,7 +35,7 @@ skeleton). ## Prerequisites -- Flutter >=3.47.0, Dart ^3.13.0, `dcc_toolkit` >=0.1.1, the app migrated to `material_ui` +- Flutter >=3.47.0, Dart ^3.13.0, `dcc_toolkit` >=0.1.2, the app migrated to `material_ui` (see `doc/migrate_to_material_ui.md`). - The app's screens are split into a **page** (cubit, DI, routing) and a **view** (plain widget with data and callbacks). The showcase only uses views. A screen without a separate view has to be split first. @@ -54,7 +54,8 @@ skeleton). | `FinishPrompt` | Wrap home in it. Once the demo is finished it offers to start over | | `context.showOutOfScope([feature])` | Shows a "not in this demo" notice | | `ShowcaseShell` | For `MaterialApp.builder`: adds `insets` and shows notices above everything | -| `ShowcaseConfig.fromUri` | Reads `?highlights=off` and `?insets=ios` from the url | +| `ShowcaseConfig.fromUri` | Reads `?highlights=off`, `?insets=ios` and `?splash=off` from the url | +| `removeShowcaseSplash` | Call after `runApp`: removes the `#splash` of `web/index.html` after the first frame and a `minimum` duration | | `ShowcaseStrings` | All texts of the kit, English by default | Behaviour worth knowing: @@ -262,10 +263,15 @@ Future main() async { GoogleFonts.config.allowRuntimeFetching = false; // fonts are bundled, the CSP blocks fetching await initializeDateFormatting(); - runApp(ShowcaseApp(config: ShowcaseConfig.fromUri(kIsWeb ? Uri.base : Uri()))); + final config = ShowcaseConfig.fromUri(kIsWeb ? Uri.base : Uri()); + runApp(ShowcaseApp(config: config)); + unawaited(removeShowcaseSplash(minimum: config.splashEnabled ? const Duration(seconds: 2) : Duration.zero)); } ``` +`removeShowcaseSplash` removes the splash of `web/index.html` (step 7) once the app is drawn and at least `minimum` +has passed since the page started loading. + Dutch texts for the kit: ```dart @@ -303,13 +309,25 @@ send data. Allow only the own origin: App showcase + +
``` +The `#splash` element is the splash of the app, shown while Flutter loads. Take the color and logo from the +`flutter_native_splash` config of the app (copy the logo to `web/splash/`), so it looks like the app on a phone. Keep +the `id` and the `splash--hidden` class; `removeShowcaseSplash` uses them. Keep the transition under a second. The CSP +blocks inline scripts, so do not add any JavaScript for it. + Dummy images go in `web/` (loaded same-origin) or `assets/`. ### Step 8: Test and build @@ -326,7 +344,7 @@ the highlights depend on the tab. ### Embedding on a website Embed the build in an iframe inside a phone frame. Use `?insets=ios` when the frame has no status bar of its own, -`?highlights=off` for a silent version. Build with `SHOWCASE_BASE_HREF=/showcase/ just build_showcase` when it is not +`?highlights=off` for a silent version, `?splash=off` when the website shows its own loader. Build with `SHOWCASE_BASE_HREF=/showcase/ just build_showcase` when it is not served from the root. ### Easter eggs and extra apps diff --git a/test/showcase/showcase_state_test.dart b/test/showcase/showcase_state_test.dart index 6404f3e..0a4b4a3 100644 --- a/test/showcase/showcase_state_test.dart +++ b/test/showcase/showcase_state_test.dart @@ -71,18 +71,26 @@ void main() { }); group(ShowcaseConfig, () { - test('shows highlights and has no insets by default', () { + test('shows highlights and the splash and has no insets by default', () { final config = ShowcaseConfig.fromUri(Uri.parse('https://example.com/')); expect(config.highlightsEnabled, isTrue); + expect(config.splashEnabled, isTrue); expect(config.insets.top, 0); }); - test('reads highlights=off and insets=ios from the url', () { - final config = ShowcaseConfig.fromUri(Uri.parse('https://example.com/?highlights=off&insets=ios')); + test('reads highlights=off, insets=ios and splash=off from the url', () { + final config = ShowcaseConfig.fromUri(Uri.parse('https://example.com/?highlights=off&insets=ios&splash=off')); expect(config.highlightsEnabled, isFalse); expect(config.insets, iosInsets); + expect(config.splashEnabled, isFalse); + }); + }); + + group('removeShowcaseSplash', () { + test('does nothing outside the browser', () async { + await expectLater(removeShowcaseSplash(), completes); }); }); } From fefc00ce59507d8d51ccdb9a2727b8a79f59cc95 Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Wed, 14 Oct 2026 14:58:44 +0200 Subject: [PATCH 2/3] :bookmark: bump to v0.1.2 --- example/pubspec.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index b8feb2f..ea63445 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -215,7 +215,7 @@ packages: path: ".." relative: true source: path - version: "0.1.1" + version: "0.1.2" equatable: dependency: transitive description: From c1ca6f5b5da3181d2364d3ccd97d9c8770f31065 Mon Sep 17 00:00:00 2001 From: Job Guldemeester Date: Wed, 14 Oct 2026 14:59:05 +0200 Subject: [PATCH 3/3] :memo: Add showcase docs --- doc/showcase.md | 107 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 doc/showcase.md diff --git a/doc/showcase.md b/doc/showcase.md new file mode 100644 index 0000000..48cc8a8 --- /dev/null +++ b/doc/showcase.md @@ -0,0 +1,107 @@ +# Showcase + +Building blocks for a clickable demo of an app, made from its real screens. The demo runs as a Flutter web app, +usually embedded on a website, fed with fixed dummy data instead of a backend. + +```dart +import 'package:dcc_toolkit/showcase.dart'; +``` + +It is a separate entrypoint, so apps that do not have a showcase do not see any of it. + +## Setup + +```dart +void main() => runApp(ShowcaseApp(ShowcaseConfig.fromUri(kIsWeb ? Uri.base : Uri()))); + +class _ShowcaseAppState extends State { + late final DemoState _demo = DemoState(highlightsEnabled: widget.config.highlightsEnabled); + + @override + Widget build(BuildContext context) { + return ShowcaseScope( + state: _demo, + child: MaterialApp( + home: ShowcaseNavigator( + home: DemoPage.home, + builder: (context, page, args) => switch (page) { + DemoPage.home => const HomePage(), + DemoPage.detail => DetailPage(id: args! as String), + }, + highlights: (context, page, state) => highlightsFor(page, state as DemoState), + ), + builder: (context, child) => ShowcaseShell(insets: widget.config.insets, child: child!), + ), + ); + } +} +``` + +- **`ShowcaseState`** holds what the demo remembers. Extend it with the data of your app and put that data back + at the start in `resetData`. Read it with `ShowcaseScope.of(context)`. +- **`ShowcaseNavigator`** navigates with the plain Flutter `Navigator`, so the url of the browser never changes. + The pages are the values of an enum, opened with `context.showcase.push(DemoPage.detail, args: id)`. +- **`ShowcaseShell`** adds the safe areas that the config asks for and shows notices above everything else. +- **`ShowcaseStrings`** holds the texts of the kit. The defaults are English, pass your own to `ShowcaseScope`. + Pass `noticeBuilder` to make notices look like the snackbars of the app. + +## Highlights + +A `Highlight` explains part of a screen with a pulsing ring around the target and a small card, without dimming +the app. The real views do not change for the showcase, so the target is found in the widget tree: + +```dart +Highlight( + title: 'Your balance', + body: 'Grows with every scan.', + target: Highlight.type(), +) +``` + +A page explains itself once. After that, the explanation waits behind a small info button. + +## The story + +- Call `begin()` on the state when a visitor starts a flow, and `finish()` at the end of the story. +- Wrap home in `FinishPrompt`: back on home it offers to start over. When the visitor keeps looking around, the demo + starts over by itself at the next `begin()`. +- Call `context.showOutOfScope('Payout')` for a part of the app that is not in the demo. + +## Splash + +Like the native splash on a phone, the demo shows a splash while it loads. Put it in `web/index.html` as an +element with id `splash`, with the color and logo of the app, so it is there before Flutter has loaded: + +```html + +... + +
+ + +``` + +Remove it after `runApp`: + +```dart +runApp(ShowcaseApp(config: config)); +unawaited(removeShowcaseSplash(minimum: config.splashEnabled ? const Duration(seconds: 2) : Duration.zero)); +``` + +The splash stays until the app is drawn and at least `minimum` has passed since the page started loading, then fades +out with the class `splash--hidden`. Keep the transition under a second, the element is removed after that. + +## Url parameters + +`ShowcaseConfig.fromUri` lets the website configure the demo: + +| Parameter | Effect | +|------------------|-----------------------------------------------------------------------| +| `highlights=off` | Hides the explanations. | +| `insets=ios` | Adds the safe areas of an iPhone, for embedding without a status bar. | +| `splash=off` | Removes the splash as soon as the app is drawn. |