Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
107 changes: 107 additions & 0 deletions doc/showcase.md
Original file line number Diff line number Diff line change
@@ -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<ShowcaseApp> {
late final DemoState _demo = DemoState(highlightsEnabled: widget.config.highlightsEnabled);

@override
Widget build(BuildContext context) {
return ShowcaseScope(
state: _demo,
child: MaterialApp(
home: ShowcaseNavigator<DemoPage>(
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<DemoState>(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<BalanceWidget>(),
)
```

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
<style>
#splash { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;
background: #FFFFFF; transition: opacity 300ms; }
#splash img { width: 192px; }
#splash.splash--hidden { opacity: 0; pointer-events: none; }
</style>
...
<body>
<div id="splash"><img src="splash/logo.png" alt=""></div>
<script src="flutter_bootstrap.js" async></script>
</body>
```

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. |
2 changes: 1 addition & 1 deletion example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.1.0"
version: "0.1.2"
equatable:
dependency: transitive
description:
Expand Down
1 change: 1 addition & 0 deletions lib/showcase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
11 changes: 9 additions & 2 deletions lib/showcase/showcase_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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',
);
}
}
20 changes: 20 additions & 0 deletions lib/showcase/showcase_splash.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<void>.delayed(remaining);

hideSplashDom();
}
8 changes: 8 additions & 0 deletions lib/showcase/splash_dom.dart
Original file line number Diff line number Diff line change
@@ -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() {}
46 changes: 46 additions & 0 deletions lib/showcase/splash_dom_web.dart
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
26 changes: 22 additions & 4 deletions skills/dcc-toolkit-create-showcase/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -262,10 +263,15 @@ Future<void> 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
Expand Down Expand Up @@ -303,13 +309,25 @@ send data. Allow only the own origin:
<meta charset="UTF-8">
<link rel="icon" type="image/png" href="favicon.png"/>
<title>App showcase</title>
<style>
#splash { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;
background: #FFFFFF; transition: opacity 300ms; }
#splash img { width: 192px; }
#splash.splash--hidden { opacity: 0; pointer-events: none; }
</style>
</head>
<body>
<div id="splash"><img src="splash/logo.png" alt=""></div>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
```

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
Expand All @@ -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
Expand Down
14 changes: 11 additions & 3 deletions test/showcase/showcase_state_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
Loading