diff --git a/.gitignore b/.gitignore index b08ed72..00698d8 100644 --- a/.gitignore +++ b/.gitignore @@ -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 @@ -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/ \ No newline at end of file diff --git a/apps/playground/.env.example b/apps/playground/.env.example new file mode 100644 index 0000000..e73fae4 --- /dev/null +++ b/apps/playground/.env.example @@ -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 diff --git a/apps/playground/README.md b/apps/playground/README.md index cf79cdb..ecc1453 100644 --- a/apps/playground/README.md +++ b/apps/playground/README.md @@ -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. diff --git a/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md index b5b843a..89c2725 100644 --- a/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart index 7e03aba..017563b 100644 --- a/apps/playground/lib/main.dart +++ b/apps/playground/lib/main.dart @@ -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()); @@ -22,9 +31,49 @@ class PlaygroundApp extends StatelessWidget { } } -class PlaygroundHome extends StatelessWidget { +class PlaygroundHome extends StatefulWidget { const PlaygroundHome({super.key}); + @override + State createState() => _PlaygroundHomeState(); +} + +class _PlaygroundHomeState extends State { + String _status = 'initializing…'; + + @override + void initState() { + super.initState(); + _initFormbricks(); + } + + Future _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. @@ -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')"), @@ -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( diff --git a/docs/FLUTTER_SDK_PLAN.md b/docs/FLUTTER_SDK_PLAN.md index 3d2a457..92f29c9 100644 --- a/docs/FLUTTER_SDK_PLAN.md +++ b/docs/FLUTTER_SDK_PLAN.md @@ -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`). @@ -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? @@ -307,7 +307,7 @@ No new permissions. The SDK uses the **embedding app's** existing internet permi ## Appendix A — Suggested package layout -```text +``` formbricks_flutter/ ├── lib/ │ ├── formbricks_flutter.dart # public exports only diff --git a/packages/formbricks_flutter/README.md b/packages/formbricks_flutter/README.md index cc02bfe..571e504 100644 --- a/packages/formbricks_flutter/README.md +++ b/packages/formbricks_flutter/README.md @@ -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'); diff --git a/packages/formbricks_flutter/lib/formbricks_flutter.dart b/packages/formbricks_flutter/lib/formbricks_flutter.dart index ff67e9f..9d66d8e 100644 --- a/packages/formbricks_flutter/lib/formbricks_flutter.dart +++ b/packages/formbricks_flutter/lib/formbricks_flutter.dart @@ -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> setup({ + required String appUrl, + required String workspaceId, + LogLevel? logLevel, + }) { + return _queue.add>( + () => 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 debugStoredConfig() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(FormbricksConfig.storageKey); + } +} diff --git a/packages/formbricks_flutter/lib/src/common/api_client.dart b/packages/formbricks_flutter/lib/src/common/api_client.dart new file mode 100644 index 0000000..4b14f44 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/api_client.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../types/config.dart'; +import '../types/errors.dart'; +import 'result.dart'; + +/// Thin wrapper over `package:http` for the two client endpoints the SDK needs. +/// +/// Ported from the RN `ApiClient` / `makeRequest` (`lib/common/api.ts`). The +/// `http.Client` is injectable so tests can swap in a mock; no `dio`, to keep +/// the dependency graph minimal. +class ApiClient { + /// Creates an API client for [appUrl] / [workspaceId]. + ApiClient({ + required this.appUrl, + required this.workspaceId, + http.Client? client, + this.isDebug = false, + }) : _client = client ?? http.Client(); + + /// The base app URL (Formbricks Cloud or self-hosted). + final String appUrl; + + /// The workspace scope for all requests. + final String workspaceId; + + /// When true, adds `Cache-Control: no-cache` to every request. + final bool isDebug; + + final http.Client _client; + + /// Shared request helper: builds headers, performs the call, decodes JSON, and + /// normalizes failures into an [ApiErrorResponse]. + /// + /// Error mapping: a thrown transport error (e.g. `SocketException`, + /// `ClientException`) → `network_error`; a 4xx (incl. 404) or a body + /// `code: "forbidden"` → `forbidden`; any other non-2xx → `network_error`. + Future> _request({ + required String method, + required String endpoint, + required T Function(Map data) parse, + Object? body, + }) async { + final url = Uri.parse('$appUrl$endpoint'); + final headers = { + 'Content-Type': 'application/json', + if (isDebug) 'Cache-Control': 'no-cache', + }; + + http.Response response; + try { + response = method == 'GET' + ? await _client.get(url, headers: headers) + : await _client.post( + url, + headers: headers, + body: body == null ? null : jsonEncode(body), + ); + } catch (e) { + return Result.err( + ApiErrorResponse( + code: 'network_error', + status: 500, + message: 'Something went wrong', + url: url, + responseMessage: e.toString(), + ), + ); + } + + final status = response.statusCode; + Map? json; + try { + json = response.body.isEmpty + ? null + : jsonDecode(response.body) as Map; + } catch (_) { + json = null; + } + + if (status < 200 || status >= 300) { + final serverCode = json?['code'] as String?; + final isForbidden = + serverCode == 'forbidden' || (status >= 400 && status < 500); + final details = (json?['details'] as Map?)?.cast(); + return Result.err( + ApiErrorResponse( + code: isForbidden ? 'forbidden' : 'network_error', + status: status, + message: (json?['message'] as String?) ?? 'Something went wrong', + url: url, + responseMessage: json?['message'] as String?, + details: (details?.isNotEmpty ?? false) ? details : null, + ), + ); + } + + final data = + (json?['data'] as Map?)?.cast() ?? {}; + try { + return Result.ok(parse(data)); + } catch (e) { + // A 2xx body with a missing/wrong-typed field makes a `fromJson` factory + // throw. Normalize that to `network_error` so callers (setup) get an + // `Err` and run their error path instead of a bare TypeError/CastError. + return Result.err( + ApiErrorResponse( + code: 'network_error', + status: status, + message: 'Malformed response', + url: url, + responseMessage: e.toString(), + ), + ); + } + } + + /// Fetches workspace state via `GET /api/v2/client/{workspaceId}/environment`. + /// + /// Normalizes the legacy field name: the server may return the settings object + /// under `data.settings` (new), `data.workspace`, or legacy `data.project`. + /// All three are mapped to `data.settings` (port of `workspace/state.ts`). + Future> getWorkspaceState() { + return _request( + method: 'GET', + endpoint: '/api/v2/client/$workspaceId/environment', + parse: (data) { + final inner = (data['data'] as Map?)?.cast() ?? + {}; + if (inner['settings'] == null) { + if (inner['workspace'] != null) { + inner['settings'] = inner['workspace']; + inner.remove('workspace'); + } else if (inner['project'] != null) { + inner['settings'] = inner['project']; + inner.remove('project'); + } + } + return TWorkspaceState.fromJson({ + 'expiresAt': data['expiresAt'], + 'data': inner, + }); + }, + ); + } + + /// Creates or updates a user via `POST /api/v2/client/{workspaceId}/user`. + /// + /// Attributes are passed through as-is so `num` types stay numbers (the + /// backend infers the attribute type from the JSON type). Any `DateTime` + /// attribute must already be an ISO string before reaching this layer. + Future> + createOrUpdateUser({ + required String userId, + Map? attributes, + }) { + return _request( + method: 'POST', + endpoint: '/api/v2/client/$workspaceId/user', + body: { + 'userId': userId, + if (attributes != null) 'attributes': attributes, + }, + parse: CreateOrUpdateUserResponse.fromJson, + ); + } + + /// Closes the underlying HTTP client. + void close() => _client.close(); +} diff --git a/packages/formbricks_flutter/lib/src/common/command_queue.dart b/packages/formbricks_flutter/lib/src/common/command_queue.dart new file mode 100644 index 0000000..85249db --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/command_queue.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:collection'; + +import '../types/errors.dart'; +import 'logger.dart'; + +typedef _Runner = Future Function(); + +/// A FIFO, single-in-flight command queue. +/// +/// Every public SDK method routes through this so calls execute in strict +/// submission order — `setUserId` followed by `setAttribute` can't race, because +/// the attribute call needs the userId already applied. Ported from the RN +/// `CommandQueue`, with one difference: each [add] returns its own `Future` +/// instead of a shared `wait()`. +class CommandQueue { + /// Creates a queue. [isSetup] reports whether `setup()` has completed; it is + /// injected so the queue stays decoupled from the setup module (and testable). + CommandQueue({required this.isSetup}); + + /// Reports whether the SDK is set up. Injected for decoupling/testability. + final bool Function() isSetup; + final Queue<_Runner> _queue = Queue<_Runner>(); + bool _running = false; + + /// Enqueues [command] and returns a future that completes after it runs. + /// + /// - When [checkSetup] is true and the SDK isn't set up, the returned future + /// completes with [NotSetupError] and [command] never runs. + /// - Any error thrown by [command] is caught, logged, and surfaced on *that* + /// command's future only — it never breaks the worker loop. + Future add(FutureOr Function() command, {bool checkSetup = true}) { + // The completer is created and completed inside this generic method, so the + // queue can hold type-erased `_Runner` closures while `add` stays + // type-safe. + final completer = Completer(); + + _queue.add(() async { + try { + if (checkSetup && !isSetup()) { + throw NotSetupError(); + } + final result = await command(); + if (!completer.isCompleted) completer.complete(result); + } catch (error, stackTrace) { + Logger.error('Global error: $error'); + if (!completer.isCompleted) completer.completeError(error, stackTrace); + } + }); + + unawaited(_drain()); + return completer.future; + } + + Future _drain() async { + if (_running) return; + _running = true; + try { + while (_queue.isNotEmpty) { + final runner = _queue.removeFirst(); + // `runner` swallows its own errors, so this await never throws and the + // loop is never broken by a failing command. + await runner(); + } + } finally { + _running = false; + } + } +} diff --git a/packages/formbricks_flutter/lib/src/common/config.dart b/packages/formbricks_flutter/lib/src/common/config.dart new file mode 100644 index 0000000..354337a --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/config.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../types/config.dart'; +import 'logger.dart'; +import 'time.dart'; + +/// Persistent SDK config, backed by [SharedPreferences] under [storageKey]. +/// +/// Design choices (improvements over the RN `RNConfig`): +/// - **Init once.** [init] reads storage a single time during `setup()`; after +/// that [get] is synchronous and does not re-read the disk. +/// - **Awaited persistence.** [update] completes only after the disk write, so a +/// crash can't lose a write that already "returned". +/// - **Dates cross one boundary.** All ISO↔`DateTime` conversion lives in +/// [TConfig.fromJson] / [TConfig.toJson]; nothing here calls `DateTime.parse`. +class FormbricksConfig { + FormbricksConfig._(); + + static FormbricksConfig? _instance; + + /// The process-wide config singleton. + static FormbricksConfig get instance => _instance ??= FormbricksConfig._(); + + /// SharedPreferences key. Distinct from RN's `formbricks-react-native` so the + /// two SDKs never collide on one device. + static const String storageKey = 'formbricks-flutter'; + + TConfig? _config; + SharedPreferences? _prefs; + + /// Loads and parses the cached config exactly once. + /// + /// A cached config whose **workspace** has expired is discarded (matches RN's + /// `loadFromStorage`). An error-only config (no workspace) is kept so the + /// error cooldown survives a reload. + Future init() async { + final prefs = _prefs ??= await SharedPreferences.getInstance(); + final saved = prefs.getString(storageKey); + if (saved == null) { + _config = null; + return; + } + + try { + final parsed = TConfig.fromJson( + jsonDecode(saved) as Map, + ); + final workspace = parsed.workspace; + if (workspace != null && isNowExpired(workspace.expiresAt)) { + Logger.debug('Config in local storage has expired.'); + _config = null; + return; + } + _config = parsed; + } catch (e) { + Logger.error('Error loading config from storage: $e'); + _config = null; + } + } + + /// Returns the current config. Throws if [init] never produced a valid config. + TConfig get() { + final config = _config; + if (config == null) { + throw StateError( + 'Config is null — was init() called, or is the cached config invalid?', + ); + } + return config; + } + + /// Returns the current config, or null when none is loaded. + TConfig? getOrNull() => _config; + + /// Whether a valid config is currently loaded. + bool get isInitialized => _config != null; + + /// Updates the in-memory config and awaits the disk write before completing. + Future update(TConfig config) async { + _config = config; + final prefs = _prefs ??= await SharedPreferences.getInstance(); + await prefs.setString(storageKey, jsonEncode(config.toJson())); + } + + /// Clears the persisted config and the in-memory copy. + Future reset() async { + _config = null; + final prefs = _prefs ??= await SharedPreferences.getInstance(); + await prefs.remove(storageKey); + } + + /// Drops the singleton so each test starts clean. Test-only. + @visibleForTesting + static void resetInstance() => _instance = null; +} diff --git a/packages/formbricks_flutter/lib/src/common/expiry_ticker.dart b/packages/formbricks_flutter/lib/src/common/expiry_ticker.dart new file mode 100644 index 0000000..dbb9f64 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/expiry_ticker.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:flutter/widgets.dart'; + +import '../types/config.dart'; +import 'api_client.dart'; +import 'config.dart'; +import 'logger.dart'; +import 'result.dart'; +import 'time.dart'; + +/// A single, lifecycle-aware expiry ticker. +/// +/// Replaces the RN SDK's two separate 60s tickers (workspace + user) with one +/// `Timer.periodic` that checks both. It is also lifecycle-aware: it cancels the +/// timer while the app is backgrounded and runs an immediate check the moment +/// the app resumes — RN ran its tickers regardless of app state, wasting battery +/// and racing storage on resume. +class ExpiryTicker with WidgetsBindingObserver { + /// Creates a ticker bound to [config] and [apiClient]. + /// + /// [onTick] overrides the work done each tick (test seam). [binding] overrides + /// the [WidgetsBinding] used to observe lifecycle (defaults to the instance). + ExpiryTicker({ + required this.config, + required this.apiClient, + this.interval = const Duration(seconds: 60), + this.onTick, + this.binding, + }); + + /// The config to read/persist expiry state against. + final FormbricksConfig config; + + /// The API client used to refetch workspace state on expiry. + final ApiClient apiClient; + + /// The check interval. + final Duration interval; + + /// Optional override for the per-tick work (test seam). + final Future Function()? onTick; + + /// Optional override for the lifecycle binding (test seam). + final WidgetsBinding? binding; + + Timer? _timer; + bool _started = false; + + /// The +30 min extension applied when a sync fails / user state is refreshed. + static const Duration _kExtension = Duration(minutes: 30); + + WidgetsBinding get _resolvedBinding => binding ?? WidgetsBinding.instance; + + /// Whether a timer is currently scheduled (test helper / introspection). + bool get hasActiveTimer => _timer?.isActive ?? false; + + /// Starts observing lifecycle and schedules the periodic check. + void start() { + if (_started) return; + _started = true; + _resolvedBinding.addObserver(this); + _scheduleTimer(); + } + + /// Cancels the timer and stops observing lifecycle. + void stop() { + _timer?.cancel(); + _timer = null; + _resolvedBinding.removeObserver(this); + _started = false; + } + + void _scheduleTimer() { + // Always cancel any existing timer first, so a resume can never leave two + // periodic timers running (the RN dual-ticker bug this design guards + // against). + _timer?.cancel(); + _timer = Timer.periodic(interval, (_) => unawaited(_tick())); + } + + Future _tick() => (onTick ?? _check)(); + + /// Runs one expiry check and awaits it. Test-only. + @visibleForTesting + Future debugCheck() => _check(); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + case AppLifecycleState.detached: + _timer?.cancel(); + _timer = null; + case AppLifecycleState.resumed: + unawaited(_tick()); + _scheduleTimer(); + } + } + + /// One expiry check: refetch the workspace if expired; extend the user state + /// expiry if it lapsed while a user is identified. + Future _check() async { + final snapshot = config.getOrNull(); + if (snapshot == null) return; + + final workspace = snapshot.workspace; + if (workspace != null && isNowExpired(workspace.expiresAt)) { + Logger.debug('Workspace state has expired. Starting sync.'); + final result = await apiClient.getWorkspaceState(); + switch (result) { + case Ok(:final value): + await config.update(snapshot.copyWith(workspace: value)); + case Err(:final error): + Logger.error('Error during workspace expiry sync: ${error.code}'); + // Extend validity so we retry later instead of hammering the backend. + await config.update( + snapshot.copyWith( + workspace: TWorkspaceState( + expiresAt: clock.now().add(_kExtension), + data: workspace.data, + ), + ), + ); + } + } + + final current = config.getOrNull(); + if (current == null) return; + final user = current.user; + final expiresAt = user.expiresAt; + if (user.data.userId != null && + expiresAt != null && + isNowExpired(expiresAt)) { + // Mirror RN's user ticker: extend the identified user's state by 30 min. + await config.update( + current.copyWith( + user: user.copyWith(expiresAt: clock.now().add(_kExtension)), + ), + ); + } + } +} diff --git a/packages/formbricks_flutter/lib/src/common/logger.dart b/packages/formbricks_flutter/lib/src/common/logger.dart new file mode 100644 index 0000000..4a0c7ad --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/logger.dart @@ -0,0 +1,52 @@ +import 'dart:developer' as developer; + +import 'package:clock/clock.dart'; +import 'package:flutter/foundation.dart'; + +/// Log verbosity. `error` (default) logs only errors; `debug` logs everything. +enum LogLevel { + /// Verbose: logs debug and error messages. + debug, + + /// Quiet: logs only error messages. + error, +} + +/// Static logging facade over a hidden singleton. +/// +/// Mirrors the RN SDK's `Logger` (same `🧱 Formbricks - [LEVEL] - msg` +/// format) but is configured once in `setup()` for deterministic behavior. +/// Routes through `dart:developer`'s `log()` rather than `print()` (the +/// `avoid_print` lint is on) and never logs attribute values or other PII. +class Logger { + Logger._(); + + static final Logger _instance = Logger._(); + + LogLevel _level = LogLevel.error; + + /// Sets the log [level]. Called from `setup()`. + static void configure({LogLevel? level}) { + if (level != null) _instance._level = level; + } + + /// Logs a debug message (suppressed unless level is [LogLevel.debug]). + static void debug(String message) => _instance._log(message, LogLevel.debug); + + /// Logs an error message (always emitted). + static void error(String message) => _instance._log(message, LogLevel.error); + + void _log(String message, LogLevel level) { + if (level == LogLevel.debug && _level != LogLevel.debug) return; + final timestamp = clock.now().toIso8601String(); + developer.log( + '🧱 Formbricks - $timestamp [${level.name.toUpperCase()}] - $message', + name: 'Formbricks', + level: level == LogLevel.error ? 1000 : 500, + ); + } + + /// Resets the singleton level to the default. Test-only. + @visibleForTesting + static void resetInstance() => _instance._level = LogLevel.error; +} diff --git a/packages/formbricks_flutter/lib/src/common/result.dart b/packages/formbricks_flutter/lib/src/common/result.dart new file mode 100644 index 0000000..9e76904 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/result.dart @@ -0,0 +1,49 @@ +/// A Rust-style result type: either an [Ok] value or an [Err] error. +/// +/// Ported from the React Native SDK's `Result` (`types/error.ts`). Using a +/// `sealed` class lets callers `switch` exhaustively without a `default` arm: +/// +/// ```dart +/// switch (result) { +/// case Ok(:final value): +/// useValue(value); +/// case Err(:final error): +/// handle(error); +/// } +/// ``` +/// +/// [Ok] and [Err] must stay in this library for the exhaustiveness guarantee to +/// hold. +sealed class Result { + const Result(); + + /// Wraps a success [value]. + const factory Result.ok(T value) = Ok; + + /// Wraps an error [error]. + const factory Result.err(E error) = Err; + + /// Whether this result is an [Ok]. + bool get isOk => this is Ok; + + /// Whether this result is an [Err]. + bool get isErr => this is Err; +} + +/// The success variant of [Result], holding a [value]. +final class Ok extends Result { + /// Creates a success result wrapping [value]. + const Ok(this.value); + + /// The wrapped success value. + final T value; +} + +/// The error variant of [Result], holding an [error]. +final class Err extends Result { + /// Creates an error result wrapping [error]. + const Err(this.error); + + /// The wrapped error. + final E error; +} diff --git a/packages/formbricks_flutter/lib/src/common/setup.dart b/packages/formbricks_flutter/lib/src/common/setup.dart new file mode 100644 index 0000000..641eaf0 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/setup.dart @@ -0,0 +1,243 @@ +import 'package:clock/clock.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../types/config.dart'; +import '../types/errors.dart'; +import 'api_client.dart'; +import 'config.dart'; +import 'expiry_ticker.dart'; +import 'logger.dart'; +import 'result.dart'; +import 'time.dart'; + +/// How long the SDK stays in the error state after a failed first setup before +/// it will retry. Named constant rather than an inline magic number (RN buried +/// this as `Date.now() + 10 * 60000`). +const Duration _kErrorCooldown = Duration(minutes: 10); + +bool _isSetup = false; +ExpiryTicker? _ticker; + +/// Whether `setup()` has completed. Read by the [CommandQueue]. +bool get isSetup => _isSetup; + +/// Overrides the setup flag. Exposed for the command queue + tests. +void setIsSetup({required bool value}) => _isSetup = value; + +/// Resets the setup module's state (flag + ticker). Test-only. Tests should +/// also call `FormbricksConfig.resetInstance()` and `Logger.resetInstance()`. +@visibleForTesting +void resetSetupForTest() { + _isSetup = false; + _ticker?.stop(); + _ticker = null; +} + +/// Initializes the SDK: validates input, hydrates the cached config, syncs +/// workspace (and user, when one is cached and expired), installs the expiry +/// ticker, and marks setup complete. +/// +/// Returns `Ok` on success and `Err(MissingFieldError)` for bad input. On a +/// **first-setup** network/forbidden failure it persists the error-cooldown +/// state and **throws** [FormbricksSetupError] (matching the RN SDK). +/// +/// [httpClient], [startTicker] and [logLevel] are seams for tests / the demo. +Future> setup({ + required String appUrl, + required String workspaceId, + http.Client? httpClient, + LogLevel? logLevel, + @visibleForTesting bool startTicker = true, +}) async { + if (logLevel != null) Logger.configure(level: logLevel); + + if (_isSetup) { + Logger.debug('Already set up, skipping setup.'); + return const Result.ok(null); + } + + final config = FormbricksConfig.instance; + await config.init(); + final existing = config.getOrNull(); + + // Error-cooldown gate. NOTE: this is the corrected logic — RN skipped setup + // when the cooldown had *expired* (a bug). Here: still cooling down (expiresAt + // in the future) → short-circuit; cooldown elapsed → clear and continue. + if (existing != null && existing.status.isError) { + final expiresAt = existing.status.expiresAt; + if (expiresAt != null && !isNowExpired(expiresAt)) { + Logger.debug('Within error cooldown. Skipping setup.'); + // The SDK stays inert during cooldown, so report an Err — not Ok — so + // callers don't mistake the suppressed-retry state for "ready". + return Result.err(SetupCooldownError(retryAt: expiresAt)); + } + Logger.debug('Error cooldown elapsed. Continuing with setup.'); + } + + // Validation. + if (workspaceId.isEmpty) { + Logger.debug('No workspaceId provided'); + return Result.err(MissingFieldError('workspaceId')); + } + if (appUrl.isEmpty) { + Logger.debug('No appUrl provided'); + return Result.err(MissingFieldError('appUrl')); + } + final uri = Uri.tryParse(appUrl); + if (uri == null || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.host.isEmpty) { + Logger.debug('appUrl is not a valid http(s) URL'); + return Result.err( + MissingFieldError( + 'appUrl', + message: 'appUrl must be a valid http(s) URL', + ), + ); + } + + // Strip any trailing slash so endpoints (which all start with `/api/...`) + // don't produce a double-slash URL the backend won't route. + final normalizedAppUrl = appUrl.replaceAll(RegExp(r'/+$'), ''); + + Logger.debug('Start setup'); + + final api = ApiClient( + appUrl: normalizedAppUrl, + workspaceId: workspaceId, + client: httpClient, + isDebug: logLevel == LogLevel.debug, + ); + + final matches = existing != null && + existing.workspace != null && + existing.workspaceId == workspaceId && + existing.appUrl == normalizedAppUrl; + + // The ticker takes ownership of `api` for its lifetime; every other path must + // close it so error/early-return/no-ticker paths don't leak the http.Client. + var handedToTicker = false; + try { + if (matches) { + final result = await _syncExistingConfig(config, api, existing); + if (result case Err()) return result; + } else { + // Fresh setup: reset any stale config, fetch, persist or enter error state. + await config.reset(); + final response = await api.getWorkspaceState(); + switch (response) { + case Ok(:final value): + await config.update( + TConfig( + workspaceId: workspaceId, + appUrl: normalizedAppUrl, + workspace: value, + user: TUserState.defaultNoUserId, + filteredSurveys: const [], + status: TStatus.success, + ), + ); + case Err(:final error): + // Persists error state and throws FormbricksSetupError. + await _handleErrorOnFirstSetup(config, error); + } + } + + if (startTicker) { + final ticker = ExpiryTicker(config: config, apiClient: api)..start(); + _ticker = ticker; + handedToTicker = true; + } + _isSetup = true; + Logger.debug('Set up complete'); + return const Result.ok(null); + } finally { + if (!handedToTicker) api.close(); + } +} + +/// Sync path when the cached config already matches the requested workspace/app: +/// refresh the user if it expired while identified, then persist. The workspace +/// is always still valid here — `FormbricksConfig.init` discards any cached +/// config whose workspace has expired before we reach this point. Returns +/// `Err(NetworkError)` on failure (does not throw — only first-setup throws). +Future> _syncExistingConfig( + FormbricksConfig config, + ApiClient api, + TConfig existing, +) async { + Logger.debug('Configuration fits setup parameters.'); + + final workspace = existing.workspace!; + + // User. + TUserState user; + final existingUser = existing.user; + final userExpiresAt = existingUser.expiresAt; + if (userExpiresAt == null || !isNowExpired(userExpiresAt)) { + user = existingUser; + } else if (existingUser.data.userId != null) { + Logger.debug('Person state expired. Syncing.'); + final response = await api.createOrUpdateUser( + userId: existingUser.data.userId!, + ); + switch (response) { + case Ok(:final value): + user = value.state; + case Err(:final error): + return Result.err(_toNetworkError(error)); + } + } else { + user = TUserState.defaultNoUserId; + } + + await config.update( + existing.copyWith( + workspace: workspace, + user: user, + filteredSurveys: const [], + status: TStatus.success, + ), + ); + return const Result.ok(null); +} + +/// Persists the error-cooldown state and throws [FormbricksSetupError]. Mirrors +/// RN's `handleErrorOnFirstSetup`. +Future _handleErrorOnFirstSetup( + FormbricksConfig config, + ApiErrorResponse error, +) async { + final isForbidden = error.code == 'forbidden'; + if (isForbidden) { + Logger.error('Authorization error: ${error.message}'); + } else { + Logger.error( + 'Error during first setup: ${error.code} - ${error.message}. ' + 'Please try again later.', + ); + } + + await config.update( + TConfig( + status: TStatus( + value: 'error', + expiresAt: clock.now().add(_kErrorCooldown), + ), + ), + ); + + throw FormbricksSetupError( + code: isForbidden + ? FormbricksErrorCode.forbidden + : FormbricksErrorCode.networkError, + ); +} + +NetworkError _toNetworkError(ApiErrorResponse error) => NetworkError( + message: error.message, + status: error.status, + url: error.url, + responseMessage: error.responseMessage, + ); diff --git a/packages/formbricks_flutter/lib/src/common/time.dart b/packages/formbricks_flutter/lib/src/common/time.dart new file mode 100644 index 0000000..1debf0a --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/time.dart @@ -0,0 +1,8 @@ +import 'package:clock/clock.dart'; + +/// Whether [expiresAt] is at or before "now". +/// +/// Mirrors the RN SDK's `isNowExpired` (`now >= expirationDate`). Reads the +/// current time via `clock.now()` so expiry logic is deterministic under test +/// (`withClock(...)`). +bool isNowExpired(DateTime expiresAt) => !clock.now().isBefore(expiresAt); diff --git a/packages/formbricks_flutter/lib/src/types/config.dart b/packages/formbricks_flutter/lib/src/types/config.dart new file mode 100644 index 0000000..5175239 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/types/config.dart @@ -0,0 +1,364 @@ +/// Persisted SDK configuration models. +/// +/// These mirror the React Native SDK's `types/config.ts`. The one rule that +/// matters: **`DateTime` lives in memory, ISO-8601 strings live on the wire and +/// on disk**, and the conversion happens *only* inside the `fromJson` / `toJson` +/// methods here. No `DateTime.parse` anywhere else in the codebase. +/// +/// Survey and action-class entries are intentionally loosely typed +/// (`Map`) until the track + survey-rendering work lands. +library; + +/// Parses an optional ISO-8601 string into a [DateTime]. The single inbound +/// date boundary. +DateTime? _parseDate(Object? value) => + value == null ? null : DateTime.parse(value as String); + +/// Serializes an optional [DateTime] to an ISO-8601 string. The single outbound +/// date boundary. +String? _dateToIso(DateTime? value) => value?.toIso8601String(); + +/// A single survey display record. +class TDisplay { + /// Creates a display record. + const TDisplay({required this.surveyId, required this.createdAt}); + + /// Builds a [TDisplay] from decoded JSON. + factory TDisplay.fromJson(Map json) => TDisplay( + surveyId: json['surveyId'] as String, + createdAt: _parseDate(json['createdAt'])!, + ); + + /// The id of the displayed survey. + final String surveyId; + + /// When the survey was displayed. + final DateTime createdAt; + + /// Encodes this record to JSON with ISO-8601 dates. + Map toJson() => { + 'surveyId': surveyId, + 'createdAt': _dateToIso(createdAt), + }; +} + +/// The user-scoped slice of state (identity, segments, displays, responses). +class TUserData { + /// Creates user data. + const TUserData({ + this.userId, + this.contactId, + this.segments = const [], + this.displays = const [], + this.responses = const [], + this.lastDisplayAt, + this.language, + }); + + /// Builds [TUserData] from decoded JSON, tolerating missing keys. + factory TUserData.fromJson(Map json) => TUserData( + userId: json['userId'] as String?, + contactId: json['contactId'] as String?, + segments: + (json['segments'] as List?)?.cast() ?? const [], + displays: (json['displays'] as List?) + ?.map((e) => TDisplay.fromJson(e as Map)) + .toList() ?? + const [], + responses: + (json['responses'] as List?)?.cast() ?? const [], + lastDisplayAt: _parseDate(json['lastDisplayAt']), + language: json['language'] as String?, + ); + + /// The identified user id, or null when anonymous. + final String? userId; + + /// The resolved contact id, or null. + final String? contactId; + + /// Segment keys the user belongs to. + final List segments; + + /// Recent survey displays. + final List displays; + + /// Response ids the user has submitted. + final List responses; + + /// When the last survey was shown, or null. + final DateTime? lastDisplayAt; + + /// The active language code, or null. + final String? language; + + /// Encodes this user data to JSON with ISO-8601 dates. + Map toJson() => { + 'userId': userId, + 'contactId': contactId, + 'segments': segments, + 'displays': displays.map((d) => d.toJson()).toList(), + 'responses': responses, + 'lastDisplayAt': _dateToIso(lastDisplayAt), + if (language != null) 'language': language, + }; +} + +/// The user state envelope: an expiry plus the user [data]. +class TUserState { + /// Creates a user state. + const TUserState({required this.expiresAt, required this.data}); + + /// Builds [TUserState] from decoded JSON. + factory TUserState.fromJson(Map json) => TUserState( + expiresAt: _parseDate(json['expiresAt']), + data: TUserData.fromJson( + (json['data'] as Map?)?.cast() ?? + const {}, + ), + ); + + /// When this user state expires, or null for anonymous (never expires). + final DateTime? expiresAt; + + /// The user data slice. + final TUserData data; + + /// The default anonymous user state (no user id, never expires). Mirrors RN's + /// `DEFAULT_USER_STATE_NO_USER_ID`. + static const TUserState defaultNoUserId = TUserState( + expiresAt: null, + data: TUserData(), + ); + + /// Returns a copy with the given fields overridden. + TUserState copyWith({DateTime? expiresAt, TUserData? data}) => TUserState( + expiresAt: expiresAt ?? this.expiresAt, + data: data ?? this.data, + ); + + /// Encodes this user state to JSON with ISO-8601 dates. + Map toJson() => { + 'expiresAt': _dateToIso(expiresAt), + 'data': data.toJson(), + }; +} + +/// The workspace-scoped data: surveys, action classes, and settings. +/// +/// Surveys and action classes stay loosely typed until track + rendering land. +class TWorkspaceData { + /// Creates workspace data. + const TWorkspaceData({ + this.surveys = const [], + this.actionClasses = const [], + this.settings = const {}, + }); + + /// Builds [TWorkspaceData] from decoded JSON. + factory TWorkspaceData.fromJson(Map json) => TWorkspaceData( + surveys: (json['surveys'] as List?) ?? const [], + actionClasses: (json['actionClasses'] as List?) ?? const [], + settings: (json['settings'] as Map?)?.cast() ?? + const {}, + ); + + /// The workspace's surveys. + final List surveys; + + /// The workspace's action classes. + final List actionClasses; + + /// Workspace settings (recontactDays, placement, styling, …). + final Map settings; + + /// Encodes this workspace data to JSON. + Map toJson() => { + 'surveys': surveys, + 'actionClasses': actionClasses, + 'settings': settings, + }; +} + +/// The workspace state envelope: an expiry plus the workspace [data]. +class TWorkspaceState { + /// Creates a workspace state. + const TWorkspaceState({required this.expiresAt, required this.data}); + + /// Builds [TWorkspaceState] from decoded JSON. + factory TWorkspaceState.fromJson(Map json) => + TWorkspaceState( + expiresAt: _parseDate(json['expiresAt'])!, + data: TWorkspaceData.fromJson( + (json['data'] as Map?)?.cast() ?? + const {}, + ), + ); + + /// When this workspace state expires. + final DateTime expiresAt; + + /// The workspace data slice. + final TWorkspaceData data; + + /// Encodes this workspace state to JSON with ISO-8601 dates. + Map toJson() => { + 'expiresAt': _dateToIso(expiresAt), + 'data': data.toJson(), + }; +} + +/// The success/error status envelope, with an optional cooldown expiry. +class TStatus { + /// Creates a status. + const TStatus({required this.value, this.expiresAt}); + + /// Builds [TStatus] from decoded JSON. + factory TStatus.fromJson(Map json) => TStatus( + value: json['value'] as String? ?? 'success', + expiresAt: _parseDate(json['expiresAt']), + ); + + /// The success default status. + static const TStatus success = TStatus(value: 'success'); + + /// Either `'success'` or `'error'`. + final String value; + + /// When an error cooldown expires, or null. + final DateTime? expiresAt; + + /// Whether this status represents an error state. + bool get isError => value == 'error'; + + /// Encodes this status to JSON with ISO-8601 dates. + Map toJson() => { + 'value': value, + 'expiresAt': _dateToIso(expiresAt), + }; +} + +/// The full persisted SDK config. +/// +/// [workspace] is nullable: a first-setup failure persists a config that only +/// carries an error [status] (no workspace yet), and [fromJson] must round-trip +/// that shape so the error cooldown survives a reload. +class TConfig { + /// Creates a config. + const TConfig({ + this.workspaceId, + this.appUrl, + this.workspace, + this.user = TUserState.defaultNoUserId, + this.filteredSurveys = const [], + this.status = TStatus.success, + }); + + /// Builds [TConfig] from decoded JSON, tolerating missing `workspace`/`user`. + factory TConfig.fromJson(Map json) => TConfig( + workspaceId: json['workspaceId'] as String?, + appUrl: json['appUrl'] as String?, + workspace: json['workspace'] == null + ? null + : TWorkspaceState.fromJson( + (json['workspace'] as Map).cast(), + ), + user: json['user'] == null + ? TUserState.defaultNoUserId + : TUserState.fromJson( + (json['user'] as Map).cast(), + ), + filteredSurveys: (json['filteredSurveys'] as List?) ?? const [], + status: json['status'] == null + ? TStatus.success + : TStatus.fromJson((json['status'] as Map).cast()), + ); + + /// The workspace id this config belongs to. + final String? workspaceId; + + /// The app URL the SDK talks to. + final String? appUrl; + + /// The cached workspace state, or null in an error-only config. + final TWorkspaceState? workspace; + + /// The cached user state (anonymous by default). + final TUserState user; + + /// Surveys eligible to show. Populated by the filter logic in a later ticket; + /// left empty here. + final List filteredSurveys; + + /// The success/error status. + final TStatus status; + + /// Returns a copy with the given fields overridden. + TConfig copyWith({ + String? workspaceId, + String? appUrl, + TWorkspaceState? workspace, + TUserState? user, + List? filteredSurveys, + TStatus? status, + }) => + TConfig( + workspaceId: workspaceId ?? this.workspaceId, + appUrl: appUrl ?? this.appUrl, + workspace: workspace ?? this.workspace, + user: user ?? this.user, + filteredSurveys: filteredSurveys ?? this.filteredSurveys, + status: status ?? this.status, + ); + + /// Encodes this config to JSON with ISO-8601 dates. + Map toJson() => { + 'workspaceId': workspaceId, + 'appUrl': appUrl, + 'workspace': workspace?.toJson(), + 'user': user.toJson(), + 'filteredSurveys': filteredSurveys, + 'status': status.toJson(), + }; +} + +/// Input for a user create/update call. +class TUpdates { + /// Creates an update payload. + const TUpdates({required this.userId, this.attributes}); + + /// The user id to identify. + final String userId; + + /// Optional attributes to set (numbers preserved as numbers). + final Map? attributes; +} + +/// Response body of `POST /api/v2/client/{workspaceId}/user`. +class CreateOrUpdateUserResponse { + /// Creates a response. + const CreateOrUpdateUserResponse({ + required this.state, + this.messages, + this.errors, + }); + + /// Builds the response from decoded JSON. + factory CreateOrUpdateUserResponse.fromJson(Map json) => + CreateOrUpdateUserResponse( + state: TUserState.fromJson( + (json['state'] as Map).cast(), + ), + messages: (json['messages'] as List?)?.cast(), + errors: (json['errors'] as List?)?.cast(), + ); + + /// The synced user state. + final TUserState state; + + /// Optional informational messages. + final List? messages; + + /// Optional error messages. + final List? errors; +} diff --git a/packages/formbricks_flutter/lib/src/types/errors.dart b/packages/formbricks_flutter/lib/src/types/errors.dart new file mode 100644 index 0000000..4272e3e --- /dev/null +++ b/packages/formbricks_flutter/lib/src/types/errors.dart @@ -0,0 +1,152 @@ +/// Stable error codes used across the SDK. The [wire] value matches the string +/// the backend and the React Native SDK use, so logs and tests stay comparable. +enum FormbricksErrorCode { + /// A required input field was missing or empty. + missingField('missing_field'), + + /// A network/transport failure or non-2xx server response. + networkError('network_error'), + + /// The SDK was used before [setup] completed. + notSetup('not_setup'), + + /// The backend rejected the request (auth / 404 environment). + forbidden('forbidden'), + + /// `setup()` was called while the SDK is in its post-failure cooldown window. + setupCooldown('setup_cooldown'), + + /// An invalid code was supplied (reserved for code actions). + invalidCode('invalid_code'); + + const FormbricksErrorCode(this.wire); + + /// The on-the-wire string representation of this code. + final String wire; +} + +/// Base type for all SDK errors. Sealed so callers can switch exhaustively. +sealed class FormbricksError implements Exception { + const FormbricksError(this.code, this.message); + + /// The stable error code. + final FormbricksErrorCode code; + + /// A human-readable description (never contains PII). + final String message; + + @override + String toString() => 'FormbricksError(${code.wire}): $message'; +} + +/// A required field (`appUrl`, `workspaceId`, …) was missing or invalid. +final class MissingFieldError extends FormbricksError { + /// Creates a missing-field error for [field]. + MissingFieldError(this.field, {String? message}) + : super( + FormbricksErrorCode.missingField, + message ?? 'No $field provided', + ); + + /// The name of the offending field. + final String field; +} + +/// The SDK was used before [setup] completed. +final class NotSetupError extends FormbricksError { + /// Creates a not-setup error. + NotSetupError([ + String message = 'Formbricks is not set up. Call setup() first.', + ]) : super(FormbricksErrorCode.notSetup, message); +} + +/// A network failure or non-2xx response while talking to the backend. +final class NetworkError extends FormbricksError { + /// Creates a network error with the HTTP [status] and optional [url]. + NetworkError({ + required String message, + required this.status, + this.url, + this.responseMessage, + }) : super(FormbricksErrorCode.networkError, message); + + /// The HTTP status code (or 500 when unknown). + final int status; + + /// The endpoint that failed, when known. + final Uri? url; + + /// The raw message returned by the server, when present. + final String? responseMessage; +} + +/// An invalid code action was supplied. Reserved for the track feature. +final class InvalidCodeError extends FormbricksError { + /// Creates an invalid-code error. + InvalidCodeError([String message = 'Invalid code']) + : super(FormbricksErrorCode.invalidCode, message); +} + +/// Thrown when the very first [setup] attempt fails and the SDK is placed into +/// the error-cooldown state. Mirrors the RN SDK throwing on first-setup failure. +final class FormbricksSetupError extends FormbricksError { + /// Creates a setup error. Carries the underlying [code] (network/forbidden). + FormbricksSetupError({ + String message = 'Could not set up Formbricks', + FormbricksErrorCode code = FormbricksErrorCode.networkError, + }) : super(code, message); +} + +/// Returned by `setup()` when it is called while the SDK is still inside the +/// error cooldown that a previous failed setup opened. The SDK is **not** set up; +/// callers should retry after [retryAt]. +final class SetupCooldownError extends FormbricksError { + /// Creates a cooldown error, optionally carrying the cooldown expiry. + SetupCooldownError({this.retryAt}) + : super( + FormbricksErrorCode.setupCooldown, + 'Formbricks is in an error cooldown after a failed setup. ' + 'Retry later.', + ); + + /// When the cooldown expires and `setup()` will attempt again, if known. + final DateTime? retryAt; +} + +/// A normalized API error returned by [ApiClient]. Distinct from +/// [FormbricksError] because the API layer preserves the raw server `code` +/// string (which may be wider than [FormbricksErrorCode]). +class ApiErrorResponse { + /// Creates an API error response. + const ApiErrorResponse({ + required this.code, + required this.status, + required this.message, + this.url, + this.details, + this.responseMessage, + }); + + /// The server-supplied (or normalized) error code, e.g. `forbidden`, + /// `network_error`. + final String code; + + /// The HTTP status code (or 500 when unknown). + final int status; + + /// A human-readable description. + final String message; + + /// The endpoint that failed, when known. + final Uri? url; + + /// Optional structured error details from the server. + final Map? details; + + /// The raw message returned by the server, when present. + final String? responseMessage; + + @override + String toString() => + 'ApiErrorResponse(code: $code, status: $status, message: $message)'; +} diff --git a/packages/formbricks_flutter/pubspec.yaml b/packages/formbricks_flutter/pubspec.yaml index 1734080..59c8621 100644 --- a/packages/formbricks_flutter/pubspec.yaml +++ b/packages/formbricks_flutter/pubspec.yaml @@ -24,14 +24,17 @@ environment: dependencies: flutter: sdk: flutter - # Runtime deps (webview_flutter, shared_preferences, url_launcher, - # connectivity_plus, http) are added in the implementation work, - # not in this skeleton. + http: ^1.2.0 + shared_preferences: ^2.3.0 + clock: ^1.1.2 + # Survey-rendering deps (webview_flutter, url_launcher, connectivity_plus) + # are added when track + WebView land. dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - # mocktail / http MockClient added with the first real source files. + mocktail: ^1.0.0 + fake_async: ^1.3.0 flutter: diff --git a/packages/formbricks_flutter/test/common/api_client_test.dart b/packages/formbricks_flutter/test/common/api_client_test.dart new file mode 100644 index 0000000..7dfe625 --- /dev/null +++ b/packages/formbricks_flutter/test/common/api_client_test.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/api_client.dart'; +import 'package:formbricks_flutter/src/common/result.dart'; +import 'package:formbricks_flutter/src/types/config.dart'; +import 'package:formbricks_flutter/src/types/errors.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +const _appUrl = 'https://app.formbricks.com'; +const _workspaceId = 'wsp_1'; + +String _envBody(Map data) => jsonEncode({ + 'data': {'expiresAt': '2100-01-01T00:00:00.000', 'data': data}, + }); + +String _userBody() => jsonEncode({ + 'data': { + 'state': { + 'expiresAt': null, + 'data': { + 'userId': 'u1', + 'contactId': null, + 'segments': [], + 'displays': [], + 'responses': [], + 'lastDisplayAt': null, + }, + }, + }, + }); + +ApiClient _client(MockClient mock, {bool isDebug = false}) => ApiClient( + appUrl: _appUrl, + workspaceId: _workspaceId, + client: mock, + isDebug: isDebug, + ); + +TWorkspaceState _okWorkspace(Result r) => + switch (r) { + Ok(:final value) => value, + Err(:final error) => fail('expected Ok, got $error'), + }; + +ApiErrorResponse _errOf(Result r) => switch (r) { + Ok() => fail('expected Err'), + Err(:final error) => error, + }; + +void main() { + group('getWorkspaceState', () { + test('parses a happy response', () async { + late http.Request captured; + final mock = MockClient((req) async { + captured = req; + return http.Response( + _envBody({ + 'surveys': [], + 'actionClasses': [], + 'settings': {'recontactDays': 5}, + }), + 200, + ); + }); + + final result = await _client(mock).getWorkspaceState(); + expect(captured.method, 'GET'); + expect(captured.url.path, '/api/v2/client/$_workspaceId/environment'); + expect(_okWorkspace(result).data.settings, {'recontactDays': 5}); + }); + + test('maps legacy data.workspace to settings', () async { + final mock = MockClient( + (req) async => http.Response( + _envBody({ + 'surveys': [], + 'actionClasses': [], + 'workspace': {'placement': 'bottomRight'}, + }), + 200, + ), + ); + + final result = await _client(mock).getWorkspaceState(); + expect(_okWorkspace(result).data.settings, {'placement': 'bottomRight'}); + }); + + test('maps legacy data.project to settings', () async { + final mock = MockClient( + (req) async => http.Response( + _envBody({ + 'surveys': [], + 'actionClasses': [], + 'project': {'placement': 'center'}, + }), + 200, + ), + ); + + final result = await _client(mock).getWorkspaceState(); + expect(_okWorkspace(result).data.settings, {'placement': 'center'}); + }); + + test('maps a 4xx response to forbidden', () async { + final mock = MockClient( + (req) async => http.Response('{"message":"nope"}', 403), + ); + final error = _errOf(await _client(mock).getWorkspaceState()); + expect(error.code, 'forbidden'); + expect(error.status, 403); + }); + + test('maps a 404 to forbidden', () async { + final mock = MockClient((req) async => http.Response('{}', 404)); + expect(_errOf(await _client(mock).getWorkspaceState()).code, 'forbidden'); + }); + + test('maps a 5xx response to network_error', () async { + final mock = MockClient((req) async => http.Response('{}', 500)); + final error = _errOf(await _client(mock).getWorkspaceState()); + expect(error.code, 'network_error'); + expect(error.status, 500); + }); + + test('catches a thrown SocketException as network_error', () async { + final mock = MockClient( + (req) async => throw const SocketException('offline'), + ); + expect( + _errOf(await _client(mock).getWorkspaceState()).code, + 'network_error', + ); + }); + + test('catches a thrown ClientException as network_error', () async { + final mock = MockClient( + (req) async => throw http.ClientException('boom'), + ); + expect( + _errOf(await _client(mock).getWorkspaceState()).code, + 'network_error', + ); + }); + + test('maps a 200 with a malformed body to network_error', () async { + // 2xx, but data.expiresAt is missing → TWorkspaceState.fromJson throws. + // Must normalize to Err(network_error), not leak a raw TypeError. + final mock = MockClient( + (req) async => http.Response( + jsonEncode({ + 'data': { + 'data': {'surveys': [], 'actionClasses': []}, + }, + }), + 200, + ), + ); + final error = _errOf(await _client(mock).getWorkspaceState()); + expect(error.code, 'network_error'); + expect(error.status, 200); + expect(error.message, 'Malformed response'); + }); + + test('maps a 200 with an empty body to network_error', () async { + final mock = MockClient((req) async => http.Response('', 200)); + final error = _errOf(await _client(mock).getWorkspaceState()); + expect(error.code, 'network_error'); + expect(error.status, 200); + }); + }); + + group('createOrUpdateUser', () { + test( + 'preserves number types and sends the right URL/method/body', + () async { + late http.Request captured; + final mock = MockClient((req) async { + captured = req; + return http.Response(_userBody(), 200); + }); + + final result = await _client(mock).createOrUpdateUser( + userId: 'u1', + attributes: {'plan': 'pro', 'mrr': 99}, + ); + + expect(result.isOk, isTrue); + expect(captured.method, 'POST'); + expect(captured.url.path, '/api/v2/client/$_workspaceId/user'); + final body = jsonDecode(captured.body) as Map; + expect(body['userId'], 'u1'); + expect(body['attributes']['plan'], 'pro'); + expect(body['attributes']['mrr'], 99); + expect(body['attributes']['mrr'], isA()); + }, + ); + + test('omits attributes when none are given', () async { + late http.Request captured; + final mock = MockClient((req) async { + captured = req; + return http.Response(_userBody(), 200); + }); + + await _client(mock).createOrUpdateUser(userId: 'u1'); + + final body = jsonDecode(captured.body) as Map; + expect(body.containsKey('attributes'), isFalse); + }); + }); + + group('headers', () { + test('isDebug adds Cache-Control: no-cache', () async { + late http.Request captured; + final mock = MockClient((req) async { + captured = req; + return http.Response(_envBody({'settings': {}}), 200); + }); + + await _client(mock, isDebug: true).getWorkspaceState(); + expect(captured.headers['cache-control'], 'no-cache'); + }); + + test('default does not set Cache-Control', () async { + late http.Request captured; + final mock = MockClient((req) async { + captured = req; + return http.Response(_envBody({'settings': {}}), 200); + }); + + await _client(mock).getWorkspaceState(); + expect(captured.headers.containsKey('cache-control'), isFalse); + }); + }); +} diff --git a/packages/formbricks_flutter/test/common/command_queue_test.dart b/packages/formbricks_flutter/test/common/command_queue_test.dart new file mode 100644 index 0000000..7566954 --- /dev/null +++ b/packages/formbricks_flutter/test/common/command_queue_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/command_queue.dart'; +import 'package:formbricks_flutter/src/types/errors.dart'; + +void main() { + group('CommandQueue', () { + test('runs parallel adds in submission order', () async { + final queue = CommandQueue(isSetup: () => true); + final order = []; + + // First command has the longest delay; FIFO must still run it first. + final f1 = queue.add(() async { + await Future.delayed(const Duration(milliseconds: 30)); + order.add(1); + }); + final f2 = queue.add(() async { + await Future.delayed(const Duration(milliseconds: 5)); + order.add(2); + }); + final f3 = queue.add(() async => order.add(3)); + + await Future.wait([f1, f2, f3]); + expect(order, [1, 2, 3]); + }); + + test( + 'completes with NotSetupError and never runs the body when not set up', + () async { + final queue = CommandQueue(isSetup: () => false); + var ran = false; + + final future = queue.add(() => ran = true); + + await expectLater(future, throwsA(isA())); + expect(ran, isFalse); + }, + ); + + test('a throwing command does not stop the next command', () async { + final queue = CommandQueue(isSetup: () => true); + var secondRan = false; + + final failing = queue.add(() => throw const FormatException('bad')); + final next = queue.add(() => secondRan = true); + + await expectLater(failing, throwsA(isA())); + await next; + expect(secondRan, isTrue); + }); + + test( + 'surfaces the original error on the throwing command future', + () async { + final queue = CommandQueue(isSetup: () => true); + final future = queue.add(() => throw StateError('original')); + await expectLater( + future, + throwsA( + isA().having((e) => e.message, 'message', 'original'), + ), + ); + }, + ); + + test('runs when checkSetup is false even if not set up', () async { + final queue = CommandQueue(isSetup: () => false); + final result = await queue.add(() => 42, checkSetup: false); + expect(result, 42); + }); + + test('returns the typed command result', () async { + final queue = CommandQueue(isSetup: () => true); + final value = await queue.add(() async => 'hi'); + expect(value, 'hi'); + }); + }); +} diff --git a/packages/formbricks_flutter/test/common/config_test.dart b/packages/formbricks_flutter/test/common/config_test.dart new file mode 100644 index 0000000..901cb4b --- /dev/null +++ b/packages/formbricks_flutter/test/common/config_test.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/config.dart'; +import 'package:formbricks_flutter/src/types/config.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + FormbricksConfig.resetInstance(); + }); + + TConfig sampleConfig({DateTime? workspaceExpiry}) => TConfig( + workspaceId: 'wsp_1', + appUrl: 'https://app.formbricks.com', + workspace: TWorkspaceState( + expiresAt: workspaceExpiry ?? DateTime(2100), + data: const TWorkspaceData(), + ), + user: TUserState.defaultNoUserId, + filteredSurveys: const [], + status: TStatus.success, + ); + + test('update() persists and completes only after the disk write', () async { + final config = FormbricksConfig.instance; + await config.init(); + + await config.update(sampleConfig()); + + // The write has completed by the time update() returns. + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(FormbricksConfig.storageKey); + expect(stored, isNotNull); + expect(jsonDecode(stored!)['workspaceId'], 'wsp_1'); + expect(config.get().workspaceId, 'wsp_1'); + }); + + test('reset() clears the stored entry and in-memory config', () async { + final config = FormbricksConfig.instance; + await config.init(); + await config.update(sampleConfig()); + + await config.reset(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(FormbricksConfig.storageKey), isNull); + expect(config.getOrNull(), isNull); + expect(config.isInitialized, isFalse); + expect(config.get, throwsStateError); + }); + + test('init() loads a valid cached config', () async { + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: jsonEncode(sampleConfig().toJson()), + }); + FormbricksConfig.resetInstance(); + + final config = FormbricksConfig.instance; + await config.init(); + + expect(config.isInitialized, isTrue); + expect(config.get().workspaceId, 'wsp_1'); + }); + + test('init() drops a config whose workspace has expired', () async { + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: jsonEncode( + sampleConfig(workspaceExpiry: DateTime(2000)).toJson(), + ), + }); + FormbricksConfig.resetInstance(); + + final config = FormbricksConfig.instance; + await config.init(); + + expect(config.getOrNull(), isNull); + }); + + test('init() keeps an error-only config (no workspace)', () async { + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: jsonEncode( + TConfig( + status: TStatus(value: 'error', expiresAt: DateTime(2100)), + ).toJson(), + ), + }); + FormbricksConfig.resetInstance(); + + final config = FormbricksConfig.instance; + await config.init(); + + expect(config.getOrNull(), isNotNull); + expect(config.get().status.isError, isTrue); + }); + + test('get() throws before init produces a config', () { + final config = FormbricksConfig.instance; + expect(config.get, throwsStateError); + }); +} diff --git a/packages/formbricks_flutter/test/common/expiry_ticker_test.dart b/packages/formbricks_flutter/test/common/expiry_ticker_test.dart new file mode 100644 index 0000000..be46bad --- /dev/null +++ b/packages/formbricks_flutter/test/common/expiry_ticker_test.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; + +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/api_client.dart'; +import 'package:formbricks_flutter/src/common/config.dart'; +import 'package:formbricks_flutter/src/common/expiry_ticker.dart'; +import 'package:formbricks_flutter/src/types/config.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +String _envBody(String expiresAt) => jsonEncode({ + 'data': { + 'expiresAt': expiresAt, + 'data': { + 'surveys': [], + 'actionClasses': [], + 'settings': {}, + }, + }, + }); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late ApiClient api; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + FormbricksConfig.resetInstance(); + api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient((_) async => http.Response('{}', 200)), + ); + }); + + ExpiryTicker makeTicker(void Function() onEachTick) => ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + interval: const Duration(seconds: 60), + onTick: () async => onEachTick(), + ); + + test('does not tick while paused', () { + fakeAsync((async) { + var ticks = 0; + final ticker = makeTicker(() => ticks++)..start(); + ticker.didChangeAppLifecycleState(AppLifecycleState.paused); + async.elapse(const Duration(minutes: 5)); + expect(ticks, 0); + ticker.stop(); + }); + }); + + test('runs an immediate check on resume', () { + fakeAsync((async) { + var ticks = 0; + final ticker = makeTicker(() => ticks++)..start(); + expect(ticks, 0, reason: 'start() should not tick immediately'); + ticker.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(ticks, 1, reason: 'resume should run an immediate check'); + ticker.stop(); + }); + }); + + test('keeps exactly one timer across a pause/resume cycle', () { + fakeAsync((async) { + var ticks = 0; + final ticker = makeTicker(() => ticks++)..start(); + ticker.didChangeAppLifecycleState(AppLifecycleState.paused); + ticker.didChangeAppLifecycleState(AppLifecycleState.resumed); + final afterResume = ticks; // 1 (the immediate check) + async.elapse(const Duration(seconds: 60)); + // One timer → exactly one more tick. Two timers would add two. + expect(ticks - afterResume, 1); + ticker.stop(); + }); + }); + + test('fires on the interval while active', () { + fakeAsync((async) { + var ticks = 0; + final ticker = makeTicker(() => ticks++)..start(); + async.elapse(const Duration(seconds: 60)); + expect(ticks, 1); + async.elapse(const Duration(seconds: 60)); + expect(ticks, 2); + ticker.stop(); + }); + }); + + group('check()', () { + final now = DateTime(2026, 6, 1, 12); + + Future seed(TConfig config) async { + final c = FormbricksConfig.instance; + await c.init(); + await c.update(config); + } + + TConfig configWith({ + required DateTime workspaceExpiry, + DateTime? userExpiry, + String? userId, + }) => + TConfig( + workspaceId: 'w', + appUrl: 'https://app.x', + workspace: TWorkspaceState( + expiresAt: workspaceExpiry, + data: const TWorkspaceData(), + ), + user: TUserState( + expiresAt: userExpiry, + data: TUserData(userId: userId), + ), + status: TStatus.success, + ); + + test('refetches the workspace when it has expired', () async { + await seed( + configWith(workspaceExpiry: now.subtract(const Duration(minutes: 1))), + ); + var calls = 0; + final api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient((_) async { + calls++; + return http.Response(_envBody('2100-01-01T00:00:00.000'), 200); + }), + ); + final ticker = ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + ); + + await withClock(Clock.fixed(now), ticker.debugCheck); + + expect(calls, 1); + expect(FormbricksConfig.instance.get().workspace!.expiresAt.year, 2100); + }); + + test('extends workspace validity when the refetch fails', () async { + await seed( + configWith(workspaceExpiry: now.subtract(const Duration(minutes: 1))), + ); + final api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient((_) async => http.Response('{}', 500)), + ); + final ticker = ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + ); + + await withClock(Clock.fixed(now), ticker.debugCheck); + + expect( + FormbricksConfig.instance.get().workspace!.expiresAt, + now.add(const Duration(minutes: 30)), + ); + }); + + test('extends an identified user state that has expired', () async { + await seed( + configWith( + workspaceExpiry: now.add(const Duration(hours: 1)), + userExpiry: now.subtract(const Duration(minutes: 1)), + userId: 'u1', + ), + ); + var calls = 0; + final api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient((_) async { + calls++; + return http.Response('{}', 200); + }), + ); + final ticker = ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + ); + + await withClock(Clock.fixed(now), ticker.debugCheck); + + expect(calls, 0, reason: 'workspace not expired → no refetch'); + expect( + FormbricksConfig.instance.get().user.expiresAt, + now.add(const Duration(minutes: 30)), + ); + }); + + test('does nothing when no config is loaded', () async { + FormbricksConfig.resetInstance(); + final api = ApiClient( + appUrl: 'https://app.x', + workspaceId: 'w', + client: MockClient((_) async => http.Response('{}', 200)), + ); + final ticker = ExpiryTicker( + config: FormbricksConfig.instance, + apiClient: api, + ); + await withClock(Clock.fixed(now), ticker.debugCheck); + expect(FormbricksConfig.instance.getOrNull(), isNull); + }); + }); +} diff --git a/packages/formbricks_flutter/test/common/result_test.dart b/packages/formbricks_flutter/test/common/result_test.dart new file mode 100644 index 0000000..7e3c18a --- /dev/null +++ b/packages/formbricks_flutter/test/common/result_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/result.dart'; + +void main() { + group('Result', () { + test('Ok holds its value and reports isOk', () { + const Result result = Result.ok(5); + expect(result.isOk, isTrue); + expect(result.isErr, isFalse); + expect((result as Ok).value, 5); + }); + + test('Err holds its error and reports isErr', () { + const Result result = Result.err('boom'); + expect(result.isErr, isTrue); + expect(result.isOk, isFalse); + expect((result as Err).error, 'boom'); + }); + + test('can be matched exhaustively with a switch', () { + String describe(Result r) => switch (r) { + Ok(:final value) => 'ok:$value', + Err(:final error) => 'err:$error', + }; + + expect(describe(const Result.ok(1)), 'ok:1'); + expect(describe(const Result.err('x')), 'err:x'); + }); + }); +} diff --git a/packages/formbricks_flutter/test/common/setup_test.dart b/packages/formbricks_flutter/test/common/setup_test.dart new file mode 100644 index 0000000..cc93162 --- /dev/null +++ b/packages/formbricks_flutter/test/common/setup_test.dart @@ -0,0 +1,403 @@ +import 'dart:convert'; + +import 'package:clock/clock.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/common/config.dart'; +import 'package:formbricks_flutter/src/common/logger.dart'; +import 'package:formbricks_flutter/src/common/result.dart'; +import 'package:formbricks_flutter/src/common/setup.dart'; +import 'package:formbricks_flutter/src/types/config.dart'; +import 'package:formbricks_flutter/src/types/errors.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _appUrl = 'https://app.formbricks.com'; +const _workspaceId = 'wsp_1'; + +String _envBody() => jsonEncode({ + 'data': { + 'expiresAt': '2100-01-01T00:00:00.000', + 'data': { + 'surveys': [], + 'actionClasses': [], + 'settings': {}, + }, + }, + }); + +String _userBody() => jsonEncode({ + 'data': { + 'state': { + 'expiresAt': null, + 'data': { + 'userId': 'u1', + 'contactId': null, + 'segments': [], + 'displays': [], + 'responses': [], + 'lastDisplayAt': null, + }, + }, + }, + }); + +FormbricksError _errOf(Result r) => switch (r) { + Ok() => fail('expected Err'), + Err(:final error) => error, + }; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + FormbricksConfig.resetInstance(); + Logger.resetInstance(); + resetSetupForTest(); + }); + + test( + 'happy path returns Ok and persists config with empty filteredSurveys', + () async { + final mock = MockClient((_) async => http.Response(_envBody(), 200)); + + final result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + + expect(result.isOk, isTrue); + final config = FormbricksConfig.instance.get(); + expect(config.workspaceId, _workspaceId); + expect(config.appUrl, _appUrl); + expect(config.workspace, isNotNull); + expect(config.filteredSurveys, isEmpty); + }, + ); + + test('missing workspaceId returns Err(MissingFieldError)', () async { + final mock = MockClient((_) async => http.Response(_envBody(), 200)); + final result = await setup( + appUrl: _appUrl, + workspaceId: '', + httpClient: mock, + startTicker: false, + ); + final error = _errOf(result); + expect(error, isA()); + expect((error as MissingFieldError).field, 'workspaceId'); + }); + + test('missing appUrl returns Err(MissingFieldError)', () async { + final mock = MockClient((_) async => http.Response(_envBody(), 200)); + final result = await setup( + appUrl: '', + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + expect((_errOf(result) as MissingFieldError).field, 'appUrl'); + }); + + test('non-http(s) appUrl is rejected', () async { + final mock = MockClient((_) async => http.Response(_envBody(), 200)); + final result = await setup( + appUrl: 'ftp://app.x', + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + expect((_errOf(result) as MissingFieldError).field, 'appUrl'); + }); + + test('hostless appUrl is rejected', () async { + final mock = MockClient((_) async => http.Response(_envBody(), 200)); + final result = await setup( + appUrl: 'http://', + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + expect((_errOf(result) as MissingFieldError).field, 'appUrl'); + }); + + test('a trailing slash in appUrl is stripped before requesting', () async { + late Uri requested; + final mock = MockClient((req) async { + requested = req.url; + return http.Response(_envBody(), 200); + }); + + final result = await setup( + appUrl: 'https://app.formbricks.com/', + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + + expect(result.isOk, isTrue); + // No double slash before /api. + expect(requested.path, '/api/v2/client/$_workspaceId/environment'); + expect(requested.toString(), isNot(contains('com//'))); + // Persisted appUrl is the normalized form. + expect( + FormbricksConfig.instance.get().appUrl, + 'https://app.formbricks.com', + ); + }); + + test('is idempotent — the second call makes no HTTP request', () async { + var calls = 0; + final mock = MockClient((_) async { + calls++; + return http.Response(_envBody(), 200); + }); + + await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + + expect(calls, 1); + }); + + test('first-setup network failure persists error state and throws', () async { + final mock = MockClient((_) async => http.Response('{}', 500)); + final now = DateTime(2026, 6, 1, 12); + + await withClock(Clock.fixed(now), () async { + await expectLater( + setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ), + throwsA(isA()), + ); + }); + + final config = FormbricksConfig.instance.getOrNull(); + expect(config, isNotNull); + expect(config!.status.isError, isTrue); + expect(config.status.expiresAt, now.add(const Duration(minutes: 10))); + }); + + test('re-call within the cooldown short-circuits (no HTTP)', () async { + final now = DateTime(2026, 6, 1, 12); + final failMock = MockClient((_) async => http.Response('{}', 500)); + + await withClock(Clock.fixed(now), () async { + await expectLater( + setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: failMock, + startTicker: false, + ), + throwsA(isA()), + ); + }); + + var calls = 0; + final mock = MockClient((_) async { + calls++; + return http.Response(_envBody(), 200); + }); + + late Result result; + await withClock(Clock.fixed(now.add(const Duration(minutes: 5))), () async { + result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + }); + + expect(calls, 0); + expect(_errOf(result), isA()); + expect( + (_errOf(result) as SetupCooldownError).retryAt, + now.add(const Duration(minutes: 10)), + ); + }); + + test( + 're-call after the cooldown elapses proceeds and hits the network', + () async { + final now = DateTime(2026, 6, 1, 12); + final failMock = MockClient((_) async => http.Response('{}', 500)); + + await withClock(Clock.fixed(now), () async { + await expectLater( + setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: failMock, + startTicker: false, + ), + throwsA(isA()), + ); + }); + + var calls = 0; + final mock = MockClient((_) async { + calls++; + return http.Response(_envBody(), 200); + }); + + late Result result; + await withClock( + Clock.fixed(now.add(const Duration(minutes: 11))), + () async { + result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + }, + ); + + expect(calls, 1); + expect(result.isOk, isTrue); + }, + ); + + test( + 'cached userId with expired user state triggers createOrUpdateUser', + () async { + final now = DateTime(2026, 6, 1, 12); + final cached = TConfig( + workspaceId: _workspaceId, + appUrl: _appUrl, + workspace: TWorkspaceState( + expiresAt: now.add(const Duration(hours: 1)), + data: const TWorkspaceData(), + ), + user: TUserState( + expiresAt: now.subtract(const Duration(minutes: 1)), + data: const TUserData(userId: 'u1'), + ), + filteredSurveys: const [], + status: TStatus.success, + ); + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: jsonEncode(cached.toJson()), + }); + FormbricksConfig.resetInstance(); + + var userCalls = 0; + final mock = MockClient((req) async { + if (req.url.path.endsWith('/user')) { + userCalls++; + return http.Response(_userBody(), 200); + } + return http.Response(_envBody(), 200); + }); + + late Result result; + await withClock(Clock.fixed(now), () async { + result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + }); + + expect(result.isOk, isTrue); + expect(userCalls, 1); + }, + ); + + test('matching config with a still-valid user makes no user call', () async { + final now = DateTime(2026, 6, 1, 12); + final cached = TConfig( + workspaceId: _workspaceId, + appUrl: _appUrl, + workspace: TWorkspaceState( + expiresAt: now.add(const Duration(hours: 1)), + data: const TWorkspaceData(), + ), + user: TUserState( + expiresAt: now.add(const Duration(minutes: 30)), + data: const TUserData(userId: 'u1'), + ), + status: TStatus.success, + ); + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: jsonEncode(cached.toJson()), + }); + FormbricksConfig.resetInstance(); + + var calls = 0; + final mock = MockClient((_) async { + calls++; + return http.Response(_envBody(), 200); + }); + + late Result result; + await withClock(Clock.fixed(now), () async { + result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + }); + + expect(result.isOk, isTrue); + expect(calls, 0); + }); + + test('a failed user refresh in the matching path returns Err', () async { + final now = DateTime(2026, 6, 1, 12); + final cached = TConfig( + workspaceId: _workspaceId, + appUrl: _appUrl, + workspace: TWorkspaceState( + expiresAt: now.add(const Duration(hours: 1)), + data: const TWorkspaceData(), + ), + user: TUserState( + expiresAt: now.subtract(const Duration(minutes: 1)), + data: const TUserData(userId: 'u1'), + ), + status: TStatus.success, + ); + SharedPreferences.setMockInitialValues({ + FormbricksConfig.storageKey: jsonEncode(cached.toJson()), + }); + FormbricksConfig.resetInstance(); + + final mock = MockClient((req) async { + if (req.url.path.endsWith('/user')) return http.Response('{}', 500); + return http.Response(_envBody(), 200); + }); + + late Result result; + await withClock(Clock.fixed(now), () async { + result = await setup( + appUrl: _appUrl, + workspaceId: _workspaceId, + httpClient: mock, + startTicker: false, + ); + }); + + expect(_errOf(result), isA()); + }); +} diff --git a/packages/formbricks_flutter/test/formbricks_flutter_test.dart b/packages/formbricks_flutter/test/formbricks_flutter_test.dart index 1ac68f6..6a00af4 100644 --- a/packages/formbricks_flutter/test/formbricks_flutter_test.dart +++ b/packages/formbricks_flutter/test/formbricks_flutter_test.dart @@ -1,8 +1,52 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks_flutter/formbricks_flutter.dart'; +import 'package:formbricks_flutter/src/common/config.dart'; +import 'package:formbricks_flutter/src/common/setup.dart'; +import 'package:formbricks_flutter/src/types/config.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { - test('welcome returns the Formbricks greeting', () { - expect(welcome(), 'Welcome to Formbricks'); + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + FormbricksConfig.resetInstance(); + resetSetupForTest(); }); + + test( + 'Formbricks.setup routes through the queue and validates input', + () async { + // Missing workspaceId fails validation before any network call, so this + // exercises the public facade → command queue → setup path end-to-end. + final result = await Formbricks.setup( + appUrl: 'https://app.x', + workspaceId: '', + ); + + expect(result.isErr, isTrue); + final error = switch (result) { + Ok() => fail('expected Err'), + Err(:final error) => error, + }; + expect(error, isA()); + }, + ); + + test( + 'debugStoredConfig returns null when empty, raw JSON after a write', + () async { + expect(await Formbricks.debugStoredConfig(), isNull); + + final config = FormbricksConfig.instance; + await config.init(); + await config.update(const TConfig(workspaceId: 'w', appUrl: 'https://x')); + + final raw = await Formbricks.debugStoredConfig(); + expect(raw, isNotNull); + expect(jsonDecode(raw!)['workspaceId'], 'w'); + }, + ); } diff --git a/packages/formbricks_flutter/test/types/config_test.dart b/packages/formbricks_flutter/test/types/config_test.dart new file mode 100644 index 0000000..fcaeca0 --- /dev/null +++ b/packages/formbricks_flutter/test/types/config_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/types/config.dart'; + +void main() { + group('TConfig JSON', () { + test('round-trips losslessly, including every DateTime field', () { + final workspaceExpiry = DateTime(2026, 6, 1, 12, 30, 15); + final userExpiry = DateTime(2026, 6, 1, 13); + final displayAt = DateTime(2026, 5, 30, 9, 15); + final lastDisplayAt = DateTime(2026, 5, 31, 10); + final statusExpiry = DateTime(2026, 6, 1, 12, 40); + + final config = TConfig( + workspaceId: 'wsp_1', + appUrl: 'https://app.formbricks.com', + workspace: TWorkspaceState( + expiresAt: workspaceExpiry, + data: const TWorkspaceData( + surveys: [ + {'id': 's1'}, + ], + actionClasses: [ + {'id': 'a1'}, + ], + settings: {'recontactDays': 3}, + ), + ), + user: TUserState( + expiresAt: userExpiry, + data: TUserData( + userId: 'u1', + contactId: 'c1', + segments: const ['seg1'], + displays: [TDisplay(surveyId: 's1', createdAt: displayAt)], + responses: const ['r1'], + lastDisplayAt: lastDisplayAt, + language: 'de', + ), + ), + filteredSurveys: const [ + {'id': 's1'}, + ], + status: TStatus(value: 'success', expiresAt: statusExpiry), + ); + + // Through a full encode → decode → parse cycle (as it hits disk). + final decoded = TConfig.fromJson( + jsonDecode(jsonEncode(config.toJson())) as Map, + ); + + expect(decoded.workspaceId, 'wsp_1'); + expect(decoded.appUrl, 'https://app.formbricks.com'); + expect(decoded.workspace!.expiresAt, workspaceExpiry); + expect(decoded.workspace!.data.settings, {'recontactDays': 3}); + expect(decoded.user.expiresAt, userExpiry); + expect(decoded.user.data.userId, 'u1'); + expect(decoded.user.data.contactId, 'c1'); + expect(decoded.user.data.segments, ['seg1']); + expect(decoded.user.data.displays.single.surveyId, 's1'); + expect(decoded.user.data.displays.single.createdAt, displayAt); + expect(decoded.user.data.lastDisplayAt, lastDisplayAt); + expect(decoded.user.data.language, 'de'); + expect(decoded.filteredSurveys, [ + {'id': 's1'}, + ]); + expect(decoded.status.value, 'success'); + expect(decoded.status.expiresAt, statusExpiry); + }); + + test('tolerates a partial payload missing workspace and user keys', () { + final json = jsonDecode( + '{"status":{"value":"error","expiresAt":"2026-06-01T12:00:00.000"}}', + ) as Map; + + final config = TConfig.fromJson(json); + + expect(config.workspace, isNull); + expect(config.user.data.userId, isNull); + expect(config.user, same(TUserState.defaultNoUserId)); + expect(config.filteredSurveys, isEmpty); + expect(config.status.value, 'error'); + expect(config.status.isError, isTrue); + }); + + test('defaults status to success when absent', () { + final config = TConfig.fromJson(const {}); + expect(config.status.value, 'success'); + expect(config.status.isError, isFalse); + }); + }); +} diff --git a/packages/formbricks_flutter/test/types/errors_test.dart b/packages/formbricks_flutter/test/types/errors_test.dart new file mode 100644 index 0000000..2f1fff7 --- /dev/null +++ b/packages/formbricks_flutter/test/types/errors_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/src/types/errors.dart'; + +void main() { + test('error codes expose their wire strings', () { + expect(FormbricksErrorCode.missingField.wire, 'missing_field'); + expect(FormbricksErrorCode.networkError.wire, 'network_error'); + expect(FormbricksErrorCode.notSetup.wire, 'not_setup'); + expect(FormbricksErrorCode.forbidden.wire, 'forbidden'); + expect(FormbricksErrorCode.setupCooldown.wire, 'setup_cooldown'); + expect(FormbricksErrorCode.invalidCode.wire, 'invalid_code'); + }); + + test('SetupCooldownError carries the code and retryAt', () { + final retryAt = DateTime(2026, 6, 1, 12); + final error = SetupCooldownError(retryAt: retryAt); + expect(error.code, FormbricksErrorCode.setupCooldown); + expect(error.retryAt, retryAt); + expect(error.toString(), contains('setup_cooldown')); + }); + + test('MissingFieldError defaults and overrides its message', () { + final error = MissingFieldError('appUrl'); + expect(error.field, 'appUrl'); + expect(error.code, FormbricksErrorCode.missingField); + expect(error.message, 'No appUrl provided'); + expect(error.toString(), contains('missing_field')); + expect(MissingFieldError('x', message: 'custom').message, 'custom'); + }); + + test('NotSetupError has a default message', () { + final error = NotSetupError(); + expect(error.code, FormbricksErrorCode.notSetup); + expect(error.message, contains('not set up')); + }); + + test('NetworkError carries status, url and responseMessage', () { + final error = NetworkError( + message: 'm', + status: 500, + url: Uri.parse('https://x'), + responseMessage: 'r', + ); + expect(error.code, FormbricksErrorCode.networkError); + expect(error.status, 500); + expect(error.url, Uri.parse('https://x')); + expect(error.responseMessage, 'r'); + }); + + test('InvalidCodeError uses the invalid_code code', () { + expect(InvalidCodeError().code, FormbricksErrorCode.invalidCode); + }); + + test( + 'FormbricksSetupError defaults to network_error and accepts forbidden', + () { + expect(FormbricksSetupError().code, FormbricksErrorCode.networkError); + expect( + FormbricksSetupError(code: FormbricksErrorCode.forbidden).code, + FormbricksErrorCode.forbidden, + ); + }, + ); + + test('ApiErrorResponse exposes its fields and toString', () { + const error = ApiErrorResponse( + code: 'forbidden', + status: 403, + message: 'no', + ); + expect(error.code, 'forbidden'); + expect(error.status, 403); + expect(error.toString(), contains('forbidden')); + }); +} diff --git a/pubspec.lock b/pubspec.lock index ccb256a..9db65c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -139,6 +147,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -251,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" mustache_template: dependency: transitive description: @@ -267,6 +288,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -283,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -331,6 +384,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0 + url: "https://pub.dev" + source: hosted + version: "2.4.25" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -416,6 +525,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -442,4 +559,4 @@ packages: version: "2.2.4" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.27.0" + flutter: ">=3.44.0" diff --git a/tool/run.sh b/tool/run.sh index 68a9416..4019084 100755 --- a/tool/run.sh +++ b/tool/run.sh @@ -26,6 +26,13 @@ if command -v fvm >/dev/null 2>&1; then flutter() { fvm flutter "$@"; } fi +# Auto-load APP_URL / WORKSPACE_ID from a local .env (git-ignored) so you don't +# have to pass --dart-define on every run. Explicit --dart-define args still win. +define_arg="" +if [[ -f .env ]]; then + define_arg="--dart-define-from-file=.env" +fi + run_ios() { booted_udid() { xcrun simctl list devices booted \ @@ -41,7 +48,7 @@ run_ios() { open -a Simulator # bring the simulator window to the front local udid; udid="$(booted_udid)" echo "Running on iOS simulator $udid" - exec flutter run -d "$udid" "$@" + exec flutter run -d "$udid" $define_arg "$@" } run_android() { @@ -65,7 +72,7 @@ run_android() { fi local serial; serial="$(booted_serial)" echo "Running on Android emulator $serial" - exec flutter run -d "$serial" "$@" + exec flutter run -d "$serial" $define_arg "$@" } case "$platform" in