From 7af3c538f301f491eb185f7db6f62de065375f7c Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 1 Jun 2026 15:58:14 +0530 Subject: [PATCH 1/5] sets up the repo as a monorepo --- .fvmrc | 3 + .gitignore | 59 ++ LICENSE | 21 + README.md | 183 +++++ analysis_options.yaml | 27 + apps/playground/.gitignore | 45 ++ apps/playground/.metadata | 33 + apps/playground/README.md | 21 + apps/playground/android/.gitignore | 14 + apps/playground/android/app/build.gradle.kts | 45 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 ++ .../formbricks_playground/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + apps/playground/android/build.gradle.kts | 24 + apps/playground/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + apps/playground/android/settings.gradle.kts | 26 + apps/playground/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 24 + apps/playground/ios/Flutter/Debug.xcconfig | 1 + apps/playground/ios/Flutter/Release.xcconfig | 1 + .../ios/Runner.xcodeproj/project.pbxproj | 644 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 ++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + apps/playground/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 ++++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 26 + apps/playground/ios/Runner/Info.plist | 70 ++ .../ios/Runner/Runner-Bridging-Header.h | 1 + .../playground/ios/Runner/SceneDelegate.swift | 6 + .../ios/RunnerTests/RunnerTests.swift | 12 + apps/playground/lib/main.dart | 79 +++ apps/playground/pubspec.yaml | 96 +++ apps/playground/test/widget_test.dart | 22 + docs/FLUTTER_SDK_PLAN.md | 373 ++++++++++ packages/formbricks_flutter/.gitignore | 31 + packages/formbricks_flutter/CHANGELOG.md | 4 + packages/formbricks_flutter/LICENSE | 21 + packages/formbricks_flutter/README.md | 27 + .../lib/formbricks_flutter.dart | 11 + packages/formbricks_flutter/pubspec.yaml | 36 + .../test/formbricks_flutter_test.dart | 8 + pubspec.lock | 445 ++++++++++++ pubspec.yaml | 58 ++ sonar-project.properties | 24 +- tool/run.sh | 74 ++ 82 files changed, 3123 insertions(+), 9 deletions(-) create mode 100644 .fvmrc create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 apps/playground/.gitignore create mode 100644 apps/playground/.metadata create mode 100644 apps/playground/README.md create mode 100644 apps/playground/android/.gitignore create mode 100644 apps/playground/android/app/build.gradle.kts create mode 100644 apps/playground/android/app/src/debug/AndroidManifest.xml create mode 100644 apps/playground/android/app/src/main/AndroidManifest.xml create mode 100644 apps/playground/android/app/src/main/kotlin/com/formbricks/formbricks_playground/MainActivity.kt create mode 100644 apps/playground/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 apps/playground/android/app/src/main/res/drawable/launch_background.xml create mode 100644 apps/playground/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 apps/playground/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 apps/playground/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 apps/playground/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 apps/playground/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 apps/playground/android/app/src/main/res/values-night/styles.xml create mode 100644 apps/playground/android/app/src/main/res/values/styles.xml create mode 100644 apps/playground/android/app/src/profile/AndroidManifest.xml create mode 100644 apps/playground/android/build.gradle.kts create mode 100644 apps/playground/android/gradle.properties create mode 100644 apps/playground/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 apps/playground/android/settings.gradle.kts create mode 100644 apps/playground/ios/.gitignore create mode 100644 apps/playground/ios/Flutter/AppFrameworkInfo.plist create mode 100644 apps/playground/ios/Flutter/Debug.xcconfig create mode 100644 apps/playground/ios/Flutter/Release.xcconfig create mode 100644 apps/playground/ios/Runner.xcodeproj/project.pbxproj create mode 100644 apps/playground/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 apps/playground/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 apps/playground/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 apps/playground/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 apps/playground/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 apps/playground/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 apps/playground/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 apps/playground/ios/Runner/AppDelegate.swift create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 apps/playground/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 apps/playground/ios/Runner/Base.lproj/Main.storyboard create mode 100644 apps/playground/ios/Runner/Info.plist create mode 100644 apps/playground/ios/Runner/Runner-Bridging-Header.h create mode 100644 apps/playground/ios/Runner/SceneDelegate.swift create mode 100644 apps/playground/ios/RunnerTests/RunnerTests.swift create mode 100644 apps/playground/lib/main.dart create mode 100644 apps/playground/pubspec.yaml create mode 100644 apps/playground/test/widget_test.dart create mode 100644 docs/FLUTTER_SDK_PLAN.md create mode 100644 packages/formbricks_flutter/.gitignore create mode 100644 packages/formbricks_flutter/CHANGELOG.md create mode 100644 packages/formbricks_flutter/LICENSE create mode 100644 packages/formbricks_flutter/README.md create mode 100644 packages/formbricks_flutter/lib/formbricks_flutter.dart create mode 100644 packages/formbricks_flutter/pubspec.yaml create mode 100644 packages/formbricks_flutter/test/formbricks_flutter_test.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100755 tool/run.sh diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..457360f --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.44.0" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b08ed72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Dart / Flutter monorepo .gitignore + +# Dart tooling +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies + +# Pub workspace resolves a single lockfile at the root. We commit it (the repo +# contains an app → reproducible builds, paired with the fvm-pinned SDK). +# Members don't generate their own lockfile under a workspace, but ignore any +# stray ones just in case. +/packages/formbricks_flutter/pubspec.lock +/apps/playground/pubspec.lock + +# Melos +.melos_tool/ +pubspec_overrides.yaml + +# fvm: .fvmrc (the pinned version) is committed; the SDK cache/symlink under +# .fvm/ is ignored — see the "FVM Version Cache" rule at the bottom of this file. + +# IDE / editor (no shared editor config committed) +*.iml +.idea/ +.vscode/ +.zed/ +*.swp + +# OS +.DS_Store + +# Coverage +coverage/ +*.lcov + +# Android +**/android/.gradle/ +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.keystore +*.jks + +# iOS +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/flutter_export_environment.sh +**/ios/Pods/ +**/ios/.symlinks/ +**/ios/Flutter/ephemeral/ +**/.symlinks/ + +# Demo app env +*.env +.env* + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f35683e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Formbricks GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f4162d --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# Formbricks Flutter + +Monorepo for the first-party **Flutter SDK** for [Formbricks](https://formbricks.com) +and its demo app. The SDK mirrors the [React Native SDK](https://github.com/formbricks/react-native): +initialize a workspace, identify users, track actions, and render targeted in-app +surveys inside a WebView backed by `{appUrl}/js/surveys.umd.cjs`. + +> **Status:** repository skeleton. The SDK currently exposes only a placeholder +> `welcome()`. The public API, survey rendering, CI, and pub.dev publishing land +> in follow-up work — see [Roadmap](#roadmap). + +## Repository layout + +``` +flutter/ +├── pubspec.yaml # pub workspace root + Melos script config (never published) +├── analysis_options.yaml # shared analyzer + lint rules for every package +├── sonar-project.properties # SonarCloud config (finalised in a follow-up) +├── LICENSE # MIT +├── packages/ +│ └── formbricks_flutter/ # the SDK package (the thing we publish) +│ ├── lib/ +│ │ ├── formbricks_flutter.dart # public exports +│ │ └── src/ # (added in implementation tickets) +│ ├── test/ # one test file per source file +│ ├── pubspec.yaml +│ ├── CHANGELOG.md +│ ├── LICENSE +│ └── README.md +└── apps/ + └── playground/ # demo / manual-QA app (equivalent of RN's apps/playground) + ├── lib/main.dart # SDK test buttons: track, setUserId, setAttributes, … + ├── android/ ios/ # platform projects (iOS + Android only for v1) + ├── test/ + └── pubspec.yaml +``` + +This mirrors the RN repo's `packages/*` + `apps/*` split, so anyone moving +between the two SDKs finds the same shape. + +### Why these locations + +| Path | Holds | Rationale | +|------|-------|-----------| +| `packages/formbricks_flutter` | The publishable SDK | Single source of the pub.dev package. `src/` is private; only `lib/formbricks_flutter.dart` re-exports the public API. | +| `apps/playground` | Demo app | Real Flutter app on iOS + Android for manual QA of WebView / keyboard / modal behaviour. Excluded from SonarCloud + pub scoring. The RN SDK proved this app is what catches keyboard/touch regressions before customers do, so it ships from day one. | + +## Monorepo tooling + +Uses **Dart pub workspaces** (Dart ≥ 3.6) + **[Melos](https://melos.invertase.dev) 7**. + +- The root `pubspec.yaml` declares `workspace:` members. Each member sets + `resolution: workspace`, so the whole repo shares **one** lockfile and one + resolved dependency graph — no version drift between SDK and demo app. +- Melos 7 sits on top of native workspaces and adds cross-package scripts + (analyze / test / format across everything at once). Its config lives under + the `melos:` key in the root `pubspec.yaml` (Melos 7 dropped `melos.yaml`). + +### Common commands + +All run from the repo root. The Flutter version is pinned with **fvm** (see +[Toolchain](#toolchain)), so prefix Flutter/Dart calls with `fvm` to use the +exact pinned SDK: + +```bash +fvm flutter pub get # resolve the whole workspace (one lockfile) +fvm dart run melos run analyze # dart analyze --fatal-infos across all packages +fvm dart run melos run format # dart format . +fvm dart run melos run format-check # CI: fail if unformatted +fvm dart run melos run test --no-select # flutter test in every package +fvm dart run melos run test-coverage --no-select +``` + +> - `--no-select` skips Melos's interactive package picker — required in CI and +> any non-TTY shell. +> - `fvm dart run melos` runs the workspace's pinned Melos under the pinned SDK. +> If you prefer the global `melos` binary (`dart pub global activate melos`), +> run it as `fvm exec melos run …` so it still uses the pinned Flutter. + +## Conventions + +These are locked in for all follow-up work (full rationale in the RN repo's +`FLUTTER_SDK_PLAN.md`): + +- **Naming.** Internal types are `FormbricksConfig`, `Logger`, etc. — no + redundant `Flutter` prefix inside a Flutter package. Storage key is + `formbricks-flutter` (distinct from RN's `formbricks-react-native`). +- **Static public API, hidden singleton.** `Formbricks.track(...)` / + `Formbricks.setup(...)` are static facades over a private singleton — **not** + `Formbricks.instance.track(...)`. The host widget and static API share the + one `Formbricks` class. +- **No `environmentId`.** The SDK accepts `workspaceId` only — no legacy alias + to ever deprecate (RN still carries that debt). +- **Dates cross one boundary.** `DateTime` in memory, ISO-8601 `String` on the + wire and on disk; convert only in `fromJson` / `toJson`. No stray + `DateTime.parse` elsewhere. +- **Testing is mandatory.** Every source file gets a dedicated test file (happy + path + errors + edge cases). PRs aren't mergeable without it; aim for ≥ 80 % + coverage on touched files. Unit tests use `flutter_test` + `mocktail` / + `http`'s `MockClient` — no real network. +- **Targets.** iOS + Android only for v1. A `kIsWeb` guard throws on Flutter Web. + +## Toolchain + +The exact Flutter version is pinned in **`.fvmrc`** (currently `3.44.0`, which +ships Dart 3.12) and managed with [fvm](https://fvm.app). Pinning means every +contributor and CI run uses a byte-identical SDK — no "works on my machine" +version drift. + +First-time setup: + +```bash +dart pub global activate fvm # install the fvm tool (one-time, global) +fvm install # download the version from .fvmrc into fvm's cache +fvm flutter pub get # resolve the workspace +``` + +- fvm itself needs a Dart/Flutter on `PATH` only to bootstrap; it then downloads + and isolates the pinned Flutter under `~/fvm/versions/` — you don't clone + Flutter by hand. `.fvm/flutter_sdk` (a symlink to the active version) and the + version cache are git-ignored; only `.fvmrc` is committed. +- Editors: point your editor's Flutter/Dart SDK path at `.fvm/flutter_sdk` so + analysis uses the pinned SDK. +- Bumping the version: `fvm use --force`, commit the changed `.fvmrc`. +- fvm docs: . Flutter floor enforced by pubspecs: ≥ 3.22 / Dart + ≥ 3.12. + +## Running the demo app + +The playground targets **iOS + Android only** — there is no `macos/`/`web/` +project. Note that `flutter run` cannot boot a simulator on its own: with no +device running it falls back to the macOS desktop target (which this app does +not support), so a simulator/emulator must be started first. + +**Easiest — one command** (boots the device if needed, then runs): + +```bash +./tool/run.sh # iOS simulator (default) +./tool/run.sh android # Android emulator +# or via Melos (package.json-style scripts, see Monorepo tooling): +melos run ios +melos run android +``` + +**Manual CLI:** a simulator/emulator must be booted *first* — `flutter run` +never boots one itself. Start a device, then target it by name: + +```bash +fvm flutter emulators --launch apple_ios_simulator # iOS sim +# or: fvm flutter emulators --launch Pixel_9a # Android emulator +cd apps/playground +fvm flutter run -d iphone # iOS; -d emulator for Android. + # -d matches a device NAME/id substring, NOT the + # platform — `-d ios` will NOT match, and with no + # device booted this falls back to the + # (unsupported) macOS desktop target. +``` + +> First Android build is slow — Gradle downloads the NDK + CMake (~3 GB, +> one-time) before compiling. Subsequent builds reuse them. + +Once running, the `flutter run` session is interactive: press **`r`** for hot +reload, **`R`** for hot restart, **`q`** to quit. `./tool/run.sh` keeps that +session in your terminal, so hot reload works there too. + +You should see a **"Welcome to Formbricks"** header and six SDK-test buttons +(track / setUserId / setAttributes ×2 / setLanguage / logout). They are inert +stubs — each shows a "not wired to the SDK yet" snackbar — until the SDK API +lands in follow-up work. + +## Roadmap + +| Stage | Scope | +|-------|-------| +| Repo + monorepo skeleton | This ✅ | +| `setup` + command queue | `setup` function, `CommandQueue`, `FormbricksConfig`, `ApiClient` | +| Track + show survey | `track` + survey rendering (WebView) | +| CI | GitHub Actions (format / analyze / test / build) | +| Code quality | SonarCloud wiring + quality gate | + +The canonical spec and the React-Native→Flutter architecture mapping live in +[`docs/FLUTTER_SDK_PLAN.md`](docs/FLUTTER_SDK_PLAN.md). The reference RN source is +the `formbricks/react-native` repo. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..d729e64 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,27 @@ +# Shared analyzer config for the whole monorepo. +# Each package/app includes this file so lint rules stay identical everywhere. +include: package:flutter_lints/flutter.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Treat missing public-API docs as a warning to keep pub.dev score high later. + public_member_api_docs: ignore + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "**/build/**" + +linter: + rules: + - prefer_single_quotes + - require_trailing_commas + - always_declare_return_types + - avoid_print + - prefer_const_constructors + - prefer_final_locals + - unawaited_futures + - directives_ordering diff --git a/apps/playground/.gitignore b/apps/playground/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/playground/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/playground/.metadata b/apps/playground/.metadata new file mode 100644 index 0000000..5d8703b --- /dev/null +++ b/apps/playground/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: android + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: ios + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/playground/README.md b/apps/playground/README.md new file mode 100644 index 0000000..cf79cdb --- /dev/null +++ b/apps/playground/README.md @@ -0,0 +1,21 @@ +# Playground + +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. + +## Run + +From the repo root (a simulator/emulator must be booted — `flutter run` won't +boot one itself): + +```bash +./tool/run.sh # iOS simulator (boots one if needed) +./tool/run.sh android # Android emulator (boots one if needed) +``` + +See the repo root README for full toolchain + run details. diff --git a/apps/playground/android/.gitignore b/apps/playground/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/playground/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/playground/android/app/build.gradle.kts b/apps/playground/android/app/build.gradle.kts new file mode 100644 index 0000000..00814b3 --- /dev/null +++ b/apps/playground/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.formbricks.formbricks_playground" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.formbricks.formbricks_playground" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/apps/playground/android/app/src/debug/AndroidManifest.xml b/apps/playground/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/playground/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/playground/android/app/src/main/AndroidManifest.xml b/apps/playground/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a032750 --- /dev/null +++ b/apps/playground/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/playground/android/app/src/main/kotlin/com/formbricks/formbricks_playground/MainActivity.kt b/apps/playground/android/app/src/main/kotlin/com/formbricks/formbricks_playground/MainActivity.kt new file mode 100644 index 0000000..cffba88 --- /dev/null +++ b/apps/playground/android/app/src/main/kotlin/com/formbricks/formbricks_playground/MainActivity.kt @@ -0,0 +1,5 @@ +package com.formbricks.formbricks_playground + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/playground/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/playground/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/playground/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/playground/android/app/src/main/res/drawable/launch_background.xml b/apps/playground/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/playground/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/playground/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/playground/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/apps/playground/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/playground/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/apps/playground/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/playground/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/apps/playground/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/playground/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/apps/playground/android/app/src/main/res/values-night/styles.xml b/apps/playground/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/playground/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/playground/android/app/src/main/res/values/styles.xml b/apps/playground/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/playground/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/playground/android/app/src/profile/AndroidManifest.xml b/apps/playground/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/playground/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/playground/android/build.gradle.kts b/apps/playground/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/playground/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/playground/android/gradle.properties b/apps/playground/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/apps/playground/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/apps/playground/android/gradle/wrapper/gradle-wrapper.properties b/apps/playground/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/apps/playground/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/apps/playground/android/settings.gradle.kts b/apps/playground/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/apps/playground/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/apps/playground/ios/.gitignore b/apps/playground/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/apps/playground/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/apps/playground/ios/Flutter/AppFrameworkInfo.plist b/apps/playground/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/apps/playground/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/apps/playground/ios/Flutter/Debug.xcconfig b/apps/playground/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/playground/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/playground/ios/Flutter/Release.xcconfig b/apps/playground/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/apps/playground/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/playground/ios/Runner.xcodeproj/project.pbxproj b/apps/playground/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b7158df --- /dev/null +++ b/apps/playground/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,644 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.formbricksPlayground; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.formbricksPlayground.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.formbricksPlayground.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.formbricksPlayground.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.formbricksPlayground; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.formbricks.formbricksPlayground; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/playground/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/playground/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/playground/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/apps/playground/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/playground/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/playground/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/apps/playground/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/playground/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/playground/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/playground/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/playground/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/playground/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/playground/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/playground/ios/Runner/AppDelegate.swift b/apps/playground/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/apps/playground/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/playground/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/apps/playground/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +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. \ No newline at end of file diff --git a/apps/playground/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/playground/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/apps/playground/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/playground/ios/Runner/Base.lproj/Main.storyboard b/apps/playground/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/apps/playground/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/playground/ios/Runner/Info.plist b/apps/playground/ios/Runner/Info.plist new file mode 100644 index 0000000..379ceed --- /dev/null +++ b/apps/playground/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Formbricks Playground + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + formbricks_playground + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/apps/playground/ios/Runner/Runner-Bridging-Header.h b/apps/playground/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/apps/playground/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/playground/ios/Runner/SceneDelegate.swift b/apps/playground/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/apps/playground/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/apps/playground/ios/RunnerTests/RunnerTests.swift b/apps/playground/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/apps/playground/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart new file mode 100644 index 0000000..ab01876 --- /dev/null +++ b/apps/playground/lib/main.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:formbricks_flutter/formbricks_flutter.dart'; + +void main() { + runApp(const PlaygroundApp()); +} + +class PlaygroundApp extends StatelessWidget { + const PlaygroundApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Formbricks Flutter Playground', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const PlaygroundHome(), + ); + } +} + +class PlaygroundHome extends StatelessWidget { + const PlaygroundHome({super.key}); + + /// 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. + void _stub(BuildContext context, String action) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('$action — not wired to the SDK yet'), + duration: const Duration(seconds: 1), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Proves the SDK package links into the app via the pub workspace. + final greeting = welcome(); + + final actions = <({String label, String action})>[ + (label: 'Trigger Code Action', action: "track('code')"), + (label: 'Set userId', action: "setUserId('random-user-id')"), + (label: 'Set User Attributes (multiple)', action: 'setAttributes({...})'), + (label: 'Set User Attribute (single)', action: "setAttribute('k', 'v')"), + (label: 'Set Language (de)', action: "setLanguage('de')"), + (label: 'Logout', action: 'logout()'), + ]; + + return Scaffold( + appBar: AppBar(title: const Text('Formbricks Flutter Playground')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(greeting, textAlign: TextAlign.center), + const SizedBox(height: 24), + for (final a in actions) ...[ + FilledButton( + onPressed: () => _stub(context, a.action), + child: Text(a.label), + ), + const SizedBox(height: 12), + ], + ], + ), + ), + ), + ); + } +} diff --git a/apps/playground/pubspec.yaml b/apps/playground/pubspec.yaml new file mode 100644 index 0000000..9c4b3bb --- /dev/null +++ b/apps/playground/pubspec.yaml @@ -0,0 +1,96 @@ +name: formbricks_playground +description: "Demo / manual-QA app for the formbricks_flutter SDK." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.0 + +# Part of the root pub workspace — shares the root lockfile + analysis config. +resolution: workspace + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # The local SDK package, resolved through the pub workspace. + formbricks_flutter: + path: ../../packages/formbricks_flutter + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/apps/playground/test/widget_test.dart b/apps/playground/test/widget_test.dart new file mode 100644 index 0000000..67845b0 --- /dev/null +++ b/apps/playground/test/widget_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_playground/main.dart'; + +void main() { + testWidgets('playground renders the SDK test buttons', (tester) async { + await tester.pumpWidget(const PlaygroundApp()); + + expect(find.text('Welcome to Formbricks'), findsOneWidget); + expect(find.text('Trigger Code Action'), findsOneWidget); + expect(find.text('Set userId'), findsOneWidget); + expect(find.text('Logout'), findsOneWidget); + }); + + testWidgets('tapping a button shows a not-wired snackbar', (tester) async { + await tester.pumpWidget(const PlaygroundApp()); + + await tester.tap(find.text('Trigger Code Action')); + await tester.pump(); + + expect(find.textContaining('not wired to the SDK yet'), findsOneWidget); + }); +} diff --git a/docs/FLUTTER_SDK_PLAN.md b/docs/FLUTTER_SDK_PLAN.md new file mode 100644 index 0000000..92f29c9 --- /dev/null +++ b/docs/FLUTTER_SDK_PLAN.md @@ -0,0 +1,373 @@ +# Formbricks Flutter SDK — Project Definition & Tech Spec + +Project Lead: TBD +Author: anshuman@formbricks.com +Last updated: 2026-05-28 +Status: Draft for review + +--- + +## 1. Summary + +Build `formbricks_flutter`, a first-party Flutter SDK that mirrors the surface area of `@formbricks/react-native` (this repo). It lets a Flutter app initialize a Formbricks workspace, identify users, set attributes, track code actions, and render targeted in-app surveys inside a `WebView` overlay backed by the existing `surveys.umd.cjs` runtime hosted at `${appUrl}/js/surveys.umd.cjs`. + +The SDK ships as a single pub.dev package (`formbricks_flutter`) targeting iOS and Android, supporting both Material and Cupertino apps, with no native platform code beyond the WebView and storage plugins it depends on. + +--- + +## 2. Context / Why + +- Flutter is a top-3 mobile cross-platform framework. We currently have RN + iOS + Android + Web. Flutter is the only major mobile gap. +- Several enterprise prospects (and existing customers) have Flutter apps and have been blocked from adopting Formbricks. Today they either embed our web SDK in a `webview_flutter` themselves (no targeting, no state sync) or skip Formbricks entirely. +- We already proved the WebView-renderer pattern works in RN. Porting the same architecture to Flutter is cheap relative to the GTM upside. +- Doing this now (before V5 churn settles) lets us bake the V5 client API contract (`/api/v1/client/{workspaceId}/environment`, `/api/v2/client/{workspaceId}/user`) into the new SDK from day one instead of carrying a legacy `environmentId` debt like RN does. + +--- + +## 3. Scope + +### In scope (v1.0) + +- Pure-Dart SDK package on pub.dev: `formbricks_flutter`. +- Public Dart API surface (mirrors RN `index.ts`): + - `Formbricks` widget — drop-in host that initializes the SDK and renders any active survey. + - `Formbricks.setup({appUrl, workspaceId})` + - `Formbricks.track(String name)` + - `Formbricks.setUserId(String userId)` + - `Formbricks.setAttribute(String key, dynamic value)` — accepts `String`, `num`, `DateTime`. + - `Formbricks.setAttributes(Map attrs)` + - `Formbricks.setLanguage(String language)` + - `Formbricks.logout()` +- Persistent config in `SharedPreferences` (key `formbricks-flutter`), schema identical to RN's `RNConfig` payload so the backend contract is unchanged. +- Workspace state fetch + cache with expiry refresh (matches RN's `workspace/state.ts`). +- User state fetch via `POST /api/v2/client/{workspaceId}/user` with debounce-batched attribute updates. +- Code action tracking against cached `actionClasses`, with `displayPercentage` gate and survey filtering by `displayOption`, `recontactDays`, and `segments`. +- WebView host (`webview_flutter`) that loads `surveys.umd.cjs` from `appUrl`, posts message bridge events (`onDisplayCreated`, `onResponseCreated`, `onClose`, `onOpenExternalURL`), enforces same-origin navigation, and opens external links via `url_launcher`. +- Sequential command queue with `await Formbricks.track(...)` semantics (same contract as RN). +- Multi-language survey resolution. +- Example app under `example/` (equivalent to `apps/playground`) for manual QA. +- CI: format (`dart format`), analyze (`flutter analyze` with `package:lints/recommended.yaml`), unit tests (`flutter test`), integration test on iOS sim + Android emulator, pub.dev dry-run publish. + +### Explicitly out of scope (v1.0) + +- Link surveys (URL-based survey rendering outside the embedded WebView). +- File upload questions inside surveys (RN has a `fileUploadParams` bridge stub but the RN SDK does not implement upload either — defer until product priority is set). +- Native-rendered surveys (no WebView). Same WebView strategy as RN — we will not reimplement the survey runtime in Flutter widgets. +- Offline queueing of responses. Backend submission goes through the embedded WebView's `ResponseQueue`, same as RN. No Dart-side offline retry. +- Web (Flutter Web) and desktop (macOS/Windows/Linux) targets. iOS + Android only for v1. +- Push-notification triggers, in-app messages, or any non-survey product surface. +- Server-side rendering, SSG, or any non-mobile-app context. +- A migration path from `environmentId` → `workspaceId`. New SDK only accepts `workspaceId`. + +--- + +## 4. Success Criteria + +Ship is successful when **all** of the following hold: + +1. `formbricks_flutter` is published to pub.dev with a stable `^1.0.0` and `pub.dev/packages/formbricks_flutter` shows a Pub Points score ≥ 130. +2. Integration test suite renders a survey, submits a response, and verifies the backend recorded the display + response in a staging workspace — running green on both iOS sim and Android emulator in CI. +3. Behavior parity matrix vs RN SDK passes for all rows: `setup`, `track`, `setUserId`, `setAttribute`/`setAttributes`, `setLanguage`, `logout`, multi-language survey, `displayOnce`/`displayMultiple`/`displaySome`/`respondMultiple`, `recontactDays`, `displayPercentage`, `segments` filtering, error-state cooldown after first setup fails. +4. Example app demonstrates: app-action survey trigger, code-action survey trigger, attribute-based segment, language switch, logout/identity reset. +5. At least one design-partner customer has the SDK in their app and successfully collected ≥ 100 in-app responses through it before GA. +6. Bundle-size impact on a vanilla Flutter app < 800 KB compressed APK delta (mostly `webview_flutter` + `shared_preferences` + `url_launcher`, all of which most apps already have). + +--- + +## 5. Requirements & Constraints + +### Tech stack + +- 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`). + - `url_launcher: ^6.x` — open external URLs surfaced from inside the survey. + - `connectivity_plus: ^6.x` — connectivity check before `track()` (RN equivalent: `@react-native-community/netinfo`). + - `http: ^1.x` — REST calls. We will **not** depend on `dio` — keep the dep graph minimal and avoid forcing a transitive interceptor stack on host apps. +- Dev / test: + - `flutter_test`, `mocktail`, `http_mock_adapter` or a custom `http.Client` mock. + - `melos` (optional) if we ever go monorepo; for v1 a single `formbricks_flutter` package is sufficient. + +### Public API surface (1:1 with RN where it makes sense) + +```dart +// Initialization (widget — preferred) +Formbricks(appUrl: 'https://app.formbricks.com', workspaceId: 'wsp_...') + +// Imperative API — static methods, all return Future, sequenced via CommandQueue. +// State (command queue, config) lives in a private singleton, not exposed publicly. +await Formbricks.track('button_clicked'); +await Formbricks.setUserId('user_123'); +await Formbricks.setAttribute('plan', 'pro'); +await Formbricks.setAttributes({'plan': 'pro', 'mrr': 99}); +await Formbricks.setLanguage('de'); +await Formbricks.logout(); +``` + +### Internal architecture (mirrors `packages/react-native/src/lib/`) + +| Concern | RN file | Flutter equivalent | +|---|---|---| +| Command queue (sequential public API) | `lib/common/command-queue.ts` | `lib/src/common/command_queue.dart` — `Future`-based queue, single in-flight worker | +| Persistent config singleton | `lib/common/config.ts` | `lib/src/common/config.dart` — `FormbricksConfig` singleton wrapping `SharedPreferences` | +| AsyncStorage shim | `lib/common/storage.ts` | direct `SharedPreferences` use, no shim needed | +| HTTP client | `lib/common/api.ts` | `lib/src/common/api_client.dart` — thin wrapper over `package:http` `Client` | +| Setup orchestration | `lib/common/setup.ts` | `lib/src/common/setup.dart` | +| Logger | `lib/common/logger.ts` | `lib/src/common/logger.dart` — same `🧱 Formbricks - ...` format, `debug`/`error` levels | +| Expiry tick listeners | `lib/common/event-listeners.ts` | `lib/src/common/expiry_ticker.dart` — `Timer.periodic`, lifecycle-aware (cancel on `AppLifecycleState.paused`, restart on `resumed`) | +| Survey trigger / `track` | `lib/survey/action.ts` | `lib/src/survey/action.dart` | +| In-memory survey store + listeners | `lib/survey/store.ts` | `lib/src/survey/survey_store.dart` — `ValueNotifier` (replaces RN's `useSyncExternalStore`) | +| User update debouncer | `lib/user/update-queue.ts` | `lib/src/user/update_queue.dart` — 500 ms debounce, single-flight | +| Workspace state fetch | `lib/workspace/state.ts` | `lib/src/workspace/workspace_state.dart` | +| Survey filtering (`displayOption`, `recontactDays`, segments) | `lib/common/utils.ts` `filterSurveys` | `lib/src/common/filter_surveys.dart` | +| WebView host | `components/survey-web-view.tsx` | `lib/src/widgets/survey_webview.dart` using `webview_flutter` `WebViewController`. Same generated `` template — keep the inline JS bridge byte-for-byte where possible so the contract with `surveys.umd.cjs` stays identical | +| Top-level Formbricks widget | `components/formbricks.tsx` | `lib/src/widgets/formbricks_widget.dart` — `StatefulWidget` listening to `SurveyStore` via `AnimatedBuilder` / `ValueListenableBuilder` | + +### Command queue + +Required, same reasoning as RN: public API must be sequential so a `setUserId` followed by a `setAttribute` cannot race (attribute update needs the userId in the request body). Dart-side implementation: + +```dart +class CommandQueue { + final _queue = Queue<_QueuedCommand>(); + Completer? _idle; + bool _running = false; + + Future add(FutureOr Function() cmd, {bool checkSetup = true}) { + final completer = Completer(); + _queue.add(_QueuedCommand(cmd, checkSetup, completer)); + _run(); + return completer.future; + } + + Future _run() async { + if (_running) return; + _running = true; + while (_queue.isNotEmpty) { + final item = _queue.removeFirst(); + if (item.checkSetup && !FormbricksSetup.isSetup) { + item.completer.complete(); + continue; + } + try { await item.cmd(); item.completer.complete(); } + catch (e, s) { Logger.error('Global error: $e\n$s'); item.completer.completeError(e, s); } + } + _running = false; + } +} +``` + +Each public API method `add`s its work and returns the completer's `Future`. This replicates RN's `queue.add(...); await queue.wait();` pattern. + +### Update queue (attribute debounce) + +Mirror RN exactly: 500 ms debounce, single in-flight, accumulator merges `userId` + `attributes`. Critical detail to port: **language updates without a userId must update local config only, not hit the backend** (`update-queue.ts:68–96`). Attribute updates without a userId throw and clear the buffer (`update-queue.ts:98–112`). + +### REST endpoints (unchanged from RN) + +- `GET {appUrl}/api/v1/client/{workspaceId}/environment` → workspace state. Server may return `data.settings`, `data.workspace`, or legacy `data.project` — map to `data.settings` (see `workspace/state.ts:42–60`). +- `POST {appUrl}/api/v2/client/{workspaceId}/user` body `{ userId, attributes }` → `{ state: UserState, messages?: string[], errors?: string[] }`. +- Surveys runtime script: `{appUrl}/js/surveys.umd.cjs` (loaded inside the WebView, not by Dart). + +### Storage schema + +Key: `formbricks-flutter` (distinct from RN's `formbricks-react-native` so a single device with both SDKs doesn't collide). Value: JSON blob with the same shape as RN's `TConfig`: + +```json +{ + "workspaceId": "wsp_...", + "appUrl": "https://app.formbricks.com", + "workspace": { "expiresAt": "...", "data": { "surveys": [...], "actionClasses": [...], "settings": {...} } }, + "user": { "expiresAt": null|"...", "data": { "userId": null|"...", "contactId": null|"...", "segments": [], "displays": [], "responses": [], "lastDisplayAt": null|"...", "language": "..." } }, + "filteredSurveys": [...], + "status": { "value": "success"|"error", "expiresAt": null|"..." } +} +``` + +Dates serialize as ISO 8601 strings (Dart `DateTime.toIso8601String()` ↔ JS `new Date(...)`). Decoder must accept both `null` and missing keys for forward-compat. + +### WebView bridge protocol (unchanged from RN) + +JS → Dart (via `JavaScriptChannel`, replaces `window.ReactNativeWebView.postMessage`): + +```js +function onClose() { Formbricks.postMessage(JSON.stringify({ onClose: true })); } +function onDisplayCreated() { Formbricks.postMessage(JSON.stringify({ onDisplayCreated: true })); } +function onResponseCreated() { Formbricks.postMessage(JSON.stringify({ onResponseCreated: true })); } +// console.* → { type: 'Console', data: { type, log } } (dev only) +``` + +Dart side validates message shape with a Zod-equivalent — there is no Zod in Dart, so we hand-roll a minimal validator with `Map` shape checks. Do not auto-trust the WebView (see Security). + +### Constraints + +- Backend contract is **frozen**. The SDK must not require backend changes. Anything that doesn't work today through the existing client API endpoints is out of scope. +- Minimum supported iOS: 12. Minimum supported Android: API 21 (matches `webview_flutter`). +- No null-safety opt-outs. No `dynamic` in public API except where the value is genuinely polymorphic (attribute values). +- Plugin authors must avoid `flutter_inappwebview` despite it being more featureful — `webview_flutter` is the official Flutter team plugin, is lighter, and is what enterprise security reviewers expect. +- No analytics calls, no telemetry beacons from the SDK itself. + +### Dependencies on other teams + +- Backend: confirms `/api/v1/client/{workspaceId}/environment` and `/api/v2/client/{workspaceId}/user` will remain stable through the Flutter SDK v1 lifecycle. No changes requested. +- Web team (`@formbricks/surveys`): keep the JS bridge contract (`window.formbricksSurveys.renderSurvey({ onDisplayCreated, onResponseCreated, onClose, ... })`) stable. +- Docs: a new "Flutter" framework guide alongside the React Native one. + +--- + +## 6. Security Considerations (mandatory) + +### Does this feature process user data? + +Yes. The SDK receives user-provided identifiers (`userId`) and attribute values, persists them locally in `SharedPreferences`, and transmits them to the customer's configured `appUrl` over HTTPS. Survey responses are submitted by the embedded WebView directly to the same backend; Dart never touches response content. + +### Are third-party services involved? + +No third parties. The SDK only talks to the customer's `appUrl` (Formbricks Cloud or self-hosted). All dependencies are first-party Flutter team plugins (`webview_flutter`, `shared_preferences`, `url_launcher`) plus `connectivity_plus` and `http` from `dart.dev`-published, well-audited packages. + +### Does this affect data privacy? + +- `SharedPreferences` on Android is **not encrypted by default**. PII in attribute values therefore lives in plaintext at rest in `/data/data//shared_prefs/`. We will: + - Document this clearly in the README. + - 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 userIds at `debug` level. Default level is `error`. + +### Does this affect tenant isolation? + +The `workspaceId` scopes all API calls. The SDK MUST: +- Reject `appUrl` values that don't parse as `http://` or `https://` (port `survey-script-url.ts:8–10`). +- Enforce same-origin for in-WebView navigation; any URL whose origin ≠ `appUrl` origin is opened via `url_launcher` instead of inside the WebView (port `survey-web-view.tsx:275–305`). +- Reject WebView messages that don't validate against the expected shape. +- Never echo a cached config when `workspaceId` or `appUrl` changes — must reset (port `setup.ts:209–218`). + +### Does this affect permissions or access control? + +No new permissions. The SDK uses the **embedding app's** existing internet permission. It does not request camera, location, contacts, or any runtime permission. + +### Required safeguards before shipping + +1. **Origin allow-list for WebView**: only allow `appUrl` origin to load full pages; all other URLs go to `url_launcher.launchUrl()` with `mode: LaunchMode.externalApplication`. +2. **URL scheme allow-list**: only `http:` and `https:` open externally. Block `javascript:`, `file:`, `intent:`, `data:`. (Port `survey-web-view.tsx:291–296`.) +3. **WebView hardening**: `allowFileAccess: false`, `allowContentAccess: false`, `javaScriptEnabled: true` only on the survey HTML, `setSupportMultipleWindows: false`. Disable cookie persistence beyond the survey lifetime. +4. **Message validation**: every JS→Dart payload goes through a strict validator. Reject extra keys, wrong types, or non-JSON. +5. **HTTPS enforcement**: on iOS, the SDK should not weaken ATS. Document that customers using a self-hosted HTTP `appUrl` must add their own ATS exception — we will not bundle one. +6. **No eval, no dynamic code loading on the Dart side**. The Dart code is fully AOT-compiled. The only loaded JS is the customer's own `surveys.umd.cjs` from their `appUrl`. +7. **Secret handling**: `workspaceId` is not a secret (it's public per the existing product model). We document this; no token rotation needed. +8. **Dependency audit**: pin minor versions in `pubspec.yaml`, run `flutter pub outdated` weekly via Dependabot equivalent. No git-source or path-source dependencies in the published package. +9. **SBOM**: publish a Software Bill of Materials with every release (CycloneDX via `cyclonedx-dart`). +10. **Security review**: required from security@formbricks.com sign-off before pub.dev publish. + +--- + +## 7. Open Questions / Risks + +### Open questions + +- **Repo layout**: ship as a new repo `formbricks/formbricks-flutter`, or add `packages/flutter` to this monorepo? RN lives in its own repo today. Flutter as a new repo keeps releases independent; sibling monorepo improves shared-spec evolution. *Recommendation: new repo, mirror the RN repo structure.* +- **Versioning**: do we align v1.0.0 of Flutter SDK with the workspace API generation (V5)? If yes, we lock the public API around the workspace-only world from day one. +- **Survey runtime bundle URL**: we currently load `/js/surveys.umd.cjs` from the customer's `appUrl`. For self-hosters running airgapped, this requires their own static asset hosting — same constraint as RN, but worth re-confirming with the self-host docs team. +- **`displayPercentage`**: RN uses `Math.random()` which is fine for non-security gating. Dart's `Random()` is equivalent. No question here, just noting we will not import `dart:math` `Random.secure()` for this gate. +- **`flutter_secure_storage` default vs opt-in**: do we keep parity with RN (unencrypted) or upgrade by default? Tradeoff: encrypted storage costs ~250 KB on Android, breaks reads after a clear-on-uninstall edge case on iOS, and requires native keychain entitlements. *Recommendation: opt-in, document loudly.* + +### Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| WebView Android touch-event regressions (RN hit this — see `formbricks.tsx:56–61` `pointerEvents="box-none"`) | Med | Med | Wrap `WebView` in `Stack` with `IgnorePointer` outside the active survey area; integration test on a real Android device + Pixel emulator before GA | +| `webview_flutter` Android requires Hybrid Composition / SurfaceAndroidWebView quirks → keyboard avoidance bugs (RN had to add `KeyboardAvoidingView`) | Med | Med | Test text input questions on Android with soft keyboard early; default to `MediaQuery.viewInsets` padding inside the modal route | +| Survey script (`surveys.umd.cjs`) makes a breaking change to the JS→host message shape | Low | High | Lock the bridge schema in a shared spec doc with the web team; run the integration test suite against the prod surveys script on every release | +| `SharedPreferences` size limit (~few MB on Android) blown by large cached `workspace.surveys` payloads | Low | Med | Cap cached `surveys` count per workspace; surface a warning log when serialized config > 256 KB | +| Apple App Store rejects the embedded WebView as a "browser-like" app | Very low | High | Survey WebView is modal, no URL bar, no general browsing — same pattern Apple has accepted from countless in-app survey SDKs (Hotjar, Sprig, etc.). Document review notes for the App Store review team | +| Pub.dev publish account / package name squatting | Low | Med | Reserve `formbricks_flutter` (and `formbricks`) on pub.dev now under `formbricks.com` verified publisher | +| Flutter SDK drifts out of parity with RN as new features land on RN | Med | Med | Shared client-API contract doc; quarterly parity review; mark RN as the canonical reference until Flutter reaches v1.0 | +| Customers ship the SDK on Flutter Web (out of scope) and file bugs | Med | Low | Add a runtime guard: throw a clear `UnsupportedError` with a doc link when `kIsWeb` | + +### Lessons from the RN SDK to **avoid** repeating in Flutter + +1. **Don't bake in a legacy alias from day one.** RN still carries `environmentId` → `workspaceId` migration code in `config.ts` `migrateLegacyConfig` and `setup.ts` lines 283–295. Flutter SDK should accept `workspaceId` only — no deprecated alias to ever remove later. +2. **Don't make `RNConfig.getInstance()` async + side-effectful.** RN's singleton calls `await init()` on **every** `getInstance()` call (`config.ts:71–74`), which re-reads `SharedPreferences`/`AsyncStorage` every time. In Flutter, initialize once at SDK setup and expose a synchronous accessor; gate the public API behind `setup()` having completed. +3. **Don't let `Logger` be a process-global singleton with mutable level configured deep inside a component.** RN configures the logger from inside `survey-web-view.tsx:19` based on `__DEV__`. Move logger configuration into `Formbricks.setup()` so behavior is deterministic and testable. +4. **Don't store `Date` objects in JSON.** RN's `TConfig` typings claim `Date` fields but storage round-trips them as strings — leading to subtle bugs like `new Date(workspace.expiresAt) >= new Date()` having to defensively re-construct (`workspace/state.ts:91`). Use `DateTime` in memory, `String` (ISO 8601) on the wire and on disk, and convert at exactly one boundary in `FormbricksConfig.fromJson` / `toJson`. +5. **Don't couple WebView rendering to the host widget tree's React lifecycle.** RN's `SurveyWebView` `useEffect` chain (lines 34–90) does state sync, language resolution, and delay-timer setup as three separate effects, which has caused at least one race on remount. In Flutter, drive the survey lifecycle as a single state machine inside the widget's `State` with explicit transitions (`idle → loading → presenting → closing`). +6. **Don't expose a synchronous `update()` that fires-and-forgets `saveToStorage`.** RN's `config.ts:76–87` returns synchronously but persists asynchronously via `void this.saveToStorage()`. A crash between in-memory update and disk write loses state. Flutter version should return a `Future` from `update()` that completes after persistence, and the command queue should `await` it. +7. **Don't put two separate expiry tickers on two separate intervals.** RN has `workspaceSyncIntervalId` and `userStateSyncIntervalId` (`workspace/state.ts:9`, `user/state.ts:4`) each ticking every 60s. Unify into one `Timer.periodic` that walks both expiries — half the wakeups, simpler lifecycle management. +8. **Don't ignore the foreground/background lifecycle.** RN's expiry tickers keep running even when the app is backgrounded, wasting battery and occasionally racing with `AsyncStorage` rehydration on resume. In Flutter, observe `WidgetsBindingObserver.didChangeAppLifecycleState` and pause the ticker on `paused`/`inactive`, run an immediate expiry check on `resumed`. +9. **Don't bury error-state recovery in a 10-minute magic number.** RN sets `status.expiresAt = Date.now() + 10 * 60000` in `handleErrorOnFirstSetup` (`setup.ts:407`). Make this a named constant `_errorCooldown = Duration(minutes: 10)` at the top of `setup.dart` so it's discoverable. +10. **Don't bridge JS → host via positional `JSON.parse` of a free-form payload.** RN's `survey-web-view.tsx:171–267` parses, then runs a Zod safeParse, then does five separate `if (onClose) / if (onDisplayCreated) / ...` branches. Replace with a tagged-union `WebViewEvent` Dart class + `sealed class` exhaustive `switch` so adding a new event is a compile error if a handler is missing. +11. **Don't conflate "no userId set" with "no segments matched" in `filterSurveys`.** RN's `utils.ts:128–143` has two early returns that look similar but mean different things — `!userId` returns surveys without segment filters, `!segments.length` returns `[]`. Port the logic, but rename to make the distinction obvious (`_filterAnonymous` vs `_filterIdentifiedWithoutSegments`) and unit-test both branches. +12. **Don't ship without a real example app from day one.** RN's `apps/playground` was added late and is the only thing that catches keyboard/modal/touch regressions before they reach customers. Build `example/` in the first PR. +13. **Don't use exact-pinned plugin versions in the published package**. RN's `package.json` pins every dep exactly (`react: 19.2.4`, `react-native: 0.84.1`) — that is fine for the playground but in `formbricks_flutter` `pubspec.yaml` we should use caret ranges (`webview_flutter: ^4.4.0`) so the SDK doesn't force-downgrade customers' transitive deps. +14. **Don't postpone the rename problem.** RN still references `RNConfig`, `RN_ASYNC_STORAGE_KEY`, etc. in public-adjacent code. Pick Flutter naming up front: types are `FormbricksConfig`, storage key is `formbricks-flutter`, no `Flutter` prefix on internal types (it's redundant inside a Flutter package). +15. **Don't skip the `connectivity_plus`-equivalent guard on `track()`.** RN guards via `@react-native-community/netinfo` (`action.ts:89–101`). Without it, the WebView renders, fails to fetch the surveys script, and the user sees an empty modal with no signal. Mirror the guard in Flutter. + +--- + +## Appendix A — Suggested package layout + +``` +formbricks_flutter/ +├── lib/ +│ ├── formbricks_flutter.dart # public exports only +│ └── src/ +│ ├── common/ +│ │ ├── api_client.dart +│ │ ├── command_queue.dart +│ │ ├── config.dart +│ │ ├── expiry_ticker.dart +│ │ ├── filter_surveys.dart +│ │ ├── logger.dart +│ │ └── setup.dart +│ ├── survey/ +│ │ ├── action.dart +│ │ └── survey_store.dart +│ ├── user/ +│ │ ├── attribute.dart +│ │ ├── update_queue.dart +│ │ └── user.dart +│ ├── workspace/ +│ │ └── workspace_state.dart +│ ├── types/ +│ │ ├── config.dart +│ │ ├── survey.dart +│ │ ├── action_class.dart +│ │ ├── workspace.dart +│ │ └── result.dart # Rust-style Result, ports types/error.ts +│ └── widgets/ +│ ├── formbricks_widget.dart +│ └── survey_webview.dart +├── example/ # full Flutter app, equivalent of apps/playground +├── test/ # unit tests, one file per src/ file +├── integration_test/ # end-to-end on iOS sim + Android emulator +├── pubspec.yaml +├── CHANGELOG.md +├── LICENSE # MIT, same as RN SDK +└── README.md +``` + +## Appendix B — Parity matrix vs RN SDK + +| Feature | RN | Flutter v1 | +|---|---|---| +| `setup({appUrl, workspaceId})` | ✓ | ✓ | +| Backward-compat `environmentId` alias | ✓ (deprecated) | ✗ (intentional) | +| `track(name)` with action-class lookup | ✓ | ✓ | +| `setUserId` with previous-user teardown | ✓ | ✓ | +| `setAttribute` / `setAttributes` (string, number, Date) | ✓ | ✓ (String, num, DateTime) | +| `setLanguage` w/o userId → local-only update | ✓ | ✓ | +| `logout` → reset user state | ✓ | ✓ | +| Workspace state cache + 60s expiry ticker | ✓ | ✓ (unified ticker) | +| User state 30 min expiry refresh | ✓ | ✓ | +| `displayOption` filtering (4 modes) | ✓ | ✓ | +| `recontactDays` (per-survey + workspace fallback) | ✓ | ✓ | +| `displayPercentage` gate | ✓ | ✓ | +| Segment filtering (anonymous + identified) | ✓ | ✓ | +| Multi-language survey resolution | ✓ | ✓ | +| Error-state 10-min cooldown after setup fail | ✓ | ✓ (named constant) | +| Same-origin WebView nav, external links via `url_launcher` | ✓ (`Linking`) | ✓ | +| `displayPercentage`-style soft randomness | ✓ | ✓ | +| Survey file upload | ✗ (stubbed) | ✗ (out of scope) | +| Encrypted storage by default | ✗ | ✗ (opt-in slot) | +| Offline response queue | ✗ (delegated to WebView) | ✗ (same delegation) | diff --git a/packages/formbricks_flutter/.gitignore b/packages/formbricks_flutter/.gitignore new file mode 100644 index 0000000..dd5eb98 --- /dev/null +++ b/packages/formbricks_flutter/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/formbricks_flutter/CHANGELOG.md b/packages/formbricks_flutter/CHANGELOG.md new file mode 100644 index 0000000..34f2630 --- /dev/null +++ b/packages/formbricks_flutter/CHANGELOG.md @@ -0,0 +1,4 @@ +## 0.0.1 + +* Initial package skeleton. Establishes the monorepo wiring and a placeholder + `welcome()` API. No SDK behavior yet. diff --git a/packages/formbricks_flutter/LICENSE b/packages/formbricks_flutter/LICENSE new file mode 100644 index 0000000..fb63c24 --- /dev/null +++ b/packages/formbricks_flutter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Formbricks GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/formbricks_flutter/README.md b/packages/formbricks_flutter/README.md new file mode 100644 index 0000000..cc02bfe --- /dev/null +++ b/packages/formbricks_flutter/README.md @@ -0,0 +1,27 @@ +# formbricks_flutter + +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()`. + +## Planned public API + +```dart +// Host widget — initializes the SDK and renders any active survey. +Formbricks(appUrl: 'https://app.formbricks.com', workspaceId: 'wsp_...'); + +// Static imperative API (sequenced via an internal command queue). +await Formbricks.track('button_clicked'); +await Formbricks.setUserId('user_123'); +await Formbricks.setAttribute('plan', 'pro'); +await Formbricks.setAttributes({'plan': 'pro', 'mrr': 99}); +await Formbricks.setLanguage('de'); +await Formbricks.logout(); +``` + +See [`docs/FLUTTER_SDK_PLAN.md`](../../docs/FLUTTER_SDK_PLAN.md) for the full spec +and the React Native → Flutter architecture mapping. diff --git a/packages/formbricks_flutter/lib/formbricks_flutter.dart b/packages/formbricks_flutter/lib/formbricks_flutter.dart new file mode 100644 index 0000000..ff67e9f --- /dev/null +++ b/packages/formbricks_flutter/lib/formbricks_flutter.dart @@ -0,0 +1,11 @@ +/// 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. +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'; diff --git a/packages/formbricks_flutter/pubspec.yaml b/packages/formbricks_flutter/pubspec.yaml new file mode 100644 index 0000000..453e994 --- /dev/null +++ b/packages/formbricks_flutter/pubspec.yaml @@ -0,0 +1,36 @@ +name: formbricks_flutter +description: >- + Formbricks Flutter SDK — connect your Flutter app to Formbricks, identify + users, track actions, and render targeted in-app surveys. +version: 0.0.1 +homepage: https://formbricks.com +repository: https://github.com/formbricks/flutter +issue_tracker: https://github.com/formbricks/flutter/issues + +# Not published yet. This only establishes the monorepo skeleton; the public +# API and pub.dev publish flow land in follow-up work. +# NOTE: the bare name `formbricks_flutter` is currently taken on pub.dev — the +# final published name is an open decision (see repo README). +publish_to: none + +# Part of the root pub workspace — shares the root lockfile + analysis config. +resolution: workspace + +environment: + sdk: ^3.12.0 + flutter: ">=3.22.0" + +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. + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + # mocktail / http MockClient added with the first real source files. + +flutter: diff --git a/packages/formbricks_flutter/test/formbricks_flutter_test.dart b/packages/formbricks_flutter/test/formbricks_flutter_test.dart new file mode 100644 index 0000000..1ac68f6 --- /dev/null +++ b/packages/formbricks_flutter/test/formbricks_flutter_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks_flutter/formbricks_flutter.dart'; + +void main() { + test('welcome returns the Formbricks greeting', () { + expect(welcome(), 'Welcome to Formbricks'); + }); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..20f56c8 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,445 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + ansi_styles: + dependency: transitive + description: + name: ansi_styles + sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a" + url: "https://pub.dev" + source: hosted + version: "0.3.2+1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_launcher: + dependency: transitive + description: + name: cli_launcher + sha256: "35cf15a3ffaeb9c11849eaa0afba761bb76dceb42d050532bfd3e1299c9748cd" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + conventional_commit: + dependency: transitive + description: + name: conventional_commit + sha256: c40b1b449ce2a63fa2ce852f35e3890b1e182f5951819934c0e4a66254bc0dc3 + url: "https://pub.dev" + source: hosted + version: "0.6.1+1" + cupertino_icons: + dependency: transitive + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: transitive + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + melos: + dependency: "direct dev" + description: + name: melos + sha256: "9dcac9ca25da86540d1e843f85fffb29967cc5c79d76ca1aacddb57590cee84e" + url: "https://pub.dev" + source: hosted + version: "7.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mustache_template: + dependency: transitive + description: + name: mustache_template + sha256: "544ff0b837836f5ad6b351e7a676ff7c2cad4fa412c465908aeb9358caddb6fc" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + prompts: + dependency: transitive + description: + name: prompts + sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.22.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9c1afaa --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,58 @@ +name: formbricks_flutter_workspace +publish_to: none + +# Root of the Formbricks Flutter monorepo. +# This package is never published; it only declares the pub workspace +# (Dart 3.6+ / Melos 7) that ties the SDK package and demo app together +# under a single resolved dependency graph + lockfile. +environment: + sdk: ^3.12.0 + +# Members of the pub workspace. Each member pubspec sets `resolution: workspace`. +workspace: + - packages/formbricks_flutter + - apps/playground + +dev_dependencies: + melos: ^7.0.0 + +# Melos 7 builds on Dart's native pub workspaces (the `workspace:` key above). +# It adds cross-package scripting: run analyze / test / format across every +# package with one command. Used locally and in CI. +melos: + scripts: + ios: + description: Run the playground on an iOS simulator (boots one if needed). + run: ./tool/run.sh ios + + android: + description: Run the playground on an Android emulator (boots one if needed). + run: ./tool/run.sh android + + analyze: + description: Analyze the whole workspace with the shared lint rules. + run: dart analyze --fatal-infos . + + format: + description: Format all Dart code. + run: dart format . + + format-check: + description: Verify formatting (used in CI). + run: dart format --output=none --set-exit-if-changed . + + test: + description: Run tests in every package that has a test/ dir. + run: flutter test + exec: + concurrency: 1 + packageFilters: + dirExists: test + + test-coverage: + description: Run tests with coverage in every package that has tests. + run: flutter test --coverage + exec: + concurrency: 1 + packageFilters: + dirExists: test diff --git a/sonar-project.properties b/sonar-project.properties index 3f42a3f..3fedff2 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,14 +1,20 @@ +# SonarQube / SonarCloud configuration for the Formbricks Flutter monorepo. +# Full SonarQube wiring (coverage upload, CI gate) is finalised in a follow-up; +# this file gives the analysis a correct source/test map to build on. sonar.projectKey=formbricks_flutter sonar.organization=formbricks +sonar.sourceEncoding=UTF-8 +# Dart source lives in the SDK package and the demo app. +sonar.sources=packages/formbricks_flutter/lib,apps/playground/lib +sonar.tests=packages/formbricks_flutter/test,apps/playground/test +sonar.test.inclusions=**/*_test.dart -# This is the name and version displayed in the SonarCloud UI. -#sonar.projectName=flutter -#sonar.projectVersion=1.0 +# The demo app is excluded from quality metrics — it is QA scaffolding, not +# shipped SDK code (mirrors how RN excludes apps/playground). +sonar.exclusions=**/build/**,**/.dart_tool/**,**/*.g.dart,**/*.freezed.dart,**/android/**,**/ios/**,apps/playground/** +sonar.coverage.exclusions=**/build/**,**/.dart_tool/**,apps/playground/**,**/*_test.dart - -# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -#sonar.sources=. - -# Encoding of the source code. Default is default system encoding -#sonar.sourceEncoding=UTF-8 +# Coverage report (LCOV) produced by `flutter test --coverage`. +# Generated under each package's coverage/ dir; wired up in a follow-up. +sonar.dart.lcov.reportPaths=packages/formbricks_flutter/coverage/lcov.info diff --git a/tool/run.sh b/tool/run.sh new file mode 100755 index 0000000..68a9416 --- /dev/null +++ b/tool/run.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Run the playground demo app on a simulator/emulator from one command. +# +# `flutter run` cannot boot a device by itself — with none running it falls back +# to the macOS desktop target (which the app doesn't support). This script boots +# the right device if needed, then runs the app on that exact device. +# +# Usage: ./tool/run.sh [ios|android] [extra flutter run args...] +# ./tool/run.sh # iOS simulator (default) +# ./tool/run.sh android # Android emulator +# ./tool/run.sh ios --profile +set -euo pipefail + +platform="${1:-ios}" +case "$platform" in + ios|android) shift || true ;; + -*) platform="ios" ;; # first arg was a flutter flag, default platform + *) echo "Unknown platform '$platform' (use: ios | android)" >&2; exit 2 ;; +esac + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root/apps/playground" + +# Prefer the fvm-pinned SDK; fall back to a plain `flutter` on PATH. +if command -v fvm >/dev/null 2>&1; then + flutter() { fvm flutter "$@"; } +fi + +run_ios() { + booted_udid() { + xcrun simctl list devices booted \ + | grep -ioE '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' \ + | head -1 + } + if [[ -z "$(booted_udid)" ]]; then + echo "No iOS simulator running — booting one..." + flutter emulators --launch apple_ios_simulator + echo "Waiting for the simulator to finish booting..." + until [[ -n "$(booted_udid)" ]]; do sleep 1; done + fi + 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" "$@" +} + +run_android() { + local adb="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}/platform-tools/adb" + booted_serial() { "$adb" devices 2>/dev/null | awk '/emulator-[0-9]+\tdevice/ {print $1; exit}'; } + if [[ -z "$(booted_serial)" ]]; then + # Pick the first Android AVD that `flutter emulators` knows about. + local avd + avd="$(flutter emulators 2>/dev/null | awk -F '•' '$4 ~ /android/ {gsub(/^[ \t]+|[ \t]+$/,"",$1); print $1; exit}')" + if [[ -z "$avd" ]]; then + echo "No Android emulator (AVD) found. Create one in Android Studio." >&2 + exit 1 + fi + echo "No Android emulator running — booting '$avd'..." + flutter emulators --launch "$avd" + echo "Waiting for the emulator to come online..." + until [[ -n "$(booted_serial)" ]]; do sleep 2; done + echo "Waiting for the emulator to finish booting..." + "$adb" -s "$(booted_serial)" wait-for-device shell \ + 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' 2>/dev/null || true + fi + local serial; serial="$(booted_serial)" + echo "Running on Android emulator $serial" + exec flutter run -d "$serial" "$@" +} + +case "$platform" in + ios) run_ios "$@" ;; + android) run_android "$@" ;; +esac From 2b85f05002450eefff0e55b338e19a30864390e5 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 2 Jun 2026 14:10:25 +0530 Subject: [PATCH 2/5] setup function --- .gitignore | 4 +- README.md | 7 + apps/playground/.env.example | 4 + apps/playground/README.md | 35 +- apps/playground/lib/main.dart | 61 ++- packages/formbricks_flutter/README.md | 34 +- .../lib/formbricks_flutter.dart | 65 +++- .../lib/src/common/api_client.dart | 156 ++++++++ .../lib/src/common/command_queue.dart | 69 ++++ .../lib/src/common/config.dart | 98 +++++ .../lib/src/common/expiry_ticker.dart | 146 +++++++ .../lib/src/common/logger.dart | 52 +++ .../lib/src/common/result.dart | 49 +++ .../lib/src/common/setup.dart | 228 +++++++++++ .../lib/src/common/time.dart | 8 + .../lib/src/types/config.dart | 361 +++++++++++++++++ .../lib/src/types/errors.dart | 130 +++++++ packages/formbricks_flutter/pubspec.yaml | 11 +- .../test/common/api_client_test.dart | 212 ++++++++++ .../test/common/command_queue_test.dart | 77 ++++ .../test/common/config_test.dart | 104 +++++ .../test/common/expiry_ticker_test.dart | 215 +++++++++++ .../test/common/result_test.dart | 30 ++ .../test/common/setup_test.dart | 363 ++++++++++++++++++ .../test/formbricks_flutter_test.dart | 48 ++- .../test/types/config_test.dart | 95 +++++ .../test/types/errors_test.dart | 66 ++++ pubspec.lock | 119 +++++- tool/run.sh | 11 +- 29 files changed, 2820 insertions(+), 38 deletions(-) create mode 100644 apps/playground/.env.example create mode 100644 packages/formbricks_flutter/lib/src/common/api_client.dart create mode 100644 packages/formbricks_flutter/lib/src/common/command_queue.dart create mode 100644 packages/formbricks_flutter/lib/src/common/config.dart create mode 100644 packages/formbricks_flutter/lib/src/common/expiry_ticker.dart create mode 100644 packages/formbricks_flutter/lib/src/common/logger.dart create mode 100644 packages/formbricks_flutter/lib/src/common/result.dart create mode 100644 packages/formbricks_flutter/lib/src/common/setup.dart create mode 100644 packages/formbricks_flutter/lib/src/common/time.dart create mode 100644 packages/formbricks_flutter/lib/src/types/config.dart create mode 100644 packages/formbricks_flutter/lib/src/types/errors.dart create mode 100644 packages/formbricks_flutter/test/common/api_client_test.dart create mode 100644 packages/formbricks_flutter/test/common/command_queue_test.dart create mode 100644 packages/formbricks_flutter/test/common/config_test.dart create mode 100644 packages/formbricks_flutter/test/common/expiry_ticker_test.dart create mode 100644 packages/formbricks_flutter/test/common/result_test.dart create mode 100644 packages/formbricks_flutter/test/common/setup_test.dart create mode 100644 packages/formbricks_flutter/test/types/config_test.dart create mode 100644 packages/formbricks_flutter/test/types/errors_test.dart diff --git a/.gitignore b/.gitignore index b08ed72..a17fd67 100644 --- a/.gitignore +++ b/.gitignore @@ -51,9 +51,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/README.md b/README.md index 2f4162d..adc952a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,9 @@ not support), so a simulator/emulator must be started first. **Easiest — one command** (boots the device if needed, then runs): ```bash +# one-time: copy the template and fill in your workspace credentials +cp apps/playground/.env.example apps/playground/.env + ./tool/run.sh # iOS simulator (default) ./tool/run.sh android # Android emulator # or via Melos (package.json-style scripts, see Monorepo tooling): @@ -142,6 +145,10 @@ melos run ios melos run android ``` +`tool/run.sh` auto-passes `apps/playground/.env` to the app via +`--dart-define-from-file` (the `.env` is git-ignored). You can still override +with explicit `--dart-define=APP_URL=… --dart-define=WORKSPACE_ID=…` flags. + **Manual CLI:** a simulator/emulator must be booted *first* — `flutter run` never boots one itself. Start a device, then target it by name: 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/lib/main.dart b/apps/playground/lib/main.dart index ab01876..1c6876c 100644 --- a/apps/playground/lib/main.dart +++ b/apps/playground/lib/main.dart @@ -1,6 +1,12 @@ import 'package:flutter/material.dart'; import 'package:formbricks_flutter/formbricks_flutter.dart'; +/// Credentials are injected at build time, mirroring the React Native +/// playground's use of `EXPO_PUBLIC_*` env vars. Pass them with: +/// --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'); + void main() { runApp(const PlaygroundApp()); } @@ -21,9 +27,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. @@ -40,9 +86,6 @@ class PlaygroundHome extends StatelessWidget { @override Widget build(BuildContext context) { - // Proves the SDK package links into the app via the pub workspace. - final greeting = welcome(); - final actions = <({String label, String action})>[ (label: 'Trigger Code Action', action: "track('code')"), (label: 'Set userId', action: "setUserId('random-user-id')"), @@ -61,8 +104,16 @@ class PlaygroundHome extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(greeting, textAlign: TextAlign.center), + const Text('Welcome to Formbricks', textAlign: TextAlign.center), + const SizedBox(height: 8), + Text( + 'setup: $_status', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 12), for (final a in actions) ...[ FilledButton( onPressed: () => _stub(context, a.action), diff --git a/packages/formbricks_flutter/README.md b/packages/formbricks_flutter/README.md index cc02bfe..45b51c4 100644 --- a/packages/formbricks_flutter/README.md +++ b/packages/formbricks_flutter/README.md @@ -4,17 +4,37 @@ 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'; + +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/!http(s) appUrl) — error.message explains. +} +``` -// Static imperative API (sequenced via an internal command queue). +`setup` is idempotent (a second call is a no-op), runs through an internal +command queue, and installs a lifecycle-aware expiry ticker. A **first**-setup +network failure throws `FormbricksSetupError` and puts the SDK into a 10-minute +error cooldown; subsequent `setup` calls within that window short-circuit. + +## 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..8376c56 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/api_client.dart @@ -0,0 +1,156 @@ +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() ?? {}; + return Result.ok(parse(data)); + } + + /// 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?rand=${DateTime.now().millisecondsSinceEpoch}', + 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, '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..8848477 --- /dev/null +++ b/packages/formbricks_flutter/lib/src/common/setup.dart @@ -0,0 +1,228 @@ +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.'); + return const Result.ok(null); + } + 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')) { + Logger.debug('appUrl is not a valid http(s) URL'); + return Result.err( + MissingFieldError( + 'appUrl', + message: 'appUrl must be a valid http(s) URL', + ), + ); + } + + Logger.debug('Start setup'); + + final api = ApiClient( + appUrl: appUrl, + workspaceId: workspaceId, + client: httpClient, + isDebug: logLevel == LogLevel.debug, + ); + + final matches = + existing != null && + existing.workspace != null && + existing.workspaceId == workspaceId && + existing.appUrl == appUrl; + + 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: appUrl, + 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; + } + _isSetup = true; + Logger.debug('Set up complete'); + return const Result.ok(null); +} + +/// 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..0038c2f --- /dev/null +++ b/packages/formbricks_flutter/lib/src/types/config.dart @@ -0,0 +1,361 @@ +/// 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..409bc1e --- /dev/null +++ b/packages/formbricks_flutter/lib/src/types/errors.dart @@ -0,0 +1,130 @@ +/// 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'), + + /// 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); +} + +/// 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 453e994..b2cc540 100644 --- a/packages/formbricks_flutter/pubspec.yaml +++ b/packages/formbricks_flutter/pubspec.yaml @@ -23,14 +23,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..b9c625b --- /dev/null +++ b/packages/formbricks_flutter/test/common/api_client_test.dart @@ -0,0 +1,212 @@ +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', + ); + }); + }); + + 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..d61cfa7 --- /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..722a022 --- /dev/null +++ b/packages/formbricks_flutter/test/common/expiry_ticker_test.dart @@ -0,0 +1,215 @@ +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..c97f479 --- /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..f629e34 --- /dev/null +++ b/packages/formbricks_flutter/test/common/setup_test.dart @@ -0,0 +1,363 @@ +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('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(result.isOk, isTrue); + }); + + 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..20b6f7a --- /dev/null +++ b/packages/formbricks_flutter/test/types/config_test.dart @@ -0,0 +1,95 @@ +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..b0c83f0 --- /dev/null +++ b/packages/formbricks_flutter/test/types/errors_test.dart @@ -0,0 +1,66 @@ +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.invalidCode.wire, 'invalid_code'); + }); + + 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 20f56c8..d0caf8f 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: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + 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.22.0" + flutter: ">=3.35.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 From ff89dfce221b1eba126bed829c372f6191a3eb35 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 2 Jun 2026 15:55:30 +0530 Subject: [PATCH 3/5] fixes coderabbit feedback --- packages/formbricks_flutter/README.md | 37 +++++++---- .../lib/src/common/api_client.dart | 3 +- .../lib/src/common/setup.dart | 66 +++++++++++-------- 3 files changed, 63 insertions(+), 43 deletions(-) diff --git a/packages/formbricks_flutter/README.md b/packages/formbricks_flutter/README.md index 45b51c4..64e0366 100644 --- a/packages/formbricks_flutter/README.md +++ b/packages/formbricks_flutter/README.md @@ -13,23 +13,34 @@ targeted in-app surveys. ```dart import 'package:formbricks_flutter/formbricks_flutter.dart'; -final result = await Formbricks.setup( - appUrl: 'https://app.formbricks.com', - workspaceId: 'wsp_...', -); - -switch (result) { - case Ok(): +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/!http(s) appUrl) — error.message explains. + 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` is idempotent (a second call is a no-op), runs through an internal -command queue, and installs a lifecycle-aware expiry ticker. A **first**-setup -network failure throws `FormbricksSetupError` and puts the SDK into a 10-minute -error cooldown; subsequent `setup` calls within that window short-circuit. +`setup` reports problems through **two** channels: + +- 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; `setup` calls + within that window short-circuit and return `Ok` without hitting the network. + +`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 diff --git a/packages/formbricks_flutter/lib/src/common/api_client.dart b/packages/formbricks_flutter/lib/src/common/api_client.dart index 8376c56..a70e792 100644 --- a/packages/formbricks_flutter/lib/src/common/api_client.dart +++ b/packages/formbricks_flutter/lib/src/common/api_client.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:clock/clock.dart'; import 'package:http/http.dart' as http; import '../types/config.dart'; @@ -111,7 +112,7 @@ class ApiClient { return _request( method: 'GET', endpoint: - '/api/v2/client/$workspaceId/environment?rand=${DateTime.now().millisecondsSinceEpoch}', + '/api/v2/client/$workspaceId/environment?rand=${clock.now().millisecondsSinceEpoch}', parse: (data) { final inner = (data['data'] as Map?)?.cast() ?? diff --git a/packages/formbricks_flutter/lib/src/common/setup.dart b/packages/formbricks_flutter/lib/src/common/setup.dart index 8848477..49db342 100644 --- a/packages/formbricks_flutter/lib/src/common/setup.dart +++ b/packages/formbricks_flutter/lib/src/common/setup.dart @@ -108,38 +108,46 @@ Future> setup({ existing.workspaceId == workspaceId && existing.appUrl == appUrl; - 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: appUrl, - workspace: value, - user: TUserState.defaultNoUserId, - filteredSurveys: const [], - status: TStatus.success, - ), - ); - case Err(:final error): - // Persists error state and throws FormbricksSetupError. - await _handleErrorOnFirstSetup(config, error); + // 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: appUrl, + 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; + 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(); } - _isSetup = true; - Logger.debug('Set up complete'); - return const Result.ok(null); } /// Sync path when the cached config already matches the requested workspace/app: From a8a495d4bf5809b7c28615d80667d21791c4a660 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 4 Jun 2026 11:12:38 +0530 Subject: [PATCH 4/5] addresses PR feedback --- packages/formbricks_flutter/README.md | 7 +++- .../lib/src/common/api_client.dart | 21 ++++++++-- .../lib/src/common/setup.dart | 18 +++++--- .../lib/src/types/errors.dart | 19 +++++++++ .../test/common/api_client_test.dart | 26 ++++++++++++ .../test/common/setup_test.dart | 42 ++++++++++++++++++- .../test/types/errors_test.dart | 9 ++++ 7 files changed, 130 insertions(+), 12 deletions(-) diff --git a/packages/formbricks_flutter/README.md b/packages/formbricks_flutter/README.md index 64e0366..571e504 100644 --- a/packages/formbricks_flutter/README.md +++ b/packages/formbricks_flutter/README.md @@ -36,8 +36,11 @@ try { `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; `setup` calls - within that window short-circuit and return `Ok` without hitting the network. + 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. diff --git a/packages/formbricks_flutter/lib/src/common/api_client.dart b/packages/formbricks_flutter/lib/src/common/api_client.dart index a70e792..5e3c4ee 100644 --- a/packages/formbricks_flutter/lib/src/common/api_client.dart +++ b/packages/formbricks_flutter/lib/src/common/api_client.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:clock/clock.dart'; import 'package:http/http.dart' as http; import '../types/config.dart'; @@ -100,7 +99,22 @@ class ApiClient { final data = (json?['data'] as Map?)?.cast() ?? {}; - return Result.ok(parse(data)); + 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`. @@ -111,8 +125,7 @@ class ApiClient { Future> getWorkspaceState() { return _request( method: 'GET', - endpoint: - '/api/v2/client/$workspaceId/environment?rand=${clock.now().millisecondsSinceEpoch}', + endpoint: '/api/v2/client/$workspaceId/environment', parse: (data) { final inner = (data['data'] as Map?)?.cast() ?? diff --git a/packages/formbricks_flutter/lib/src/common/setup.dart b/packages/formbricks_flutter/lib/src/common/setup.dart index 49db342..d972b7a 100644 --- a/packages/formbricks_flutter/lib/src/common/setup.dart +++ b/packages/formbricks_flutter/lib/src/common/setup.dart @@ -68,7 +68,9 @@ Future> setup({ final expiresAt = existing.status.expiresAt; if (expiresAt != null && !isNowExpired(expiresAt)) { Logger.debug('Within error cooldown. Skipping setup.'); - return const Result.ok(null); + // 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.'); } @@ -83,7 +85,9 @@ Future> setup({ return Result.err(MissingFieldError('appUrl')); } final uri = Uri.tryParse(appUrl); - if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + 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( @@ -93,10 +97,14 @@ Future> setup({ ); } + // 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: appUrl, + appUrl: normalizedAppUrl, workspaceId: workspaceId, client: httpClient, isDebug: logLevel == LogLevel.debug, @@ -106,7 +114,7 @@ Future> setup({ existing != null && existing.workspace != null && existing.workspaceId == workspaceId && - existing.appUrl == appUrl; + 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. @@ -124,7 +132,7 @@ Future> setup({ await config.update( TConfig( workspaceId: workspaceId, - appUrl: appUrl, + appUrl: normalizedAppUrl, workspace: value, user: TUserState.defaultNoUserId, filteredSurveys: const [], diff --git a/packages/formbricks_flutter/lib/src/types/errors.dart b/packages/formbricks_flutter/lib/src/types/errors.dart index 409bc1e..4805960 100644 --- a/packages/formbricks_flutter/lib/src/types/errors.dart +++ b/packages/formbricks_flutter/lib/src/types/errors.dart @@ -13,6 +13,9 @@ enum FormbricksErrorCode { /// 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'); @@ -91,6 +94,22 @@ final class FormbricksSetupError extends FormbricksError { }) : 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]). diff --git a/packages/formbricks_flutter/test/common/api_client_test.dart b/packages/formbricks_flutter/test/common/api_client_test.dart index b9c625b..3efb808 100644 --- a/packages/formbricks_flutter/test/common/api_client_test.dart +++ b/packages/formbricks_flutter/test/common/api_client_test.dart @@ -144,6 +144,32 @@ void main() { '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', () { diff --git a/packages/formbricks_flutter/test/common/setup_test.dart b/packages/formbricks_flutter/test/common/setup_test.dart index f629e34..356af02 100644 --- a/packages/formbricks_flutter/test/common/setup_test.dart +++ b/packages/formbricks_flutter/test/common/setup_test.dart @@ -113,6 +113,42 @@ void main() { 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 { @@ -191,7 +227,11 @@ void main() { }); expect(calls, 0); - expect(result.isOk, isTrue); + expect(_errOf(result), isA()); + expect( + (_errOf(result) as SetupCooldownError).retryAt, + now.add(const Duration(minutes: 10)), + ); }); test( diff --git a/packages/formbricks_flutter/test/types/errors_test.dart b/packages/formbricks_flutter/test/types/errors_test.dart index b0c83f0..2f1fff7 100644 --- a/packages/formbricks_flutter/test/types/errors_test.dart +++ b/packages/formbricks_flutter/test/types/errors_test.dart @@ -7,9 +7,18 @@ void main() { 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'); From bd7e6091eeeb71bbbb9b773e7c9f4ee76966894c Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 4 Jun 2026 13:52:26 +0530 Subject: [PATCH 5/5] fixes lint --- .gitignore | 3 + apps/playground/lib/main.dart | 1 + .../lib/src/common/api_client.dart | 10 +- .../lib/src/common/setup.dart | 13 +- .../lib/src/types/config.dart | 177 +++++++++--------- .../lib/src/types/errors.dart | 17 +- .../test/common/api_client_test.dart | 44 ++--- .../test/common/config_test.dart | 20 +- .../test/common/expiry_ticker_test.dart | 55 +++--- .../test/common/result_test.dart | 6 +- .../test/common/setup_test.dart | 48 ++--- .../test/types/config_test.dart | 8 +- 12 files changed, 206 insertions(+), 196 deletions(-) diff --git a/.gitignore b/.gitignore index a17fd67..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 diff --git a/apps/playground/lib/main.dart b/apps/playground/lib/main.dart index f35c74c..017563b 100644 --- a/apps/playground/lib/main.dart +++ b/apps/playground/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:formbricks_flutter/formbricks_flutter.dart'; /// Credentials are injected at build time, mirroring the React Native /// playground's use of `EXPO_PUBLIC_*` env vars. Pass them via `--dart-define` diff --git a/packages/formbricks_flutter/lib/src/common/api_client.dart b/packages/formbricks_flutter/lib/src/common/api_client.dart index 5e3c4ee..4b14f44 100644 --- a/packages/formbricks_flutter/lib/src/common/api_client.dart +++ b/packages/formbricks_flutter/lib/src/common/api_client.dart @@ -127,8 +127,7 @@ class ApiClient { method: 'GET', endpoint: '/api/v2/client/$workspaceId/environment', parse: (data) { - final inner = - (data['data'] as Map?)?.cast() ?? + final inner = (data['data'] as Map?)?.cast() ?? {}; if (inner['settings'] == null) { if (inner['workspace'] != null) { @@ -153,14 +152,17 @@ class ApiClient { /// backend infers the attribute type from the JSON type). Any `DateTime` /// attribute must already be an ISO string before reaching this layer. Future> - createOrUpdateUser({ + createOrUpdateUser({ required String userId, Map? attributes, }) { return _request( method: 'POST', endpoint: '/api/v2/client/$workspaceId/user', - body: {'userId': userId, 'attributes': ?attributes}, + body: { + 'userId': userId, + if (attributes != null) 'attributes': attributes, + }, parse: CreateOrUpdateUserResponse.fromJson, ); } diff --git a/packages/formbricks_flutter/lib/src/common/setup.dart b/packages/formbricks_flutter/lib/src/common/setup.dart index d972b7a..641eaf0 100644 --- a/packages/formbricks_flutter/lib/src/common/setup.dart +++ b/packages/formbricks_flutter/lib/src/common/setup.dart @@ -110,8 +110,7 @@ Future> setup({ isDebug: logLevel == LogLevel.debug, ); - final matches = - existing != null && + final matches = existing != null && existing.workspace != null && existing.workspaceId == workspaceId && existing.appUrl == normalizedAppUrl; @@ -237,8 +236,8 @@ Future _handleErrorOnFirstSetup( } NetworkError _toNetworkError(ApiErrorResponse error) => NetworkError( - message: error.message, - status: error.status, - url: error.url, - responseMessage: error.responseMessage, -); + message: error.message, + status: error.status, + url: error.url, + responseMessage: error.responseMessage, + ); diff --git a/packages/formbricks_flutter/lib/src/types/config.dart b/packages/formbricks_flutter/lib/src/types/config.dart index 0038c2f..5175239 100644 --- a/packages/formbricks_flutter/lib/src/types/config.dart +++ b/packages/formbricks_flutter/lib/src/types/config.dart @@ -25,9 +25,9 @@ class TDisplay { /// Builds a [TDisplay] from decoded JSON. factory TDisplay.fromJson(Map json) => TDisplay( - surveyId: json['surveyId'] as String, - createdAt: _parseDate(json['createdAt'])!, - ); + surveyId: json['surveyId'] as String, + createdAt: _parseDate(json['createdAt'])!, + ); /// The id of the displayed survey. final String surveyId; @@ -37,9 +37,9 @@ class TDisplay { /// Encodes this record to JSON with ISO-8601 dates. Map toJson() => { - 'surveyId': surveyId, - 'createdAt': _dateToIso(createdAt), - }; + 'surveyId': surveyId, + 'createdAt': _dateToIso(createdAt), + }; } /// The user-scoped slice of state (identity, segments, displays, responses). @@ -57,18 +57,19 @@ class TUserData { /// 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?, - ); + 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; @@ -93,14 +94,14 @@ class TUserData { /// 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, - }; + '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]. @@ -110,12 +111,12 @@ class TUserState { /// Builds [TUserState] from decoded JSON. factory TUserState.fromJson(Map json) => TUserState( - expiresAt: _parseDate(json['expiresAt']), - data: TUserData.fromJson( - (json['data'] as Map?)?.cast() ?? - const {}, - ), - ); + 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; @@ -132,15 +133,15 @@ class TUserState { /// Returns a copy with the given fields overridden. TUserState copyWith({DateTime? expiresAt, TUserData? data}) => TUserState( - expiresAt: expiresAt ?? this.expiresAt, - data: data ?? this.data, - ); + 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(), - }; + 'expiresAt': _dateToIso(expiresAt), + 'data': data.toJson(), + }; } /// The workspace-scoped data: surveys, action classes, and settings. @@ -156,12 +157,11 @@ class TWorkspaceData { /// 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 {}, - ); + 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; @@ -174,10 +174,10 @@ class TWorkspaceData { /// Encodes this workspace data to JSON. Map toJson() => { - 'surveys': surveys, - 'actionClasses': actionClasses, - 'settings': settings, - }; + 'surveys': surveys, + 'actionClasses': actionClasses, + 'settings': settings, + }; } /// The workspace state envelope: an expiry plus the workspace [data]. @@ -203,9 +203,9 @@ class TWorkspaceState { /// Encodes this workspace state to JSON with ISO-8601 dates. Map toJson() => { - 'expiresAt': _dateToIso(expiresAt), - 'data': data.toJson(), - }; + 'expiresAt': _dateToIso(expiresAt), + 'data': data.toJson(), + }; } /// The success/error status envelope, with an optional cooldown expiry. @@ -215,9 +215,9 @@ class TStatus { /// Builds [TStatus] from decoded JSON. factory TStatus.fromJson(Map json) => TStatus( - value: json['value'] as String? ?? 'success', - expiresAt: _parseDate(json['expiresAt']), - ); + value: json['value'] as String? ?? 'success', + expiresAt: _parseDate(json['expiresAt']), + ); /// The success default status. static const TStatus success = TStatus(value: 'success'); @@ -233,9 +233,9 @@ class TStatus { /// Encodes this status to JSON with ISO-8601 dates. Map toJson() => { - 'value': value, - 'expiresAt': _dateToIso(expiresAt), - }; + 'value': value, + 'expiresAt': _dateToIso(expiresAt), + }; } /// The full persisted SDK config. @@ -256,21 +256,23 @@ class TConfig { /// 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()), - ); + 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; @@ -299,24 +301,25 @@ class TConfig { 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, - ); + }) => + 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(), - }; + 'workspaceId': workspaceId, + 'appUrl': appUrl, + 'workspace': workspace?.toJson(), + 'user': user.toJson(), + 'filteredSurveys': filteredSurveys, + 'status': status.toJson(), + }; } /// Input for a user create/update call. diff --git a/packages/formbricks_flutter/lib/src/types/errors.dart b/packages/formbricks_flutter/lib/src/types/errors.dart index 4805960..4272e3e 100644 --- a/packages/formbricks_flutter/lib/src/types/errors.dart +++ b/packages/formbricks_flutter/lib/src/types/errors.dart @@ -43,7 +43,10 @@ sealed class FormbricksError implements Exception { final class MissingFieldError extends FormbricksError { /// Creates a missing-field error for [field]. MissingFieldError(this.field, {String? message}) - : super(FormbricksErrorCode.missingField, message ?? 'No $field provided'); + : super( + FormbricksErrorCode.missingField, + message ?? 'No $field provided', + ); /// The name of the offending field. final String field; @@ -81,7 +84,7 @@ final class NetworkError extends FormbricksError { final class InvalidCodeError extends FormbricksError { /// Creates an invalid-code error. InvalidCodeError([String message = 'Invalid code']) - : super(FormbricksErrorCode.invalidCode, message); + : super(FormbricksErrorCode.invalidCode, message); } /// Thrown when the very first [setup] attempt fails and the SDK is placed into @@ -100,11 +103,11 @@ final class FormbricksSetupError extends FormbricksError { 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.', - ); + : 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; diff --git a/packages/formbricks_flutter/test/common/api_client_test.dart b/packages/formbricks_flutter/test/common/api_client_test.dart index 3efb808..7dfe625 100644 --- a/packages/formbricks_flutter/test/common/api_client_test.dart +++ b/packages/formbricks_flutter/test/common/api_client_test.dart @@ -13,31 +13,31 @@ 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}, -}); + '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, + '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, -); + appUrl: _appUrl, + workspaceId: _workspaceId, + client: mock, + isDebug: isDebug, + ); TWorkspaceState _okWorkspace(Result r) => switch (r) { @@ -46,9 +46,9 @@ TWorkspaceState _okWorkspace(Result r) => }; ApiErrorResponse _errOf(Result r) => switch (r) { - Ok() => fail('expected Err'), - Err(:final error) => error, -}; + Ok() => fail('expected Err'), + Err(:final error) => error, + }; void main() { group('getWorkspaceState', () { diff --git a/packages/formbricks_flutter/test/common/config_test.dart b/packages/formbricks_flutter/test/common/config_test.dart index d61cfa7..901cb4b 100644 --- a/packages/formbricks_flutter/test/common/config_test.dart +++ b/packages/formbricks_flutter/test/common/config_test.dart @@ -14,16 +14,16 @@ void main() { }); 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, - ); + 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; diff --git a/packages/formbricks_flutter/test/common/expiry_ticker_test.dart b/packages/formbricks_flutter/test/common/expiry_ticker_test.dart index 722a022..be46bad 100644 --- a/packages/formbricks_flutter/test/common/expiry_ticker_test.dart +++ b/packages/formbricks_flutter/test/common/expiry_ticker_test.dart @@ -13,15 +13,15 @@ import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; String _envBody(String expiresAt) => jsonEncode({ - 'data': { - 'expiresAt': expiresAt, - 'data': { - 'surveys': [], - 'actionClasses': [], - 'settings': {}, - }, - }, -}); + 'data': { + 'expiresAt': expiresAt, + 'data': { + 'surveys': [], + 'actionClasses': [], + 'settings': {}, + }, + }, + }); void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -39,11 +39,11 @@ void main() { }); ExpiryTicker makeTicker(void Function() onEachTick) => ExpiryTicker( - config: FormbricksConfig.instance, - apiClient: api, - interval: const Duration(seconds: 60), - onTick: () async => onEachTick(), - ); + config: FormbricksConfig.instance, + apiClient: api, + interval: const Duration(seconds: 60), + onTick: () async => onEachTick(), + ); test('does not tick while paused', () { fakeAsync((async) { @@ -106,19 +106,20 @@ void main() { 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, - ); + }) => + 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( diff --git a/packages/formbricks_flutter/test/common/result_test.dart b/packages/formbricks_flutter/test/common/result_test.dart index c97f479..7e3c18a 100644 --- a/packages/formbricks_flutter/test/common/result_test.dart +++ b/packages/formbricks_flutter/test/common/result_test.dart @@ -19,9 +19,9 @@ void main() { test('can be matched exhaustively with a switch', () { String describe(Result r) => switch (r) { - Ok(:final value) => 'ok:$value', - Err(:final error) => 'err:$error', - }; + 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 index 356af02..cc93162 100644 --- a/packages/formbricks_flutter/test/common/setup_test.dart +++ b/packages/formbricks_flutter/test/common/setup_test.dart @@ -16,36 +16,36 @@ 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': {}, - }, - }, -}); + '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, + '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, -}; + Ok() => fail('expected Err'), + Err(:final error) => error, + }; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/packages/formbricks_flutter/test/types/config_test.dart b/packages/formbricks_flutter/test/types/config_test.dart index 20b6f7a..fcaeca0 100644 --- a/packages/formbricks_flutter/test/types/config_test.dart +++ b/packages/formbricks_flutter/test/types/config_test.dart @@ -70,11 +70,9 @@ void main() { }); 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 json = jsonDecode( + '{"status":{"value":"error","expiresAt":"2026-06-01T12:00:00.000"}}', + ) as Map; final config = TConfig.fromJson(json);