diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart index 19a79c3..61f4075 100644 --- a/apps/playground/lib/main.dart +++ b/apps/playground/lib/main.dart @@ -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 appThemeMode = ValueNotifier(ThemeMode.light); + void main() { runApp(const PlaygroundApp()); } @@ -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( + 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(), ); } } @@ -307,6 +322,36 @@ class _PlaygroundHomeState extends State { ], 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( diff --git a/apps/playground/test/widget_test.dart b/apps/playground/test/widget_test.dart index 0d9d943..631f99b 100644 --- a/apps/playground/test/widget_test.dart +++ b/apps/playground/test/widget_test.dart @@ -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(); diff --git a/packages/formbricks/README.md b/packages/formbricks/README.md index d74dd9f..3ed5cb2 100644 --- a/packages/formbricks/README.md +++ b/packages/formbricks/README.md @@ -82,6 +82,7 @@ Each returns a `Future>`. | `Formbricks.setAttribute(String key, Object value)` | Set one attribute (`String` / `num` / `DateTime`). | | `Formbricks.setAttributes(Map)` | 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 @@ -89,6 +90,22 @@ 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 diff --git a/packages/formbricks/lib/formbricks.dart b/packages/formbricks/lib/formbricks.dart index 7210c64..4fff18e 100644 --- a/packages/formbricks/lib/formbricks.dart +++ b/packages/formbricks/lib/formbricks.dart @@ -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'; diff --git a/packages/formbricks/lib/src/common/appearance.dart b/packages/formbricks/lib/src/common/appearance.dart new file mode 100644 index 0000000..19f02dd --- /dev/null +++ b/packages/formbricks/lib/src/common/appearance.dart @@ -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() != 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>? buildCustomCss( + Object? workspace, + Object? survey, +) { + Map? scope(Object? raw) { + if (raw is! Map) return null; + final result = {}; + 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> { + /// Creates a scope around [child]. + const AppearanceScope({ + super.key, + required ValueNotifier 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? maybeOf(BuildContext context) => + context.getInheritedWidgetOfExactType()?.notifier; + + /// The value the page was built with, if any. + static String? initialOf(BuildContext context) => + context.getInheritedWidgetOfExactType()?.initial; +} diff --git a/packages/formbricks/lib/src/types/survey.dart b/packages/formbricks/lib/src/types/survey.dart index 432e4f2..258df62 100644 --- a/packages/formbricks/lib/src/types/survey.dart +++ b/packages/formbricks/lib/src/types/survey.dart @@ -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 toJson() => _raw; } diff --git a/packages/formbricks/lib/src/widgets/default_webview_host.dart b/packages/formbricks/lib/src/widgets/default_webview_host.dart index 728cc1c..b053b6e 100644 --- a/packages/formbricks/lib/src/widgets/default_webview_host.dart +++ b/packages/formbricks/lib/src/widgets/default_webview_host.dart @@ -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'; @@ -73,6 +74,36 @@ class _DefaultWebViewHost extends StatefulWidget { class _DefaultWebViewHostState extends State<_DefaultWebViewHost> { late final WebViewController _controller; + ValueNotifier? _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; + unawaited(_controller.runJavaScript(appearanceSwitchScript(resolved))); + } + + @override + void dispose() { + _appearance?.removeListener(_onAppearanceChanged); + super.dispose(); + } @override void initState() { @@ -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); } }, diff --git a/packages/formbricks/lib/src/widgets/formbricks_widget.dart b/packages/formbricks/lib/src/widgets/formbricks_widget.dart index c8825d6..bcf298a 100644 --- a/packages/formbricks/lib/src/widgets/formbricks_widget.dart +++ b/packages/formbricks/lib/src/widgets/formbricks_widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/widgets.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; +import '../common/appearance.dart'; import '../common/command_queue.dart'; import '../common/config.dart'; import '../common/logger.dart'; @@ -53,6 +54,7 @@ class Formbricks extends StatefulWidget { required this.appUrl, required this.workspaceId, this.logLevel, + this.appearance, @visibleForTesting this.webViewHostBuilder, @visibleForTesting this.launch, @visibleForTesting this.httpClient, @@ -68,6 +70,10 @@ class Formbricks extends StatefulWidget { /// Optional log level (defaults from build mode in `setup`). final LogLevel? logLevel; + /// How surveys render, applied before anything else. Null leaves whatever + /// [Formbricks.setAppearance] already set. + final FormbricksAppearance? appearance; + /// WebView host builder override. final WebViewHostBuilder? webViewHostBuilder; @@ -109,6 +115,23 @@ class Formbricks extends StatefulWidget { ); } + /// Sets how surveys render: [FormbricksAppearance.light] (default), + /// [FormbricksAppearance.dark], or [FormbricksAppearance.system] to follow the + /// app's own theme (its `Theme` / `ThemeMode`), not the phone's. + /// + /// Synchronous and not routed through the command queue, so it works before + /// `setup` completes. An open survey switches in place and keeps its answers. + /// Kept across [logout], forgotten when the app restarts, and never sent to the + /// server. A string (`'dark'`) is accepted too; an unknown value is logged and + /// falls back to light. + /// + /// ```dart + /// Formbricks.setAppearance(FormbricksAppearance.dark); + /// ``` + static void setAppearance(Object appearance) { + AppearanceState.instance.set(appearance); + } + /// Tracks a code action through the command queue (`checkSetup: true`). /// /// Returns `Ok` when the action matched (or matched nothing), an @@ -261,6 +284,9 @@ class _FormbricksState extends State { @override void initState() { super.initState(); + // Local state, not part of setup, and in place before the first survey renders. + final appearance = widget.appearance; + if (appearance != null) AppearanceState.instance.set(appearance); unawaited(_setup()); } diff --git a/packages/formbricks/lib/src/widgets/survey_html.dart b/packages/formbricks/lib/src/widgets/survey_html.dart index 19975d9..3c2e859 100644 --- a/packages/formbricks/lib/src/widgets/survey_html.dart +++ b/packages/formbricks/lib/src/widgets/survey_html.dart @@ -29,6 +29,8 @@ class SurveyHtmlOptions { this.clickOutside, this.overlay, this.hiddenFieldsRecord = const {}, + this.appearance = 'light', + this.customCss, }); /// The survey to render. @@ -68,6 +70,14 @@ class SurveyHtmlOptions { /// re-runs all of it on ingest. final Map hiddenFieldsRecord; + /// What the survey opens with: `'light'` or `'dark'`. It is part of the page, + /// so a later change goes through `runJavaScript` instead of loading again, + /// which would lose the respondent's answers. + final String appearance; + + /// The compiled `{workspace?, survey?}` custom CSS, or null to omit the key. + final Map>? customCss; + /// The `renderSurvey` options object. Map toRenderOptions() => { 'workspaceId': workspaceId, @@ -82,6 +92,8 @@ class SurveyHtmlOptions { if (overlay != null) 'overlay': overlay, 'isWebEnvironment': false, 'hiddenFieldsRecord': hiddenFieldsRecord, + 'appearance': appearance, + if (customCss != null) 'customCss': customCss, }; } @@ -197,6 +209,7 @@ String buildSurveyHtml(SurveyHtmlOptions options) { return; } runtime.renderSurvey(surveyProps); + postFormbricksMessage({ onSurveyRendered: true }); } catch (error) { closeOnError('Failed to render Formbricks survey:', error); } diff --git a/packages/formbricks/lib/src/widgets/survey_webview.dart b/packages/formbricks/lib/src/widgets/survey_webview.dart index c51bdfa..420bbf5 100644 --- a/packages/formbricks/lib/src/widgets/survey_webview.dart +++ b/packages/formbricks/lib/src/widgets/survey_webview.dart @@ -12,6 +12,7 @@ import 'package:clock/clock.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import '../common/appearance.dart'; import '../common/config.dart'; import '../common/filter_surveys.dart'; import '../common/logger.dart'; @@ -81,6 +82,11 @@ class _SurveyWebViewState extends State { final ValueNotifier _touchRegion = ValueNotifier(SurveyTouchRegion.everything); + // The resolved appearance of the open survey. The default host watches it. + final ValueNotifier _appearance = ValueNotifier('light'); + // The value [_start] baked into the page; the host diffs changes against it. + String? _frozenAppearance; + // Serializes config read-modify-writes so back-to-back events (e.g. response // then close) can't clobber each other. Future _configOps = Future.value(); @@ -94,10 +100,28 @@ class _SurveyWebViewState extends State { @override void initState() { super.initState(); + AppearanceState.instance.addListener(_syncAppearance); // All store mutations / route pushes happen post-frame, never during build. WidgetsBinding.instance.addPostFrameCallback((_) => _start()); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // The app's theme changed (`system` follows it); reading the theme in + // [resolveAppearance] subscribed this State to it. Notifying the scope here + // would mark it dirty during build, so it waits for the frame to finish. + if (_phase == _SurveyPhase.presenting) { + WidgetsBinding.instance.addPostFrameCallback((_) => _syncAppearance()); + } + } + + void _syncAppearance() { + if (!mounted || _phase != _SurveyPhase.presenting) return; + _appearance.value = + resolveAppearance(context, AppearanceState.instance.current); + } + void _start() { if (!mounted) return; final current = _config.getOrNull(); @@ -156,6 +180,11 @@ class _SurveyWebViewState extends State { // A backdrop ("dark"/"light") is a real modal: it should block the host. No // overlay (or "none") is a corner/inline card and must be box-none. _hasOverlay = overlay != null && overlay != 'none'; + // Frozen into the page here; later changes reach the open survey through + // [_appearance] and the host's `runJavaScript`. + _appearance.value = + resolveAppearance(context, AppearanceState.instance.current); + _frozenAppearance = _appearance.value; final html = buildSurveyHtml( SurveyHtmlOptions( survey: widget.survey, @@ -174,6 +203,9 @@ class _SurveyWebViewState extends State { // handed to the WebView once. A value set after this point reaches the // next response, never the one on screen. hiddenFieldsRecord: EmbeddedDataStore.instance.snapshot(), + appearance: _appearance.value, + customCss: + buildCustomCss(settings['customCss'], widget.survey.customCss), ), ); @@ -235,37 +267,45 @@ class _SurveyWebViewState extends State { ) { // Builder so the keyboard inset is read in a context that rebuilds on // keyboard show/hide. - return Builder( - builder: (ctx) { - final webView = Padding( - padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(ctx).bottom), - child: builder( - ctx, - html: html, - appUrl: appUrl, - onEvent: _onEvent, - launch: widget.launch, - onLoadError: _handleWebViewLoadError, - ), - ); - // Backdrop placements fill and block the screen. Non-overlay placements - // only accept pointers within the reported card rect; the WebView paints - // full-bleed (so shadows show) but rejects hits elsewhere. Passing the - // WebView as `child` keeps its controller alive across geometry updates. - if (_hasOverlay) return webView; - return ValueListenableBuilder( - valueListenable: _touchRegion, - builder: (_, region, child) => - _PointerMask(region: region, child: child!), - child: webView, - ); - }, + return AppearanceScope( + appearance: _appearance, + initial: _frozenAppearance, + child: Builder( + builder: (ctx) { + final webView = Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(ctx).bottom), + child: builder( + ctx, + html: html, + appUrl: appUrl, + onEvent: _onEvent, + launch: widget.launch, + onLoadError: _handleWebViewLoadError, + ), + ); + // Backdrop placements fill and block the screen. Non-overlay placements + // only accept pointers within the reported card rect; the WebView paints + // full-bleed (so shadows show) but rejects hits elsewhere. Passing the + // WebView as `child` keeps its controller alive across geometry updates. + if (_hasOverlay) return webView; + return ValueListenableBuilder( + valueListenable: _touchRegion, + builder: (_, region, child) => + _PointerMask(region: region, child: child!), + child: webView, + ); + }, + ), ); } void _onEvent(WebViewEvent event) { if (!mounted) return; switch (event) { + case SurveyRenderedEvent(): + // Consumed by the WebView host, which flushes any held appearance change. + break; case DisplayCreatedEvent(): _enqueueConfigOp(_recordDisplay); _refreshSegmentsOnce(InteractionSource.onDisplay); @@ -390,6 +430,8 @@ class _SurveyWebViewState extends State { @override void dispose() { _delayTimer?.cancel(); + AppearanceState.instance.removeListener(_syncAppearance); + _appearance.dispose(); // External store reset can unmount us without a close; tear down the active // presentation via captured handles (no live BuildContext required). The // guards make removal idempotent if it was already dismissed. diff --git a/packages/formbricks/lib/src/widgets/webview_event.dart b/packages/formbricks/lib/src/widgets/webview_event.dart index aaf7750..c45cc0e 100644 --- a/packages/formbricks/lib/src/widgets/webview_event.dart +++ b/packages/formbricks/lib/src/widgets/webview_event.dart @@ -24,6 +24,12 @@ final class DisplayCreatedEvent extends WebViewEvent { const DisplayCreatedEvent(); } +/// `renderSurvey` returned, so `formbricksSurveys.setAppearance` exists. +final class SurveyRenderedEvent extends WebViewEvent { + /// Creates a survey-rendered event. + const SurveyRenderedEvent(); +} + /// The runtime recorded a response. final class ResponseCreatedEvent extends WebViewEvent { /// Creates a response-created event. @@ -101,6 +107,7 @@ List parseWebViewEvents(String raw) { final events = []; if (map['onDisplayCreated'] == true) events.add(const DisplayCreatedEvent()); + if (map['onSurveyRendered'] == true) events.add(const SurveyRenderedEvent()); if (map['onResponseCreated'] == true) { events.add(const ResponseCreatedEvent()); } @@ -164,6 +171,7 @@ bool _hasValidShape(Map map) { 'onResponseCreated', 'onOpenExternalURL', 'onFinished', + 'onSurveyRendered', ]; for (final key in boolFlags) { final value = map[key]; diff --git a/packages/formbricks/test/common/appearance_test.dart b/packages/formbricks/test/common/appearance_test.dart new file mode 100644 index 0000000..c8cfd37 --- /dev/null +++ b/packages/formbricks/test/common/appearance_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks/formbricks.dart'; +import 'package:formbricks/src/common/appearance.dart'; + +void main() { + setUp(AppearanceState.instance.reset); + tearDown(AppearanceState.instance.reset); + + group('AppearanceState', () { + test('defaults to light', () { + expect(AppearanceState.instance.current, FormbricksAppearance.light); + }); + + test('set before setup, from the enum or a string', () { + Formbricks.setAppearance(FormbricksAppearance.dark); + expect(AppearanceState.instance.current, FormbricksAppearance.dark); + Formbricks.setAppearance('system'); + expect(AppearanceState.instance.current, FormbricksAppearance.system); + }); + + test('an unknown value falls back to light without throwing', () { + Formbricks.setAppearance('dark'); + expect(() => Formbricks.setAppearance('sepia'), returnsNormally); + expect(AppearanceState.instance.current, FormbricksAppearance.light); + }); + + test('notifies listeners on every change', () { + var calls = 0; + void listener() => calls++; + AppearanceState.instance.addListener(listener); + Formbricks.setAppearance(FormbricksAppearance.dark); + expect(calls, 1); + AppearanceState.instance.removeListener(listener); + Formbricks.setAppearance(FormbricksAppearance.light); + expect(calls, 1); + }); + }); + + group('resolveAppearance', () { + Future resolveIn( + WidgetTester tester, + FormbricksAppearance requested, { + Widget Function(Widget child)? wrap, + Brightness platform = Brightness.light, + }) async { + late String result; + Widget child = Builder( + builder: (context) { + result = resolveAppearance(context, requested); + return const SizedBox.shrink(); + }, + ); + if (wrap != null) child = wrap(child); + tester.platformDispatcher.platformBrightnessTestValue = platform; + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + await tester.pumpWidget(child); + return result; + } + + testWidgets('light and dark win over the app theme', (tester) async { + Widget dark(Widget c) => MaterialApp(theme: ThemeData.dark(), home: c); + expect( + await resolveIn(tester, FormbricksAppearance.light, wrap: dark), + 'light', + ); + expect( + await resolveIn(tester, FormbricksAppearance.dark), + 'dark', + ); + }); + + testWidgets('system follows a dark app theme on a light phone', + (tester) async { + Widget darkApp(Widget c) => MaterialApp(theme: ThemeData.dark(), home: c); + expect( + await resolveIn( + tester, + FormbricksAppearance.system, + wrap: darkApp, + platform: Brightness.light, + ), + 'dark', + ); + }); + + testWidgets('system follows an app pinned to light on a dark phone', + (tester) async { + Widget lightApp(Widget c) => MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.light, + home: c, + ); + expect( + await resolveIn( + tester, + FormbricksAppearance.system, + wrap: lightApp, + platform: Brightness.dark, + ), + 'light', + ); + }); + + testWidgets( + 'system falls back to the platform brightness with no app theme', + (tester) async { + Widget bare(Widget c) => MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: c, + ); + expect( + await resolveIn(tester, FormbricksAppearance.system, wrap: bare), + 'dark', + ); + }); + + testWidgets('system follows a Cupertino app theme', (tester) async { + Widget cupertino(Widget c) => CupertinoApp( + theme: const CupertinoThemeData(brightness: Brightness.dark), + home: c, + ); + expect( + await resolveIn(tester, FormbricksAppearance.system, wrap: cupertino), + 'dark', + ); + }); + }); + + test('appearanceSwitchScript tolerates an older renderer', () { + expect( + appearanceSwitchScript('dark'), + "window.formbricksSurveys?.setAppearance?.('dark');", + ); + }); + + group('buildCustomCss', () { + test('sends no key when neither scope has CSS', () { + expect(buildCustomCss(null, null), isNull); + expect(buildCustomCss({}, {'light': ''}), isNull); + }); + + test('forwards workspace and survey CSS untouched', () { + expect( + buildCustomCss( + {'light': '.a{color:red}', 'dark': '.a{color:blue}'}, + {'dark': '.b{margin:0}'}, + ), + { + 'workspace': {'light': '.a{color:red}', 'dark': '.a{color:blue}'}, + 'survey': {'dark': '.b{margin:0}'}, + }, + ); + }); + + test('omits an empty scope and null fields instead of sending null', () { + final result = buildCustomCss({'light': '.a{}', 'dark': null}, null); + expect(result, { + 'workspace': {'light': '.a{}'}, + }); + expect(result!.containsKey('survey'), isFalse); + }); + }); +} diff --git a/packages/formbricks/test/widgets/formbricks_widget_test.dart b/packages/formbricks/test/widgets/formbricks_widget_test.dart index 33e55fd..ecda96a 100644 --- a/packages/formbricks/test/widgets/formbricks_widget_test.dart +++ b/packages/formbricks/test/widgets/formbricks_widget_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks/src/common/appearance.dart'; import 'package:formbricks/src/common/config.dart'; import 'package:formbricks/src/common/logger.dart'; import 'package:formbricks/src/common/setup.dart'; @@ -146,4 +147,26 @@ void main() { expect(envFetches, 1); }); + + testWidgets('applies the appearance prop before setup runs', (tester) async { + AppearanceState.instance.reset(); + addTearDown(AppearanceState.instance.reset); + final mock = MockClient((req) async => http.Response(_envBody(), 200)); + + await tester.pumpWidget( + MaterialApp( + home: Formbricks( + appUrl: 'https://app.formbricks.com', + workspaceId: 'wsp_1', + appearance: FormbricksAppearance.dark, + httpClient: mock, + startTicker: false, + webViewHostBuilder: _StubHost().build, + ), + ), + ); + + expect(AppearanceState.instance.current, FormbricksAppearance.dark); + await tester.pumpAndSettle(); + }); } diff --git a/packages/formbricks/test/widgets/survey_html_test.dart b/packages/formbricks/test/widgets/survey_html_test.dart index 5c53d26..ed3639e 100644 --- a/packages/formbricks/test/widgets/survey_html_test.dart +++ b/packages/formbricks/test/widgets/survey_html_test.dart @@ -110,6 +110,16 @@ void main() { ); }); + test('reports the render once renderSurvey has returned', () { + final html = buildSurveyHtml(_opts()); + final render = html.indexOf('runtime.renderSurvey(surveyProps);'); + final rendered = + html.indexOf('postFormbricksMessage({ onSurveyRendered: true })'); + + expect(render, isNonNegative); + expect(rendered, greaterThan(render)); + }); + test('console bridge forwards multiple log arguments', () { final html = buildSurveyHtml(_opts()); @@ -202,4 +212,48 @@ void main() { ); }); }); + + group('appearance and customCss', () { + String optionsJson(SurveyHtmlOptions o) => buildSurveyHtml(o); + + test('hands the resolved appearance to renderSurvey', () { + final options = SurveyHtmlOptions( + survey: _survey(), + appUrl: 'https://app.formbricks.com', + workspaceId: 'wsp_1', + isBrandingEnabled: true, + languageCode: 'default', + appearance: 'dark', + ); + expect(options.toRenderOptions()['appearance'], 'dark'); + expect(optionsJson(options), contains('"appearance":"dark"')); + }); + + test('forwards customCss, and sends no key when there is none', () { + final withCss = SurveyHtmlOptions( + survey: _survey(), + appUrl: 'https://app.formbricks.com', + workspaceId: 'wsp_1', + isBrandingEnabled: true, + languageCode: 'default', + customCss: const { + 'workspace': {'dark': '.a{color:red}'}, + }, + ); + expect(withCss.toRenderOptions()['customCss'], { + 'workspace': {'dark': '.a{color:red}'}, + }); + expect(_opts().toRenderOptions().containsKey('customCss'), isFalse); + }); + + test('TSurvey exposes its compiled customCss', () { + expect( + _survey({ + 'customCss': {'light': '.x{}'}, + }).customCss, + {'light': '.x{}'}, + ); + expect(_survey().customCss, isNull); + }); + }); } diff --git a/packages/formbricks/test/widgets/survey_webview_test.dart b/packages/formbricks/test/widgets/survey_webview_test.dart index d187581..c1ba45f 100644 --- a/packages/formbricks/test/widgets/survey_webview_test.dart +++ b/packages/formbricks/test/widgets/survey_webview_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks/src/common/appearance.dart'; import 'package:formbricks/src/common/config.dart'; import 'package:formbricks/src/common/logger.dart'; import 'package:formbricks/src/survey/embedded_data.dart'; @@ -21,6 +22,8 @@ class _StubHost { void Function(WebViewEvent)? onEvent; VoidCallback? onLoadError; String? html; + ValueNotifier? appearance; + String? initialAppearance; Widget build( BuildContext context, { @@ -30,6 +33,8 @@ class _StubHost { LaunchUrlFn? launch, VoidCallback? onLoadError, }) { + appearance = AppearanceScope.maybeOf(context); + initialAppearance = AppearanceScope.initialOf(context); this.onEvent = onEvent; this.onLoadError = onLoadError; this.html = html; @@ -156,6 +161,123 @@ void main() { tearDown(EmbeddedDataStore.instance.clear); + group('appearance and custom CSS', () { + setUp(AppearanceState.instance.reset); + tearDown(AppearanceState.instance.reset); + + testWidgets('opens light by default and sends no customCss key', + (tester) async { + await _seedConfig(); + final host = await _present( + tester, + _survey({'id': 's1', 'languages': []}), + ); + + expect(host.html, contains('"appearance":"light"')); + expect(host.html, isNot(contains('customCss'))); + expect(host.appearance?.value, 'light'); + }); + + testWidgets('opens in the appearance set before display, with the CSS', + (tester) async { + await _seedConfig( + settings: { + 'customCss': {'dark': '.w{color:red}'}, + }, + ); + AppearanceState.instance.set(FormbricksAppearance.dark); + final host = await _present( + tester, + _survey({ + 'id': 's1', + 'languages': [], + 'customCss': {'light': '.s{margin:0}'}, + }), + ); + + expect(host.html, contains('"appearance":"dark"')); + expect( + host.html, + contains( + '"customCss":{"workspace":{"dark":".w{color:red}"},' + '"survey":{"light":".s{margin:0}"}}', + ), + ); + }); + + testWidgets('the host can read the value baked into the page', + (tester) async { + await _seedConfig(); + AppearanceState.instance.set(FormbricksAppearance.dark); + final host = await _present( + tester, + _survey({'id': 's1', 'languages': []}), + ); + + expect(host.initialAppearance, 'dark'); + + // A change after display moves the notifier, not the baked-in value. + AppearanceState.instance.set(FormbricksAppearance.light); + await tester.pump(); + expect(host.appearance?.value, 'light'); + expect(host.initialAppearance, 'dark'); + }); + + testWidgets('a survey-rendered event is consumed by the host', + (tester) async { + await _seedConfig(); + final host = await _present( + tester, + _survey({'id': 's1', 'languages': []}), + ); + + host.onEvent!(const SurveyRenderedEvent()); + await tester.pump(); + + expect(find.byKey(_stub), findsOneWidget); // still showing + }); + + testWidgets('a change while open reaches the host without a reload', + (tester) async { + await _seedConfig(); + final host = await _present( + tester, + _survey({'id': 's1', 'languages': []}), + ); + final openedWith = host.html; + + AppearanceState.instance.set(FormbricksAppearance.dark); + await tester.pump(); + + expect(host.appearance?.value, 'dark'); + // The page itself is frozen: reloading it would lose the answers. + expect(host.html, openedWith); + }); + + testWidgets('system follows the app theme live', (tester) async { + await _seedConfig(); + AppearanceState.instance.set(FormbricksAppearance.system); + final survey = _survey({'id': 's1', 'languages': []}); + final host = _StubHost(); + SurveyStore.instance.setSurvey(survey); + Widget app(ThemeMode mode) => MaterialApp( + themeMode: mode, + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + home: SurveyWebView(survey: survey, webViewHostBuilder: host.build), + ); + + await tester.pumpWidget(app(ThemeMode.light)); + await tester.pump(); + await tester.pump(); + expect(host.appearance?.value, 'light'); + + await tester.pumpWidget(app(ThemeMode.dark)); + await tester.pumpAndSettle(); + expect(host.appearance?.value, 'dark'); + }); + }); + group('the Embedded Data pipe', () { // `_present` passing `hiddenFieldsRecord: EmbeddedDataStore.instance // .snapshot()` is the single line joining the store, the render options and diff --git a/packages/formbricks/test/widgets/webview_event_test.dart b/packages/formbricks/test/widgets/webview_event_test.dart index 3845976..c3371ff 100644 --- a/packages/formbricks/test/widgets/webview_event_test.dart +++ b/packages/formbricks/test/widgets/webview_event_test.dart @@ -10,6 +10,17 @@ void main() { ); }); + test('survey-rendered', () { + expect( + parseWebViewEvents('{"onSurveyRendered":true}').single, + isA(), + ); + }); + + test('wrong-typed survey-rendered flag → empty', () { + expect(parseWebViewEvents('{"onSurveyRendered":"yes"}'), isEmpty); + }); + test('response-created', () { expect( parseWebViewEvents('{"onResponseCreated":true}').single,