Skip to content
Merged
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
130 changes: 115 additions & 15 deletions apps/playground/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ class _PlaygroundHomeState extends State<PlaygroundHome> {
};
} on FormbricksError catch (e) {
message = "track('$code') -> ${e.code.wire}: ${e.message}";
} catch (e) {
message = "track('$code') -> unexpected error: $e";
}
if (!mounted) return;
setState(() => _lastTrack = message);
Expand All @@ -143,26 +145,108 @@ class _PlaygroundHomeState extends State<PlaygroundHome> {
);
}

void _stub(BuildContext context, String action) {
ScaffoldMessenger.of(context)
/// Runs an identity command and reports the result in a snackbar.
///
/// The user-update queue debounces the backend call ~500 ms after the last
/// identity/attribute change, so an `ok` here means the command was accepted,
/// not that the network round-trip has finished.
Future<void> _runAction(
BuildContext context,
String action,
Future<Result<void, FormbricksError>> Function() op,
) async {
final messenger = ScaffoldMessenger.of(context);
String message;
try {
final result = await op();
message = switch (result) {
Ok() => '$action -> ok',
Err(:final error) => '$action -> ${error.code.wire}: ${error.message}',
};
} on FormbricksError catch (e) {
message = '$action -> ${e.code.wire}: ${e.message}';
} catch (e) {
message = '$action -> unexpected error: $e';
}
if (!mounted) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text('$action - not wired to the SDK yet'),
duration: const Duration(seconds: 1),
),
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
);
}
Comment thread
pandeymangg marked this conversation as resolved.

/// Reads the persisted config and dumps it to the console.
Future<void> _logStorage(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
String message;
try {
final raw = await Formbricks.debugStoredConfig();
debugPrint('Formbricks local storage: ${raw ?? '<empty>'}');
message = raw == null
? 'local storage is empty'
: 'local storage logged to console (${raw.length} chars)';
} catch (e) {
message = 'log local storage -> unexpected error: $e';
}
if (!mounted) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}

/// Clears the persisted config + in-memory copy.
Future<void> _clearStorage(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
String message;
try {
await Formbricks.debugClearStoredConfig();
message = 'local storage cleared';
} catch (e) {
message = 'clear local storage -> unexpected error: $e';
}
if (!mounted) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}

