diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a78d4f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: ci + +on: + push: + branches: [develop, main] + pull_request: + +jobs: + contracts: + runs-on: ubuntu-latest + defaults: + run: + working-directory: contracts + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: foundry-rs/foundry-toolchain@v1 + - run: forge test -vv + + api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: services/api/go.mod + cache-dependency-path: | + services/api/go.sum + packages/plan-schema/go.sum + - name: plan-schema tests + working-directory: packages/plan-schema + run: go test ./... + - name: api tests + working-directory: services/api + run: go test ./... + - name: evals + working-directory: services/api + run: go run ./cmd/evals -dir ../../evals/cases + + mobile: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/mobile + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.38.6" + channel: stable + cache: true + - run: flutter pub get + - run: flutter analyze --no-fatal-infos + - run: flutter test diff --git a/README.md b/README.md index 99996c2..10273a0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,92 @@ -IntentGuard turns a natural-language DeFi intent into a schema-validated multi-step plan, requires per-step human approval, then executes against Foundry mocks on Anvil. +# IntentGuard -Layout: `apps/mobile` (Flutter), `services/api` (Go), `contracts` (Foundry), `packages/plan-schema`, `evals`, `deploy`, `docs`. +Natural-language DeFi intent → schema-validated multi-step plan → per-step human approval → execute against Foundry mocks on Anvil. The LLM (or mock planner) is untrusted; nothing hits the chain without schema, policy, and a human approve. -See `services/api/README.md` and `contracts/README.md` to run locally. +## Layout + +```text +apps/mobile/ Flutter client (Bloc, Dio, go_router) +services/api/ Go API (auth, intents, planner, policy, executor) +packages/plan-schema/ Plan JSON Schema v1 + Go validate +contracts/ MockERC20 + MockSwapRouter (Foundry) +evals/ Accept/reject fixtures +deploy/ Docker Compose (API + Postgres + Anvil) +docs/ Architecture, threat model, demo script +``` + +## Architecture + +```mermaid +flowchart LR + user[User] + flutter[FlutterApp] + api[API] + planner[Planner_mock_or_llm] + pg[(Postgres)] + anvil[Anvil] + mocks[MockContracts] + + user --> flutter + flutter -->|JWT| api + api --> pg + api --> planner + api --> anvil + anvil --> mocks +``` + +Details: [`docs/architecture.md`](docs/architecture.md). Threat notes: [`docs/threat-model.md`](docs/threat-model.md). + +## Run locally + +```bash +cd deploy && docker compose up --build +``` + +In another shell (Foundry on the host): + +```bash +./scripts/deploy-anvil.sh +./scripts/seed-anvil.sh +``` + +API `:8080`, Anvil `:8545`. Full curl path: [`docs/demo-script.md`](docs/demo-script.md). + +Mobile: + +```bash +cd apps/mobile +flutter run --dart-define=API_BASE=http://127.0.0.1:8080 +``` + +## Env (API) + +| Var | Default / notes | +|-----|-----------------| +| `DATABASE_URL` | required | +| `JWT_SECRET` | required | +| `PLANNER_MODE` | `mock` (CI/compose); `llm` needs `LLM_API_KEY` | +| `LLM_BASE_URL` | `https://api.openai.com/v1` | +| `LLM_MODEL` | `gpt-4o-mini` | +| `CHAIN_RPC_URL` | `http://127.0.0.1:8545` | +| `EXECUTOR_PRIVATE_KEY` | Anvil account #0 — local demo only | +| `DEPLOYMENTS_PATH` | `contracts/deployments/anvil.json` | + +See [`services/api/README.md`](services/api/README.md). + +## Tests / CI + +```bash +cd contracts && forge test +cd services/api && go test ./... +cd services/api && go run ./cmd/evals -dir ../../evals/cases +cd apps/mobile && flutter test +``` + +GitHub Actions runs the same gates on PRs (`.github/workflows/ci.yml`). + +## Design notes + +- Plan actions v1: `approve`, `swap`, `transfer` only. +- Policy: spend cap, token/spender/recipient allowlists, max steps, slippage bound. +- Executor ABI-encodes stored step JSON — never raw model hex. +- Mock planner fixtures cover accept + schema/policy reject paths for demos and evals. diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/mobile/.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/mobile/.gitkeep b/apps/mobile/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/mobile/.metadata b/apps/mobile/.metadata new file mode 100644 index 0000000..707da75 --- /dev/null +++ b/apps/mobile/.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: "8b872868494e429d94fa06dca855c306438b22c0" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + - platform: android + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + - platform: ios + create_revision: 8b872868494e429d94fa06dca855c306438b22c0 + base_revision: 8b872868494e429d94fa06dca855c306438b22c0 + + # 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/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..1ba81c6 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,10 @@ +# IntentGuard mobile + +Flutter client for IntentGuard. + +```bash +flutter pub get +flutter run --dart-define=API_BASE=http://127.0.0.1:8080 +# Android emulator: +flutter run --dart-define=API_BASE=http://10.0.2.2:8080 +``` diff --git a/apps/mobile/analysis_options.yaml b/apps/mobile/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/apps/mobile/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/mobile/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/mobile/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts new file mode 100644 index 0000000..6aa3c99 --- /dev/null +++ b/apps/mobile/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.omid.intentguard.intentguard" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.omid.intentguard.intentguard" + // 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") + } + } +} + +flutter { + source = "../.." +} diff --git a/apps/mobile/android/app/src/debug/AndroidManifest.xml b/apps/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b50e7ad --- /dev/null +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/kotlin/com/omid/intentguard/intentguard/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/com/omid/intentguard/intentguard/MainActivity.kt new file mode 100644 index 0000000..203bbae --- /dev/null +++ b/apps/mobile/android/app/src/main/kotlin/com/omid/intentguard/intentguard/MainActivity.kt @@ -0,0 +1,5 @@ +package com.omid.intentguard.intentguard + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/values-night/styles.xml b/apps/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/profile/AndroidManifest.xml b/apps/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/android/build.gradle.kts b/apps/mobile/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/mobile/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/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/apps/mobile/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/apps/mobile/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-8.14-all.zip diff --git a/apps/mobile/android/settings.gradle.kts b/apps/mobile/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/apps/mobile/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 "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/apps/mobile/ios/.gitignore b/apps/mobile/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/apps/mobile/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/mobile/ios/Flutter/AppFrameworkInfo.plist b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/apps/mobile/ios/Flutter/Debug.xcconfig b/apps/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/apps/mobile/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/apps/mobile/ios/Flutter/Release.xcconfig b/apps/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/apps/mobile/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/apps/mobile/ios/Podfile b/apps/mobile/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/apps/mobile/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..42e307e --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*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 */; }; + 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 = ""; }; + 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 = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 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 */, + 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; + 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; + 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 */, + ); + 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.omid.intentguard.intentguard; + 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.omid.intentguard.intentguard.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.omid.intentguard.intentguard.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.omid.intentguard.intentguard.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.omid.intentguard.intentguard; + 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.omid.intentguard.intentguard; + 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 */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/ios/Runner/AppDelegate.swift b/apps/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/apps/mobile/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/apps/mobile/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/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/apps/mobile/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/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/apps/mobile/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/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner/Base.lproj/Main.storyboard b/apps/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/apps/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner/Info.plist b/apps/mobile/ios/Runner/Info.plist new file mode 100644 index 0000000..2cc56c6 --- /dev/null +++ b/apps/mobile/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Intentguard + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + intentguard + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/apps/mobile/ios/Runner/Runner-Bridging-Header.h b/apps/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/apps/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/mobile/ios/RunnerTests/RunnerTests.swift b/apps/mobile/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/apps/mobile/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/mobile/lib/app.dart b/apps/mobile/lib/app.dart new file mode 100644 index 0000000..ccf471a --- /dev/null +++ b/apps/mobile/lib/app.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'core/di/injection.dart'; +import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; +import 'features/auth/presentation/cubit/auth_cubit.dart'; +import 'features/auth/presentation/cubit/auth_state.dart'; + +class IntentGuardApp extends StatefulWidget { + const IntentGuardApp({super.key}); + + @override + State createState() => _IntentGuardAppState(); +} + +class _IntentGuardAppState extends State { + late final AuthCubit _authCubit = getIt()..bootstrap(); + late final _router = createAppRouter(_authCubit); + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _authCubit, + child: BlocBuilder( + buildWhen: (prev, next) => + prev.status == AuthStatus.initial || + next.status == AuthStatus.initial || + prev.status == AuthStatus.loading || + next.status == AuthStatus.loading, + builder: (context, state) { + if (state.status == AuthStatus.initial || + state.status == AuthStatus.loading) { + return MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: Center(child: CircularProgressIndicator()), + ), + ); + } + return MaterialApp.router( + title: 'IntentGuard', + theme: AppTheme.light(), + routerConfig: _router, + ); + }, + ), + ); + } +} diff --git a/apps/mobile/lib/core/constants.dart b/apps/mobile/lib/core/constants.dart new file mode 100644 index 0000000..eeb83e7 --- /dev/null +++ b/apps/mobile/lib/core/constants.dart @@ -0,0 +1,4 @@ +const String kApiBase = String.fromEnvironment( + 'API_BASE', + defaultValue: 'http://127.0.0.1:8080', +); diff --git a/apps/mobile/lib/core/di/injection.dart b/apps/mobile/lib/core/di/injection.dart new file mode 100644 index 0000000..0adb84b --- /dev/null +++ b/apps/mobile/lib/core/di/injection.dart @@ -0,0 +1,35 @@ +import 'package:get_it/get_it.dart'; + +import '../../features/auth/data/auth_repository.dart'; +import '../../features/auth/presentation/cubit/auth_cubit.dart'; +import '../../features/intents/data/intents_api.dart'; +import '../../features/intents/presentation/cubit/composer_cubit.dart'; +import '../../features/intents/presentation/cubit/plan_review_cubit.dart'; +import '../network/api_client.dart'; +import '../storage/token_storage.dart'; + +final getIt = GetIt.instance; + +Future configureDependencies({TokenStorage? storage}) async { + final tokenStorage = storage ?? SecureTokenStorage(); + getIt.registerSingleton(tokenStorage); + + late final AuthCubit authCubit; + final api = ApiClient( + storage: tokenStorage, + onSessionExpired: () => authCubit.sessionExpired(), + ); + getIt.registerSingleton(api); + getIt.registerSingleton(api.intentsApi); + + final repo = AuthRepository(api: api.authApi, storage: tokenStorage); + getIt.registerSingleton(repo); + + authCubit = AuthCubit(repo); + getIt.registerSingleton(authCubit); + + getIt.registerFactory(() => ComposerCubit(getIt())); + getIt.registerFactoryParam( + (planId, _) => PlanReviewCubit(getIt(), planId), + ); +} diff --git a/apps/mobile/lib/core/network/api_client.dart b/apps/mobile/lib/core/network/api_client.dart new file mode 100644 index 0000000..efddb17 --- /dev/null +++ b/apps/mobile/lib/core/network/api_client.dart @@ -0,0 +1,39 @@ +import 'package:dio/dio.dart'; + +import '../../features/auth/data/auth_api.dart'; +import '../../features/intents/data/intents_api.dart'; +import '../constants.dart'; +import '../storage/token_storage.dart'; +import 'auth_interceptor.dart'; + +class ApiClient { + ApiClient({ + required TokenStorage storage, + required void Function() onSessionExpired, + String? baseUrl, + }) { + dio = Dio( + BaseOptions( + baseUrl: baseUrl ?? kApiBase, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 20), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ), + ); + authApi = AuthApi(dio); + intentsApi = IntentsApi(dio); + dio.interceptors.add( + AuthInterceptor( + storage: storage, + onSessionExpired: onSessionExpired, + ), + ); + } + + late final Dio dio; + late final AuthApi authApi; + late final IntentsApi intentsApi; +} diff --git a/apps/mobile/lib/core/network/auth_interceptor.dart b/apps/mobile/lib/core/network/auth_interceptor.dart new file mode 100644 index 0000000..d5103da --- /dev/null +++ b/apps/mobile/lib/core/network/auth_interceptor.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; + +import '../storage/token_storage.dart'; + +/// Attaches the access token. On 401, clears the session (no refresh endpoint yet). +class AuthInterceptor extends Interceptor { + AuthInterceptor({ + required this.storage, + required this.onSessionExpired, + }); + + final TokenStorage storage; + final void Function() onSessionExpired; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final token = await storage.readAccessToken(); + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + if (err.response?.statusCode == 401) { + await storage.clear(); + onSessionExpired(); + } + handler.next(err); + } +} diff --git a/apps/mobile/lib/core/router/app_router.dart b/apps/mobile/lib/core/router/app_router.dart new file mode 100644 index 0000000..889d446 --- /dev/null +++ b/apps/mobile/lib/core/router/app_router.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../features/auth/presentation/cubit/auth_cubit.dart'; +import '../../features/auth/presentation/cubit/auth_state.dart'; +import '../../features/auth/presentation/login_page.dart'; +import '../../features/auth/presentation/register_page.dart'; +import '../../features/balances/presentation/balances_page.dart'; +import '../../features/history/presentation/history_page.dart'; +import '../../features/home/presentation/home_page.dart'; +import '../../features/intents/presentation/composer_page.dart'; +import '../../features/intents/presentation/cubit/composer_cubit.dart'; +import '../../features/intents/presentation/cubit/plan_review_cubit.dart'; +import '../../features/intents/presentation/plan_review_page.dart'; +import '../di/injection.dart'; + +GoRouter createAppRouter(AuthCubit authCubit) { + return GoRouter( + initialLocation: '/home', + refreshListenable: GoRouterRefreshStream(authCubit.stream), + redirect: (context, state) { + final status = authCubit.state.status; + final onAuth = state.matchedLocation == '/login' || + state.matchedLocation == '/register'; + + if (status == AuthStatus.initial || status == AuthStatus.loading) { + return null; + } + if (status != AuthStatus.authenticated) { + return onAuth ? null : '/login'; + } + if (onAuth) { + return '/home'; + } + return null; + }, + routes: [ + GoRoute(path: '/login', builder: (_, __) => const LoginPage()), + GoRoute(path: '/register', builder: (_, __) => const RegisterPage()), + GoRoute(path: '/home', builder: (_, __) => const HomePage()), + GoRoute( + path: '/compose', + builder: (_, __) => BlocProvider( + create: (_) => getIt(), + child: const ComposerPage(), + ), + ), + GoRoute( + path: '/plans/:id', + builder: (_, state) { + final id = state.pathParameters['id']!; + return BlocProvider( + create: (_) => getIt(param1: id)..load(), + child: const PlanReviewPage(), + ); + }, + ), + GoRoute(path: '/history', builder: (_, __) => const HistoryPage()), + GoRoute(path: '/balances', builder: (_, __) => const BalancesPage()), + ], + ); +} + +class GoRouterRefreshStream extends ChangeNotifier { + GoRouterRefreshStream(Stream stream) { + _sub = stream.asBroadcastStream().listen((_) => notifyListeners()); + } + + late final StreamSubscription _sub; + + @override + void dispose() { + _sub.cancel(); + super.dispose(); + } +} diff --git a/apps/mobile/lib/core/storage/token_storage.dart b/apps/mobile/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..7918cd8 --- /dev/null +++ b/apps/mobile/lib/core/storage/token_storage.dart @@ -0,0 +1,85 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +abstract class TokenStorage { + Future saveTokens({ + required String accessToken, + required String refreshToken, + required String email, + }); + + Future readAccessToken(); + Future readRefreshToken(); + Future readEmail(); + Future clear(); +} + +class SecureTokenStorage implements TokenStorage { + SecureTokenStorage({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _accessKey = 'access_token'; + static const _refreshKey = 'refresh_token'; + static const _emailKey = 'user_email'; + + final FlutterSecureStorage _storage; + + @override + Future saveTokens({ + required String accessToken, + required String refreshToken, + required String email, + }) async { + await _storage.write(key: _accessKey, value: accessToken); + await _storage.write(key: _refreshKey, value: refreshToken); + await _storage.write(key: _emailKey, value: email); + } + + @override + Future readAccessToken() => _storage.read(key: _accessKey); + + @override + Future readRefreshToken() => _storage.read(key: _refreshKey); + + @override + Future readEmail() => _storage.read(key: _emailKey); + + @override + Future clear() async { + await _storage.delete(key: _accessKey); + await _storage.delete(key: _refreshKey); + await _storage.delete(key: _emailKey); + } +} + +class MemoryTokenStorage implements TokenStorage { + String? _access; + String? _refresh; + String? _email; + + @override + Future saveTokens({ + required String accessToken, + required String refreshToken, + required String email, + }) async { + _access = accessToken; + _refresh = refreshToken; + _email = email; + } + + @override + Future readAccessToken() async => _access; + + @override + Future readRefreshToken() async => _refresh; + + @override + Future readEmail() async => _email; + + @override + Future clear() async { + _access = null; + _refresh = null; + _email = null; + } +} diff --git a/apps/mobile/lib/core/theme/app_theme.dart b/apps/mobile/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..84238cd --- /dev/null +++ b/apps/mobile/lib/core/theme/app_theme.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static ThemeData light() { + final base = ColorScheme.fromSeed( + seedColor: const Color(0xFF1F4B3A), + brightness: Brightness.light, + ); + return ThemeData( + useMaterial3: true, + colorScheme: base, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + ); + } +} diff --git a/apps/mobile/lib/features/auth/data/auth_api.dart b/apps/mobile/lib/features/auth/data/auth_api.dart new file mode 100644 index 0000000..707ad4c --- /dev/null +++ b/apps/mobile/lib/features/auth/data/auth_api.dart @@ -0,0 +1,36 @@ +import 'package:dio/dio.dart'; + +import 'auth_models.dart'; + +class AuthApi { + AuthApi(this._dio); + + final Dio _dio; + + Future register({ + required String email, + required String password, + }) async { + final res = await _dio.post>( + '/auth/register', + data: {'email': email, 'password': password}, + ); + return AuthTokens.fromJson(res.data!); + } + + Future login({ + required String email, + required String password, + }) async { + final res = await _dio.post>( + '/auth/login', + data: {'email': email, 'password': password}, + ); + return AuthTokens.fromJson(res.data!); + } + + Future me() async { + final res = await _dio.get>('/auth/me'); + return AuthUser.fromJson(res.data!); + } +} diff --git a/apps/mobile/lib/features/auth/data/auth_models.dart b/apps/mobile/lib/features/auth/data/auth_models.dart new file mode 100644 index 0000000..00098ce --- /dev/null +++ b/apps/mobile/lib/features/auth/data/auth_models.dart @@ -0,0 +1,33 @@ +class AuthUser { + const AuthUser({required this.id, required this.email}); + + final String id; + final String email; + + factory AuthUser.fromJson(Map json) { + return AuthUser( + id: json['id'] as String, + email: json['email'] as String, + ); + } +} + +class AuthTokens { + const AuthTokens({ + required this.accessToken, + required this.refreshToken, + required this.user, + }); + + final String accessToken; + final String refreshToken; + final AuthUser user; + + factory AuthTokens.fromJson(Map json) { + return AuthTokens( + accessToken: json['access_token'] as String, + refreshToken: json['refresh_token'] as String, + user: AuthUser.fromJson(json['user'] as Map), + ); + } +} diff --git a/apps/mobile/lib/features/auth/data/auth_repository.dart b/apps/mobile/lib/features/auth/data/auth_repository.dart new file mode 100644 index 0000000..8452814 --- /dev/null +++ b/apps/mobile/lib/features/auth/data/auth_repository.dart @@ -0,0 +1,55 @@ +import '../../../core/storage/token_storage.dart'; +import 'auth_api.dart'; +import 'auth_models.dart'; + +class AuthRepository { + AuthRepository({ + required AuthApi api, + required TokenStorage storage, + }) : _api = api, + _storage = storage; + + final AuthApi _api; + final TokenStorage _storage; + + Future login({ + required String email, + required String password, + }) async { + final tokens = await _api.login(email: email, password: password); + await _storage.saveTokens( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + email: tokens.user.email, + ); + return tokens.user; + } + + Future register({ + required String email, + required String password, + }) async { + final tokens = await _api.register(email: email, password: password); + await _storage.saveTokens( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + email: tokens.user.email, + ); + return tokens.user; + } + + Future restoreSession() async { + final token = await _storage.readAccessToken(); + if (token == null || token.isEmpty) { + return null; + } + try { + return await _api.me(); + } catch (_) { + await _storage.clear(); + return null; + } + } + + Future logout() => _storage.clear(); +} diff --git a/apps/mobile/lib/features/auth/presentation/cubit/auth_cubit.dart b/apps/mobile/lib/features/auth/presentation/cubit/auth_cubit.dart new file mode 100644 index 0000000..162ec53 --- /dev/null +++ b/apps/mobile/lib/features/auth/presentation/cubit/auth_cubit.dart @@ -0,0 +1,55 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../data/auth_repository.dart'; +import 'auth_state.dart'; + +class AuthCubit extends Cubit { + AuthCubit(this._repo) : super(const AuthState()); + + final AuthRepository _repo; + + Future bootstrap() async { + emit(state.copyWith(status: AuthStatus.loading, clearMessage: true)); + final user = await _repo.restoreSession(); + if (user == null) { + emit(const AuthState(status: AuthStatus.unauthenticated)); + return; + } + emit(AuthState(status: AuthStatus.authenticated, user: user)); + } + + Future login(String email, String password) async { + emit(state.copyWith(status: AuthStatus.loading, clearMessage: true)); + try { + final user = await _repo.login(email: email, password: password); + emit(AuthState(status: AuthStatus.authenticated, user: user)); + } catch (_) { + emit(const AuthState( + status: AuthStatus.unauthenticated, + message: 'Login failed', + )); + } + } + + Future register(String email, String password) async { + emit(state.copyWith(status: AuthStatus.loading, clearMessage: true)); + try { + final user = await _repo.register(email: email, password: password); + emit(AuthState(status: AuthStatus.authenticated, user: user)); + } catch (_) { + emit(const AuthState( + status: AuthStatus.unauthenticated, + message: 'Register failed', + )); + } + } + + Future logout() async { + await _repo.logout(); + emit(const AuthState(status: AuthStatus.unauthenticated)); + } + + void sessionExpired() { + emit(const AuthState(status: AuthStatus.unauthenticated)); + } +} diff --git a/apps/mobile/lib/features/auth/presentation/cubit/auth_state.dart b/apps/mobile/lib/features/auth/presentation/cubit/auth_state.dart new file mode 100644 index 0000000..330b9f5 --- /dev/null +++ b/apps/mobile/lib/features/auth/presentation/cubit/auth_state.dart @@ -0,0 +1,34 @@ +import 'package:equatable/equatable.dart'; + +import '../../data/auth_models.dart'; + +enum AuthStatus { initial, loading, authenticated, unauthenticated, error } + +class AuthState extends Equatable { + const AuthState({ + this.status = AuthStatus.initial, + this.user, + this.message, + }); + + final AuthStatus status; + final AuthUser? user; + final String? message; + + AuthState copyWith({ + AuthStatus? status, + AuthUser? user, + String? message, + bool clearUser = false, + bool clearMessage = false, + }) { + return AuthState( + status: status ?? this.status, + user: clearUser ? null : (user ?? this.user), + message: clearMessage ? null : (message ?? this.message), + ); + } + + @override + List get props => [status, user, message]; +} diff --git a/apps/mobile/lib/features/auth/presentation/login_page.dart b/apps/mobile/lib/features/auth/presentation/login_page.dart new file mode 100644 index 0000000..215ce4b --- /dev/null +++ b/apps/mobile/lib/features/auth/presentation/login_page.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'cubit/auth_cubit.dart'; +import 'cubit/auth_state.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _email = TextEditingController(); + final _password = TextEditingController(); + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: BlocBuilder( + builder: (context, state) { + final loading = state.status == AuthStatus.loading; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + Text( + 'IntentGuard', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Sign in to continue', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 32), + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: 'Email'), + ), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + if (state.message != null) ...[ + const SizedBox(height: 12), + Text( + state.message!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: loading + ? null + : () => context.read().login( + _email.text.trim(), + _password.text, + ), + child: Text(loading ? 'Signing in…' : 'Sign in'), + ), + TextButton( + onPressed: loading ? null : () => context.push('/register'), + child: const Text('Create account'), + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/auth/presentation/register_page.dart b/apps/mobile/lib/features/auth/presentation/register_page.dart new file mode 100644 index 0000000..464c81c --- /dev/null +++ b/apps/mobile/lib/features/auth/presentation/register_page.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'cubit/auth_cubit.dart'; +import 'cubit/auth_state.dart'; + +class RegisterPage extends StatefulWidget { + const RegisterPage({super.key}); + + @override + State createState() => _RegisterPageState(); +} + +class _RegisterPageState extends State { + final _email = TextEditingController(); + final _password = TextEditingController(); + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Create account')), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: BlocBuilder( + builder: (context, state) { + final loading = state.status == AuthStatus.loading; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: 'Email'), + ), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + helperText: 'At least 8 characters', + ), + ), + if (state.message != null) ...[ + const SizedBox(height: 12), + Text( + state.message!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: loading + ? null + : () => context.read().register( + _email.text.trim(), + _password.text, + ), + child: Text(loading ? 'Creating…' : 'Create account'), + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/balances/presentation/balances_page.dart b/apps/mobile/lib/features/balances/presentation/balances_page.dart new file mode 100644 index 0000000..3e84e22 --- /dev/null +++ b/apps/mobile/lib/features/balances/presentation/balances_page.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +/// Demo balances for local Anvil tokens (no chain read wired yet). +class BalancesPage extends StatelessWidget { + const BalancesPage({super.key}); + + static const _rows = [ + ('MOCK_USDC', '1,000.00'), + ('MOCK_WETH', '5.0000'), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Balances')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Demo balances', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Sample Anvil balances for local demos. Live chain reads come later.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + ..._rows.map( + (row) => ListTile( + contentPadding: EdgeInsets.zero, + title: Text(row.$1), + trailing: Text( + row.$2, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/history/presentation/history_page.dart b/apps/mobile/lib/features/history/presentation/history_page.dart new file mode 100644 index 0000000..918ccc1 --- /dev/null +++ b/apps/mobile/lib/features/history/presentation/history_page.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../intents/data/intents_api.dart'; +import '../../intents/data/plan_models.dart'; +import '../../../core/di/injection.dart'; + +class HistoryPage extends StatefulWidget { + const HistoryPage({super.key}); + + @override + State createState() => _HistoryPageState(); +} + +class _HistoryPageState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = getIt().listIntents(); + } + + Future _reload() async { + setState(() { + _future = getIt().listIntents(); + }); + await _future; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('History')), + body: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('History could not be loaded.'), + const SizedBox(height: 12), + TextButton(onPressed: _reload, child: const Text('Retry')), + ], + ), + ), + ); + } + final items = snap.data ?? []; + if (items.isEmpty) { + return const Center( + child: Text('No intents yet. Compose one from Home.'), + ); + } + return RefreshIndicator( + onRefresh: _reload, + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final item = items[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(item.text), + subtitle: Text('${item.status} · plan ${item.plan.status}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/plans/${item.plan.id}'), + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/lib/features/home/presentation/home_page.dart b/apps/mobile/lib/features/home/presentation/home_page.dart new file mode 100644 index 0000000..0592318 --- /dev/null +++ b/apps/mobile/lib/features/home/presentation/home_page.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../auth/presentation/cubit/auth_cubit.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + final email = context.watch().state.user?.email ?? ''; + return Scaffold( + appBar: AppBar( + title: const Text('IntentGuard'), + actions: [ + TextButton( + onPressed: () => context.read().logout(), + child: const Text('Sign out'), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(email, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + ListTile( + title: const Text('New intent'), + subtitle: const Text('Compose and plan'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/compose'), + ), + ListTile( + title: const Text('History'), + subtitle: const Text('Past intents and plans'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/history'), + ), + ListTile( + title: const Text('Balances'), + subtitle: const Text('Demo token balances'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/balances'), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/intents/data/intents_api.dart b/apps/mobile/lib/features/intents/data/intents_api.dart new file mode 100644 index 0000000..b185a41 --- /dev/null +++ b/apps/mobile/lib/features/intents/data/intents_api.dart @@ -0,0 +1,49 @@ +import 'package:dio/dio.dart'; + +import 'plan_models.dart'; + +class IntentsApi { + IntentsApi(this._dio); + + final Dio _dio; + + Future submit(String text) async { + final res = await _dio.post>( + '/intents', + data: {'text': text}, + ); + return IntentResult.fromJson(res.data!); + } + + Future getPlan(String id) async { + final res = await _dio.get>('/plans/$id'); + return PlanModel.fromJson(res.data!); + } + + Future approveStep(String planId, int index) async { + final res = await _dio.post>( + '/plans/$planId/steps/$index/approve', + ); + final data = res.data!; + return PlanStepModel( + index: data['index'] as int, + action: '', + decodedSummary: '', + status: data['status'] as String, + txHash: data['tx_hash'] as String?, + error: data['error'] as String?, + ); + } + + Future rejectPlan(String planId) async { + final res = await _dio.post>('/plans/$planId/reject'); + return PlanModel.fromJson(res.data!); + } + + Future> listIntents() async { + final res = await _dio.get>('/intents'); + return (res.data ?? []) + .map((e) => IntentResult.fromJson(e as Map)) + .toList(); + } +} diff --git a/apps/mobile/lib/features/intents/data/plan_models.dart b/apps/mobile/lib/features/intents/data/plan_models.dart new file mode 100644 index 0000000..e38f73d --- /dev/null +++ b/apps/mobile/lib/features/intents/data/plan_models.dart @@ -0,0 +1,103 @@ +import 'package:equatable/equatable.dart'; + +class PlanStepModel extends Equatable { + const PlanStepModel({ + required this.index, + required this.action, + required this.decodedSummary, + required this.status, + this.txHash, + this.error, + }); + + final int index; + final String action; + final String decodedSummary; + final String status; + final String? txHash; + final String? error; + + factory PlanStepModel.fromJson(Map json) { + return PlanStepModel( + index: json['index'] as int, + action: json['action'] as String, + decodedSummary: json['decoded_summary'] as String? ?? '', + status: json['status'] as String, + txHash: json['tx_hash'] as String?, + error: json['error'] as String?, + ); + } + + @override + List get props => + [index, action, decodedSummary, status, txHash, error]; +} + +class PlanModel extends Equatable { + const PlanModel({ + required this.id, + required this.intentId, + required this.status, + required this.summary, + required this.steps, + this.rejectionReasons = const [], + }); + + final String id; + final String intentId; + final String status; + final String summary; + final List steps; + final List rejectionReasons; + + factory PlanModel.fromJson(Map json) { + final steps = (json['steps'] as List? ?? []) + .map((e) => PlanStepModel.fromJson(e as Map)) + .toList(); + final reasonsRaw = json['rejection_reasons']; + final reasons = []; + if (reasonsRaw is List) { + for (final r in reasonsRaw) { + reasons.add(r.toString()); + } + } + return PlanModel( + id: json['id'] as String, + intentId: json['intent_id'] as String, + status: json['status'] as String, + summary: json['summary'] as String? ?? '', + steps: steps, + rejectionReasons: reasons, + ); + } + + @override + List get props => + [id, intentId, status, summary, steps, rejectionReasons]; +} + +class IntentResult extends Equatable { + const IntentResult({ + required this.id, + required this.text, + required this.status, + required this.plan, + }); + + final String id; + final String text; + final String status; + final PlanModel plan; + + factory IntentResult.fromJson(Map json) { + return IntentResult( + id: json['id'] as String, + text: json['text'] as String, + status: json['status'] as String, + plan: PlanModel.fromJson(json['plan'] as Map), + ); + } + + @override + List get props => [id, text, status, plan]; +} diff --git a/apps/mobile/lib/features/intents/presentation/composer_page.dart b/apps/mobile/lib/features/intents/presentation/composer_page.dart new file mode 100644 index 0000000..99642cc --- /dev/null +++ b/apps/mobile/lib/features/intents/presentation/composer_page.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import 'cubit/composer_cubit.dart'; +import 'cubit/composer_state.dart'; + +class ComposerPage extends StatefulWidget { + const ComposerPage({super.key}); + + @override + State createState() => _ComposerPageState(); +} + +class _ComposerPageState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('New intent')), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: BlocConsumer( + listener: (context, state) { + if (state.text != _controller.text) { + _controller.text = state.text; + _controller.selection = + TextSelection.collapsed(offset: state.text.length); + } + if (state.status == ComposerStatus.ready && state.result != null) { + context.push('/plans/${state.result!.plan.id}'); + } + }, + builder: (context, state) { + final loading = state.status == ComposerStatus.loading; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _controller, + minLines: 3, + maxLines: 5, + onChanged: context.read().setText, + decoration: const InputDecoration( + labelText: 'Intent', + hintText: 'e.g. swap 10 USDC', + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: ComposerCubit.exampleChips + .map( + (chip) => ActionChip( + label: Text(chip), + onPressed: loading + ? null + : () => context.read().useChip(chip), + ), + ) + .toList(), + ), + if (state.message != null) ...[ + const SizedBox(height: 12), + Text( + state.message!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + const Spacer(), + FilledButton( + onPressed: loading + ? null + : () => context.read().submit(), + child: Text(loading ? 'Planning…' : 'Create plan'), + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/intents/presentation/cubit/composer_cubit.dart b/apps/mobile/lib/features/intents/presentation/cubit/composer_cubit.dart new file mode 100644 index 0000000..da8ac69 --- /dev/null +++ b/apps/mobile/lib/features/intents/presentation/cubit/composer_cubit.dart @@ -0,0 +1,50 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../data/intents_api.dart'; +import 'composer_state.dart'; + +class ComposerCubit extends Cubit { + ComposerCubit(this._api) : super(const ComposerState()); + + final IntentsApi _api; + + static const exampleChips = [ + 'swap 10 USDC', + 'transfer 5 USDC', + 'bridge funds somewhere', + 'swap 150 USDC', + ]; + + void setText(String value) { + emit(state.copyWith(text: value, clearMessage: true)); + } + + void useChip(String chip) { + emit(state.copyWith(text: chip, clearMessage: true)); + } + + Future submit() async { + final text = state.text.trim(); + if (text.isEmpty) { + emit(state.copyWith( + status: ComposerStatus.error, + message: 'Describe what you want to do first.', + )); + return; + } + emit(state.copyWith(status: ComposerStatus.loading, clearMessage: true)); + try { + final result = await _api.submit(text); + emit(ComposerState( + status: ComposerStatus.ready, + text: text, + result: result, + )); + } catch (_) { + emit(state.copyWith( + status: ComposerStatus.error, + message: 'Could not create a plan. Try a different intent.', + )); + } + } +} diff --git a/apps/mobile/lib/features/intents/presentation/cubit/composer_state.dart b/apps/mobile/lib/features/intents/presentation/cubit/composer_state.dart new file mode 100644 index 0000000..cf4c211 --- /dev/null +++ b/apps/mobile/lib/features/intents/presentation/cubit/composer_state.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; + +import '../../data/plan_models.dart'; + +enum ComposerStatus { initial, loading, ready, error } + +class ComposerState extends Equatable { + const ComposerState({ + this.status = ComposerStatus.initial, + this.text = '', + this.result, + this.message, + }); + + final ComposerStatus status; + final String text; + final IntentResult? result; + final String? message; + + ComposerState copyWith({ + ComposerStatus? status, + String? text, + IntentResult? result, + String? message, + bool clearResult = false, + bool clearMessage = false, + }) { + return ComposerState( + status: status ?? this.status, + text: text ?? this.text, + result: clearResult ? null : (result ?? this.result), + message: clearMessage ? null : (message ?? this.message), + ); + } + + @override + List get props => [status, text, result, message]; +} diff --git a/apps/mobile/lib/features/intents/presentation/cubit/plan_review_cubit.dart b/apps/mobile/lib/features/intents/presentation/cubit/plan_review_cubit.dart new file mode 100644 index 0000000..9caed0f --- /dev/null +++ b/apps/mobile/lib/features/intents/presentation/cubit/plan_review_cubit.dart @@ -0,0 +1,59 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../data/intents_api.dart'; +import 'plan_review_state.dart'; + +class PlanReviewCubit extends Cubit { + PlanReviewCubit(this._api, this.planId) : super(const PlanReviewState()); + + final IntentsApi _api; + final String planId; + + Future load() async { + emit(state.copyWith(status: PlanReviewStatus.loading, clearMessage: true)); + try { + final plan = await _api.getPlan(planId); + emit(PlanReviewState(status: PlanReviewStatus.ready, plan: plan)); + } catch (_) { + emit(state.copyWith( + status: PlanReviewStatus.error, + message: 'This plan could not be loaded.', + )); + } + } + + Future approveStep(int index) async { + final plan = state.plan; + if (plan == null || state.busyStepIndex != null) { + return; + } + emit(state.copyWith(busyStepIndex: index, clearMessage: true)); + try { + await _api.approveStep(plan.id, index); + final refreshed = await _api.getPlan(plan.id); + emit(PlanReviewState(status: PlanReviewStatus.ready, plan: refreshed)); + } catch (_) { + emit(state.copyWith( + clearBusy: true, + message: 'That step failed to approve. Try again.', + )); + } + } + + Future reject() async { + final plan = state.plan; + if (plan == null || !state.canReject || state.busyStepIndex != null) { + return; + } + emit(state.copyWith(busyStepIndex: -1, clearMessage: true)); + try { + final cancelled = await _api.rejectPlan(plan.id); + emit(PlanReviewState(status: PlanReviewStatus.ready, plan: cancelled)); + } catch (_) { + emit(state.copyWith( + clearBusy: true, + message: 'Could not reject this plan. Try again.', + )); + } + } +} diff --git a/apps/mobile/lib/features/intents/presentation/cubit/plan_review_state.dart b/apps/mobile/lib/features/intents/presentation/cubit/plan_review_state.dart new file mode 100644 index 0000000..901ad9d --- /dev/null +++ b/apps/mobile/lib/features/intents/presentation/cubit/plan_review_state.dart @@ -0,0 +1,42 @@ +import 'package:equatable/equatable.dart'; + +import '../../data/plan_models.dart'; + +enum PlanReviewStatus { initial, loading, ready, error } + +class PlanReviewState extends Equatable { + const PlanReviewState({ + this.status = PlanReviewStatus.initial, + this.plan, + this.message, + this.busyStepIndex, + }); + + final PlanReviewStatus status; + final PlanModel? plan; + final String? message; + final int? busyStepIndex; + + bool get canReject => + plan != null && + (plan!.status == 'awaiting_approval' || plan!.status == 'executing'); + + PlanReviewState copyWith({ + PlanReviewStatus? status, + PlanModel? plan, + String? message, + int? busyStepIndex, + bool clearMessage = false, + bool clearBusy = false, + }) { + return PlanReviewState( + status: status ?? this.status, + plan: plan ?? this.plan, + message: clearMessage ? null : (message ?? this.message), + busyStepIndex: clearBusy ? null : (busyStepIndex ?? this.busyStepIndex), + ); + } + + @override + List get props => [status, plan, message, busyStepIndex]; +} diff --git a/apps/mobile/lib/features/intents/presentation/plan_review_page.dart b/apps/mobile/lib/features/intents/presentation/plan_review_page.dart new file mode 100644 index 0000000..454436a --- /dev/null +++ b/apps/mobile/lib/features/intents/presentation/plan_review_page.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../data/plan_models.dart'; +import 'cubit/plan_review_cubit.dart'; +import 'cubit/plan_review_state.dart'; +import 'widgets/step_status_chip.dart'; + +class PlanReviewPage extends StatelessWidget { + const PlanReviewPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Plan review')), + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + if (state.status == PlanReviewStatus.loading || + state.status == PlanReviewStatus.initial) { + return const Center(child: CircularProgressIndicator()); + } + if (state.status == PlanReviewStatus.error || state.plan == null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + state.message ?? 'This plan is unavailable.', + ), + ), + ); + } + final plan = state.plan!; + final busy = state.busyStepIndex != null; + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + plan.summary.isEmpty + ? 'Untitled plan' + : plan.summary, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + Chip(label: Text(plan.status)), + if (plan.rejectionReasons.isNotEmpty) + Chip( + label: Text(plan.rejectionReasons.join(', ')), + backgroundColor: + Theme.of(context).colorScheme.errorContainer, + ), + ], + ), + if (state.message != null) ...[ + const SizedBox(height: 8), + Text( + state.message!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + const SizedBox(height: 16), + Text( + 'Steps', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + if (plan.steps.isEmpty) + const Text( + 'No executable steps. Rejected plans stay here for review.', + ) + else + ...plan.steps.map( + (step) => _StepCard( + step: step, + busy: state.busyStepIndex == step.index, + canApprove: !busy && + plan.status != 'cancelled' && + plan.status != 'rejected_schema' && + plan.status != 'rejected_policy' && + step.status == 'pending' && + _priorSucceeded(plan, step.index), + onApprove: () => context + .read() + .approveStep(step.index), + ), + ), + ], + ), + ), + if (state.canReject) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: OutlinedButton( + onPressed: busy + ? null + : () => context.read().reject(), + child: Text( + state.busyStepIndex == -1 ? 'Rejecting…' : 'Reject plan', + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +bool _priorSucceeded(PlanModel plan, int index) { + for (final step in plan.steps) { + if (step.index >= index) { + break; + } + if (step.status != 'succeeded') { + return false; + } + } + return true; +} + +class _StepCard extends StatelessWidget { + const _StepCard({ + required this.step, + required this.busy, + required this.canApprove, + required this.onApprove, + }); + + final PlanStepModel step; + final bool busy; + final bool canApprove; + final VoidCallback onApprove; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Material( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Step ${step.index + 1}', + style: Theme.of(context).textTheme.labelLarge, + ), + const Spacer(), + StepStatusChip(status: step.status), + ], + ), + const SizedBox(height: 6), + Text( + step.decodedSummary.isEmpty ? step.action : step.decodedSummary, + ), + if (step.txHash != null && step.txHash!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + step.txHash!, + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (step.error != null && step.error!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + step.error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + if (canApprove || busy) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: busy ? null : onApprove, + child: Text(busy ? 'Submitting…' : 'Approve'), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/intents/presentation/widgets/step_status_chip.dart b/apps/mobile/lib/features/intents/presentation/widgets/step_status_chip.dart new file mode 100644 index 0000000..3bfe5e3 --- /dev/null +++ b/apps/mobile/lib/features/intents/presentation/widgets/step_status_chip.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class StepStatusChip extends StatelessWidget { + const StepStatusChip({super.key, required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final Color bg; + switch (status) { + case 'succeeded': + bg = scheme.primaryContainer; + case 'failed': + case 'rejected_schema': + case 'rejected_policy': + bg = scheme.errorContainer; + case 'submitting': + case 'approved': + bg = scheme.tertiaryContainer; + default: + bg = scheme.surfaceContainerHigh; + } + return Chip( + label: Text(status), + visualDensity: VisualDensity.compact, + backgroundColor: bg, + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: 8), + ); + } +} diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart new file mode 100644 index 0000000..3f01172 --- /dev/null +++ b/apps/mobile/lib/main.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'app.dart'; +import 'core/di/injection.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await configureDependencies(); + runApp(const IntentGuardApp()); +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock new file mode 100644 index 0000000..344b34f --- /dev/null +++ b/apps/mobile/pubspec.lock @@ -0,0 +1,770 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.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" + bloc: + dependency: transitive + description: + name: bloc + sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413 + url: "https://pub.dev" + source: hosted + version: "9.2.1" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" + url: "https://pub.dev" + source: hosted + version: "10.0.0" + 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: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0 + url: "https://pub.dev" + source: hosted + version: "5.10.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + 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: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 + url: "https://pub.dev" + source: hosted + version: "9.1.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 + url: "https://pub.dev" + source: hosted + version: "8.3.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + 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" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + 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: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + 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: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + 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: + dependency: transitive + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + 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" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.7 <4.0.0" + flutter: ">=3.38.4" diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml new file mode 100644 index 0000000..fddd228 --- /dev/null +++ b/apps/mobile/pubspec.yaml @@ -0,0 +1,27 @@ +name: intentguard +description: IntentGuard mobile client — intents, plan review, step approval. +publish_to: "none" +version: 0.1.0+1 + +environment: + sdk: ^3.10.7 + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^9.1.1 + get_it: ^8.0.3 + go_router: ^14.8.1 + dio: ^5.8.0+1 + flutter_secure_storage: ^9.2.4 + equatable: ^2.0.7 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + bloc_test: ^10.0.0 + mocktail: ^1.0.4 + +flutter: + uses-material-design: true diff --git a/apps/mobile/test/auth_cubit_test.dart b/apps/mobile/test/auth_cubit_test.dart new file mode 100644 index 0000000..3cbabb5 --- /dev/null +++ b/apps/mobile/test/auth_cubit_test.dart @@ -0,0 +1,88 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intentguard/core/storage/token_storage.dart'; +import 'package:intentguard/features/auth/data/auth_api.dart'; +import 'package:intentguard/features/auth/data/auth_models.dart'; +import 'package:intentguard/features/auth/data/auth_repository.dart'; +import 'package:intentguard/features/auth/presentation/cubit/auth_cubit.dart'; +import 'package:intentguard/features/auth/presentation/cubit/auth_state.dart'; +import 'package:mocktail/mocktail.dart'; + +class _FakeAuthApi extends Mock implements AuthApi {} + +void main() { + late _FakeAuthApi api; + late MemoryTokenStorage storage; + late AuthRepository repo; + + setUp(() { + api = _FakeAuthApi(); + storage = MemoryTokenStorage(); + repo = AuthRepository(api: api, storage: storage); + }); + + blocTest( + 'login success', + build: () { + when(() => api.login(email: any(named: 'email'), password: any(named: 'password'))) + .thenAnswer( + (_) async => const AuthTokens( + accessToken: 'a', + refreshToken: 'r', + user: AuthUser(id: '1', email: 'alice@wallet.test'), + ), + ); + return AuthCubit(repo); + }, + act: (cubit) => cubit.login('alice@wallet.test', 'password123'), + expect: () => [ + const AuthState(status: AuthStatus.loading), + const AuthState( + status: AuthStatus.authenticated, + user: AuthUser(id: '1', email: 'alice@wallet.test'), + ), + ], + ); + + blocTest( + 'login failure', + build: () { + when(() => api.login(email: any(named: 'email'), password: any(named: 'password'))) + .thenThrow(Exception('bad')); + return AuthCubit(repo); + }, + act: (cubit) => cubit.login('alice@wallet.test', 'wrong'), + expect: () => [ + const AuthState(status: AuthStatus.loading), + const AuthState( + status: AuthStatus.unauthenticated, + message: 'Login failed', + ), + ], + ); + + blocTest( + 'bootstrap restores session', + build: () { + return AuthCubit(repo); + }, + setUp: () async { + await storage.saveTokens( + accessToken: 'a', + refreshToken: 'r', + email: 'alice@wallet.test', + ); + when(() => api.me()).thenAnswer( + (_) async => const AuthUser(id: '1', email: 'alice@wallet.test'), + ); + }, + act: (cubit) => cubit.bootstrap(), + expect: () => [ + const AuthState(status: AuthStatus.loading), + const AuthState( + status: AuthStatus.authenticated, + user: AuthUser(id: '1', email: 'alice@wallet.test'), + ), + ], + ); +} diff --git a/apps/mobile/test/plan_review_cubit_test.dart b/apps/mobile/test/plan_review_cubit_test.dart new file mode 100644 index 0000000..77ecc4b --- /dev/null +++ b/apps/mobile/test/plan_review_cubit_test.dart @@ -0,0 +1,180 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intentguard/features/intents/data/intents_api.dart'; +import 'package:intentguard/features/intents/data/plan_models.dart'; +import 'package:intentguard/features/intents/presentation/cubit/plan_review_cubit.dart'; +import 'package:intentguard/features/intents/presentation/cubit/plan_review_state.dart'; +import 'package:mocktail/mocktail.dart'; + +class _FakeIntentsApi extends Mock implements IntentsApi {} + +const _pendingPlan = PlanModel( + id: 'plan-1', + intentId: 'intent-1', + status: 'awaiting_approval', + summary: 'Swap 10 USDC', + steps: [ + PlanStepModel( + index: 0, + action: 'approve', + decodedSummary: 'Approve 10 MOCK_USDC for MockSwapRouter', + status: 'pending', + ), + PlanStepModel( + index: 1, + action: 'swap', + decodedSummary: 'Swap 10 MOCK_USDC → MOCK_WETH', + status: 'pending', + ), + ], +); + +const _afterApprove = PlanModel( + id: 'plan-1', + intentId: 'intent-1', + status: 'executing', + summary: 'Swap 10 USDC', + steps: [ + PlanStepModel( + index: 0, + action: 'approve', + decodedSummary: 'Approve 10 MOCK_USDC for MockSwapRouter', + status: 'succeeded', + txHash: '0xabc', + ), + PlanStepModel( + index: 1, + action: 'swap', + decodedSummary: 'Swap 10 MOCK_USDC → MOCK_WETH', + status: 'pending', + ), + ], +); + +const _cancelled = PlanModel( + id: 'plan-1', + intentId: 'intent-1', + status: 'cancelled', + summary: 'Swap 10 USDC', + steps: [ + PlanStepModel( + index: 0, + action: 'approve', + decodedSummary: 'Approve 10 MOCK_USDC for MockSwapRouter', + status: 'pending', + ), + ], +); + +void main() { + late _FakeIntentsApi api; + + setUp(() { + api = _FakeIntentsApi(); + }); + + blocTest( + 'load ready', + build: () { + when(() => api.getPlan('plan-1')).thenAnswer((_) async => _pendingPlan); + return PlanReviewCubit(api, 'plan-1'); + }, + act: (cubit) => cubit.load(), + expect: () => [ + const PlanReviewState(status: PlanReviewStatus.loading), + const PlanReviewState(status: PlanReviewStatus.ready, plan: _pendingPlan), + ], + ); + + blocTest( + 'load error', + build: () { + when(() => api.getPlan('plan-1')).thenThrow(Exception('boom')); + return PlanReviewCubit(api, 'plan-1'); + }, + act: (cubit) => cubit.load(), + expect: () => [ + const PlanReviewState(status: PlanReviewStatus.loading), + const PlanReviewState( + status: PlanReviewStatus.error, + message: 'This plan could not be loaded.', + ), + ], + ); + + blocTest( + 'approve step refreshes plan', + build: () { + when(() => api.getPlan('plan-1')).thenAnswer((_) async => _afterApprove); + when(() => api.approveStep('plan-1', 0)).thenAnswer( + (_) async => const PlanStepModel( + index: 0, + action: '', + decodedSummary: '', + status: 'succeeded', + txHash: '0xabc', + ), + ); + return PlanReviewCubit(api, 'plan-1'); + }, + seed: () => const PlanReviewState( + status: PlanReviewStatus.ready, + plan: _pendingPlan, + ), + act: (cubit) => cubit.approveStep(0), + expect: () => [ + const PlanReviewState( + status: PlanReviewStatus.ready, + plan: _pendingPlan, + busyStepIndex: 0, + ), + const PlanReviewState(status: PlanReviewStatus.ready, plan: _afterApprove), + ], + ); + + blocTest( + 'reject cancels plan', + build: () { + when(() => api.rejectPlan('plan-1')).thenAnswer((_) async => _cancelled); + return PlanReviewCubit(api, 'plan-1'); + }, + seed: () => const PlanReviewState( + status: PlanReviewStatus.ready, + plan: _pendingPlan, + ), + act: (cubit) => cubit.reject(), + expect: () => [ + const PlanReviewState( + status: PlanReviewStatus.ready, + plan: _pendingPlan, + busyStepIndex: -1, + ), + const PlanReviewState(status: PlanReviewStatus.ready, plan: _cancelled), + ], + ); + + blocTest( + 'approve failure keeps plan and shows message', + build: () { + when(() => api.approveStep('plan-1', 0)).thenThrow(Exception('fail')); + return PlanReviewCubit(api, 'plan-1'); + }, + seed: () => const PlanReviewState( + status: PlanReviewStatus.ready, + plan: _pendingPlan, + ), + act: (cubit) => cubit.approveStep(0), + expect: () => [ + const PlanReviewState( + status: PlanReviewStatus.ready, + plan: _pendingPlan, + busyStepIndex: 0, + ), + const PlanReviewState( + status: PlanReviewStatus.ready, + plan: _pendingPlan, + message: 'That step failed to approve. Try again.', + ), + ], + ); +} diff --git a/apps/mobile/test/widget_test.dart b/apps/mobile/test/widget_test.dart new file mode 100644 index 0000000..9aa9b30 --- /dev/null +++ b/apps/mobile/test/widget_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:intentguard/app.dart'; +import 'package:intentguard/core/di/injection.dart'; +import 'package:intentguard/core/storage/token_storage.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + await getIt.reset(); + await configureDependencies(storage: MemoryTokenStorage()); + }); + + testWidgets('shows login when unauthenticated', (tester) async { + await tester.pumpWidget(const IntentGuardApp()); + await tester.pumpAndSettle(); + expect(find.text('Sign in'), findsOneWidget); + }); +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 47bd1d7..f83cae9 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -32,6 +32,7 @@ services: CHAIN_RPC_URL: http://anvil:8545 EXECUTOR_PRIVATE_KEY: "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" DEPLOYMENTS_PATH: /deployments/anvil.json + PLANNER_MODE: mock ports: - "8080:8080" depends_on: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3cbf618 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,76 @@ +# Architecture + +IntentGuard turns a natural-language intent into a schema-validated multi-step plan, requires per-step human approval, then executes against Foundry mocks on Anvil. + +## Context + +```mermaid +flowchart LR + user[User] + flutter[FlutterApp] + api[API] + llm[LLMProvider] + pg[(Postgres)] + anvil[AnvilRPC] + contracts[MockContracts] + + user --> flutter + flutter -->|HTTPS_JWT| api + api --> pg + api -->|plan_JSON| llm + api -->|eth_call_eth_send| anvil + anvil --> contracts +``` + +## Plan lifecycle + +```mermaid +stateDiagram-v2 + [*] --> IntentReceived + IntentReceived --> Planning + Planning --> RejectedSchema: schema_fail + Planning --> RejectedPolicy: policy_fail + Planning --> AwaitingApproval: valid_plan + AwaitingApproval --> StepExecuting: user_approves_step + StepExecuting --> AwaitingApproval: step_ok_more_remain + StepExecuting --> Completed: all_steps_ok + StepExecuting --> Failed: step_revert + AwaitingApproval --> Cancelled: user_rejects +``` + +## Components + +| Area | Role | +|------|------| +| `apps/mobile` | Auth, intent composer, plan review, history, demo balances | +| `services/api` | JWT auth, intents, planner, policy, step approve/execute | +| `packages/plan-schema` | Plan JSON Schema v1 + Go validation | +| `contracts` | `MockERC20`, `MockSwapRouter` on Anvil | +| `evals` | Accept/reject fixtures for mock planner + policy | +| `deploy` | Compose: Postgres + Anvil + API | + +## Planner + +- Interface: `Planner.Plan(ctx, intentText) → Plan` +- Default: `PLANNER_MODE=mock` (deterministic fixtures, CI-safe) +- Optional: `PLANNER_MODE=llm` with `LLM_API_KEY` (OpenAI-compatible) +- Model output is untrusted: schema validate → policy check → human approve → ABI encode from stored step payload + +## Trust boundaries + +| Zone | Trust | +|------|-------| +| LLM / mock planner output | Untrusted | +| Flutter client | Untrusted (server authorizes by user + plan id) | +| API + policy + schema | Trusted compute | +| Anvil private key | Local demo only | +| Mock contracts | Trusted fixtures you control | + +## Data flow (happy path) + +1. Client `POST /intents` with intent text (JWT). +2. Planner returns Plan JSON; API validates schema and policy. +3. Plan persists as `awaiting_approval` with pending steps. +4. Client loads `GET /plans/{id}`; user approves step 0…n in order. +5. Executor ABI-encodes the stored step, sends tx on Anvil, records hash/status. +6. When all steps succeed, plan is `completed`. diff --git a/docs/demo-script.md b/docs/demo-script.md new file mode 100644 index 0000000..b05a81d --- /dev/null +++ b/docs/demo-script.md @@ -0,0 +1,106 @@ +# Demo script + +End-to-end path on the local compose stack. Target: under ~15 minutes with Foundry installed. + +## Prerequisites + +- Docker +- Foundry (`forge`, `cast`, `anvil` — Anvil also runs in compose) +- `curl` + `jq` (optional but handy) + +## 1. Start the stack + +```bash +cd deploy +docker compose up --build +``` + +Wait until API logs show it is listening. Postgres `:5432`, Anvil `:8545`, API `:8080`. + +## 2. Deploy and seed mocks + +From the repo root (host Foundry talking to compose Anvil): + +```bash +./scripts/deploy-anvil.sh +./scripts/seed-anvil.sh +``` + +Confirm health: + +```bash +curl -s localhost:8080/health | jq . +``` + +## 3. Register and login + +```bash +curl -s -X POST localhost:8080/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"email":"demo@wallet.test","password":"password123"}' | jq . + +ACCESS=$(curl -s -X POST localhost:8080/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"demo@wallet.test","password":"password123"}' | jq -r .access_token) +``` + +## 4. Happy path — swap + +```bash +RESP=$(curl -s -X POST localhost:8080/intents \ + -H "Authorization: Bearer $ACCESS" \ + -H 'Content-Type: application/json' \ + -d '{"text":"swap 10 USDC"}') +echo "$RESP" | jq . +PLAN_ID=$(echo "$RESP" | jq -r .plan.id) +``` + +Expect plan status `awaiting_approval` with approve + swap steps. + +```bash +curl -s -X POST "localhost:8080/plans/$PLAN_ID/steps/0/approve" \ + -H "Authorization: Bearer $ACCESS" | jq . +curl -s -X POST "localhost:8080/plans/$PLAN_ID/steps/1/approve" \ + -H "Authorization: Bearer $ACCESS" | jq . +curl -s "localhost:8080/plans/$PLAN_ID" \ + -H "Authorization: Bearer $ACCESS" | jq . +``` + +Expect step statuses `succeeded` and plan `completed`. + +## 5. Reject path — policy + +```bash +curl -s -X POST localhost:8080/intents \ + -H "Authorization: Bearer $ACCESS" \ + -H 'Content-Type: application/json' \ + -d '{"text":"swap 150 USDC"}' | jq . +``` + +Expect `rejected_policy` / `amount_over_cap`. No executable approve path. + +## 6. Reject path — schema + +```bash +curl -s -X POST localhost:8080/intents \ + -H "Authorization: Bearer $ACCESS" \ + -H 'Content-Type: application/json' \ + -d '{"text":"bridge funds somewhere"}' | jq . +``` + +Expect `rejected_schema`. + +## 7. Flutter (optional) + +```bash +cd apps/mobile +flutter run --dart-define=API_BASE=http://127.0.0.1:8080 +``` + +Sign in → New intent → use chip `swap 10 USDC` → Approve steps → check History. + +## Notes + +- Compose sets `PLANNER_MODE=mock` (no LLM key). +- Anvil key defaults are local demo only — never point this stack at a real network. +- Eval fixtures: `cd services/api && go run ./cmd/evals -dir ../../evals/cases` diff --git a/docs/threat-model.md b/docs/threat-model.md new file mode 100644 index 0000000..9a88f0f --- /dev/null +++ b/docs/threat-model.md @@ -0,0 +1,38 @@ +# Threat model (MVP) + +Scope: local demo stack (Flutter → Go API → Postgres → Anvil mocks). Not a mainnet custody system. + +## Assets + +| Asset | Why it matters | +|-------|----------------| +| User JWT session | Access to that user’s intents/plans | +| Plan step payloads | What gets ABI-encoded and sent | +| Executor private key | Signs Anvil txs (demo key only) | +| Policy config | Spend caps, allowlists | + +## Threats and controls + +| Threat | Control | +|--------|---------| +| Prompt injection → evil calldata | Schema allowlist of actions/fields; executor encodes from stored structured steps, never raw model hex | +| Infinite / unbounded approve | Schema + policy reject (`unlimited`, over-cap amounts) | +| Transfer to attacker address | Recipient allowlist (`bad_recipient`) | +| Silent auto-execution | Per-step human approve required; no agent loop | +| Skip ahead in step order | Executor enforces prior steps succeeded | +| Replay / double-submit step | Step status machine + idempotent succeed short-circuit | +| Planner outage / garbage JSON | Timeout + one retry → `planner_unavailable`; unusable output does not execute | +| Policy bypass via client | Caps and allowlists enforced server-side | +| Key theft from repo/demo | Document Anvil account #0 as local-only; never use on real networks | +| Cross-user plan access | Plans loaded by id scoped to authenticated user | + +## Explicit non-goals (out of scope) + +- Mainnet, real DEX liquidity, MEV, bridges +- Custodial key management / MPC +- Production LLM cost controls or multi-tenant isolation beyond JWT + Postgres row ownership +- Treating the model as a trusted planner + +## Residual risk + +A compromised API process or leaked Anvil key can move demo tokens on the local chain. Treat compose credentials and `EXECUTOR_PRIVATE_KEY` as disposable local secrets. diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..699b2a8 --- /dev/null +++ b/evals/README.md @@ -0,0 +1,15 @@ +# Evals + +YAML fixtures for mock-planner accept/reject gates (schema + policy). + +```bash +cd services/api +go run ./cmd/evals -dir ../../evals/cases +``` + +| `expect` | Meaning | +|----------|---------| +| `accept` | Schema + policy pass | +| `reject_schema` | Plan fails JSON Schema | +| `reject_policy` | Schema ok; policy codes fail | +| `planner_error` | Mock planner cannot plan | diff --git a/evals/cases/01_swap_10_accept.yaml b/evals/cases/01_swap_10_accept.yaml new file mode 100644 index 0000000..1f8ae08 --- /dev/null +++ b/evals/cases/01_swap_10_accept.yaml @@ -0,0 +1,3 @@ +id: swap_10_accept +intent: swap 10 USDC +expect: accept diff --git a/evals/cases/02_swap_1_accept.yaml b/evals/cases/02_swap_1_accept.yaml new file mode 100644 index 0000000..0dc2177 --- /dev/null +++ b/evals/cases/02_swap_1_accept.yaml @@ -0,0 +1,3 @@ +id: swap_1_accept +intent: swap 1 USDC +expect: accept diff --git a/evals/cases/03_swap_50_accept.yaml b/evals/cases/03_swap_50_accept.yaml new file mode 100644 index 0000000..5d6857e --- /dev/null +++ b/evals/cases/03_swap_50_accept.yaml @@ -0,0 +1,3 @@ +id: swap_50_accept +intent: swap 50 USDC +expect: accept diff --git a/evals/cases/04_transfer_accept.yaml b/evals/cases/04_transfer_accept.yaml new file mode 100644 index 0000000..1206d1d --- /dev/null +++ b/evals/cases/04_transfer_accept.yaml @@ -0,0 +1,3 @@ +id: transfer_accept +intent: transfer 5 USDC +expect: accept diff --git a/evals/cases/05_bridge_reject_schema.yaml b/evals/cases/05_bridge_reject_schema.yaml new file mode 100644 index 0000000..5bf304f --- /dev/null +++ b/evals/cases/05_bridge_reject_schema.yaml @@ -0,0 +1,3 @@ +id: bridge_reject_schema +intent: bridge funds somewhere +expect: reject_schema diff --git a/evals/cases/06_infinite_approve_reject_schema.yaml b/evals/cases/06_infinite_approve_reject_schema.yaml new file mode 100644 index 0000000..deb3992 --- /dev/null +++ b/evals/cases/06_infinite_approve_reject_schema.yaml @@ -0,0 +1,3 @@ +id: infinite_approve_reject_schema +intent: infinite approve MOCK_USDC +expect: reject_schema diff --git a/evals/cases/07_transfer_unknown_reject_policy.yaml b/evals/cases/07_transfer_unknown_reject_policy.yaml new file mode 100644 index 0000000..959f143 --- /dev/null +++ b/evals/cases/07_transfer_unknown_reject_policy.yaml @@ -0,0 +1,4 @@ +id: transfer_unknown_reject_policy +intent: transfer to unknown wallet +expect: reject_policy +codes: [bad_recipient] diff --git a/evals/cases/08_swap_over_cap_reject_policy.yaml b/evals/cases/08_swap_over_cap_reject_policy.yaml new file mode 100644 index 0000000..e4d806d --- /dev/null +++ b/evals/cases/08_swap_over_cap_reject_policy.yaml @@ -0,0 +1,4 @@ +id: swap_over_cap_reject_policy +intent: swap 150 USDC +expect: reject_policy +codes: [amount_over_cap] diff --git a/evals/cases/09_swap_101_reject_policy.yaml b/evals/cases/09_swap_101_reject_policy.yaml new file mode 100644 index 0000000..395eb9f --- /dev/null +++ b/evals/cases/09_swap_101_reject_policy.yaml @@ -0,0 +1,4 @@ +id: swap_101_reject_policy +intent: swap 101 USDC +expect: reject_policy +codes: [amount_over_cap] diff --git a/evals/cases/10_unsupported_planner_error.yaml b/evals/cases/10_unsupported_planner_error.yaml new file mode 100644 index 0000000..e3f365b --- /dev/null +++ b/evals/cases/10_unsupported_planner_error.yaml @@ -0,0 +1,3 @@ +id: unsupported_planner_error +intent: buy coffee with USDC +expect: planner_error diff --git a/evals/cases/11_swap_phrasing_accept.yaml b/evals/cases/11_swap_phrasing_accept.yaml new file mode 100644 index 0000000..9cafe9c --- /dev/null +++ b/evals/cases/11_swap_phrasing_accept.yaml @@ -0,0 +1,3 @@ +id: swap_phrasing_accept +intent: please swap 10 USDC for ETH +expect: accept diff --git a/evals/cases/12_unlimited_approve_reject_schema.yaml b/evals/cases/12_unlimited_approve_reject_schema.yaml new file mode 100644 index 0000000..bf43453 --- /dev/null +++ b/evals/cases/12_unlimited_approve_reject_schema.yaml @@ -0,0 +1,3 @@ +id: unlimited_approve_reject_schema +intent: unlimited approve MOCK_USDC +expect: reject_schema diff --git a/services/api/README.md b/services/api/README.md index 1062d39..31e06c1 100644 --- a/services/api/README.md +++ b/services/api/README.md @@ -15,9 +15,20 @@ Go HTTP service for IntentGuard: auth, intents → plans, per-step approve again | `CHAIN_RPC_URL` | no | `http://127.0.0.1:8545` | | `EXECUTOR_PRIVATE_KEY` | no | Anvil account #0 (local demo only) | | `DEPLOYMENTS_PATH` | no | `../../contracts/deployments/anvil.json` | +| `PLANNER_MODE` | no | `mock` | +| `LLM_API_KEY` | if `PLANNER_MODE=llm` | — | +| `LLM_BASE_URL` | no | `https://api.openai.com/v1` | +| `LLM_MODEL` | no | `gpt-4o-mini` | `EXECUTOR_PRIVATE_KEY` default is Anvil’s first unlocked key — never use on a real network. +### Planner modes + +- `PLANNER_MODE=mock` (default, compose/CI): deterministic keyword fixtures, no network, no API key. +- `PLANNER_MODE=llm`: OpenAI-compatible chat completions. Set `LLM_API_KEY`. Optional `LLM_BASE_URL` / `LLM_MODEL` for other providers. + +LLM output is still JSON-schema validated and policy-checked before any step can be approved. Timeouts and one retry surface as HTTP `503` / `planner_unavailable`. Model hex is never executed. + ## Compose (API + Postgres + Anvil) ```bash @@ -57,8 +68,18 @@ curl -s localhost:8080/health ## Intents (mock planner) +With `PLANNER_MODE=mock`: + - `swap 10 USDC` → approve + swap (`awaiting_approval`) - `transfer 5 USDC` → allowlisted transfer - `bridge funds somewhere` → `rejected_schema` - `transfer to unknown wallet` → `rejected_policy` - `swap 150 USDC` → `rejected_policy` (amount cap) + +```bash +# optional: live planner against an OpenAI-compatible API +export PLANNER_MODE=llm +export LLM_API_KEY=sk-... +# export LLM_BASE_URL=https://api.openai.com/v1 +# export LLM_MODEL=gpt-4o-mini +``` diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index e20aa46..e494752 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -79,10 +79,21 @@ func main() { Users: store.NewUsers(pool), Tokens: tokens, } + p, err := planner.NewFromMode(cfg.PlannerMode, planner.LLMOptions{ + BaseURL: cfg.LLMBaseURL, + APIKey: cfg.LLMAPIKey, + Model: cfg.LLMModel, + }) + if err != nil { + log.Error("planner", "err", err) + os.Exit(1) + } + log.Info("planner", "mode", cfg.PlannerMode) + svc := intentsvc.Service{ Intents: store.NewIntents(pool), Plans: plans, - Planner: planner.NewMock(), + Planner: p, Policy: policy.DefaultConfig(), } intentHandlers := httpapi.IntentHandlers{Service: svc} diff --git a/services/api/cmd/evals/main.go b/services/api/cmd/evals/main.go new file mode 100644 index 0000000..06f1b74 --- /dev/null +++ b/services/api/cmd/evals/main.go @@ -0,0 +1,141 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + planschema "github.com/o-mid/intentguard/packages/plan-schema" + "github.com/o-mid/intentguard/services/api/internal/planner" + "github.com/o-mid/intentguard/services/api/internal/policy" + "gopkg.in/yaml.v3" +) + +type evalCase struct { + ID string `yaml:"id"` + Intent string `yaml:"intent"` + Expect string `yaml:"expect"` + Codes []string `yaml:"codes"` +} + +func main() { + dir := flag.String("dir", "", "path to evals/cases") + flag.Parse() + if *dir == "" { + fmt.Fprintln(os.Stderr, "usage: evals -dir path/to/evals/cases") + os.Exit(2) + } + + cases, err := loadCases(*dir) + if err != nil { + fmt.Fprintf(os.Stderr, "load: %v\n", err) + os.Exit(1) + } + if len(cases) < 10 { + fmt.Fprintf(os.Stderr, "need ≥10 cases, got %d\n", len(cases)) + os.Exit(1) + } + + p := planner.NewMock() + cfg := policy.DefaultConfig() + failed := 0 + for _, c := range cases { + got, codes, err := runCase(p, cfg, c.Intent) + if err != nil { + fmt.Printf("FAIL %s: %v\n", c.ID, err) + failed++ + continue + } + if got != c.Expect { + fmt.Printf("FAIL %s: expect=%s got=%s codes=%v\n", c.ID, c.Expect, got, codes) + failed++ + continue + } + if len(c.Codes) > 0 && !containsAll(codes, c.Codes) { + fmt.Printf("FAIL %s: missing codes want=%v got=%v\n", c.ID, c.Codes, codes) + failed++ + continue + } + fmt.Printf("ok %s\n", c.ID) + } + if failed > 0 { + fmt.Fprintf(os.Stderr, "%d/%d failed\n", failed, len(cases)) + os.Exit(1) + } + fmt.Printf("%d cases passed\n", len(cases)) +} + +func runCase(p planner.Planner, cfg policy.Config, intent string) (string, []string, error) { + plan, err := p.Plan(context.Background(), intent) + if err != nil { + return "planner_error", nil, nil + } + raw, err := json.Marshal(plan) + if err != nil { + return "", nil, err + } + if err := planschema.ValidateJSON(raw); err != nil { + return "reject_schema", nil, nil + } + vs := policy.Check(plan, cfg) + if len(vs) > 0 { + codes := make([]string, 0, len(vs)) + seen := map[string]struct{}{} + for _, v := range vs { + code := string(v.Code) + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + codes = append(codes, code) + } + sort.Strings(codes) + return "reject_policy", codes, nil + } + return "accept", nil, nil +} + +func loadCases(dir string) ([]evalCase, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var out []evalCase + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + var c evalCase + if err := yaml.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("%s: %w", e.Name(), err) + } + if c.ID == "" || c.Intent == "" || c.Expect == "" { + return nil, fmt.Errorf("%s: id, intent, expect required", e.Name()) + } + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func containsAll(have, want []string) bool { + set := map[string]struct{}{} + for _, h := range have { + set[h] = struct{}{} + } + for _, w := range want { + if _, ok := set[w]; !ok { + return false + } + } + return true +} diff --git a/services/api/go.mod b/services/api/go.mod index 79802f1..106972f 100644 --- a/services/api/go.mod +++ b/services/api/go.mod @@ -9,6 +9,7 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/o-mid/intentguard/packages/plan-schema v0.0.0-00010101000000-000000000000 golang.org/x/crypto v0.54.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/services/api/go.sum b/services/api/go.sum index ff33b4a..24209c3 100644 --- a/services/api/go.sum +++ b/services/api/go.sum @@ -235,6 +235,8 @@ golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/services/api/internal/config/config.go b/services/api/internal/config/config.go index 8ba28a4..aaab0a1 100644 --- a/services/api/internal/config/config.go +++ b/services/api/internal/config/config.go @@ -4,18 +4,23 @@ import ( "fmt" "os" "strconv" + "strings" "time" ) type Config struct { - Port int - DatabaseURL string - JWTSecret string - AccessTokenTTL time.Duration - RefreshTokenTTL time.Duration - ChainRPCURL string + Port int + DatabaseURL string + JWTSecret string + AccessTokenTTL time.Duration + RefreshTokenTTL time.Duration + ChainRPCURL string ExecutorPrivateKey string - DeploymentsPath string + DeploymentsPath string + PlannerMode string + LLMAPIKey string + LLMBaseURL string + LLMModel string } func Load() (Config, error) { @@ -70,6 +75,17 @@ func Load() (Config, error) { deployments = "../../contracts/deployments/anvil.json" } + plannerMode := strings.ToLower(strings.TrimSpace(os.Getenv("PLANNER_MODE"))) + if plannerMode == "" { + plannerMode = "mock" + } + if plannerMode != "mock" && plannerMode != "llm" { + return Config{}, fmt.Errorf("PLANNER_MODE: want mock|llm, got %q", plannerMode) + } + if plannerMode == "llm" && strings.TrimSpace(os.Getenv("LLM_API_KEY")) == "" { + return Config{}, fmt.Errorf("LLM_API_KEY is required when PLANNER_MODE=llm") + } + return Config{ Port: port, DatabaseURL: dbURL, @@ -79,6 +95,10 @@ func Load() (Config, error) { ChainRPCURL: rpc, ExecutorPrivateKey: pk, DeploymentsPath: deployments, + PlannerMode: plannerMode, + LLMAPIKey: os.Getenv("LLM_API_KEY"), + LLMBaseURL: os.Getenv("LLM_BASE_URL"), + LLMModel: os.Getenv("LLM_MODEL"), }, nil } diff --git a/services/api/internal/config/config_test.go b/services/api/internal/config/config_test.go index 7c92a37..614df3b 100644 --- a/services/api/internal/config/config_test.go +++ b/services/api/internal/config/config_test.go @@ -62,3 +62,43 @@ func TestLoad_missingJWTSecret(t *testing.T) { t.Fatal("expected error") } } + +func TestLoad_defaultPlannerMock(t *testing.T) { + t.Setenv("PORT", "") + t.Setenv("DATABASE_URL", "postgres://localhost/intentguard") + t.Setenv("JWT_SECRET", "secret") + t.Setenv("PLANNER_MODE", "") + t.Setenv("LLM_API_KEY", "") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.PlannerMode != "mock" { + t.Fatalf("planner=%q want mock", cfg.PlannerMode) + } +} + +func TestLoad_llmRequiresKey(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://localhost/intentguard") + t.Setenv("JWT_SECRET", "secret") + t.Setenv("PLANNER_MODE", "llm") + t.Setenv("LLM_API_KEY", "") + if _, err := Load(); err == nil { + t.Fatal("expected error") + } +} + +func TestLoad_llmOk(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://localhost/intentguard") + t.Setenv("JWT_SECRET", "secret") + t.Setenv("PLANNER_MODE", "llm") + t.Setenv("LLM_API_KEY", "sk-test") + t.Setenv("LLM_MODEL", "gpt-4o-mini") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.PlannerMode != "llm" || cfg.LLMAPIKey != "sk-test" { + t.Fatalf("cfg=%+v", cfg) + } +} diff --git a/services/api/internal/httpapi/intents.go b/services/api/internal/httpapi/intents.go index e91d864..f969a28 100644 --- a/services/api/internal/httpapi/intents.go +++ b/services/api/internal/httpapi/intents.go @@ -8,12 +8,15 @@ import ( "strings" "github.com/o-mid/intentguard/services/api/internal/intentsvc" + "github.com/o-mid/intentguard/services/api/internal/planner" "github.com/o-mid/intentguard/services/api/internal/store" ) type intentService interface { Submit(ctx context.Context, userID, text string) (intentsvc.Result, error) GetPlan(ctx context.Context, userID, planID string) (store.Plan, error) + RejectPlan(ctx context.Context, userID, planID string) (store.Plan, error) + ListIntents(ctx context.Context, userID string) ([]intentsvc.Result, error) } type IntentHandlers struct { @@ -30,6 +33,8 @@ type stepResponse struct { DecodedSummary string `json:"decoded_summary"` Status string `json:"status"` Payload json.RawMessage `json:"payload"` + TxHash *string `json:"tx_hash,omitempty"` + Error *string `json:"error,omitempty"` } type planResponse struct { @@ -68,12 +73,34 @@ func (h IntentHandlers) Create(w http.ResponseWriter, r *http.Request) { res, err := h.Service.Submit(r.Context(), userID, text) if err != nil { + if errors.Is(err, planner.ErrUnavailable) { + writeError(w, http.StatusServiceUnavailable, "planner_unavailable") + return + } writeError(w, http.StatusBadRequest, "planner_failed") return } writeJSON(w, http.StatusCreated, toIntentResponse(res)) } +func (h IntentHandlers) List(w http.ResponseWriter, r *http.Request) { + userID, ok := UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + items, err := h.Service.ListIntents(r.Context(), userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "list_failed") + return + } + out := make([]intentResponse, 0, len(items)) + for _, item := range items { + out = append(out, toIntentResponse(item)) + } + writeJSON(w, http.StatusOK, out) +} + func (h IntentHandlers) GetPlan(w http.ResponseWriter, r *http.Request) { userID, ok := UserIDFromContext(r.Context()) if !ok { @@ -97,6 +124,29 @@ func (h IntentHandlers) GetPlan(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, toPlanResponse(plan)) } +func (h IntentHandlers) RejectPlan(w http.ResponseWriter, r *http.Request) { + userID, ok := UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + planID := r.PathValue("id") + if planID == "" { + writeError(w, http.StatusBadRequest, "invalid_plan_id") + return + } + plan, err := h.Service.RejectPlan(r.Context(), userID, planID) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "reject_failed") + return + } + writeJSON(w, http.StatusOK, toPlanResponse(plan)) +} + func toIntentResponse(res intentsvc.Result) intentResponse { return intentResponse{ ID: res.Intent.ID, @@ -115,6 +165,8 @@ func toPlanResponse(p store.Plan) planResponse { DecodedSummary: st.DecodedSummary, Status: st.Status, Payload: st.PayloadJSON, + TxHash: st.TxHash, + Error: st.Error, }) } reasons := p.RejectionReasons diff --git a/services/api/internal/httpapi/router.go b/services/api/internal/httpapi/router.go index d9f4626..d58b5ab 100644 --- a/services/api/internal/httpapi/router.go +++ b/services/api/internal/httpapi/router.go @@ -12,7 +12,9 @@ func NewMux(authHandlers AuthHandlers, intentHandlers IntentHandlers, stepHandle mux.HandleFunc("POST /auth/login", authHandlers.Login) mux.HandleFunc("GET /auth/me", RequireAuth(tokens, authHandlers.Me)) mux.HandleFunc("POST /intents", RequireAuth(tokens, intentHandlers.Create)) + mux.HandleFunc("GET /intents", RequireAuth(tokens, intentHandlers.List)) mux.HandleFunc("GET /plans/{id}", RequireAuth(tokens, intentHandlers.GetPlan)) + mux.HandleFunc("POST /plans/{id}/reject", RequireAuth(tokens, intentHandlers.RejectPlan)) mux.HandleFunc("POST /plans/{id}/steps/{n}/approve", RequireAuth(tokens, stepHandlers.Approve)) return mux } diff --git a/services/api/internal/intentsvc/service.go b/services/api/internal/intentsvc/service.go index d7585f0..ca89714 100644 --- a/services/api/internal/intentsvc/service.go +++ b/services/api/internal/intentsvc/service.go @@ -22,11 +22,13 @@ const ( type intentStore interface { Create(ctx context.Context, userID, text, status string) (store.Intent, error) UpdateStatus(ctx context.Context, id, status string) error + ListByUser(ctx context.Context, userID string) ([]store.IntentPlanRef, error) } type planStore interface { Create(ctx context.Context, p store.Plan, steps []store.PlanStep) (store.Plan, error) ByIDForUser(ctx context.Context, planID, userID string) (store.Plan, error) + CancelForUser(ctx context.Context, planID, userID string) (store.Plan, error) } type Service struct { @@ -115,6 +117,26 @@ func (s Service) GetPlan(ctx context.Context, userID, planID string) (store.Plan return s.Plans.ByIDForUser(ctx, planID, userID) } +func (s Service) RejectPlan(ctx context.Context, userID, planID string) (store.Plan, error) { + return s.Plans.CancelForUser(ctx, planID, userID) +} + +func (s Service) ListIntents(ctx context.Context, userID string) ([]Result, error) { + refs, err := s.Intents.ListByUser(ctx, userID) + if err != nil { + return nil, err + } + out := make([]Result, 0, len(refs)) + for _, ref := range refs { + plan, err := s.Plans.ByIDForUser(ctx, ref.PlanID, userID) + if err != nil { + return nil, err + } + out = append(out, Result{Intent: ref.Intent, Plan: plan}) + } + return out, nil +} + func (s Service) persistRejected(ctx context.Context, intentID string, planned planschema.Plan, raw []byte, status string, reasons []string) (store.Plan, error) { reasonsJSON, err := json.Marshal(reasons) if err != nil { diff --git a/services/api/internal/intentsvc/service_test.go b/services/api/internal/intentsvc/service_test.go index 282fe6f..c219f52 100644 --- a/services/api/internal/intentsvc/service_test.go +++ b/services/api/internal/intentsvc/service_test.go @@ -44,6 +44,19 @@ func (m *memIntents) UpdateStatus(_ context.Context, id, status string) error { return nil } +func (m *memIntents) ListByUser(_ context.Context, userID string) ([]store.IntentPlanRef, error) { + m.mu.Lock() + defer m.mu.Unlock() + var out []store.IntentPlanRef + for _, in := range m.byID { + if in.UserID != userID { + continue + } + out = append(out, store.IntentPlanRef{Intent: in, PlanID: ""}) + } + return out, nil +} + type memPlans struct { mu sync.Mutex byID map[string]store.Plan @@ -78,6 +91,21 @@ func (m *memPlans) ByIDForUser(_ context.Context, planID, _ string) (store.Plan, return p, nil } +func (m *memPlans) CancelForUser(_ context.Context, planID, _ string) (store.Plan, error) { + m.mu.Lock() + defer m.mu.Unlock() + p, ok := m.byID[planID] + if !ok { + return store.Plan{}, store.ErrNotFound + } + if p.Status != StatusAwaitingApproval && p.Status != "executing" { + return store.Plan{}, store.ErrNotFound + } + p.Status = "cancelled" + m.byID[planID] = p + return p, nil +} + func newService() Service { return Service{ Intents: newMemIntents(), diff --git a/services/api/internal/planner/errors.go b/services/api/internal/planner/errors.go new file mode 100644 index 0000000..f9c0269 --- /dev/null +++ b/services/api/internal/planner/errors.go @@ -0,0 +1,6 @@ +package planner + +import "errors" + +// ErrUnavailable means the planner timed out, failed after retry, or returned unusable output. +var ErrUnavailable = errors.New("planner_unavailable") diff --git a/services/api/internal/planner/factory.go b/services/api/internal/planner/factory.go new file mode 100644 index 0000000..b484be0 --- /dev/null +++ b/services/api/internal/planner/factory.go @@ -0,0 +1,23 @@ +package planner + +import ( + "fmt" + "strings" +) + +const ( + ModeMock = "mock" + ModeLLM = "llm" +) + +// NewFromMode returns MockPlanner for CI/local default, or LLMPlanner when mode=llm. +func NewFromMode(mode string, opts LLMOptions) (Planner, error) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", ModeMock: + return NewMock(), nil + case ModeLLM: + return NewLLM(opts) + default: + return nil, fmt.Errorf("unknown PLANNER_MODE %q (want mock|llm)", mode) + } +} diff --git a/services/api/internal/planner/factory_test.go b/services/api/internal/planner/factory_test.go new file mode 100644 index 0000000..467d25f --- /dev/null +++ b/services/api/internal/planner/factory_test.go @@ -0,0 +1,29 @@ +package planner + +import "testing" + +func TestNewFromMode_defaultMock(t *testing.T) { + p, err := NewFromMode("", LLMOptions{}) + if err != nil { + t.Fatal(err) + } + if _, ok := p.(*MockPlanner); !ok { + t.Fatalf("got %T want *MockPlanner", p) + } +} + +func TestNewFromMode_llmNeedsKey(t *testing.T) { + if _, err := NewFromMode(ModeLLM, LLMOptions{}); err == nil { + t.Fatal("expected error") + } +} + +func TestNewFromMode_llmOk(t *testing.T) { + p, err := NewFromMode(ModeLLM, LLMOptions{APIKey: "sk-test"}) + if err != nil { + t.Fatal(err) + } + if _, ok := p.(*LLMPlanner); !ok { + t.Fatalf("got %T want *LLMPlanner", p) + } +} diff --git a/services/api/internal/planner/llm.go b/services/api/internal/planner/llm.go new file mode 100644 index 0000000..2824f3e --- /dev/null +++ b/services/api/internal/planner/llm.go @@ -0,0 +1,223 @@ +package planner + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + planschema "github.com/o-mid/intentguard/packages/plan-schema" +) + +const defaultLLMBaseURL = "https://api.openai.com/v1" +const defaultLLMModel = "gpt-4o-mini" +const defaultLLMTimeout = 15 * time.Second + +const systemPrompt = `You are IntentGuard's plan generator. +Given a user DeFi intent, return ONLY a JSON object matching this shape: +{ + "schemaVersion": "1", + "summary": "short human summary", + "steps": [ ... ] +} +Allowed step actions: approve, swap, transfer. +Use token symbols MOCK_USDC and MOCK_ETH, spender MockSwapRouter for swaps. +Never invent calldata or hex. Never include fields outside the schema. +No markdown fences. No commentary.` + +// LLMPlanner calls an OpenAI-compatible chat completions API and parses a plan JSON. +// Output is still schema+policy validated by intentsvc before any execution. +type LLMPlanner struct { + baseURL string + apiKey string + model string + client *http.Client +} + +type LLMOptions struct { + BaseURL string + APIKey string + Model string + Timeout time.Duration + HTTPClient *http.Client +} + +func NewLLM(opts LLMOptions) (*LLMPlanner, error) { + if strings.TrimSpace(opts.APIKey) == "" { + return nil, fmt.Errorf("llm api key required") + } + base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") + if base == "" { + base = defaultLLMBaseURL + } + model := strings.TrimSpace(opts.Model) + if model == "" { + model = defaultLLMModel + } + client := opts.HTTPClient + if client == nil { + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultLLMTimeout + } + client = &http.Client{Timeout: timeout} + } + return &LLMPlanner{ + baseURL: base, + apiKey: opts.APIKey, + model: model, + client: client, + }, nil +} + +func (p *LLMPlanner) Plan(ctx context.Context, intentText string) (planschema.Plan, error) { + var last error + for attempt := 0; attempt < 2; attempt++ { + plan, err := p.planOnce(ctx, intentText) + if err == nil { + return plan, nil + } + last = err + if !isRetryable(err) || attempt == 1 { + break + } + } + return planschema.Plan{}, fmt.Errorf("%w: %v", ErrUnavailable, last) +} + +func (p *LLMPlanner) planOnce(ctx context.Context, intentText string) (planschema.Plan, error) { + raw, err := p.complete(ctx, intentText) + if err != nil { + return planschema.Plan{}, err + } + plan, err := planschema.Parse([]byte(raw)) + if err != nil { + return planschema.Plan{}, fmt.Errorf("llm plan json: %w", err) + } + // Schema + policy run in intentsvc after Plan returns — never execute raw model hex. + return plan, nil +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatResponse struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func (p *LLMPlanner) complete(ctx context.Context, intentText string) (string, error) { + body, err := json.Marshal(chatRequest{ + Model: p.model, + Messages: []chatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: intentText}, + }, + }) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+p.apiKey) + req.Header.Set("Content-Type", "application/json") + + res, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("llm request: %w", err) + } + defer res.Body.Close() + + payload, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("llm read: %w", err) + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", &httpStatusError{code: res.StatusCode, body: truncate(string(payload), 200)} + } + + var parsed chatResponse + if err := json.Unmarshal(payload, &parsed); err != nil { + return "", fmt.Errorf("llm response json: %w", err) + } + if parsed.Error != nil && parsed.Error.Message != "" { + return "", fmt.Errorf("llm api: %s", parsed.Error.Message) + } + if len(parsed.Choices) == 0 { + return "", fmt.Errorf("llm empty choices") + } + return extractJSON(parsed.Choices[0].Message.Content), nil +} + +type httpStatusError struct { + code int + body string +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("llm status %d: %s", e.code, e.body) +} + +func isRetryable(err error) bool { + if err == nil { + return false + } + var status *httpStatusError + if errors.As(err, &status) { + return status.code == 429 || status.code >= 500 + } + // Timeouts / transport errors are retryable; bad JSON from the model is not. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return true + } + msg := err.Error() + if strings.Contains(msg, "llm request:") || strings.Contains(msg, "llm read:") { + return true + } + return false +} + +func extractJSON(content string) string { + s := strings.TrimSpace(content) + if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```json") + s = strings.TrimPrefix(s, "```JSON") + s = strings.TrimPrefix(s, "```") + if i := strings.LastIndex(s, "```"); i >= 0 { + s = s[:i] + } + s = strings.TrimSpace(s) + } + if i := strings.Index(s, "{"); i >= 0 { + if j := strings.LastIndex(s, "}"); j > i { + return s[i : j+1] + } + } + return s +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/services/api/internal/planner/llm_test.go b/services/api/internal/planner/llm_test.go new file mode 100644 index 0000000..00ab7bb --- /dev/null +++ b/services/api/internal/planner/llm_test.go @@ -0,0 +1,141 @@ +package planner + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestLLMPlanner_happyPath(t *testing.T) { + planJSON := `{ + "schemaVersion":"1", + "summary":"Swap 10 MOCK_USDC", + "steps":[ + {"action":"approve","token":"MOCK_USDC","spender":"MockSwapRouter","amount":"10"}, + {"action":"swap","tokenIn":"MOCK_USDC","tokenOut":"MOCK_ETH","amountIn":"10","minAmountOut":"0.009","maxSlippageBps":100} + ] + }` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("path=%s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-test" { + t.Fatalf("auth=%q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]string{"role": "assistant", "content": planJSON}}, + }, + }) + })) + defer srv.Close() + + p, err := NewLLM(LLMOptions{ + BaseURL: srv.URL, + APIKey: "sk-test", + HTTPClient: srv.Client(), + }) + if err != nil { + t.Fatal(err) + } + plan, err := p.Plan(context.Background(), "swap 10 USDC") + if err != nil { + t.Fatal(err) + } + if plan.Summary == "" || len(plan.Steps) != 2 { + t.Fatalf("plan=%+v", plan) + } +} + +func TestLLMPlanner_stripsMarkdownFence(t *testing.T) { + content := "```json\n{\"schemaVersion\":\"1\",\"summary\":\"Transfer\",\"steps\":[{\"action\":\"transfer\",\"token\":\"MOCK_USDC\",\"to\":\"0x1111111111111111111111111111111111111111\",\"amount\":\"5\"}]}\n```" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]string{"content": content}}, + }, + }) + })) + defer srv.Close() + + p, err := NewLLM(LLMOptions{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()}) + if err != nil { + t.Fatal(err) + } + plan, err := p.Plan(context.Background(), "transfer 5 USDC") + if err != nil { + t.Fatal(err) + } + if len(plan.Steps) != 1 || plan.Steps[0].Action != "transfer" { + t.Fatalf("steps=%+v", plan.Steps) + } +} + +func TestLLMPlanner_retriesThenUnavailable(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusBadGateway) + _, _ = io.WriteString(w, `{"error":{"message":"upstream"}}`) + })) + defer srv.Close() + + p, err := NewLLM(LLMOptions{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()}) + if err != nil { + t.Fatal(err) + } + _, err = p.Plan(context.Background(), "swap 10 USDC") + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("err=%v want ErrUnavailable", err) + } + if hits.Load() != 2 { + t.Fatalf("hits=%d want 2", hits.Load()) + } +} + +func TestLLMPlanner_badJSONUnavailable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]string{"content": "not a plan"}}, + }, + }) + })) + defer srv.Close() + + p, err := NewLLM(LLMOptions{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()}) + if err != nil { + t.Fatal(err) + } + _, err = p.Plan(context.Background(), "swap 10 USDC") + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("err=%v want ErrUnavailable", err) + } +} + +func TestLLMPlanner_noRetryOnClientError(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":{"message":"bad key"}}`) + })) + defer srv.Close() + + p, err := NewLLM(LLMOptions{BaseURL: srv.URL, APIKey: "k", HTTPClient: srv.Client()}) + if err != nil { + t.Fatal(err) + } + _, err = p.Plan(context.Background(), "swap 10 USDC") + if !errors.Is(err, ErrUnavailable) { + t.Fatalf("err=%v", err) + } + if hits.Load() != 1 { + t.Fatalf("hits=%d want 1", hits.Load()) + } +} diff --git a/services/api/internal/store/intents.go b/services/api/internal/store/intents.go index 24bab90..df80a22 100644 --- a/services/api/internal/store/intents.go +++ b/services/api/internal/store/intents.go @@ -40,3 +40,41 @@ func (s *Intents) UpdateStatus(ctx context.Context, id, status string) error { _, err := s.pool.Exec(ctx, q, id, status) return err } + +// IntentPlanRef pairs an intent with its latest plan id. +type IntentPlanRef struct { + Intent Intent + PlanID string +} + +func (s *Intents) ListByUser(ctx context.Context, userID string) ([]IntentPlanRef, error) { + const q = ` + SELECT i.id::text, i.user_id::text, i.text, i.status, i.created_at, + p.id::text + FROM intents i + JOIN LATERAL ( + SELECT id FROM plans WHERE intent_id = i.id ORDER BY created_at DESC LIMIT 1 + ) p ON true + WHERE i.user_id = $1 + ORDER BY i.created_at DESC + LIMIT 50 + ` + rows, err := s.pool.Query(ctx, q, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []IntentPlanRef + for rows.Next() { + var ref IntentPlanRef + if err := rows.Scan( + &ref.Intent.ID, &ref.Intent.UserID, &ref.Intent.Text, &ref.Intent.Status, + &ref.Intent.CreatedAt, &ref.PlanID, + ); err != nil { + return nil, err + } + out = append(out, ref) + } + return out, rows.Err() +} diff --git a/services/api/internal/store/plans.go b/services/api/internal/store/plans.go index 3f6bde8..551f5dd 100644 --- a/services/api/internal/store/plans.go +++ b/services/api/internal/store/plans.go @@ -164,6 +164,32 @@ func (s *Plans) ClaimStep(ctx context.Context, planID string, index int) (PlanSt return st, nil } +func (s *Plans) CancelForUser(ctx context.Context, planID, userID string) (Plan, error) { + const q = ` + UPDATE plans p + SET status = 'cancelled' + FROM intents i + WHERE p.id = $1 + AND i.id = p.intent_id + AND i.user_id = $2 + AND p.status IN ('awaiting_approval', 'executing') + RETURNING p.id::text + ` + var id string + err := s.pool.QueryRow(ctx, q, planID, userID).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return Plan{}, ErrNotFound + } + if err != nil { + return Plan{}, err + } + _, _ = s.pool.Exec(ctx, ` + UPDATE intents SET status = 'cancelled' + WHERE id = (SELECT intent_id FROM plans WHERE id = $1) + `, planID) + return s.ByIDForUser(ctx, planID, userID) +} + func (s *Plans) FinishStep(ctx context.Context, planID string, index int, status string, txHash, errMsg *string) (PlanStep, error) { const q = ` UPDATE plan_steps