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
57 changes: 51 additions & 6 deletions apps/playground/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const String _defaultWorkspaceId = String.fromEnvironment('WORKSPACE_ID');
const String kWelcomeMessage = 'Welcome to Formbricks';
const String _statusConnected = 'connected ✓';

/// The app's own theme mode, so the demo can show `FormbricksAppearance.system`
/// following it rather than the phone.
final ValueNotifier<ThemeMode> appThemeMode = ValueNotifier(ThemeMode.light);

void main() {
runApp(const PlaygroundApp());
}
Expand All @@ -16,13 +20,24 @@ class PlaygroundApp extends StatelessWidget {

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Formbricks Flutter Playground',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
return ValueListenableBuilder<ThemeMode>(
valueListenable: appThemeMode,
builder: (context, mode, _) => MaterialApp(
title: 'Formbricks Flutter Playground',
themeMode: mode,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const PlaygroundHome(),
),
home: const PlaygroundHome(),
);
}
}
Expand Down Expand Up @@ -307,6 +322,36 @@ class _PlaygroundHomeState extends State<PlaygroundHome> {
],
const Divider(height: 40),

Text('Survey appearance', style: textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final appearance in FormbricksAppearance.values)
OutlinedButton(
onPressed: () => Formbricks.setAppearance(appearance),
child: Text(appearance.name),
),
],
),
const SizedBox(height: 8),
Text('App theme', style: textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton(
onPressed: () => appThemeMode.value = ThemeMode.light,
child: const Text('App light'),
),
OutlinedButton(
onPressed: () => appThemeMode.value = ThemeMode.dark,
child: const Text('App dark'),
),
],
),
const Divider(height: 40),