@override
Widget build(BuildContext context) {
final stubActions = <({String label, String action})>[
(label: 'Set userId', action: "setUserId('random-user-id')"),
(label: 'Set User Attributes (multiple)', action: 'setAttributes({...})'),
(label: 'Set User Attribute (single)', action: "setAttribute('k', 'v')"),
(label: 'Set Language (de)', action: "setLanguage('de')"),
(label: 'Logout', action: 'logout()'),
];
final identityActions =
<
({
String label,
String action,
Future<Result<void, FormbricksError>> Function() op,
})
>[
(
label: 'Set userId',
action: "setUserId('playground-user')",
op: () => Formbricks.setUserId('playground-user'),
),
(
label: 'Set User Attributes (multiple)',
action: 'setAttributes({plan, mrr, signup_date})',
op: () => Formbricks.setAttributes({
'plan': 'pro',
'mrr': 99,
'signup_date': DateTime.now(),
}),
),
(
label: 'Set User Attribute (single)',
action: "setAttribute('source', 'playground')",
op: () => Formbricks.setAttribute('source', 'playground'),
),
(
label: 'Set Language (de)',
action: "setLanguage('de')",
op: () => Formbricks.setLanguage('de'),
),
(label: 'Logout', action: 'logout()', op: Formbricks.logout),
];
final textTheme = Theme.of(context).textTheme;

return Scaffold(
Expand Down Expand Up @@ -250,14 +334,30 @@ class _PlaygroundHomeState extends State<PlaygroundHome> {
],
const Divider(height: 40),

for (final a in stubActions) ...[
for (final a in identityActions) ...[
FilledButton.tonal(
onPressed: () => _stub(context, a.action),
onPressed: _connected
? () => _runAction(context, a.action, a.op)
: null,
child: Text(a.label),
),
const SizedBox(height: 12),
],

const Divider(height: 40),
Text('Local storage', style: textTheme.titleSmall),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _logStorage(context),
child: const Text('Log Local Storage'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => _clearStorage(context),
child: const Text('Clear Local Storage'),
),
const SizedBox(height: 12),

// Mounted once connected so triggered surveys can render
// against the connected workspace.
if (_connected)
Expand Down
31 changes: 24 additions & 7 deletions apps/playground/test/widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,34 @@ void main() {
);
});

testWidgets('tapping a not-yet-wired button shows the stub snackbar', (
testWidgets('identity buttons are disabled until connected', (tester) async {
await tester.pumpWidget(const PlaygroundApp());

final logoutButton = tester.widget<FilledButton>(
find.ancestor(
of: find.text('Logout'),
matching: find.byType(FilledButton),
),
);
// Not connected (no --dart-define credentials) → command would throw
// NotSetupError, so the button is gated off.
expect(logoutButton.onPressed, isNull);
});

testWidgets('local storage buttons render and are always enabled', (
tester,
) async {
await tester.pumpWidget(const PlaygroundApp());

await tester.ensureVisible(find.text('Logout'));
await tester.pumpAndSettle();
await tester.tap(find.text('Logout'));
await tester.pump();

expect(find.textContaining('not wired to the SDK yet'), findsOneWidget);
for (final label in ['Log Local Storage', 'Clear Local Storage']) {
final button = tester.widget<OutlinedButton>(
find.ancestor(
of: find.text(label),
matching: find.byType(OutlinedButton),
),
);
expect(button.onPressed, isNotNull);
}
});

testWidgets('tapping Trigger Code Action calls the wired SDK track', (
Expand Down
20 changes: 20 additions & 0 deletions packages/formbricks_flutter/lib/src/common/setup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,26 @@
);
}

/// Resets user state back to anonymous and persists it.
///
/// Called when switching identity (`setUserId` to a different id) and on
/// `logout`. Mirrors RN's `tearDown` (`setup.ts:366–387`), minus the survey
/// refilter, which lands with the filtering ticket. No-op when no config is
/// loaded yet.
Future<void> tearDown({FormbricksConfig? config}) async {
final cfg = config ?? FormbricksConfig.instance;
Logger.debug('Setting user state to default');

final current = cfg.getOrNull();
if (current == null) return;

await cfg.update(
current.copyWith(user: TUserState.defaultNoUserId),
// TODO(filtering ticket): recompute filteredSurveys against the default

Check warning on line 261 in packages/formbricks_flutter/lib/src/common/setup.dart

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

TODO(filtering ticket): recompute filteredSurveys against the default

See more on https://sonarcloud.io/project/issues?id=formbricks_flutter&issues=AZ6sM3O2RD0Uh5Iyv4th&open=AZ6sM3O2RD0Uh5Iyv4th&pullRequest=8
// user state here (filterSurveys(workspace, defaultNoUserId)) — see ENG-1128.
);
}

NetworkError _toNetworkError(ApiErrorResponse error) => NetworkError(
message: error.message,
status: error.status,
Expand Down
48 changes: 48 additions & 0 deletions packages/formbricks_flutter/lib/src/user/attribute.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/// Contact attributes: `setAttributes` and `setLanguage`.
///
/// Ports the RN `lib/user/attribute.ts`. The backend infers each attribute's
/// type from the JSON value type, so values cross the wire with their natural
/// type: `String` stays a string, `num` stays a number, and `DateTime` is
/// converted to an ISO-8601 string at this one boundary (plan §7 #4).
library;

import 'dart:async';

import '../common/result.dart';
import '../types/errors.dart';
import 'update_queue.dart';

/// Sets [attributes] on the current user/contact.
///
/// `DateTime` values become UTC ISO-8601 strings; `num` and `String` values are
/// passed through unchanged. Queues the change through the debounced
/// [UpdateQueue] and returns `Ok` immediately (the network call, and any
/// no-userId error, surface from the queue's flush).
Future<Result<void, FormbricksError>> setAttributes(
Map<String, Object?> attributes, {
UpdateQueue? queue,
}) async {
final normalized = <String, Object?>{};
attributes.forEach((key, value) {
normalized[key] =
value is DateTime ? value.toUtc().toIso8601String() : value;
});

final q = queue ?? UpdateQueue.instance;
q.updateAttributes(normalized);
// Fire-and-forget: the flush already logs and recovers from failures (e.g.
// MissingFieldError), so swallow here to keep it off the host's unhandled-
// error path. Tests await processUpdates() directly and still see the error.
unawaited(q.processUpdates().catchError((_) {}));
return const Result.ok(null);
}

/// Sets the contact's preferred [language].
///
/// Routes through [setAttributes] exactly as RN does — this is what makes the
/// "language without a userId updates local config only" path reachable.
Future<Result<void, FormbricksError>> setLanguage(
String language, {
UpdateQueue? queue,
}) =>
setAttributes({'language': language}, queue: queue);
Loading
Loading