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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pubspec_overrides.yaml
coverage/
*.lcov

# Test artifacts (machine reports from `make test-sdk-machine`)
test-results/

# Android
**/android/.gradle/
**/android/local.properties
Expand All @@ -51,9 +54,11 @@ coverage/
**/ios/Flutter/ephemeral/
**/.symlinks/

# Demo app env
# Demo app env — ignore real .env files, but commit the example template.
*.env
.env*
!.env.example
!**/.env.example

# FVM Version Cache
.fvm/
4 changes: 4 additions & 0 deletions apps/playground/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copy to `.env` and fill in your workspace credentials.
# `.env` is git-ignored; `tool/run.sh` passes it via --dart-define-from-file.
APP_URL=https://app.formbricks.com
WORKSPACE_ID=wsp_your_workspace_id
35 changes: 26 additions & 9 deletions apps/playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,36 @@
Demo / manual-QA app for the [`formbricks_flutter`](../../packages/formbricks_flutter)
SDK. Targets iOS + Android.

Right now it renders a "Welcome to Formbricks" header and six SDK-test buttons
(track / setUserId / setAttributes ×2 / setLanguage / logout). The buttons are
inert stubs until the SDK API lands; each shows a "not wired to the SDK yet"
snackbar.
It calls `Formbricks.setup` on launch (reading `APP_URL` / `WORKSPACE_ID` from
`--dart-define`, mirroring the React Native playground's env vars) and shows the
setup status. The six SDK-test buttons (track / setUserId / setAttributes ×2 /
setLanguage / logout) are inert stubs until the rest of the API lands; each shows
a "not wired to the SDK yet" snackbar.

## Run

From the repo root (a simulator/emulator must be booted — `flutter run` won't
boot one itself):
Put your workspace credentials in a local `.env` (git-ignored) once:

```bash
./tool/run.sh # iOS simulator (boots one if needed)
./tool/run.sh android # Android emulator (boots one if needed)
cp apps/playground/.env.example apps/playground/.env
# then edit APP_URL / WORKSPACE_ID
```

See the repo root README for full toolchain + run details.
Then from the repo root (`tool/run.sh` boots a device if none is running and
auto-passes the `.env` via `--dart-define-from-file`):

```bash
./tool/run.sh # iOS simulator
./tool/run.sh android # Android emulator
```

Prefer one-off flags? Pass them directly (they override the `.env`):

```bash
./tool/run.sh ios --dart-define=APP_URL=https://app.formbricks.com \
--dart-define=WORKSPACE_ID=wsp_...
```

Without any credentials the app still runs but shows a "Missing APP_URL /
WORKSPACE_ID" status and skips setup. See the repo root README for full toolchain
details.
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

You can customize the launch screen with your own desired assets by replacing the image files in this directory.

You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
64 changes: 58 additions & 6 deletions apps/playground/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import 'package:flutter/material.dart';
import 'package:formbricks_flutter/formbricks_flutter.dart';

const kWelcomeMessage = 'Welcome to Formbricks';
/// Credentials are injected at build time, mirroring the React Native
/// playground's use of `EXPO_PUBLIC_*` env vars. Pass them via `--dart-define`
/// (or a local `apps/playground/.env`, which `tool/run.sh` forwards):
/// --dart-define=APP_URL=https://app.formbricks.com --dart-define=WORKSPACE_ID=wsp_...
const String _appUrl = String.fromEnvironment('APP_URL');
const String _workspaceId = String.fromEnvironment('WORKSPACE_ID');

/// Header text. Also referenced by the widget test.
const String kWelcomeMessage = 'Welcome to Formbricks';

void main() {
runApp(const PlaygroundApp());
Expand All @@ -22,9 +31,49 @@ class PlaygroundApp extends StatelessWidget {
}
}

class PlaygroundHome extends StatelessWidget {
class PlaygroundHome extends StatefulWidget {
const PlaygroundHome({super.key});

@override
State<PlaygroundHome> createState() => _PlaygroundHomeState();
}

class _PlaygroundHomeState extends State<PlaygroundHome> {
String _status = 'initializing…';

@override
void initState() {
super.initState();
_initFormbricks();
}

Future<void> _initFormbricks() async {
if (_appUrl.isEmpty || _workspaceId.isEmpty) {
setState(
() => _status =
'Missing APP_URL / WORKSPACE_ID — pass them via --dart-define',
);
return;
}
try {
final result = await Formbricks.setup(
appUrl: _appUrl,
workspaceId: _workspaceId,
logLevel: LogLevel.debug,
);
if (!mounted) return;
setState(() {
_status = switch (result) {
Ok() => 'setup complete ✓',
Err(:final error) => 'setup error: ${error.message}',
};
});
} catch (e) {
if (!mounted) return;
setState(() => _status = 'setup failed: $e');
}
}

/// Stub for SDK calls. The real `Formbricks.*` API is not wired yet.
/// For now each button just confirms the tap so the demo's UX can be
/// exercised independently of the SDK.
Expand All @@ -41,9 +90,6 @@ class PlaygroundHome extends StatelessWidget {

@override
Widget build(BuildContext context) {
// Proves the SDK package links into the app via the pub workspace.
final greeting = kWelcomeMessage;

final actions = <({String label, String action})>[
(label: 'Trigger Code Action', action: "track('code')"),
(label: 'Set userId', action: "setUserId('random-user-id')"),
Expand All @@ -63,7 +109,13 @@ class PlaygroundHome extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(greeting, textAlign: TextAlign.center),
const Text(kWelcomeMessage, textAlign: TextAlign.center),
const SizedBox(height: 8),
Text(
'setup: $_status',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 24),
for (final a in actions) ...[
FilledButton(
Expand Down
6 changes: 3 additions & 3 deletions docs/FLUTTER_SDK_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Ship is successful when **all** of the following hold:

### Tech stack

- Language: Dart ≥ 3.12.0. Flutter ≥ 3.44.0 (stable channel).
- Language: Dart ≥ 3.4. Flutter ≥ 3.22 (stable channel).
- Required plugins (peer/direct):
- `webview_flutter: ^4.x` — survey rendering.
- `shared_preferences: ^2.x` — persistent config (RN equivalent: `AsyncStorage`).
Expand Down Expand Up @@ -233,7 +233,7 @@ No third parties. The SDK only talks to the customer's `appUrl` (Formbricks Clou
- Offer an opt-in `storage:` parameter on `Formbricks` widget that accepts a custom `FormbricksStorage` interface, so security-conscious customers can plug in `flutter_secure_storage` themselves.
- Match RN parity — RN uses unencrypted `AsyncStorage` too — so we are not regressing.
- iOS `NSUserDefaults` (backing `SharedPreferences` on iOS) is similarly unencrypted but file-system-protected. Same documentation note.
- No PII in logs. Logger never logs attribute values; only attribute keys and userId at `debug` level. Default level is `error`.
- No PII in logs. Logger never logs attribute values; only attribute keys and userIds at `debug` level. Default level is `error`.

### Does this affect tenant isolation?

Expand Down Expand Up @@ -307,7 +307,7 @@ No new permissions. The SDK uses the **embedding app's** existing internet permi

## Appendix A — Suggested package layout

```text
```
Comment thread
pandeymangg marked this conversation as resolved.
formbricks_flutter/
├── lib/
│ ├── formbricks_flutter.dart # public exports only
Expand Down
48 changes: 41 additions & 7 deletions packages/formbricks_flutter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,51 @@ First-party Flutter SDK for [Formbricks](https://formbricks.com). Connect your
Flutter app to a Formbricks workspace, identify users, track actions, and render
targeted in-app surveys.

> **Status: skeleton.** This package was scaffolded to establish the monorepo.
> The public API and survey rendering land in follow-up work. It currently
> exposes only a placeholder `welcome()`.
> **Status: in development.** Initialization (`Formbricks.setup`) and its
> foundations are implemented. `track` / identify / survey rendering land in
> follow-up work.

## Planned public API
## Initialization

```dart
// Host widget — initializes the SDK and renders any active survey.
Formbricks(appUrl: 'https://app.formbricks.com', workspaceId: 'wsp_...');
import 'package:formbricks_flutter/formbricks_flutter.dart';

try {
final result = await Formbricks.setup(
appUrl: 'https://app.formbricks.com',
workspaceId: 'wsp_...',
);

switch (result) {
case Ok():
// SDK is ready.
case Err(:final error):
// Invalid input (e.g. missing / non-http(s) appUrl) — error.message explains.
}
} on FormbricksSetupError {
// First-setup network/auth failure — see below.
}
```

`setup` reports problems through **two** channels:

// Static imperative API (sequenced via an internal command queue).
- It **returns `Err`** for invalid input (missing or non-`http(s)`
`appUrl` / `workspaceId`) and for a workspace/user sync failure when refreshing
an existing cached config.
- It **throws `FormbricksSetupError`** when the *first* setup attempt fails on the
network/auth. The SDK then enters a 10-minute error cooldown.
- It **returns `Err(SetupCooldownError)`** for any `setup` call made *within* that
cooldown window — the SDK stays inert (no network call), and `error.retryAt`
says when it will try again. Distinct from `Ok` so callers don't treat the
suppressed-retry state as "ready".

`setup` is also idempotent (a second successful call is a no-op), runs through an
internal command queue, and installs a lifecycle-aware expiry ticker.

## Planned public API

```dart
// Static imperative API (sequenced via the same command queue).
await Formbricks.track('button_clicked');
await Formbricks.setUserId('user_123');
await Formbricks.setAttribute('plan', 'pro');
Expand Down
65 changes: 58 additions & 7 deletions packages/formbricks_flutter/lib/formbricks_flutter.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,62 @@
/// Formbricks Flutter SDK.
///
/// Skeleton entry point. The real public API — `Formbricks.setup`, `track`,
/// `setUserId`, `setAttributes`, `setLanguage`, `logout`, and the `Formbricks`
/// host widget — lands in follow-up work. For now this only proves the package
/// wires into the workspace and the demo app.
/// Public entry point. This ticket lands initialization only — `Formbricks.setup`
/// plus the foundations behind it (config, API client, command queue, expiry
/// ticker). `track` / identify / survey rendering arrive in follow-up work.
library;

/// Placeholder greeting used to verify the package is linked correctly from
/// the demo app and from tests. Replaced by the real SDK surface later.
String welcome() => 'Welcome to Formbricks';
import 'package:shared_preferences/shared_preferences.dart';

import 'src/common/command_queue.dart';
import 'src/common/config.dart';
import 'src/common/logger.dart';
import 'src/common/result.dart';
import 'src/common/setup.dart' as setup_internal;
import 'src/types/errors.dart';

export 'src/common/logger.dart' show LogLevel;
export 'src/common/result.dart';
export 'src/types/errors.dart';

/// The Formbricks SDK facade.
///
/// Static methods delegate to a hidden command queue so calls execute in strict
/// submission order. `setup()` itself runs with `checkSetup: false` because it
/// is what flips the SDK into the set-up state.
class Formbricks {
Formbricks._();

static final CommandQueue _queue = CommandQueue(
isSetup: () => setup_internal.isSetup,
);

/// Initializes the SDK against [appUrl] / [workspaceId].
///
/// Completes with `Ok` on success or `Err(MissingFieldError)` for invalid
/// input. Throws [FormbricksSetupError] if the *first* setup attempt fails on
/// the network (the SDK then enters a 10-minute error cooldown).
static Future<Result<void, FormbricksError>> setup({
required String appUrl,
required String workspaceId,
LogLevel? logLevel,
}) {
return _queue.add<Result<void, FormbricksError>>(
() => setup_internal.setup(
appUrl: appUrl,
workspaceId: workspaceId,
logLevel: logLevel,
),
checkSetup: false,
);
}

/// Returns the raw JSON the SDK has persisted in `SharedPreferences` (under
/// the `formbricks-flutter` key), or `null` if nothing is stored yet.
///
/// A debugging/inspection aid — handy for seeing exactly what `setup()` and
/// future state changes write to disk. Not part of the stable API.
static Future<String?> debugStoredConfig() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(FormbricksConfig.storageKey);
}
}
Loading
Loading