Text('Track a code action', style: textTheme.titleSmall),
const SizedBox(height: 8),
TextField(
Expand Down
3 changes: 3 additions & 0 deletions apps/playground/test/widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ void main() {
) async {
await tester.pumpWidget(const PlaygroundApp());

// The appearance buttons push the trigger below the test viewport.
await tester.ensureVisible(find.text('Trigger Code Action'));
await tester.pumpAndSettle();
await tester.tap(find.text('Trigger Code Action'));
await tester.pumpAndSettle();

Expand Down
17 changes: 17 additions & 0 deletions packages/formbricks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,30 @@ Each returns a `Future<Result<void, FormbricksError>>`.
| `Formbricks.setAttribute(String key, Object value)` | Set one attribute (`String` / `num` / `DateTime`). |
| `Formbricks.setAttributes(Map<String, Object>)` | Set several attributes at once. |
| `Formbricks.setLanguage(String language)` | Set the survey language. |
| `Formbricks.setAppearance(FormbricksAppearance)` | Surveys render `light` (default), `dark`, or `system`. See Dark mode. |
| `Formbricks.logout()` | Reset to an anonymous session. |

Attribute keys must be lowercase letters, numbers, and underscores, and start
with a letter. `DateTime` values are sent as UTC ISO-8601 strings; numbers stay
numbers. Identity/attribute changes are debounced (~500 ms) and coalesced into a
single backend request.

## Dark mode

Surveys render light by default:

```dart
Formbricks.setAppearance(FormbricksAppearance.dark); // light, dark or system
```

- Works before or after `setup`, or pass `appearance:` to the `Formbricks` widget. A string (`'dark'`) is accepted too.
- An open survey switches in place; the typed answer and current question stay.
- `system` follows **your app's** theme (its `Theme` / `ThemeMode`, else the Cupertino theme), not the phone's, and updates live.
- Kept across `logout()`, forgotten on app restart, never sent to the server. An unknown value is logged and falls back to light.
- Needs a Formbricks server that supports dark mode; an older server keeps surveys light.

Custom CSS configured in Formbricks needs no SDK call; it arrives with the workspace state.

## Identification & targeting

- **Anonymous** sessions can be shown any survey that isn't gated behind a
Expand Down
1 change: 1 addition & 0 deletions packages/formbricks/lib/formbricks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
/// queue, config, API client and expiry ticker live behind the facade.
library;

export 'src/common/appearance.dart' show FormbricksAppearance;
export 'src/common/logger.dart' show LogLevel;
export 'src/common/result.dart';
export 'src/types/errors.dart';
Expand Down
148 changes: 148 additions & 0 deletions packages/formbricks/lib/src/common/appearance.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/// How surveys render, and the in-memory state behind `Formbricks.setAppearance`.
library;

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart' show Theme;

import 'logger.dart';

/// How surveys render. [system] follows the host app's own theme — its
/// `Theme` / `ThemeMode` — not the phone's.
enum FormbricksAppearance {
/// Always light (the default).
light,

/// Always dark.
dark,

/// Follows the app's theme, live.
system;

/// Parses `'light'`, `'dark'` or `'system'`; null for anything else.
static FormbricksAppearance? tryParse(Object? value) {
if (value is FormbricksAppearance) return value;
if (value is! String) return null;
for (final appearance in values) {
if (appearance.name == value.trim().toLowerCase()) return appearance;
}
return null;
}
}

/// The in-memory appearance state (ENG-3452).
///
/// Never persisted, never sent to the server, and left alone by `logout()`, so a
/// fresh app launch starts light (ENG-3551). Not routed through the command
/// queue, so it works before `setup()` completes.
class AppearanceState extends ChangeNotifier {
AppearanceState._();

/// The process-wide state.
static final AppearanceState instance = AppearanceState._();

FormbricksAppearance _current = FormbricksAppearance.light;

/// The requested appearance. May be [FormbricksAppearance.system].
FormbricksAppearance get current => _current;

/// Sets the appearance. An unknown value is logged and falls back to light.
void set(Object? value) {
final parsed = FormbricksAppearance.tryParse(value);
if (parsed == null) {
Logger.error(
'setAppearance: unknown appearance "$value", falling back to light',
);
}
_current = parsed ?? FormbricksAppearance.light;
notifyListeners();
}

/// Test-only: back to the cold-start state.
void reset() {
_current = FormbricksAppearance.light;
}
}

/// What the renderer understands: always `'light'` or `'dark'`, never `'system'`.
///
/// `system` reads the theme of [context], which is the app's own, and only falls
/// back to the platform brightness when the app sets none.
String resolveAppearance(BuildContext context, FormbricksAppearance requested) {
switch (requested) {
case FormbricksAppearance.light:
return 'light';
case FormbricksAppearance.dark:
return 'dark';
case FormbricksAppearance.system:
// A Material app (or any `Theme`) states its brightness there; a
// `Theme.of` with no ancestor would quietly answer light, so look first.
// Otherwise the Cupertino theme, then the platform as a last resort.
final brightness = context.findAncestorWidgetOfExactType<Theme>() != null
? Theme.of(context).brightness
: CupertinoTheme.maybeBrightnessOf(context) ??
MediaQuery.maybePlatformBrightnessOf(context) ??
Brightness.light;
return brightness == Brightness.dark ? 'dark' : 'light';
}
}

/// JavaScript that flips an open survey in place. Optional chaining: a server
/// whose renderer predates `setAppearance` leaves the survey light instead of
/// throwing.
String appearanceSwitchScript(String resolved) =>
"window.formbricksSurveys?.setAppearance?.('$resolved');";

/// Builds the renderer's `customCss` prop from the workspace and survey CSS.
///
/// The compiled strings pass through untouched. Empty fields are omitted rather
/// than sent as null — the renderer rejects the whole prop on a null inside a
/// scope — and null is returned when there is no CSS at all, so the key is never
/// sent.
Map<String, Map<String, String>>? buildCustomCss(
Object? workspace,
Object? survey,
) {
Map<String, String>? scope(Object? raw) {
if (raw is! Map) return null;
final result = <String, String>{};
for (final mode in const ['light', 'dark']) {
final css = raw[mode];
if (css is String && css.isNotEmpty) result[mode] = css;
}
return result.isEmpty ? null : result;
}

final workspaceScope = scope(workspace);
final surveyScope = scope(survey);
if (workspaceScope == null && surveyScope == null) return null;
return {
if (workspaceScope != null) 'workspace': workspaceScope,
if (surveyScope != null) 'survey': surveyScope,
};
}

/// Hands the open survey's resolved appearance to the WebView host.
///
/// An [InheritedNotifier] rather than a new parameter on the host builder, so
/// injected test hosts keep compiling. The default host listens and runs
/// [appearanceSwitchScript] when it changes.
class AppearanceScope extends InheritedNotifier<ValueNotifier<String>> {
/// Creates a scope around [child].
const AppearanceScope({
super.key,
required ValueNotifier<String> appearance,
this.initial,
required super.child,
}) : super(notifier: appearance);

/// The value baked into the survey page when it was built.
final String? initial;

/// The nearest resolved-appearance notifier, if any.
static ValueNotifier<String>? maybeOf(BuildContext context) =>
context.getInheritedWidgetOfExactType<AppearanceScope>()?.notifier;

/// The value the page was built with, if any.
static String? initialOf(BuildContext context) =>
context.getInheritedWidgetOfExactType<AppearanceScope>()?.initial;
}
3 changes: 3 additions & 0 deletions packages/formbricks/lib/src/types/survey.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ class TSurvey {
/// Whether the survey is available in more than one language.
bool get isMultiLanguage => languages.length > 1;

/// The survey's compiled custom CSS (`{light?, dark?}`), when it has any.
Object? get customCss => _raw['customCss'];

/// The original decoded JSON, returned verbatim for the survey runtime.
Map<String, dynamic> toJson() => _raw;
}
Expand Down
36 changes: 36 additions & 0 deletions packages/formbricks/lib/src/widgets/default_webview_host.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:flutter/widgets.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';

import '../common/appearance.dart';
import '../common/logger.dart';
import 'webview_event.dart';
import 'webview_navigation.dart';
Expand Down Expand Up @@ -73,6 +74,36 @@ class _DefaultWebViewHost extends StatefulWidget {

class _DefaultWebViewHostState extends State<_DefaultWebViewHost> {
late final WebViewController _controller;
ValueNotifier<String>? _appearance;
String? _appliedAppearance;
// No `formbricksSurveys.setAppearance` exists until the survey has rendered.
bool _surveyRendered = false;

@override
void didChangeDependencies() {
super.didChangeDependencies();
final next = AppearanceScope.maybeOf(context);
if (identical(next, _appearance)) return;
_appearance?.removeListener(_onAppearanceChanged);
_appearance = next;
// The page already opened with this value; only a change is sent.
_appliedAppearance = AppearanceScope.initialOf(context) ?? next?.value;
next?.addListener(_onAppearanceChanged);
}

void _onAppearanceChanged() {
if (!_surveyRendered) return; // hold until the renderer exists
final resolved = _appearance?.value;
if (resolved == null || resolved == _appliedAppearance) return;
_appliedAppearance = resolved;
Comment thread
Dhruwang marked this conversation as resolved.
unawaited(_controller.runJavaScript(appearanceSwitchScript(resolved)));
}

@override
void dispose() {
_appearance?.removeListener(_onAppearanceChanged);
super.dispose();
}

@override
void initState() {
Expand All @@ -99,6 +130,11 @@ class _DefaultWebViewHostState extends State<_DefaultWebViewHost> {
'Formbricks',
onMessageReceived: (JavaScriptMessage message) {
for (final event in parseWebViewEvents(message.message)) {
if (event is SurveyRenderedEvent) {
_surveyRendered = true;
_onAppearanceChanged(); // sends only if it changed while loading
continue;
}
widget.onEvent(event);
}
},
Expand Down
Loading
Loading