diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..e1afdaf98
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,4 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ commit_status: true
+ fail_commit_status: false
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3897d7ae3..65b281eab 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -22,10 +22,11 @@ jobs:
with:
fetch-depth: 0
- - name: Setup Dart
- uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c # v1.7.1
+ - name: Setup Flutter
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
- sdk: stable
+ channel: 'stable'
+ cache: true
- name: Detect affected packages
id: detect
@@ -99,10 +100,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: 'stable'
- # Caching the latest stable is flaky: the cached SDK contains
- # runner-image-specific binaries that break silently when the runner
- # image is updated.
- cache: false
+ cache: true
- name: Show Flutter version
run: flutter --version
diff --git a/.github/workflows/release-pana.yml b/.github/workflows/release-pana.yml
new file mode 100644
index 000000000..60e37bd6c
--- /dev/null
+++ b/.github/workflows/release-pana.yml
@@ -0,0 +1,78 @@
+name: Release Pana
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ - reopened
+ - edited
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ pana:
+ name: Pana (${{ matrix.package }})
+ if: startsWith(github.event.pull_request.title, 'chore(release):')
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ package:
+ - functions_client
+ - gotrue
+ - postgrest
+ - realtime_client
+ - storage_client
+ - supabase
+ - supabase_common
+ - supabase_flutter
+ - yet_another_json_isolate
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
+ with:
+ cache: true
+
+ - name: Setup Melos
+ uses: bluefireteam/melos-action@2982f8e4fc92440a219490009d6d05195cb1a6a5 # v3.8.0
+
+ - name: Activate pana
+ run: dart pub global activate pana
+
+ - name: Run pana
+ working-directory: packages/${{ matrix.package }}
+ env:
+ PACKAGE: ${{ matrix.package }}
+ run: |
+ set -euo pipefail
+
+ pana --no-dartdoc --json . > pana.json
+
+ echo "### Pana — $PACKAGE" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "| Section | Points | Status |" >> "$GITHUB_STEP_SUMMARY"
+ echo "| --- | --- | --- |" >> "$GITHUB_STEP_SUMMARY"
+ jq -r '.report.sections[] | "| \(.id) | \(.grantedPoints)/\(.maxPoints) | \(.status) |"' pana.json >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "Tags: $(jq -r '[.tags[]] | join(", ")' pana.json)" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+
+ web=$(jq -r 'any(.tags[]?; . == "platform:web")' pana.json)
+ wasm=$(jq -r 'any(.tags[]?; . == "is:wasm-ready")' pana.json)
+
+ if [ "$web" = "true" ] && [ "$wasm" != "true" ]; then
+ echo "::error::$PACKAGE supports web (platform:web) but is not WASM-ready. A published package that supports web must compile to WASM. See https://dart.dev/web/wasm"
+ exit 1
+ fi
+
+ echo "$PACKAGE passed the pana web/WASM check (web=$web, wasm-ready=$wasm)."
diff --git a/.github/workflows/sync-compliance.yml b/.github/workflows/sync-compliance.yml
new file mode 100644
index 000000000..1425433b9
--- /dev/null
+++ b/.github/workflows/sync-compliance.yml
@@ -0,0 +1,16 @@
+name: Sync SDK Compliance Matrix
+
+on:
+ schedule:
+ - cron: '0 6 * * 1'
+ workflow_dispatch:
+
+jobs:
+ sync:
+ permissions:
+ contents: write
+ pull-requests: write
+ uses: supabase/sdk/.github/workflows/sync-sdk-compliance.yml@d9f43f5be4056adc33c65fe54f1c9ed1714d67e0
+ secrets:
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.PRIVATE_KEY }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 003a7ef27..588ebb15b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -27,10 +27,11 @@ jobs:
with:
fetch-depth: 0
- - name: Setup Dart
- uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c # v1.7.1
+ - name: Setup Flutter
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
- sdk: stable
+ channel: 'stable'
+ cache: true
- name: Detect affected packages
id: detect
@@ -63,7 +64,7 @@ jobs:
echo "Affected packages:"
echo "$CHANGED"
- DART_PACKAGES="functions_client gotrue postgrest realtime_client storage_client supabase yet_another_json_isolate"
+ DART_PACKAGES="functions_client gotrue postgrest realtime_client storage_client supabase supabase_common yet_another_json_isolate"
entries=()
for package in $DART_PACKAGES; do
@@ -120,7 +121,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: ${{ matrix.channel }}
- cache: false
+ cache: true
- name: Show Flutter version
run: flutter --version
@@ -144,7 +145,7 @@ jobs:
if: ${{ matrix.package.backend }}
uses: supabase/setup-cli@3c2f5e2ae34c34e428e8e206e2c4d21fa2d20fbf # v2.1.1
with:
- version: 2.106.0
+ version: 2.109.1
- name: Print Supabase CLI version
if: ${{ matrix.package.backend }}
@@ -204,6 +205,7 @@ jobs:
- name: Upload coverage to Coveralls
if: ${{ matrix.channel == 'stable' }}
uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7
+ continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
parallel: true
@@ -251,10 +253,7 @@ jobs:
with:
flutter-version: ${{ matrix.flutter-version != 'latest' && matrix.flutter-version || '' }}
channel: 'stable'
- # Caching the latest stable is flaky: the cached SDK contains
- # runner-image-specific binaries that break silently when the runner
- # image is updated. Pinned versions like 3.35.x are stable to cache.
- cache: ${{ matrix.flutter-version != 'latest' }}
+ cache: true
- name: Show Flutter version
run: flutter --version
@@ -285,6 +284,7 @@ jobs:
- name: Upload coverage to Coveralls
if: ${{ matrix.os == 'ubuntu-latest' && matrix.flutter-version == 'latest' }}
uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7
+ continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
parallel: true
@@ -296,11 +296,6 @@ jobs:
if: ${{ matrix.os == 'ubuntu-latest' && matrix.flutter-version == 'latest'}}
run: flutter test --platform chrome
- - name: Build example for web (WASM)
- if: ${{ matrix.os == 'ubuntu-latest' && matrix.flutter-version == 'latest'}}
- working-directory: packages/supabase_flutter/example
- run: flutter build web --wasm
-
dcm:
name: DCM
needs: changes
@@ -338,10 +333,12 @@ jobs:
steps:
- name: Close parallel coverage build
uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7
+ continue-on-error: true
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
parallel-finished: true
- carryforward: 'functions_client,gotrue,postgrest,realtime_client,storage_client,supabase,yet_another_json_isolate,supabase_flutter'
+ carryforward: 'functions_client,gotrue,postgrest,realtime_client,storage_client,supabase,supabase_common,yet_another_json_isolate,supabase_flutter'
+ fail-on-error: false
test-result:
name: Test result
diff --git a/.github/workflows/validate-capabilities.yml b/.github/workflows/validate-capabilities.yml
index d591e101c..152cdb935 100644
--- a/.github/workflows/validate-capabilities.yml
+++ b/.github/workflows/validate-capabilities.yml
@@ -6,9 +6,11 @@ on:
permissions:
contents: read
+ pull-requests: write
jobs:
validate:
permissions:
contents: read
+ pull-requests: write
uses: supabase/sdk/.github/workflows/validate-sdk-compliance-dart.yml@main
\ No newline at end of file
diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml
new file mode 100644
index 000000000..1711a045c
--- /dev/null
+++ b/.github/workflows/wasm.yml
@@ -0,0 +1,125 @@
+name: WASM
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ changes:
+ name: Detect affected packages
+ runs-on: ubuntu-latest
+ outputs:
+ flutter: ${{ steps.detect.outputs.flutter }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ fetch-depth: 0
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
+ with:
+ channel: 'stable'
+ cache: true
+
+ - name: Detect affected packages
+ id: detect
+ run: |
+ set -euo pipefail
+
+ dart pub global activate melos
+
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ BASE="${{ github.event.pull_request.base.sha }}"
+ else
+ BASE="${{ github.event.before }}"
+ fi
+
+ FORCE_ALL=false
+ if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
+ FORCE_ALL=true
+ elif git diff --name-only "$BASE...HEAD" \
+ | grep -qE '^(\.github/workflows/wasm\.yml|pubspec\.yaml|pubspec\.lock|supabase/)'; then
+ FORCE_ALL=true
+ fi
+
+ if [ "$FORCE_ALL" = "true" ]; then
+ echo "Running all packages."
+ CHANGED=$(melos list --json | jq -r '.[].name')
+ else
+ CHANGED=$(melos list --json --diff="$BASE...HEAD" --include-dependents | jq -r '.[].name')
+ fi
+
+ echo "Affected packages:"
+ echo "$CHANGED"
+
+ if echo "$CHANGED" | grep -qx "supabase_flutter"; then
+ echo "flutter=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "flutter=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ test-wasm:
+ name: Test and build WASM
+ needs: changes
+ if: ${{ needs.changes.outputs.flutter == 'true' }}
+ timeout-minutes: 30
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: packages/supabase_flutter
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Setup Flutter
+ uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
+ with:
+ channel: 'stable'
+ # Caching the latest stable is flaky: the cached SDK contains
+ # runner-image-specific binaries that break silently when the runner
+ # image is updated.
+ cache: false
+
+ - name: Show Flutter version
+ run: flutter --version
+
+ - name: Bootstrap workspace
+ run: |
+ cd ../../
+ dart pub global activate melos
+ melos bootstrap
+
+ - name: Test web (WASM)
+ # Serial compilation avoids the memory pressure from parallel dart2wasm
+ # runs that previously caused suite loading timeouts in CI.
+ run: flutter test --platform chrome --wasm --concurrency=1
+
+ - name: Build example for web (WASM)
+ working-directory: packages/supabase_flutter/example
+ run: flutter build web --wasm
+
+ wasm-result:
+ name: WASM result
+ needs: [changes, test-wasm]
+ if: ${{ always() }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Verify all jobs succeeded
+ run: |
+ if ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}; then
+ echo "One or more jobs failed or were cancelled."
+ exit 1
+ fi
+ echo "All required jobs passed or were skipped."
diff --git a/.sdk-parse-ignore b/.sdk-parse-ignore
index 2e9d2c962..03f1296b8 100644
--- a/.sdk-parse-ignore
+++ b/.sdk-parse-ignore
@@ -7,3 +7,13 @@
# Prefer annotating such declarations with `@internal` (from package:meta): the
# extractor honours it and excludes them from the scan. Use this file only for
# paths that cannot carry that annotation, e.g. generated sources.
+
+# supabase_common is an internal, cross-package helper library. Its public
+# symbols are imported by the other packages, so they cannot be marked
+# @internal (that would trigger invalid_use_of_internal_member in the
+# consumers). Exclude the whole package from the public-API scan instead.
+packages/supabase_common/
+
+# The examples are standalone demo apps, not part of the published SDK, so their
+# public classes are not capability-matrix symbols.
+examples/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22f218065..5af8b949b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,155 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
+## 2026-07-15
+
+### Changes
+
+---
+
+Packages with breaking changes:
+
+ - There are no breaking changes in this release.
+
+Packages with other changes:
+
+ - [`functions_client` - `v2.7.0`](#functions_client---v270)
+ - [`gotrue` - `v2.27.0`](#gotrue---v2270)
+ - [`postgrest` - `v2.9.0`](#postgrest---v290)
+ - [`realtime_client` - `v2.12.0`](#realtime_client---v2120)
+ - [`storage_client` - `v2.7.0`](#storage_client---v270)
+ - [`supabase` - `v2.15.0`](#supabase---v2150)
+ - [`supabase_common` - `v0.1.1`](#supabase_common---v011)
+ - [`supabase_flutter` - `v2.17.0`](#supabase_flutter---v2170)
+ - [`supabase_lints` - `v0.1.1`](#supabase_lints---v011)
+
+---
+
+#### `functions_client` - `v2.7.0`
+
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **FEAT**(functions): add request cancellation to invoke via abortSignal ([#1593](https://github.com/supabase/supabase-flutter/issues/1593)). ([b994dabd](https://github.com/supabase/supabase-flutter/commit/b994dabdb23aa43540bef6953d739c48521bb532))
+ - **FEAT**(functions): expose FunctionsFetchException/FunctionsRelayException/FunctionsHttpException subtypes ([#1545](https://github.com/supabase/supabase-flutter/issues/1545)). ([2d3a7202](https://github.com/supabase/supabase-flutter/commit/2d3a720215837722e25c51507d6be8c6f8f3edb7))
+
+#### `gotrue` - `v2.27.0`
+
+ - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65))
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **REFACTOR**: drop retry dependency in favor of an in-house helper ([#1571](https://github.com/supabase/supabase-flutter/issues/1571)). ([b81d2121](https://github.com/supabase/supabase-flutter/commit/b81d212159f15bf4d9515acf75430550a715664d))
+ - **REFACTOR**(gotrue): drop rxdart dependency ([#1566](https://github.com/supabase/supabase-flutter/issues/1566)). ([3f70d5ed](https://github.com/supabase/supabase-flutter/commit/3f70d5edcea9e000156742a29f2a0ae3fb1d112b))
+ - **REFACTOR**(gotrue): derive OAuth client enum values from the snakeCase extension ([#1562](https://github.com/supabase/supabase-flutter/issues/1562)). ([ed50706f](https://github.com/supabase/supabase-flutter/commit/ed50706f0b026a3c30ece0b64f5e9e925a2262ca))
+ - **REFACTOR**(gotrue): drop jwt_decode dependency in favor of dart_jsonwebtoken ([#1565](https://github.com/supabase/supabase-flutter/issues/1565)). ([b1157efa](https://github.com/supabase/supabase-flutter/commit/b1157efaed65b8127dae0afbd61043d39f9ea2b0))
+ - **REFACTOR**(auth): use OAuthProvider.name and cover custom OIDC providers ([#1555](https://github.com/supabase/supabase-flutter/issues/1555)). ([ebb7564d](https://github.com/supabase/supabase-flutter/commit/ebb7564d7112e208b8d38f0279f949b733afca31))
+ - **FEAT**(auth): send PKCE code_challenge in updateUser when changing email ([#1601](https://github.com/supabase/supabase-flutter/issues/1601)). ([59d349e4](https://github.com/supabase/supabase-flutter/commit/59d349e43f873389ed3aec6b87a3f4ae4e85c8df))
+ - **FEAT**(auth): support friendlyName and user.name fallback for WebAuthn passkey enrollment ([#1603](https://github.com/supabase/supabase-flutter/issues/1603)). ([558b0346](https://github.com/supabase/supabase-flutter/commit/558b03461441d340b1656606d1086382481611f8))
+ - **FEAT**(auth): add signInWithWeb3 for Web3 wallet authentication ([#1590](https://github.com/supabase/supabase-flutter/issues/1590)). ([20a82beb](https://github.com/supabase/supabase-flutter/commit/20a82beb2eed6bc2dca86f14aefe0d58e0796c4f))
+ - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599))
+ - **FEAT**(auth): add OAuth server listGrants and revokeGrant methods ([#1561](https://github.com/supabase/supabase-flutter/issues/1561)). ([67fed239](https://github.com/supabase/supabase-flutter/commit/67fed239e52e0f02213bee7ae71cb043473efe28))
+ - **FEAT**(auth): add async getSession() with on-demand refresh ([#1563](https://github.com/supabase/supabase-flutter/issues/1563)). ([0b1d9b6b](https://github.com/supabase/supabase-flutter/commit/0b1d9b6b61adf9f66ed6716032b1a25f096604ce))
+ - **FEAT**(gotrue): add channel parameter to mfa.challenge() ([#1547](https://github.com/supabase/supabase-flutter/issues/1547)). ([3bde140e](https://github.com/supabase/supabase-flutter/commit/3bde140e214f6e2928108ec496eb0e96bb9ac258))
+ - **FEAT**(gotrue): add currentPassword to UserAttributes ([#1554](https://github.com/supabase/supabase-flutter/issues/1554)). ([b04c6419](https://github.com/supabase/supabase-flutter/commit/b04c6419785842c9d2af1337206b572adcb367c1))
+
+#### `postgrest` - `v2.9.0`
+
+ - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65))
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599))
+ - **FEAT**(postgrest): abort requests ([#1368](https://github.com/supabase/supabase-flutter/issues/1368)). ([c267e4fa](https://github.com/supabase/supabase-flutter/commit/c267e4fa4128508492ddd35651842abee7b41a7e))
+ - **FEAT**(database): add dryRun and stripNulls query modifiers ([#1559](https://github.com/supabase/supabase-flutter/issues/1559)). ([896bc1f7](https://github.com/supabase/supabase-flutter/commit/896bc1f71094fad8e390b47d50d5d424011749b0))
+
+#### `realtime_client` - `v2.12.0`
+
+ - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65))
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495))
+ - **FIX**(realtime): patch channel join payloads with access token before flushing ([#1553](https://github.com/supabase/supabase-flutter/issues/1553)). ([e5de0c07](https://github.com/supabase/supabase-flutter/commit/e5de0c075a36129e75462d15b61d0da571e99bc9))
+ - **FEAT**(realtime): defer socket disconnect when channels become empty ([#1589](https://github.com/supabase/supabase-flutter/issues/1589)). ([0055d1dc](https://github.com/supabase/supabase-flutter/commit/0055d1dcb6f9a0d2727d908bc68077af707327d0))
+
+#### `storage_client` - `v2.7.0`
+
+ - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65))
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **REFACTOR**: drop retry dependency in favor of an in-house helper ([#1571](https://github.com/supabase/supabase-flutter/issues/1571)). ([b81d2121](https://github.com/supabase/supabase-flutter/commit/b81d212159f15bf4d9515acf75430550a715664d))
+ - **REFACTOR**(storage): drop direct http_parser dependency ([#1570](https://github.com/supabase/supabase-flutter/issues/1570)). ([6dadaefb](https://github.com/supabase/supabase-flutter/commit/6dadaefbf847f254789c1f848b456c21cdd29d3e))
+ - **FIX**(storage): guard against empty transform routing through render endpoint ([#1551](https://github.com/supabase/supabase-flutter/issues/1551)). ([3f3e6f6c](https://github.com/supabase/supabase-flutter/commit/3f3e6f6c52dfb6020e4ea63b25a26eef855c50eb))
+ - **FIX**(storage): make storage_client WASM-compatible ([#1543](https://github.com/supabase/supabase-flutter/issues/1543)). ([7097b2ce](https://github.com/supabase/supabase-flutter/commit/7097b2ce6fdf206d56bdefb682d3ac5e43c8572d))
+ - **FEAT**(storage): add analytics (Iceberg) bucket CRUD ([#1588](https://github.com/supabase/supabase-flutter/issues/1588)). ([e3eeb9e6](https://github.com/supabase/supabase-flutter/commit/e3eeb9e6b89465a2f76ef243b42be7a14cbcf174))
+ - **FEAT**(storage): add vector buckets support ([#1585](https://github.com/supabase/supabase-flutter/issues/1585)). ([4f7fcc63](https://github.com/supabase/supabase-flutter/commit/4f7fcc63ad9b5d2d4b73e41d37e1ca145440e826))
+ - **FEAT**(storage): add downloadStream for streaming file downloads ([#1580](https://github.com/supabase/supabase-flutter/issues/1580)). ([8b839a10](https://github.com/supabase/supabase-flutter/commit/8b839a1028e591d7125e15b66164aa56e4d441e9))
+ - **FEAT**(storage): add listPaginated for cursor-based file listing ([#1579](https://github.com/supabase/supabase-flutter/issues/1579)). ([a6428c64](https://github.com/supabase/supabase-flutter/commit/a6428c6481c0b9d8d7a5b91d9e2c5424ed3f5c25))
+ - **FEAT**(storage): add cacheNonce parameter for cache invalidation ([#1578](https://github.com/supabase/supabase-flutter/issues/1578)). ([a9ff8086](https://github.com/supabase/supabase-flutter/commit/a9ff808653f16457f6caa8938610292888d65e17))
+ - **FEAT**(storage): support filter/sort/pagination options on listBuckets() ([#1557](https://github.com/supabase/supabase-flutter/issues/1557)). ([b72739e2](https://github.com/supabase/supabase-flutter/commit/b72739e231e964a867499c36c08910ef04aa78f9))
+
+#### `supabase` - `v2.15.0`
+
+ - **REFACTOR**(supabase): convert private stream-builder holders to records ([#1575](https://github.com/supabase/supabase-flutter/issues/1575)). ([58c2dad4](https://github.com/supabase/supabase-flutter/commit/58c2dad4968d6be36c92ffaa983127827ac97c73))
+ - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65))
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **REFACTOR**(supabase): drop rxdart dependency ([#1569](https://github.com/supabase/supabase-flutter/issues/1569)). ([51dcf0f4](https://github.com/supabase/supabase-flutter/commit/51dcf0f45439f1133a7a97f5ae9929ed3b39076e))
+ - **FIX**(supabase_common): align platform stats casing with other SDKs ([#1596](https://github.com/supabase/supabase-flutter/issues/1596)). ([97a6fa6e](https://github.com/supabase/supabase-flutter/commit/97a6fa6eb1f7d5f58b693a5e175fbfe4fe7d2c17))
+ - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495))
+ - **FEAT**(realtime): defer socket disconnect when channels become empty ([#1589](https://github.com/supabase/supabase-flutter/issues/1589)). ([0055d1dc](https://github.com/supabase/supabase-flutter/commit/0055d1dcb6f9a0d2727d908bc68077af707327d0))
+ - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599))
+ - **FEAT**(client): add opt-in trace context propagation headers ([#1564](https://github.com/supabase/supabase-flutter/issues/1564)). ([85780e60](https://github.com/supabase/supabase-flutter/commit/85780e607a00904870fe7f68dbd45ee5797256b6))
+ - **FEAT**(auth): add async getSession() with on-demand refresh ([#1563](https://github.com/supabase/supabase-flutter/issues/1563)). ([0b1d9b6b](https://github.com/supabase/supabase-flutter/commit/0b1d9b6b61adf9f66ed6716032b1a25f096604ce))
+
+#### `supabase_common` - `v0.1.1`
+
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **FIX**(supabase_common): align platform stats casing with other SDKs ([#1596](https://github.com/supabase/supabase-flutter/issues/1596)). ([97a6fa6e](https://github.com/supabase/supabase-flutter/commit/97a6fa6eb1f7d5f58b693a5e175fbfe4fe7d2c17))
+
+#### `supabase_flutter` - `v2.17.0`
+
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **FIX**(realtime_client): faster channel rejoin on web ([#1471](https://github.com/supabase/supabase-flutter/issues/1471)). ([bc06a310](https://github.com/supabase/supabase-flutter/commit/bc06a310cf09bdba87668695b48bc8b8f353c495))
+ - **FEAT**(auth): support friendlyName and user.name fallback for WebAuthn passkey enrollment ([#1603](https://github.com/supabase/supabase-flutter/issues/1603)). ([558b0346](https://github.com/supabase/supabase-flutter/commit/558b03461441d340b1656606d1086382481611f8))
+ - **FEAT**(client): add opt-in trace context propagation headers ([#1564](https://github.com/supabase/supabase-flutter/issues/1564)). ([85780e60](https://github.com/supabase/supabase-flutter/commit/85780e607a00904870fe7f68dbd45ee5797256b6))
+ - **FEAT**(client): add session-URL-detection predicate and persistSession flag ([#1558](https://github.com/supabase/supabase-flutter/issues/1558)). ([c8b02ed0](https://github.com/supabase/supabase-flutter/commit/c8b02ed062d78145671eecc2504875c19377a221))
+ - **DOCS**(auth): document getOAuthSignInUrl for URL-without-launch, mark oauth parity implemented ([#1548](https://github.com/supabase/supabase-flutter/issues/1548)). ([1a1c95bf](https://github.com/supabase/supabase-flutter/commit/1a1c95bf178a0af8301d54366ebbf999f911d8f0))
+
+#### `supabase_lints` - `v0.1.1`
+
+ - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65))
+
+
+## 2026-07-07
+
+### Changes
+
+---
+
+Packages with breaking changes:
+
+ - [`gotrue` - `v2.26.0`](#gotrue---v2260)
+
+Packages with other changes:
+
+ - [`realtime_client` - `v2.11.0`](#realtime_client---v2110)
+ - [`supabase` - `v2.14.0`](#supabase---v2140)
+ - [`supabase_flutter` - `v2.16.0`](#supabase_flutter---v2160)
+
+---
+
+#### `gotrue` - `v2.26.0`
+
+ - **FIX**(gotrue): OAuth server throws FormatException instead of ArgumentE… ([#1535](https://github.com/supabase/supabase-flutter/issues/1535)). ([da0f6e02](https://github.com/supabase/supabase-flutter/commit/da0f6e0236f52cd8dfb087257dbedd4a9de97533))
+ - **FEAT**(gotrue): add custom OAuth providers admin API with custom_claims_allowlist ([#1519](https://github.com/supabase/supabase-flutter/issues/1519)). ([122e5cf2](https://github.com/supabase/supabase-flutter/commit/122e5cf2036877c32ce92f807d5a9077d991a7e7))
+ - **BREAKING** **FIX**(gotrue): handle already-consented OAuth authorization responses ([#1536](https://github.com/supabase/supabase-flutter/issues/1536)). ([22d41234](https://github.com/supabase/supabase-flutter/commit/22d412346a748d282b39004771f175e37c504a5a))
+
+#### `realtime_client` - `v2.11.0`
+
+ - **FEAT**(realtime_client): support new postgres changes filter operators, multi-filter, column selection, and replication-ready events ([#1526](https://github.com/supabase/supabase-flutter/issues/1526)). ([1f9d2951](https://github.com/supabase/supabase-flutter/commit/1f9d29516032c1f4bd8416fd7fa36737a335afaf))
+
+#### `supabase` - `v2.14.0`
+
+ - **FEAT**(realtime_client): support new postgres changes filter operators, multi-filter, column selection, and replication-ready events ([#1526](https://github.com/supabase/supabase-flutter/issues/1526)). ([1f9d2951](https://github.com/supabase/supabase-flutter/commit/1f9d29516032c1f4bd8416fd7fa36737a335afaf))
+
+#### `supabase_flutter` - `v2.16.0`
+
+ - **FIX**(supabase_flutter): drop passkeys requireResidentKey workaround, bump dependency ([#1521](https://github.com/supabase/supabase-flutter/issues/1521)). ([7907c6cb](https://github.com/supabase/supabase-flutter/commit/7907c6cbc3dc31500a5cf85a81b97efc04f3006c))
+ - **FEAT**(supabase_flutter): default debug logging off under flutter test ([#1530](https://github.com/supabase/supabase-flutter/issues/1530)). ([941ee804](https://github.com/supabase/supabase-flutter/commit/941ee80498f7f6d1351351aca449b8a5bc2655da))
+
+
## 2026-07-03
### Changes
diff --git a/examples/README.md b/examples/README.md
index 4c151bf29..9cef04690 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -3,9 +3,12 @@
A collection of small apps that each show off a feature of `supabase_flutter`,
all sharing a single local Supabase instance.
-| Example | What it shows |
-| ---------------------- | ----------------------------------------- |
-| [`passkeys`](passkeys) | Passkey (WebAuthn) sign in and management |
+| Example | What it shows |
+| ------------------------------------------- | ------------------------------------------------ |
+| [`database_crud`](database_crud) | Database CRUD with PostgREST (`from(...)`) |
+| [`edge_functions`](edge_functions) | Invoking Edge Functions (`functions.invoke(...)`)|
+| [`passkeys`](passkeys) | Passkey (WebAuthn) sign in and management |
+| [`storage_transforms`](storage_transforms) | Storage uploads, downloads and image transforms |
## Quick start
diff --git a/examples/database_crud/.gitignore b/examples/database_crud/.gitignore
new file mode 100644
index 000000000..f85557248
--- /dev/null
+++ b/examples/database_crud/.gitignore
@@ -0,0 +1,6 @@
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+build/
+pubspec.lock
+pubspec_overrides.yaml
diff --git a/examples/database_crud/README.md b/examples/database_crud/README.md
new file mode 100644
index 000000000..8174352f5
--- /dev/null
+++ b/examples/database_crud/README.md
@@ -0,0 +1,56 @@
+# Database CRUD with PostgREST
+
+A small task manager that shows how to read and write data with
+`supabase.from(...)`:
+
+- **Select** tasks joined to their project (`select('*, projects(name)')`).
+- **Filter** by project (`eq`), title (`ilike`) and completion state (`eq`).
+- **Order** by priority then creation time (`order`).
+- **Insert** a new task and read the created row back (`insert().select()`).
+- **Update** a task's title and completion state (`update().eq()`).
+- **Delete** a task (`delete().eq()`).
+
+All database access is in
+[`lib/tasks_repository.dart`](lib/tasks_repository.dart), kept separate from the
+UI so the queries are easy to read and to drive from an integration test.
+
+To keep the focus on the Supabase calls, the screen uses plain `setState` and
+reloads the list after each write rather than pulling in a state management
+package or applying optimistic updates. A larger app would typically reach for a
+state management solution (for example Riverpod, Bloc or Provider) instead.
+
+The tables and sample data come from the shared Supabase config in
+[`../supabase`](../supabase): schema in
+`migrations/20240601000000_crud_example.sql`, seed rows in `seed.sql`.
+
+## Running
+
+From the `examples` directory, run the launcher and pick `database_crud`:
+
+```bash
+./run.sh
+```
+
+Or run it directly against any project:
+
+```bash
+flutter run \
+ --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \
+ --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
+```
+
+## Integration test
+
+[`integration_test/tasks_test.dart`](integration_test/tasks_test.dart) is an
+end-to-end test that drives the app widgets against the local stack: it reads the
+seeded tasks, filters them by title, then creates, completes, renames and deletes
+a task, asserting on the UI after each step.
+
+With the local stack running, pass the same defines the app uses and run it on a
+device (integration tests need one, so `-d macos`, an emulator or a real device):
+
+```bash
+flutter test integration_test/tasks_test.dart -d macos \
+ --dart-define=SUPABASE_URL=http://localhost:54321 \
+ --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY
+```
diff --git a/examples/database_crud/analysis_options.yaml b/examples/database_crud/analysis_options.yaml
new file mode 100644
index 000000000..10becad33
--- /dev/null
+++ b/examples/database_crud/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:supabase_lints/analysis_options_flutter.yaml
diff --git a/examples/database_crud/integration_test/tasks_test.dart b/examples/database_crud/integration_test/tasks_test.dart
new file mode 100644
index 000000000..1793ddf96
--- /dev/null
+++ b/examples/database_crud/integration_test/tasks_test.dart
@@ -0,0 +1,139 @@
+import 'package:database_crud_example/main.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
+const supabasePublishableKey = String.fromEnvironment(
+ 'SUPABASE_PUBLISHABLE_KEY',
+);
+
+/// End-to-end test that drives the real app widgets against the local Supabase
+/// stack, exercising the CRUD flow through the UI: reading the seeded tasks,
+/// filtering them, then creating, completing, renaming and deleting a task.
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ // A unique title so the test's own task never clashes with a leftover from a
+ // previous run.
+ final createdTitle = 'E2E task ${DateTime.now().microsecondsSinceEpoch}';
+ final renamedTitle = 'E2E renamed ${DateTime.now().microsecondsSinceEpoch}';
+
+ setUpAll(() async {
+ await Supabase.initialize(
+ url: supabaseUrl,
+ publishableKey: supabasePublishableKey,
+ );
+ });
+
+ tearDownAll(() async {
+ await Supabase.instance.dispose();
+ });
+
+ tearDown(() async {
+ // Remove anything the test created so it can run repeatedly.
+ await Supabase.instance.client
+ .from('tasks')
+ .delete()
+ .like('title', 'E2E %');
+ });
+
+ testWidgets('reads, filters and manages tasks through the UI', (
+ tester,
+ ) async {
+ await tester.pumpWidget(const CrudExampleApp());
+
+ // The seed tasks load on start.
+ await _pumpUntilGone(tester, find.byType(CircularProgressIndicator));
+ expect(find.text('Book flights'), findsOneWidget);
+ expect(find.text('Draft the landing page copy'), findsOneWidget);
+
+ // Filter by title (ilike): searching narrows the list to the match.
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Search title'),
+ 'Book',
+ );
+ await _pumpUntilGone(tester, find.text('Draft the landing page copy'));
+ expect(find.text('Book flights'), findsOneWidget);
+
+ // Clearing the filter brings the other tasks back.
+ await tester.enterText(find.widgetWithText(TextField, 'Search title'), '');
+ await _pumpUntil(tester, find.text('Draft the landing page copy'));
+
+ // Create a task through the new-task dialog (insert).
+ await tester.tap(find.byType(FloatingActionButton));
+ await tester.pumpAndSettle();
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Title'),
+ createdTitle,
+ );
+ await tester.tap(find.widgetWithText(FilledButton, 'Create'));
+ await _pumpUntil(tester, find.text(createdTitle));
+
+ // Complete it by ticking the checkbox in its tile (update).
+ await tester.tap(_inTile(createdTitle, find.byType(Checkbox)));
+ await _pumpUntil(
+ tester,
+ _inTile(
+ createdTitle,
+ find.byWidgetPredicate((widget) => widget is Checkbox && widget.value!),
+ ),
+ );
+
+ // Rename it through the edit dialog (update).
+ await tester.tap(_inTile(createdTitle, find.byIcon(Icons.edit)));
+ await tester.pumpAndSettle();
+ await tester.enterText(
+ find.descendant(
+ of: find.byType(AlertDialog),
+ matching: find.byType(TextField),
+ ),
+ renamedTitle,
+ );
+ await tester.tap(find.widgetWithText(FilledButton, 'Save'));
+ await _pumpUntil(tester, find.text(renamedTitle));
+ expect(find.text(createdTitle), findsNothing);
+
+ // Delete it (delete).
+ await tester.tap(_inTile(renamedTitle, find.byIcon(Icons.delete)));
+ await _pumpUntilGone(tester, find.text(renamedTitle));
+ });
+}
+
+/// Finds [target] within the [ListTile] that contains [title].
+Finder _inTile(String title, Finder target) => find.descendant(
+ of: find.ancestor(of: find.text(title), matching: find.byType(ListTile)),
+ matching: target,
+);
+
+/// Pumps frames until [finder] matches at least one widget or [timeout] elapses.
+///
+/// Reads and writes go over the network, so the UI can't be settled with
+/// `pumpAndSettle`; this polls the widget tree instead.
+Future _pumpUntil(
+ WidgetTester tester,
+ Finder finder, {
+ Duration timeout = const Duration(seconds: 20),
+}) async {
+ final deadline = DateTime.now().add(timeout);
+ while (DateTime.now().isBefore(deadline)) {
+ await tester.pump(const Duration(milliseconds: 100));
+ if (finder.evaluate().isNotEmpty) return;
+ }
+ fail('Timed out waiting for: $finder');
+}
+
+/// The inverse of [_pumpUntil]: pumps until [finder] matches nothing.
+Future _pumpUntilGone(
+ WidgetTester tester,
+ Finder finder, {
+ Duration timeout = const Duration(seconds: 20),
+}) async {
+ final deadline = DateTime.now().add(timeout);
+ while (DateTime.now().isBefore(deadline)) {
+ await tester.pump(const Duration(milliseconds: 100));
+ if (finder.evaluate().isEmpty) return;
+ }
+ fail('Timed out waiting for it to disappear: $finder');
+}
diff --git a/examples/database_crud/lib/main.dart b/examples/database_crud/lib/main.dart
new file mode 100644
index 000000000..d7ae282d3
--- /dev/null
+++ b/examples/database_crud/lib/main.dart
@@ -0,0 +1,467 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'models.dart';
+import 'tasks_repository.dart';
+
+const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
+const supabasePublishableKey = String.fromEnvironment(
+ 'SUPABASE_PUBLISHABLE_KEY',
+);
+
+final messengerKey = GlobalKey();
+
+Future main() async {
+ await Supabase.initialize(
+ url: supabaseUrl,
+ publishableKey: supabasePublishableKey,
+ );
+ runApp(const CrudExampleApp());
+}
+
+SupabaseClient get supabase => Supabase.instance.client;
+
+class CrudExampleApp extends StatelessWidget {
+ const CrudExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Supabase Database CRUD',
+ scaffoldMessengerKey: messengerKey,
+ theme: ThemeData(colorSchemeSeed: Colors.green, useMaterial3: true),
+ home: const TasksPage(),
+ );
+ }
+}
+
+/// Lists tasks with filtering, ordering and a join to their project, and lets
+/// you create, update and delete them.
+class TasksPage extends StatefulWidget {
+ const TasksPage({super.key});
+
+ @override
+ State createState() => _TasksPageState();
+}
+
+class _TasksPageState extends State {
+ final _repository = TasksRepository(supabase);
+ final _search = TextEditingController();
+
+ List _projects = [];
+ List _tasks = [];
+ String? _projectFilter;
+ bool _onlyIncomplete = false;
+ bool _loading = true;
+ bool _mutating = false;
+ Timer? _debounce;
+
+ /// Bumped on every task reload so a slower earlier request can't overwrite the
+ /// results of a later one.
+ int _requestId = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ unawaited(_init());
+ }
+
+ @override
+ void dispose() {
+ _debounce?.cancel();
+ _search.dispose();
+ super.dispose();
+ }
+
+ /// Loads the projects once, then the tasks for the current filters.
+ Future _init() async {
+ try {
+ final projects = await _repository.fetchProjects();
+ if (mounted) setState(() => _projects = projects);
+ } catch (error) {
+ _showError(error);
+ }
+ await _loadTasks();
+ }
+
+ /// Reloads the task list for the current filters. Leaves the previous list on
+ /// screen while it runs, so changing a filter or toggling a task doesn't flash
+ /// a spinner over the whole list.
+ Future _loadTasks() async {
+ final requestId = ++_requestId;
+ // Ignore a response if a newer reload started or the widget went away while
+ // this request was in flight.
+ bool isStale() => !mounted || requestId != _requestId;
+ try {
+ final tasks = await _repository.fetchTasks(
+ projectId: _projectFilter,
+ search: _search.text.trim(),
+ onlyIncomplete: _onlyIncomplete,
+ );
+ if (isStale()) return;
+ setState(() => _tasks = tasks);
+ } catch (error) {
+ if (isStale()) return;
+ _showError(error);
+ } finally {
+ if (!isStale()) setState(() => _loading = false);
+ }
+ }
+
+ /// Debounces the search field so typing doesn't fire a query per keystroke
+ /// and race the responses back out of order.
+ void _onSearchChanged(String _) {
+ _debounce?.cancel();
+ _debounce = Timer(const Duration(milliseconds: 300), _loadTasks);
+ }
+
+ /// Runs a single write, then reloads the list. Ignores the call if another
+ /// write is already in flight, so a double tap can't fire duplicate requests.
+ Future _mutate(Future Function() write) async {
+ if (_mutating) return;
+ setState(() => _mutating = true);
+ try {
+ await write();
+ await _loadTasks();
+ } catch (error) {
+ _showError(error);
+ } finally {
+ if (mounted) setState(() => _mutating = false);
+ }
+ }
+
+ Future _toggle(Task task) => _mutate(
+ () => _repository.setTaskComplete(
+ id: task.id,
+ isComplete: !task.isComplete,
+ ),
+ );
+
+ Future _delete(Task task) =>
+ _mutate(() => _repository.deleteTask(task.id));
+
+ Future _create() async {
+ final result = await showDialog<_TaskFormResult>(
+ context: context,
+ builder: (context) => _TaskDialog(projects: _projects),
+ );
+ if (result == null) return;
+ await _mutate(
+ () => _repository.createTask(
+ projectId: result.projectId,
+ title: result.title,
+ priority: result.priority,
+ ),
+ );
+ }
+
+ Future _rename(Task task) async {
+ final controller = TextEditingController(text: task.title);
+ final title = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Rename task'),
+ content: TextField(
+ controller: controller,
+ autofocus: true,
+ decoration: const InputDecoration(labelText: 'Title'),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(
+ onPressed: () => Navigator.pop(context, controller.text.trim()),
+ child: const Text('Save'),
+ ),
+ ],
+ ),
+ );
+ if (title == null || title.isEmpty) return;
+ await _mutate(() => _repository.renameTask(id: task.id, title: title));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Tasks')),
+ floatingActionButton: FloatingActionButton.extended(
+ onPressed: _projects.isEmpty || _mutating ? null : _create,
+ icon: const Icon(Icons.add),
+ label: const Text('New task'),
+ ),
+ body: Column(
+ children: [
+ _Filters(
+ projects: _projects,
+ projectFilter: _projectFilter,
+ search: _search,
+ onlyIncomplete: _onlyIncomplete,
+ onProjectChanged: (value) {
+ setState(() => _projectFilter = value);
+ unawaited(_loadTasks());
+ },
+ onSearchChanged: _onSearchChanged,
+ onOnlyIncompleteChanged: (value) {
+ setState(() => _onlyIncomplete = value);
+ unawaited(_loadTasks());
+ },
+ ),
+ const Divider(height: 1),
+ Expanded(
+ child: _loading
+ ? const Center(child: CircularProgressIndicator())
+ : _tasks.isEmpty
+ ? const Center(child: Text('No tasks match these filters.'))
+ : RefreshIndicator(
+ onRefresh: _loadTasks,
+ child: ListView.builder(
+ itemCount: _tasks.length,
+ itemBuilder: (context, index) {
+ final task = _tasks[index];
+ return _TaskTile(
+ task: task,
+ enabled: !_mutating,
+ onToggle: () => _toggle(task),
+ onRename: () => _rename(task),
+ onDelete: () => _delete(task),
+ );
+ },
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Filters extends StatelessWidget {
+ const _Filters({
+ required this.projects,
+ required this.projectFilter,
+ required this.search,
+ required this.onlyIncomplete,
+ required this.onProjectChanged,
+ required this.onSearchChanged,
+ required this.onOnlyIncompleteChanged,
+ });
+
+ final List projects;
+ final String? projectFilter;
+ final TextEditingController search;
+ final bool onlyIncomplete;
+ final ValueChanged onProjectChanged;
+ final ValueChanged onSearchChanged;
+ final ValueChanged onOnlyIncompleteChanged;
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: search,
+ onChanged: onSearchChanged,
+ decoration: const InputDecoration(
+ labelText: 'Search title',
+ prefixIcon: Icon(Icons.search),
+ isDense: true,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: DropdownButtonFormField(
+ initialValue: projectFilter,
+ isExpanded: true,
+ decoration: const InputDecoration(
+ labelText: 'Project',
+ isDense: true,
+ ),
+ items: [
+ const DropdownMenuItem(child: Text('All projects')),
+ for (final project in projects)
+ DropdownMenuItem(
+ value: project.id,
+ child: Text(project.name),
+ ),
+ ],
+ onChanged: onProjectChanged,
+ ),
+ ),
+ ],
+ ),
+ CheckboxListTile(
+ value: onlyIncomplete,
+ onChanged: (value) => onOnlyIncompleteChanged(value ?? false),
+ title: const Text('Only incomplete'),
+ controlAffinity: ListTileControlAffinity.leading,
+ contentPadding: EdgeInsets.zero,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _TaskTile extends StatelessWidget {
+ const _TaskTile({
+ required this.task,
+ required this.enabled,
+ required this.onToggle,
+ required this.onRename,
+ required this.onDelete,
+ });
+
+ final Task task;
+ final bool enabled;
+ final VoidCallback onToggle;
+ final VoidCallback onRename;
+ final VoidCallback onDelete;
+
+ @override
+ Widget build(BuildContext context) {
+ return ListTile(
+ leading: Checkbox(
+ value: task.isComplete,
+ onChanged: enabled ? (_) => onToggle() : null,
+ ),
+ title: Text(
+ task.title,
+ style: task.isComplete
+ ? const TextStyle(decoration: TextDecoration.lineThrough)
+ : null,
+ ),
+ subtitle: Text(
+ '${task.projectName ?? 'Unknown'} · ${task.priority.label} priority',
+ ),
+ trailing: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ IconButton(
+ icon: const Icon(Icons.edit),
+ tooltip: 'Rename',
+ onPressed: enabled ? onRename : null,
+ ),
+ IconButton(
+ icon: const Icon(Icons.delete),
+ tooltip: 'Delete',
+ onPressed: enabled ? onDelete : null,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _TaskFormResult {
+ const _TaskFormResult({
+ required this.projectId,
+ required this.title,
+ required this.priority,
+ });
+
+ final String projectId;
+ final String title;
+ final Priority priority;
+}
+
+class _TaskDialog extends StatefulWidget {
+ const _TaskDialog({required this.projects});
+
+ final List projects;
+
+ @override
+ State<_TaskDialog> createState() => _TaskDialogState();
+}
+
+class _TaskDialogState extends State<_TaskDialog> {
+ final _title = TextEditingController();
+ late String _projectId = widget.projects.first.id;
+ Priority _priority = Priority.low;
+
+ @override
+ void dispose() {
+ _title.dispose();
+ super.dispose();
+ }
+
+ void _submit() {
+ final title = _title.text.trim();
+ if (title.isEmpty) return;
+ Navigator.pop(
+ context,
+ _TaskFormResult(
+ projectId: _projectId,
+ title: title,
+ priority: _priority,
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: const Text('New task'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextField(
+ controller: _title,
+ autofocus: true,
+ decoration: const InputDecoration(labelText: 'Title'),
+ ),
+ const SizedBox(height: 12),
+ DropdownButtonFormField(
+ initialValue: _projectId,
+ isExpanded: true,
+ decoration: const InputDecoration(labelText: 'Project'),
+ items: [
+ for (final project in widget.projects)
+ DropdownMenuItem(
+ value: project.id,
+ child: Text(project.name),
+ ),
+ ],
+ onChanged: (value) => setState(() => _projectId = value!),
+ ),
+ const SizedBox(height: 12),
+ DropdownButtonFormField(
+ initialValue: _priority,
+ isExpanded: true,
+ decoration: const InputDecoration(labelText: 'Priority'),
+ items: [
+ for (final priority in Priority.values)
+ DropdownMenuItem(value: priority, child: Text(priority.label)),
+ ],
+ onChanged: (value) => setState(() => _priority = value!),
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Cancel'),
+ ),
+ FilledButton(onPressed: _submit, child: const Text('Create')),
+ ],
+ );
+ }
+}
+
+void _showError(Object error) {
+ final message = error is PostgrestException
+ ? error.message
+ : error.toString();
+ messengerKey.currentState?.showSnackBar(
+ SnackBar(content: Text(message), backgroundColor: Colors.red),
+ );
+}
diff --git a/examples/database_crud/lib/models.dart b/examples/database_crud/lib/models.dart
new file mode 100644
index 000000000..73f60b67f
--- /dev/null
+++ b/examples/database_crud/lib/models.dart
@@ -0,0 +1,66 @@
+/// How urgent a [Task] is. Stored in the database as the integer [value].
+enum Priority {
+ low(1, 'Low'),
+ medium(2, 'Medium'),
+ high(3, 'High');
+
+ const Priority(this.value, this.label);
+
+ factory Priority.fromValue(int value) =>
+ values.firstWhere((priority) => priority.value == value);
+
+ final int value;
+ final String label;
+}
+
+/// A project that groups tasks together.
+class Project {
+ const Project({required this.id, required this.name});
+
+ factory Project.fromJson(Map json) => Project(
+ id: json['id'] as String,
+ name: json['name'] as String,
+ );
+
+ final String id;
+ final String name;
+}
+
+/// A single task belonging to a [Project].
+class Task {
+ const Task({
+ required this.id,
+ required this.projectId,
+ required this.title,
+ required this.isComplete,
+ required this.priority,
+ required this.createdAt,
+ this.projectName,
+ });
+
+ factory Task.fromJson(Map json) {
+ // When the task is fetched with a join, the related project is nested under
+ // the `projects` key.
+ final project = json['projects'] as Map?;
+ return Task(
+ id: json['id'] as String,
+ projectId: json['project_id'] as String,
+ title: json['title'] as String,
+ isComplete: json['is_complete'] as bool,
+ priority: Priority.fromValue(json['priority'] as int),
+ createdAt: DateTime.parse(json['created_at'] as String),
+ projectName: project?['name'] as String?,
+ );
+ }
+
+ final String id;
+ final String projectId;
+ final String title;
+ final bool isComplete;
+ final Priority priority;
+ final DateTime createdAt;
+
+ /// Name of the task's project, populated from the embedded `projects` row when
+ /// the task is fetched with a join.
+ final String? projectName;
+}
diff --git a/examples/database_crud/lib/tasks_repository.dart b/examples/database_crud/lib/tasks_repository.dart
new file mode 100644
index 000000000..512c3bac9
--- /dev/null
+++ b/examples/database_crud/lib/tasks_repository.dart
@@ -0,0 +1,101 @@
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'models.dart';
+
+/// All database access for the CRUD example lives here, so the UI stays thin and
+/// every `supabase.from(...)` call is easy to read and to exercise from an
+/// integration test.
+class TasksRepository {
+ TasksRepository(this._client);
+
+ final SupabaseClient _client;
+
+ /// Columns for a task plus its related project, used everywhere a task is
+ /// returned so the join is consistent.
+ static const _taskColumns = '*, projects(name)';
+
+ /// SELECT every project, ordered alphabetically by name.
+ Future> fetchProjects() async {
+ final rows = await _client.from('projects').select().order('name');
+ return rows.map(Project.fromJson).toList();
+ }
+
+ /// SELECT tasks joined to their project, with optional filters and ordering.
+ ///
+ /// * [projectId] restricts to a single project (`eq`).
+ /// * [search] matches the title case-insensitively (`ilike`).
+ /// * [onlyIncomplete] hides finished tasks (`eq`).
+ ///
+ /// Results are ordered by priority (highest first) then creation time.
+ Future> fetchTasks({
+ String? projectId,
+ String? search,
+ bool onlyIncomplete = false,
+ }) async {
+ // Embed the related project row (`projects(name)`) to demonstrate a join.
+ var query = _client.from('tasks').select(_taskColumns);
+
+ if (projectId != null) {
+ query = query.eq('project_id', projectId);
+ }
+ if (search != null && search.isNotEmpty) {
+ query = query.ilike('title', '%$search%');
+ }
+ if (onlyIncomplete) {
+ query = query.eq('is_complete', false);
+ }
+
+ final rows = await query
+ .order('priority', ascending: false)
+ .order('created_at');
+ return rows.map(Task.fromJson).toList();
+ }
+
+ /// INSERT a task and return the created row (joined to its project).
+ Future createTask({
+ required String projectId,
+ required String title,
+ Priority priority = Priority.low,
+ }) async {
+ final row = await _client
+ .from('tasks')
+ .insert({
+ 'project_id': projectId,
+ 'title': title,
+ 'priority': priority.value,
+ })
+ .select(_taskColumns)
+ .single();
+ return Task.fromJson(row);
+ }
+
+ /// UPDATE a task's completion state and return the updated row.
+ Future setTaskComplete({
+ required String id,
+ required bool isComplete,
+ }) async {
+ final row = await _client
+ .from('tasks')
+ .update({'is_complete': isComplete})
+ .eq('id', id)
+ .select(_taskColumns)
+ .single();
+ return Task.fromJson(row);
+ }
+
+ /// UPDATE a task's title and return the updated row.
+ Future renameTask({required String id, required String title}) async {
+ final row = await _client
+ .from('tasks')
+ .update({'title': title})
+ .eq('id', id)
+ .select(_taskColumns)
+ .single();
+ return Task.fromJson(row);
+ }
+
+ /// DELETE a task.
+ Future deleteTask(String id) async {
+ await _client.from('tasks').delete().eq('id', id);
+ }
+}
diff --git a/examples/database_crud/pubspec.yaml b/examples/database_crud/pubspec.yaml
new file mode 100644
index 000000000..6edaae42d
--- /dev/null
+++ b/examples/database_crud/pubspec.yaml
@@ -0,0 +1,27 @@
+name: database_crud_example
+description: Demonstrates database CRUD with PostgREST using supabase_flutter.
+
+publish_to: 'none'
+
+version: 1.0.0+1
+
+environment:
+ sdk: '>=3.9.0 <4.0.0'
+ flutter: '>=3.35.0'
+
+resolution: workspace
+
+dependencies:
+ flutter:
+ sdk: flutter
+ supabase_flutter: ^2.17.0
+
+dev_dependencies:
+ supabase_lints: ^0.1.1
+ flutter_test:
+ sdk: flutter
+ integration_test:
+ sdk: flutter
+
+flutter:
+ uses-material-design: true
diff --git a/examples/database_crud/web/index.html b/examples/database_crud/web/index.html
new file mode 100644
index 000000000..cc01d6f17
--- /dev/null
+++ b/examples/database_crud/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ Supabase Database CRUD
+
+
+
+
+
diff --git a/examples/edge_functions/.gitignore b/examples/edge_functions/.gitignore
new file mode 100644
index 000000000..f85557248
--- /dev/null
+++ b/examples/edge_functions/.gitignore
@@ -0,0 +1,6 @@
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+build/
+pubspec.lock
+pubspec_overrides.yaml
diff --git a/examples/edge_functions/README.md b/examples/edge_functions/README.md
new file mode 100644
index 000000000..fd2477485
--- /dev/null
+++ b/examples/edge_functions/README.md
@@ -0,0 +1,68 @@
+# Edge Functions
+
+A small app that shows how to call Supabase Edge Functions with
+`supabase.functions.invoke(...)`:
+
+- **Invoke with a JSON body** over POST and read the JSON response back
+ (`invoke('greet', body: {...})`).
+- **Invoke over GET** with query parameters instead of a body
+ (`invoke('greet', method: HttpMethod.get, queryParameters: {...})`).
+- **Send custom headers** to the function (`headers: {...}`), echoed back in the
+ response.
+- **Send and receive plain text**: a `String` body is sent as `text/plain` and a
+ `text/plain` response comes back as a `String` (`invoke('shout', body: text)`).
+- **Handle errors**: a non-2xx response throws a `FunctionException` whose
+ `details` carry the response body (`invoke('word-count', ...)`).
+
+All Edge Function access is in
+[`lib/functions_repository.dart`](lib/functions_repository.dart), kept separate
+from the UI so the calls are easy to read and to drive from an integration test.
+
+To keep the focus on the Supabase calls, the screen uses plain `setState` rather
+than pulling in a state management package. A larger app would typically reach
+for a state management solution (for example Riverpod, Bloc or Provider) instead.
+
+The functions live in the shared Supabase config in
+[`../supabase`](../supabase): their code is under `functions/` (`greet`, `shout`
+and `word-count`), and the Edge Runtime is enabled in `config.toml`
+(`[edge_runtime]`). `supabase start` serves every function while the stack is
+running. The functions need no database tables or seed data.
+
+The demo functions set `verify_jwt = false` in `config.toml` so the example can
+call them with just the publishable (anon) key, matching the other examples,
+which run unauthenticated. A real app would leave JWT verification on and read
+the caller's user from the request.
+
+## Running
+
+From the `examples` directory, run the launcher and pick `edge_functions`:
+
+```bash
+./run.sh
+```
+
+Or run it directly against any project (with the functions deployed there):
+
+```bash
+flutter run \
+ --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \
+ --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
+```
+
+## Integration test
+
+[`integration_test/functions_test.dart`](integration_test/functions_test.dart)
+is an end-to-end test that runs against the local stack. It drives the flow
+through the repository (a JSON greeting over POST and GET, a plain-text
+transform, and a validation error that surfaces as a `FunctionException`) and
+drives the app widgets to greet through the UI.
+
+With the local stack running, pass the same defines the app uses and run it on a
+device (integration tests need one, so `-d macos`, an emulator or a real
+device):
+
+```bash
+flutter test integration_test/functions_test.dart -d macos \
+ --dart-define=SUPABASE_URL=http://localhost:54321 \
+ --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY
+```
diff --git a/examples/edge_functions/analysis_options.yaml b/examples/edge_functions/analysis_options.yaml
new file mode 100644
index 000000000..10becad33
--- /dev/null
+++ b/examples/edge_functions/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:supabase_lints/analysis_options_flutter.yaml
diff --git a/examples/edge_functions/integration_test/functions_test.dart b/examples/edge_functions/integration_test/functions_test.dart
new file mode 100644
index 000000000..dbc170a01
--- /dev/null
+++ b/examples/edge_functions/integration_test/functions_test.dart
@@ -0,0 +1,103 @@
+import 'package:edge_functions_example/functions_repository.dart';
+import 'package:edge_functions_example/main.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
+const supabasePublishableKey = String.fromEnvironment(
+ 'SUPABASE_PUBLISHABLE_KEY',
+);
+
+/// End-to-end tests that drive the Edge Functions example against the local
+/// stack.
+///
+/// The first test exercises the core flow through the repository (a JSON
+/// greeting over POST and GET, a plain-text transform, and a validation error),
+/// asserting on what each function returns. The second drives the app widgets to
+/// confirm the greeting card is wired to the function. Edge Functions are
+/// stateless, so there is nothing to clean up between runs.
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ late FunctionsRepository repository;
+
+ setUpAll(() async {
+ await Supabase.initialize(
+ url: supabaseUrl,
+ publishableKey: supabasePublishableKey,
+ );
+ repository = FunctionsRepository(Supabase.instance.client);
+ });
+
+ tearDownAll(() async {
+ await Supabase.instance.dispose();
+ });
+
+ testWidgets('invokes the example functions through the repository', (
+ tester,
+ ) async {
+ // POST with a JSON body: the function greets and echoes how it was called.
+ final posted = await repository.greet(name: 'Ada', excited: true);
+ expect(posted.message, 'Hello, Ada!!!');
+ expect(posted.method, 'POST');
+ expect(posted.source, 'flutter-app');
+
+ // GET with a query parameter reaches the same function a different way.
+ final fetched = await repository.greetViaQuery('Grace');
+ expect(fetched.message, 'Hello, Grace.');
+ expect(fetched.method, 'GET');
+
+ // A plain-text response comes back as a String.
+ final shouted = await repository.shout('edge functions');
+ expect(shouted, 'EDGE FUNCTIONS');
+
+ // A JSON response decodes into the model.
+ final count = await repository.countWords('one two three');
+ expect(count.words, 3);
+ expect(count.characters, 13);
+
+ // An empty input makes the function respond with a 400, which surfaces as a
+ // FunctionException carrying the JSON error body.
+ await expectLater(
+ repository.countWords(''),
+ throwsA(
+ isA()
+ .having((error) => error.status, 'status', 400)
+ .having(
+ (error) => (error.details as Map)['error'],
+ 'details.error',
+ isA(),
+ ),
+ ),
+ );
+ });
+
+ testWidgets('greets through the UI', (tester) async {
+ await tester.pumpWidget(const EdgeFunctionsExampleApp());
+ await tester.pumpAndSettle();
+
+ // Tapping "Greet (POST)" invokes the function and shows its message.
+ await tester.tap(find.widgetWithText(FilledButton, 'Greet (POST)'));
+ await _pumpUntil(tester, find.textContaining('Hello, Ada'));
+ expect(find.textContaining('via POST'), findsOneWidget);
+ });
+}
+
+/// Pumps frames until [finder] matches at least one widget or [timeout] elapses.
+///
+/// Invoking a function goes over the network, so the UI can't be settled with
+/// `pumpAndSettle`; this polls the widget tree instead.
+Future _pumpUntil(
+ WidgetTester tester,
+ Finder finder, {
+ Duration timeout = const Duration(seconds: 20),
+}) async {
+ final deadline = DateTime.now().add(timeout);
+ while (DateTime.now().isBefore(deadline)) {
+ await tester.pump(const Duration(milliseconds: 100));
+ if (finder.evaluate().isNotEmpty) return;
+ }
+ fail('Timed out waiting for: $finder');
+}
diff --git a/examples/edge_functions/lib/functions_repository.dart b/examples/edge_functions/lib/functions_repository.dart
new file mode 100644
index 000000000..2c99c3aca
--- /dev/null
+++ b/examples/edge_functions/lib/functions_repository.dart
@@ -0,0 +1,63 @@
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'models.dart';
+
+/// All Edge Function access for the example lives here, so the UI stays thin and
+/// every `supabase.functions.invoke(...)` call is easy to read and to exercise
+/// from an integration test.
+class FunctionsRepository {
+ FunctionsRepository(this._client);
+
+ final SupabaseClient _client;
+
+ FunctionsClient get _functions => _client.functions;
+
+ /// Invokes the `greet` function over POST with a JSON body and returns the
+ /// greeting it builds server-side.
+ ///
+ /// The custom `x-greeting-source` header is echoed back in the response, so
+ /// the example can show that headers set here reach the function. A JSON body
+ /// comes back decoded as a `Map`, which [Greeting.fromJson] turns into a model.
+ Future greet({required String name, bool excited = false}) async {
+ final response = await _functions.invoke(
+ 'greet',
+ body: {'name': name, 'excited': excited},
+ headers: const {'x-greeting-source': 'flutter-app'},
+ );
+ return Greeting.fromJson(response.data as Map);
+ }
+
+ /// Invokes the same `greet` function over GET, passing the name as a query
+ /// parameter instead of a body.
+ Future greetViaQuery(String name) async {
+ final response = await _functions.invoke(
+ 'greet',
+ method: HttpMethod.get,
+ queryParameters: {'name': name},
+ );
+ return Greeting.fromJson(response.data as Map);
+ }
+
+ /// Sends [text] to the `shout` function and returns the uppercased text it
+ /// responds with.
+ ///
+ /// A `String` body is sent as `text/plain`, and the function replies with
+ /// `text/plain` too, so `response.data` is a `String` rather than decoded JSON.
+ Future shout(String text) async {
+ final response = await _functions.invoke('shout', body: text);
+ return response.data as String;
+ }
+
+ /// Invokes the `word-count` function, which validates its input.
+ ///
+ /// When [text] is empty the function replies with a 400 and a JSON error body,
+ /// which surfaces here as a [FunctionException] whose `details` hold that body.
+ /// The caller is expected to handle that exception.
+ Future countWords(String text) async {
+ final response = await _functions.invoke(
+ 'word-count',
+ body: {'text': text},
+ );
+ return WordCount.fromJson(response.data as Map);
+ }
+}
diff --git a/examples/edge_functions/lib/main.dart b/examples/edge_functions/lib/main.dart
new file mode 100644
index 000000000..753f86e0f
--- /dev/null
+++ b/examples/edge_functions/lib/main.dart
@@ -0,0 +1,374 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'functions_repository.dart';
+import 'models.dart';
+
+const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
+const supabasePublishableKey = String.fromEnvironment(
+ 'SUPABASE_PUBLISHABLE_KEY',
+);
+
+final messengerKey = GlobalKey();
+
+Future main() async {
+ await Supabase.initialize(
+ url: supabaseUrl,
+ publishableKey: supabasePublishableKey,
+ );
+ runApp(const EdgeFunctionsExampleApp());
+}
+
+SupabaseClient get supabase => Supabase.instance.client;
+
+class EdgeFunctionsExampleApp extends StatelessWidget {
+ const EdgeFunctionsExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Supabase Edge Functions',
+ scaffoldMessengerKey: messengerKey,
+ theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true),
+ home: const FunctionsPage(),
+ );
+ }
+}
+
+/// Invokes the example's Edge Functions and shows what each one returns: a JSON
+/// greeting (over POST and GET), a plain-text transform, and a validating
+/// function whose error response is surfaced to the user. Each card drives one
+/// function through the shared [FunctionsRepository].
+class FunctionsPage extends StatefulWidget {
+ const FunctionsPage({super.key});
+
+ @override
+ State createState() => _FunctionsPageState();
+}
+
+class _FunctionsPageState extends State {
+ final _repository = FunctionsRepository(supabase);
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Edge Functions')),
+ body: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ _GreetCard(repository: _repository),
+ const SizedBox(height: 16),
+ _ShoutCard(repository: _repository),
+ const SizedBox(height: 16),
+ _WordCountCard(repository: _repository),
+ ],
+ ),
+ );
+ }
+}
+
+/// Invokes `greet` over POST and GET, both building the message server-side.
+class _GreetCard extends StatefulWidget {
+ const _GreetCard({required this.repository});
+
+ final FunctionsRepository repository;
+
+ @override
+ State<_GreetCard> createState() => _GreetCardState();
+}
+
+class _GreetCardState extends State<_GreetCard> {
+ final _name = TextEditingController(text: 'Ada');
+ bool _excited = false;
+ Greeting? _greeting;
+
+ /// Which button is running (`post` or `get`), or null when idle, so only the
+ /// tapped button shows a spinner and neither fires twice.
+ String? _running;
+
+ @override
+ void dispose() {
+ _name.dispose();
+ super.dispose();
+ }
+
+ Future _run(String key, Future Function() action) async {
+ if (_running != null) return;
+ setState(() => _running = key);
+ try {
+ final greeting = await action();
+ if (mounted) setState(() => _greeting = greeting);
+ } catch (error) {
+ _showError(error);
+ } finally {
+ if (mounted) setState(() => _running = null);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return _DemoCard(
+ title: 'Greeting',
+ description:
+ 'Invokes the greet function, which builds the message server-side. '
+ 'POST sends the name in a JSON body; GET sends it as a query '
+ 'parameter.',
+ children: [
+ TextField(
+ controller: _name,
+ decoration: const InputDecoration(labelText: 'Name'),
+ ),
+ CheckboxListTile(
+ value: _excited,
+ onChanged: (value) => setState(() => _excited = value ?? false),
+ title: const Text('Excited'),
+ controlAffinity: ListTileControlAffinity.leading,
+ contentPadding: EdgeInsets.zero,
+ ),
+ Row(
+ children: [
+ _RunButton(
+ label: 'Greet (POST)',
+ running: _running == 'post',
+ onPressed: () => _run(
+ 'post',
+ () => widget.repository.greet(
+ name: _name.text.trim(),
+ excited: _excited,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ _RunButton(
+ label: 'Greet (GET)',
+ filled: false,
+ running: _running == 'get',
+ onPressed: () => _run(
+ 'get',
+ () => widget.repository.greetViaQuery(_name.text.trim()),
+ ),
+ ),
+ ],
+ ),
+ if (_greeting case final greeting?)
+ _Result(
+ '${greeting.message}\n'
+ 'via ${greeting.method}, source "${greeting.source}"',
+ ),
+ ],
+ );
+ }
+}
+
+/// Invokes `shout`, which returns the text uppercased as plain text.
+class _ShoutCard extends StatefulWidget {
+ const _ShoutCard({required this.repository});
+
+ final FunctionsRepository repository;
+
+ @override
+ State<_ShoutCard> createState() => _ShoutCardState();
+}
+
+class _ShoutCardState extends State<_ShoutCard> {
+ final _text = TextEditingController(text: 'edge functions');
+ String? _result;
+ bool _running = false;
+
+ @override
+ void dispose() {
+ _text.dispose();
+ super.dispose();
+ }
+
+ Future _run() async {
+ if (_running) return;
+ setState(() => _running = true);
+ try {
+ final result = await widget.repository.shout(_text.text);
+ if (mounted) setState(() => _result = result);
+ } catch (error) {
+ _showError(error);
+ } finally {
+ if (mounted) setState(() => _running = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return _DemoCard(
+ title: 'Plain text',
+ description:
+ 'Sends text to the shout function, which returns it uppercased as '
+ 'text/plain. A plain-text response arrives as a Dart String.',
+ children: [
+ TextField(
+ controller: _text,
+ decoration: const InputDecoration(labelText: 'Text to shout'),
+ ),
+ _RunButton(label: 'Shout', running: _running, onPressed: _run),
+ if (_result case final result?) _Result(result),
+ ],
+ );
+ }
+}
+
+/// Invokes `word-count`, which validates its input and responds with a 400 when
+/// the text is empty, surfacing here as a [FunctionException].
+class _WordCountCard extends StatefulWidget {
+ const _WordCountCard({required this.repository});
+
+ final FunctionsRepository repository;
+
+ @override
+ State<_WordCountCard> createState() => _WordCountCardState();
+}
+
+class _WordCountCardState extends State<_WordCountCard> {
+ final _text = TextEditingController(text: 'Functions run close to the user');
+ WordCount? _result;
+ bool _running = false;
+
+ @override
+ void dispose() {
+ _text.dispose();
+ super.dispose();
+ }
+
+ Future _run() async {
+ if (_running) return;
+ setState(() => _running = true);
+ try {
+ // Send the text unchanged so the function counts the submitted
+ // characters; it trims internally to validate and split into words.
+ final result = await widget.repository.countWords(_text.text);
+ if (mounted) setState(() => _result = result);
+ } catch (error) {
+ _showError(error);
+ } finally {
+ if (mounted) setState(() => _running = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return _DemoCard(
+ title: 'Validation and errors',
+ description:
+ 'Sends text to the word-count function. Clearing the field makes it '
+ 'respond with a 400, which surfaces as a FunctionException shown '
+ 'below.',
+ children: [
+ TextField(
+ controller: _text,
+ decoration: const InputDecoration(labelText: 'Text to count'),
+ ),
+ _RunButton(label: 'Count words', running: _running, onPressed: _run),
+ if (_result case final result?)
+ _Result('${result.words} words, ${result.characters} characters'),
+ ],
+ );
+ }
+}
+
+class _DemoCard extends StatelessWidget {
+ const _DemoCard({
+ required this.title,
+ required this.description,
+ required this.children,
+ });
+
+ final String title;
+ final String description;
+ final List children;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(title, style: theme.textTheme.titleLarge),
+ const SizedBox(height: 4),
+ Text(description, style: theme.textTheme.bodySmall),
+ const SizedBox(height: 12),
+ ...children,
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _RunButton extends StatelessWidget {
+ const _RunButton({
+ required this.label,
+ required this.running,
+ required this.onPressed,
+ this.filled = true,
+ });
+
+ final String label;
+ final bool running;
+ final VoidCallback onPressed;
+ final bool filled;
+
+ @override
+ Widget build(BuildContext context) {
+ final child = running
+ ? const SizedBox(
+ height: 18,
+ width: 18,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : Text(label);
+ final onPressedOrNull = running ? null : onPressed;
+ return filled
+ ? FilledButton(onPressed: onPressedOrNull, child: child)
+ : OutlinedButton(onPressed: onPressedOrNull, child: child);
+ }
+}
+
+class _Result extends StatelessWidget {
+ const _Result(this.text);
+
+ final String text;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ return Container(
+ width: double.infinity,
+ margin: const EdgeInsets.only(top: 12),
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Text(text, style: theme.textTheme.bodyMedium),
+ );
+ }
+}
+
+void _showError(Object error) {
+ // A function that responds with a non-2xx status throws a FunctionException.
+ // Its `details` hold the response body, which is the `{ "error": ... }` JSON
+ // the word-count function returns when validation fails.
+ String message;
+ if (error is FunctionException) {
+ final details = error.details;
+ message = details is Map && details['error'] is String
+ ? details['error'] as String
+ : 'Function failed with status ${error.status}';
+ } else {
+ message = error.toString();
+ }
+ messengerKey.currentState?.showSnackBar(
+ SnackBar(content: Text(message), backgroundColor: Colors.red),
+ );
+}
diff --git a/examples/edge_functions/lib/models.dart b/examples/edge_functions/lib/models.dart
new file mode 100644
index 000000000..47a2ad6c0
--- /dev/null
+++ b/examples/edge_functions/lib/models.dart
@@ -0,0 +1,35 @@
+/// The JSON greeting returned by the `greet` Edge Function.
+class Greeting {
+ const Greeting({
+ required this.message,
+ required this.method,
+ required this.source,
+ });
+
+ factory Greeting.fromJson(Map json) => Greeting(
+ message: json['message'] as String,
+ // How the function was invoked (`GET` or `POST`), echoed back so the app can
+ // show that the same function was reached two different ways.
+ method: json['method'] as String,
+ // The `x-greeting-source` header the app sent, echoed back to show that
+ // custom headers reach the function.
+ source: json['source'] as String,
+ );
+
+ final String message;
+ final String method;
+ final String source;
+}
+
+/// The JSON result returned by the `word-count` Edge Function.
+class WordCount {
+ const WordCount({required this.words, required this.characters});
+
+ factory WordCount.fromJson(Map json) => WordCount(
+ words: json['words'] as int,
+ characters: json['characters'] as int,
+ );
+
+ final int words;
+ final int characters;
+}
diff --git a/examples/edge_functions/pubspec.yaml b/examples/edge_functions/pubspec.yaml
new file mode 100644
index 000000000..ec912103d
--- /dev/null
+++ b/examples/edge_functions/pubspec.yaml
@@ -0,0 +1,27 @@
+name: edge_functions_example
+description: Demonstrates invoking Edge Functions using supabase_flutter.
+
+publish_to: 'none'
+
+version: 1.0.0+1
+
+environment:
+ sdk: '>=3.9.0 <4.0.0'
+ flutter: '>=3.35.0'
+
+resolution: workspace
+
+dependencies:
+ flutter:
+ sdk: flutter
+ supabase_flutter: ^2.17.0
+
+dev_dependencies:
+ supabase_lints: ^0.1.1
+ flutter_test:
+ sdk: flutter
+ integration_test:
+ sdk: flutter
+
+flutter:
+ uses-material-design: true
diff --git a/examples/edge_functions/web/index.html b/examples/edge_functions/web/index.html
new file mode 100644
index 000000000..985f03bea
--- /dev/null
+++ b/examples/edge_functions/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ Supabase Edge Functions
+
+
+
+
+
diff --git a/examples/launcher/analysis_options.yaml b/examples/launcher/analysis_options.yaml
index 572dd239d..a8ab68344 100644
--- a/examples/launcher/analysis_options.yaml
+++ b/examples/launcher/analysis_options.yaml
@@ -1 +1 @@
-include: package:lints/recommended.yaml
+include: package:supabase_lints/analysis_options.yaml
diff --git a/examples/launcher/pubspec.yaml b/examples/launcher/pubspec.yaml
index a48b93e75..a0c91113b 100644
--- a/examples/launcher/pubspec.yaml
+++ b/examples/launcher/pubspec.yaml
@@ -11,7 +11,7 @@ environment:
resolution: workspace
dependencies:
- mason_logger: ^0.3.0
+ mason_logger: ^0.3.5
dev_dependencies:
- lints: ^4.0.0
+ supabase_lints: ^0.1.1
diff --git a/examples/passkeys/README.md b/examples/passkeys/README.md
index 0f42d7877..47cd26e76 100644
--- a/examples/passkeys/README.md
+++ b/examples/passkeys/README.md
@@ -77,3 +77,23 @@ Then:
> Passkeys are bound to the domain (relying party) they were created on, so a
> passkey registered on `localhost` only works on `localhost`.
+
+## Integration test
+
+[`integration_test/passkeys_test.dart`](integration_test/passkeys_test.dart) is
+an end-to-end test that drives the app widgets against the local stack: it
+creates an account, lands on the passkey management screen, signs out, tries a
+wrong password and signs back in.
+
+The WebAuthn ceremony itself (`registerPasskey` / `signInWithPasskey`) drives a
+platform authenticator prompt that cannot be automated headlessly, so it is
+exercised manually with the steps above rather than in this test.
+
+With the local stack running, pass the same defines the app uses and run it on a
+device (integration tests need one, so `-d macos`, an emulator or a real device):
+
+```bash
+flutter test integration_test/passkeys_test.dart -d macos \
+ --dart-define=SUPABASE_URL=http://localhost:54321 \
+ --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY
+```
diff --git a/examples/passkeys/analysis_options.yaml b/examples/passkeys/analysis_options.yaml
index e7db0910a..01ce0e823 100644
--- a/examples/passkeys/analysis_options.yaml
+++ b/examples/passkeys/analysis_options.yaml
@@ -1,4 +1,4 @@
-include: package:flutter_lints/flutter.yaml
+include: package:supabase_lints/analysis_options_flutter.yaml
analyzer:
errors:
diff --git a/examples/passkeys/integration_test/passkeys_test.dart b/examples/passkeys/integration_test/passkeys_test.dart
new file mode 100644
index 000000000..8a88ce295
--- /dev/null
+++ b/examples/passkeys/integration_test/passkeys_test.dart
@@ -0,0 +1,111 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:passkeys_example/main.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
+const supabasePublishableKey = String.fromEnvironment(
+ 'SUPABASE_PUBLISHABLE_KEY',
+);
+
+/// End-to-end test that drives the passkeys app widgets against the local
+/// Supabase stack.
+///
+/// It covers everything around the WebAuthn ceremony: creating an account,
+/// landing on the passkey management screen, signing out, a failed sign in and a
+/// successful password sign in. The ceremony itself (`registerPasskey` /
+/// `signInWithPasskey`) drives a platform authenticator prompt (Face ID, Windows
+/// Hello, a security key, ...) that can't be automated headlessly, so it is
+/// exercised manually per the README rather than here.
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ // A unique account each run so sign up never collides with an existing user.
+ final email =
+ 'passkeys-e2e-${DateTime.now().microsecondsSinceEpoch}@example.com';
+ const password = 'password123';
+
+ setUpAll(() async {
+ await Supabase.initialize(
+ url: supabaseUrl,
+ publishableKey: supabasePublishableKey,
+ );
+ });
+
+ tearDownAll(() async {
+ await Supabase.instance.client.auth.signOut();
+ await Supabase.instance.dispose();
+ });
+
+ testWidgets('signs up, manages the session and signs back in', (
+ tester,
+ ) async {
+ await tester.pumpWidget(const PasskeyExampleApp());
+ await tester.pumpAndSettle();
+
+ // Create an account. Email confirmations are disabled in the shared config,
+ // so this returns a session and lands on the passkey management screen.
+ await tester.enterText(find.widgetWithText(TextField, 'Email'), email);
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Password'),
+ password,
+ );
+ await tester.tap(find.widgetWithText(OutlinedButton, 'Create an account'));
+
+ // The signed-in screen lists passkeys; a fresh account has none.
+ await _pumpUntil(tester, find.text('No passkeys yet.'));
+ expect(find.text(email), findsOneWidget);
+ expect(
+ find.widgetWithText(FilledButton, 'Register a passkey'),
+ findsOneWidget,
+ );
+
+ // Sign out returns to the sign-in screen.
+ await tester.tap(find.widgetWithText(OutlinedButton, 'Sign out'));
+ await _pumpUntil(
+ tester,
+ find.widgetWithText(FilledButton, 'Sign in with password'),
+ );
+
+ // A wrong password surfaces an error instead of signing in.
+ await tester.enterText(find.widgetWithText(TextField, 'Email'), email);
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Password'),
+ 'wrong-password',
+ );
+ await tester.tap(
+ find.widgetWithText(FilledButton, 'Sign in with password'),
+ );
+ await _pumpUntil(tester, find.byType(SnackBar));
+ expect(find.text('No passkeys yet.'), findsNothing);
+
+ // The correct password signs in again.
+ await tester.enterText(find.widgetWithText(TextField, 'Email'), email);
+ await tester.enterText(
+ find.widgetWithText(TextField, 'Password'),
+ password,
+ );
+ await tester.tap(
+ find.widgetWithText(FilledButton, 'Sign in with password'),
+ );
+ await _pumpUntil(tester, find.text('No passkeys yet.'));
+ });
+}
+
+/// Pumps frames until [finder] matches at least one widget or [timeout] elapses.
+///
+/// Auth calls go over the network, so the UI can't be settled with
+/// `pumpAndSettle`; this polls the widget tree instead.
+Future _pumpUntil(
+ WidgetTester tester,
+ Finder finder, {
+ Duration timeout = const Duration(seconds: 20),
+}) async {
+ final deadline = DateTime.now().add(timeout);
+ while (DateTime.now().isBefore(deadline)) {
+ await tester.pump(const Duration(milliseconds: 100));
+ if (finder.evaluate().isNotEmpty) return;
+ }
+ fail('Timed out waiting for: $finder');
+}
diff --git a/examples/passkeys/lib/main.dart b/examples/passkeys/lib/main.dart
index 0b269e965..0f54ae405 100644
--- a/examples/passkeys/lib/main.dart
+++ b/examples/passkeys/lib/main.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:passkeys/authenticator.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@@ -172,7 +174,7 @@ class _SignedInViewState extends State {
@override
void initState() {
super.initState();
- _refresh();
+ unawaited(_refresh());
}
Future _refresh() async {
diff --git a/examples/passkeys/pubspec.yaml b/examples/passkeys/pubspec.yaml
index 7c572e541..157da2e05 100644
--- a/examples/passkeys/pubspec.yaml
+++ b/examples/passkeys/pubspec.yaml
@@ -15,12 +15,14 @@ dependencies:
flutter:
sdk: flutter
passkeys: ^2.21.1
- supabase_flutter: ^2.15.4
+ supabase_flutter: ^2.17.0
dev_dependencies:
- flutter_lints: ^4.0.0
+ supabase_lints: ^0.1.1
flutter_test:
sdk: flutter
+ integration_test:
+ sdk: flutter
flutter:
uses-material-design: true
diff --git a/examples/storage_transforms/.gitignore b/examples/storage_transforms/.gitignore
new file mode 100644
index 000000000..f85557248
--- /dev/null
+++ b/examples/storage_transforms/.gitignore
@@ -0,0 +1,6 @@
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+build/
+pubspec.lock
+pubspec_overrides.yaml
diff --git a/examples/storage_transforms/README.md b/examples/storage_transforms/README.md
new file mode 100644
index 000000000..18a67a7f4
--- /dev/null
+++ b/examples/storage_transforms/README.md
@@ -0,0 +1,64 @@
+# Storage: uploads and image transformations
+
+A small image gallery that shows how to store and serve files with
+`supabase.storage`:
+
+- **Upload** generated PNG bytes to a bucket (`uploadBinary`).
+- **List** the objects in the bucket, newest first (`list`).
+- **Transform** images on the fly by passing `TransformOptions` (width, height,
+ resize mode, quality) to `getPublicUrl`, so the gallery loads a small cropped
+ thumbnail and the detail view renders several resized variants.
+- **Download** the transformed bytes of an image (`download`).
+- **Delete** an object (`remove`).
+
+All Storage access is in
+[`lib/storage_repository.dart`](lib/storage_repository.dart), kept separate from
+the UI so the calls are easy to read and to drive from an integration test. The
+sample images are drawn in memory in
+[`lib/sample_image.dart`](lib/sample_image.dart), so the example uploads real
+image bytes without bundling asset files or depending on an image picker.
+
+To keep the focus on the Supabase calls, the screen uses plain `setState` and
+reloads the gallery after each write rather than pulling in a state management
+package. A larger app would typically reach for a state management solution (for
+example Riverpod, Bloc or Provider) instead.
+
+The `images` bucket and its access policies come from the shared Supabase config
+in [`../supabase`](../supabase): schema in
+`migrations/20240603000000_storage_transforms_example.sql`. Image
+transformations are enabled in `config.toml`
+(`[storage.image_transformation]`). The bucket starts empty; the app uploads its
+own images, so there are no seed rows.
+
+## Running
+
+From the `examples` directory, run the launcher and pick `storage_transforms`:
+
+```bash
+./run.sh
+```
+
+Or run it directly against any project:
+
+```bash
+flutter run \
+ --dart-define=SUPABASE_URL=https://YOUR_PROJECT.supabase.co \
+ --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
+```
+
+## Integration test
+
+[`integration_test/storage_test.dart`](integration_test/storage_test.dart) is an
+end-to-end test that runs against the local stack. It drives the flow through the
+repository (upload, list, a transformed download whose bytes it decodes to check
+the image was resized, then delete) and drives the app widgets to upload an image
+and delete it again from the detail view.
+
+With the local stack running, pass the same defines the app uses and run it on a
+device (integration tests need one, so `-d macos`, an emulator or a real device):
+
+```bash
+flutter test integration_test/storage_test.dart -d macos \
+ --dart-define=SUPABASE_URL=http://localhost:54321 \
+ --dart-define=SUPABASE_PUBLISHABLE_KEY=YOUR_LOCAL_PUBLISHABLE_KEY
+```
diff --git a/examples/storage_transforms/analysis_options.yaml b/examples/storage_transforms/analysis_options.yaml
new file mode 100644
index 000000000..10becad33
--- /dev/null
+++ b/examples/storage_transforms/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:supabase_lints/analysis_options_flutter.yaml
diff --git a/examples/storage_transforms/integration_test/storage_test.dart b/examples/storage_transforms/integration_test/storage_test.dart
new file mode 100644
index 000000000..d4098a440
--- /dev/null
+++ b/examples/storage_transforms/integration_test/storage_test.dart
@@ -0,0 +1,162 @@
+import 'dart:typed_data';
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:storage_transforms_example/main.dart';
+import 'package:storage_transforms_example/sample_image.dart';
+import 'package:storage_transforms_example/storage_repository.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
+const supabasePublishableKey = String.fromEnvironment(
+ 'SUPABASE_PUBLISHABLE_KEY',
+);
+
+/// End-to-end tests that drive the Storage example against the local stack.
+///
+/// The first test exercises the whole flow through the repository (upload, list,
+/// transformed download and delete), asserting on the returned bytes. The second
+/// drives the app widgets to confirm the gallery, detail view and delete button
+/// are wired to those calls.
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ late StorageRepository repository;
+
+ setUpAll(() async {
+ await Supabase.initialize(
+ url: supabaseUrl,
+ publishableKey: supabasePublishableKey,
+ );
+ repository = StorageRepository(Supabase.instance.client);
+ // Start from an empty bucket so the gallery is deterministic.
+ await _clearBucket(repository);
+ });
+
+ tearDownAll(() async {
+ await Supabase.instance.dispose();
+ });
+
+ tearDown(() async {
+ // Remove anything a test uploaded so it can run repeatedly.
+ await _clearBucket(repository);
+ });
+
+ testWidgets('uploads, transforms, downloads and deletes through the '
+ 'repository', (tester) async {
+ final name = 'e2e-${DateTime.now().microsecondsSinceEpoch}.png';
+ final bytes = await generateSampleImagePng(color: Colors.teal, size: 640);
+
+ // Upload returns the stored path, and the object then shows up in the list.
+ final path = await repository.uploadImage(name: name, bytes: bytes);
+ expect(path, contains(name));
+ final images = await repository.listImages();
+ expect(images.map((image) => image.path), contains(name));
+
+ // A public URL with a transform points at the render endpoint and carries
+ // the transform query parameters.
+ final url = repository.imageUrl(
+ name,
+ transform: const TransformOptions(width: 100, height: 100),
+ );
+ expect(url, contains('/render/image/'));
+ expect(url, contains('width=100'));
+
+ // The original downloads at its full size.
+ final original = await repository.downloadImage(name);
+ expect(await _decodeSize(original), const Size(640, 640));
+
+ // Downloading with a transform resizes the image server-side.
+ final resized = await repository.downloadImage(
+ name,
+ transform: const TransformOptions(
+ width: 100,
+ height: 100,
+ resize: ResizeMode.cover,
+ ),
+ );
+ expect(await _decodeSize(resized), const Size(100, 100));
+
+ // Delete removes it from the bucket.
+ await repository.deleteImage(name);
+ final remaining = await repository.listImages();
+ expect(remaining.map((image) => image.path), isNot(contains(name)));
+ });
+
+ testWidgets('uploads and deletes an image through the UI', (tester) async {
+ await tester.pumpWidget(const StorageExampleApp());
+
+ // A freshly cleared bucket shows the empty state.
+ await _pumpUntil(tester, find.text('No images yet. Upload one to start.'));
+
+ // Uploading through the FAB adds a tile to the gallery.
+ await tester.tap(find.widgetWithText(FloatingActionButton, 'Upload'));
+ await _pumpUntilGone(
+ tester,
+ find.text('No images yet. Upload one to start.'),
+ );
+ expect(find.byType(Card), findsWidgets);
+
+ // Opening the tile shows the transformation variants by name.
+ await tester.tap(find.byType(InkWell).first);
+ await tester.pumpAndSettle();
+ expect(find.text('Original'), findsOneWidget);
+ expect(find.text('120×120 cover'), findsOneWidget);
+
+ // Deleting from the detail view returns to an empty gallery.
+ await tester.tap(find.byIcon(Icons.delete));
+ await _pumpUntil(tester, find.text('No images yet. Upload one to start.'));
+ });
+}
+
+/// Removes every object currently in the bucket.
+Future _clearBucket(StorageRepository repository) async {
+ final images = await repository.listImages();
+ for (final image in images) {
+ await repository.deleteImage(image.path);
+ }
+}
+
+/// Decodes [bytes] far enough to read the image's pixel dimensions.
+Future _decodeSize(Uint8List bytes) async {
+ final codec = await ui.instantiateImageCodec(bytes);
+ final frame = await codec.getNextFrame();
+ final image = frame.image;
+ final size = Size(image.width.toDouble(), image.height.toDouble());
+ image.dispose();
+ codec.dispose();
+ return size;
+}
+
+/// Pumps frames until [finder] matches at least one widget or [timeout] elapses.
+///
+/// Storage calls go over the network, so the UI can't be settled with
+/// `pumpAndSettle`; this polls the widget tree instead.
+Future _pumpUntil(
+ WidgetTester tester,
+ Finder finder, {
+ Duration timeout = const Duration(seconds: 20),
+}) async {
+ final deadline = DateTime.now().add(timeout);
+ while (DateTime.now().isBefore(deadline)) {
+ await tester.pump(const Duration(milliseconds: 100));
+ if (finder.evaluate().isNotEmpty) return;
+ }
+ fail('Timed out waiting for: $finder');
+}
+
+/// The inverse of [_pumpUntil]: pumps until [finder] matches nothing.
+Future _pumpUntilGone(
+ WidgetTester tester,
+ Finder finder, {
+ Duration timeout = const Duration(seconds: 20),
+}) async {
+ final deadline = DateTime.now().add(timeout);
+ while (DateTime.now().isBefore(deadline)) {
+ await tester.pump(const Duration(milliseconds: 100));
+ if (finder.evaluate().isEmpty) return;
+ }
+ fail('Timed out waiting for it to disappear: $finder');
+}
diff --git a/examples/storage_transforms/lib/main.dart b/examples/storage_transforms/lib/main.dart
new file mode 100644
index 000000000..452d8a596
--- /dev/null
+++ b/examples/storage_transforms/lib/main.dart
@@ -0,0 +1,262 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'models.dart';
+import 'sample_image.dart';
+import 'storage_repository.dart';
+
+const supabaseUrl = String.fromEnvironment('SUPABASE_URL');
+const supabasePublishableKey = String.fromEnvironment(
+ 'SUPABASE_PUBLISHABLE_KEY',
+);
+
+final messengerKey = GlobalKey();
+
+Future main() async {
+ await Supabase.initialize(
+ url: supabaseUrl,
+ publishableKey: supabasePublishableKey,
+ );
+ runApp(const StorageExampleApp());
+}
+
+SupabaseClient get supabase => Supabase.instance.client;
+
+class StorageExampleApp extends StatelessWidget {
+ const StorageExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Supabase Storage transformations',
+ scaffoldMessengerKey: messengerKey,
+ theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
+ home: const GalleryPage(),
+ );
+ }
+}
+
+/// Uploads generated images to a public bucket and shows them in a gallery.
+/// Tapping an image opens the transformations view.
+class GalleryPage extends StatefulWidget {
+ const GalleryPage({super.key});
+
+ @override
+ State createState() => _GalleryPageState();
+}
+
+class _GalleryPageState extends State {
+ final _repository = StorageRepository(supabase);
+
+ /// Seed colors cycled through for each uploaded image, so successive uploads
+ /// look different.
+ static const _palette = [
+ Colors.indigo,
+ Colors.teal,
+ Colors.deepOrange,
+ Colors.pink,
+ Colors.green,
+ ];
+
+ List _images = [];
+ bool _loading = true;
+ bool _mutating = false;
+
+ @override
+ void initState() {
+ super.initState();
+ unawaited(_load());
+ }
+
+ /// Reloads the gallery. Leaves the previous list on screen while it runs so a
+ /// refresh doesn't flash a spinner over the whole grid.
+ Future _load() async {
+ try {
+ final images = await _repository.listImages();
+ if (mounted) setState(() => _images = images);
+ } catch (error) {
+ _showError(error);
+ } finally {
+ if (mounted) setState(() => _loading = false);
+ }
+ }
+
+ /// Generates a fresh PNG and uploads it, then reloads the gallery. Ignores the
+ /// call while another upload is in flight so a double tap can't fire twice.
+ Future _upload() async {
+ if (_mutating) return;
+ setState(() => _mutating = true);
+ try {
+ final color = _palette[_images.length % _palette.length];
+ final bytes = await generateSampleImagePng(color: color);
+ final name = 'sample-${DateTime.now().microsecondsSinceEpoch}.png';
+ await _repository.uploadImage(name: name, bytes: bytes);
+ await _load();
+ } catch (error) {
+ _showError(error);
+ } finally {
+ if (mounted) setState(() => _mutating = false);
+ }
+ }
+
+ Future _openDetail(StoredImage image) async {
+ final deleted = await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ImageDetailPage(repository: _repository, image: image),
+ ),
+ );
+ if (deleted ?? false) await _load();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Images')),
+ floatingActionButton: FloatingActionButton.extended(
+ onPressed: _mutating ? null : _upload,
+ icon: const Icon(Icons.add_photo_alternate),
+ label: const Text('Upload'),
+ ),
+ body: _loading
+ ? const Center(child: CircularProgressIndicator())
+ : _images.isEmpty
+ ? const Center(child: Text('No images yet. Upload one to start.'))
+ : RefreshIndicator(
+ onRefresh: _load,
+ child: GridView.builder(
+ padding: const EdgeInsets.all(12),
+ gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 180,
+ mainAxisSpacing: 12,
+ crossAxisSpacing: 12,
+ ),
+ itemCount: _images.length,
+ itemBuilder: (context, index) {
+ final image = _images[index];
+ return _GalleryTile(
+ // A downscaled, cropped thumbnail keeps the grid light: the
+ // resize happens server-side, so the app never downloads the
+ // full-size image here.
+ url: _repository.imageUrl(
+ image.path,
+ transform: TransformPreset.thumbnail.options,
+ ),
+ onTap: () => _openDetail(image),
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
+
+class _GalleryTile extends StatelessWidget {
+ const _GalleryTile({required this.url, required this.onTap});
+
+ final String url;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: onTap,
+ child: Image.network(
+ url,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error, stackTrace) =>
+ const Center(child: Icon(Icons.broken_image_outlined)),
+ ),
+ ),
+ );
+ }
+}
+
+/// Shows a single stored image rendered through every [TransformPreset], each
+/// one a public URL built with different [TransformOptions].
+class ImageDetailPage extends StatelessWidget {
+ const ImageDetailPage({
+ required this.repository,
+ required this.image,
+ super.key,
+ });
+
+ final StorageRepository repository;
+ final StoredImage image;
+
+ Future _delete(BuildContext context) async {
+ try {
+ await repository.deleteImage(image.path);
+ if (context.mounted) Navigator.of(context).pop(true);
+ } catch (error) {
+ _showError(error);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(image.path),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.delete),
+ tooltip: 'Delete',
+ onPressed: () => _delete(context),
+ ),
+ ],
+ ),
+ body: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ for (final preset in TransformPreset.values)
+ _TransformCard(
+ label: preset.label,
+ url: repository.imageUrl(image.path, transform: preset.options),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _TransformCard extends StatelessWidget {
+ const _TransformCard({required this.label, required this.url});
+
+ final String label;
+ final String url;
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ margin: const EdgeInsets.only(bottom: 16),
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(label, style: Theme.of(context).textTheme.titleMedium),
+ const SizedBox(height: 8),
+ Image.network(
+ url,
+ errorBuilder: (context, error, stackTrace) => const Padding(
+ padding: EdgeInsets.all(24),
+ child: Icon(Icons.broken_image_outlined),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+void _showError(Object error) {
+ final message = error is StorageException ? error.message : error.toString();
+ messengerKey.currentState?.showSnackBar(
+ SnackBar(content: Text(message), backgroundColor: Colors.red),
+ );
+}
diff --git a/examples/storage_transforms/lib/models.dart b/examples/storage_transforms/lib/models.dart
new file mode 100644
index 000000000..56f6dc453
--- /dev/null
+++ b/examples/storage_transforms/lib/models.dart
@@ -0,0 +1,44 @@
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+/// A single image stored in the bucket, built from the Storage [FileObject]
+/// returned by `list()`.
+class StoredImage {
+ const StoredImage({required this.path, this.createdAt});
+
+ factory StoredImage.fromFileObject(FileObject file) => StoredImage(
+ path: file.name,
+ createdAt: file.createdAt == null
+ ? null
+ : DateTime.tryParse(file.createdAt!),
+ );
+
+ /// Path within the bucket, which is also the object's file name here.
+ final String path;
+ final DateTime? createdAt;
+}
+
+/// A named image transformation shown in the detail view.
+///
+/// Each preset maps to the Storage [TransformOptions] passed to `getPublicUrl`
+/// and `download`. The [original] preset carries no options, so it requests the
+/// untransformed image.
+enum TransformPreset {
+ original('Original', null),
+ thumbnail(
+ '120×120 cover',
+ TransformOptions(width: 120, height: 120, resize: ResizeMode.cover),
+ ),
+ fit(
+ '300×160 contain',
+ TransformOptions(width: 300, height: 160, resize: ResizeMode.contain),
+ ),
+ lowQuality(
+ '320 wide · quality 20',
+ TransformOptions(width: 320, quality: 20),
+ );
+
+ const TransformPreset(this.label, this.options);
+
+ final String label;
+ final TransformOptions? options;
+}
diff --git a/examples/storage_transforms/lib/sample_image.dart b/examples/storage_transforms/lib/sample_image.dart
new file mode 100644
index 000000000..9078333ea
--- /dev/null
+++ b/examples/storage_transforms/lib/sample_image.dart
@@ -0,0 +1,41 @@
+import 'dart:typed_data';
+import 'dart:ui' as ui;
+
+import 'package:flutter/material.dart';
+
+/// Draws a colorful [size]×[size] PNG entirely in memory.
+///
+/// This lets the example upload real image bytes without bundling asset files
+/// or depending on an image picker. The returned bytes are what get sent to
+/// Storage, and the image is large enough that the transformations in the
+/// detail view visibly resize it.
+Future generateSampleImagePng({
+ required Color color,
+ int size = 640,
+}) async {
+ final recorder = ui.PictureRecorder();
+ final canvas = Canvas(recorder);
+ final bounds = Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble());
+
+ // A diagonal gradient background derived from the seed color.
+ final background = Paint()
+ ..shader = ui.Gradient.linear(bounds.topLeft, bounds.bottomRight, [
+ color,
+ Color.lerp(color, Colors.black, 0.6)!,
+ ]);
+ canvas.drawRect(bounds, background);
+
+ // A few translucent circles for some visual texture.
+ final circle = Paint()..color = Colors.white.withValues(alpha: 0.15);
+ for (var i = 0; i < 6; i++) {
+ final t = (i + 1) / 7;
+ canvas.drawCircle(Offset(size * t, size * (1 - t)), size * 0.18, circle);
+ }
+
+ final picture = recorder.endRecording();
+ final image = await picture.toImage(size, size);
+ final data = await image.toByteData(format: ui.ImageByteFormat.png);
+ image.dispose();
+ picture.dispose();
+ return data!.buffer.asUint8List();
+}
diff --git a/examples/storage_transforms/lib/storage_repository.dart b/examples/storage_transforms/lib/storage_repository.dart
new file mode 100644
index 000000000..1dacfd9ff
--- /dev/null
+++ b/examples/storage_transforms/lib/storage_repository.dart
@@ -0,0 +1,68 @@
+import 'dart:typed_data';
+
+import 'package:supabase_flutter/supabase_flutter.dart';
+
+import 'models.dart';
+
+/// All Storage access for the example lives here, so the UI stays thin and every
+/// `supabase.storage` call is easy to read and to exercise from an integration
+/// test.
+class StorageRepository {
+ StorageRepository(this._client);
+
+ final SupabaseClient _client;
+
+ /// Public bucket created by the example migration. Being public lets the
+ /// gallery load images straight from their public URL without a signed URL.
+ static const bucket = 'images';
+
+ StorageFileApi get _files => _client.storage.from(bucket);
+
+ /// Uploads [bytes] under [name] and returns the stored path.
+ ///
+ /// `upsert: true` overwrites an object that already has the same name instead
+ /// of failing, and `contentType` tells Storage the bytes are a PNG.
+ Future uploadImage({
+ required String name,
+ required Uint8List bytes,
+ }) {
+ return _files.uploadBinary(
+ name,
+ bytes,
+ fileOptions: const FileOptions(contentType: 'image/png', upsert: true),
+ );
+ }
+
+ /// Lists the images in the bucket, newest first.
+ ///
+ /// `list()` also returns folder placeholders, which have no `id`, so those are
+ /// filtered out before mapping to [StoredImage].
+ Future> listImages() async {
+ final files = await _files.list(
+ searchOptions: const SearchOptions(
+ sortBy: SortBy(column: 'created_at', order: 'desc'),
+ ),
+ );
+ return files
+ .where((file) => file.id != null)
+ .map(StoredImage.fromFileObject)
+ .toList();
+ }
+
+ /// Builds a public URL for [path], optionally applying an image
+ /// [transform] server-side. `Image.network` can render the result directly.
+ String imageUrl(String path, {TransformOptions? transform}) {
+ return _files.getPublicUrl(path, transform: transform);
+ }
+
+ /// Downloads the bytes for [path], optionally applying an image [transform]
+ /// server-side before the bytes are returned.
+ Future downloadImage(String path, {TransformOptions? transform}) {
+ return _files.download(path, transform: transform);
+ }
+
+ /// Deletes [path] from the bucket.
+ Future deleteImage(String path) async {
+ await _files.remove([path]);
+ }
+}
diff --git a/examples/storage_transforms/pubspec.yaml b/examples/storage_transforms/pubspec.yaml
new file mode 100644
index 000000000..08dcb4365
--- /dev/null
+++ b/examples/storage_transforms/pubspec.yaml
@@ -0,0 +1,27 @@
+name: storage_transforms_example
+description: Demonstrates Storage uploads, downloads and image transformations using supabase_flutter.
+
+publish_to: 'none'
+
+version: 1.0.0+1
+
+environment:
+ sdk: '>=3.9.0 <4.0.0'
+ flutter: '>=3.35.0'
+
+resolution: workspace
+
+dependencies:
+ flutter:
+ sdk: flutter
+ supabase_flutter: ^2.17.0
+
+dev_dependencies:
+ supabase_lints: ^0.1.1
+ flutter_test:
+ sdk: flutter
+ integration_test:
+ sdk: flutter
+
+flutter:
+ uses-material-design: true
diff --git a/examples/storage_transforms/web/index.html b/examples/storage_transforms/web/index.html
new file mode 100644
index 000000000..1fc85b232
--- /dev/null
+++ b/examples/storage_transforms/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ Supabase Storage transformations
+
+
+
+
+
diff --git a/examples/supabase/config.toml b/examples/supabase/config.toml
index 6133b0198..a4e132d95 100644
--- a/examples/supabase/config.toml
+++ b/examples/supabase/config.toml
@@ -26,6 +26,11 @@ rp_display_name = "Supabase Flutter examples"
rp_id = "localhost"
rp_origins = ["http://localhost:3000"]
+# Image transformations (resize, re-encode) are used by the storage_transforms
+# example to render images on the fly.
+[storage.image_transformation]
+enabled = true
+
# Keep the stack lean: turn off services the examples don't use. The data plane
# (database, API/PostgREST, auth, realtime, storage) stays on. Enable any of
# these if a new example needs it.
@@ -36,9 +41,22 @@ enabled = false
[inbucket]
enabled = false
-# Edge Functions runtime.
+# Edge Functions runtime, used by the edge_functions example. `supabase start`
+# serves every function under `supabase/functions/` while the stack is running.
[edge_runtime]
-enabled = false
+enabled = true
+
+# The edge_functions example runs unauthenticated with the publishable (anon)
+# key, so JWT verification is turned off for its demo functions. A real app would
+# leave verify_jwt on and read the caller's user from the request.
+[functions.greet]
+verify_jwt = false
+
+[functions.shout]
+verify_jwt = false
+
+[functions.word-count]
+verify_jwt = false
# Analytics (Logflare) and its log collector. Also avoids the Docker socket
# mount that the vector container needs, which some runtimes (e.g. Colima)
diff --git a/examples/supabase/functions/greet/index.ts b/examples/supabase/functions/greet/index.ts
new file mode 100644
index 000000000..214dafffa
--- /dev/null
+++ b/examples/supabase/functions/greet/index.ts
@@ -0,0 +1,31 @@
+// Greeting Edge Function for the edge_functions example.
+//
+// Builds a greeting server-side and returns it as JSON. The name is read from
+// the JSON body on a POST or from the `name` query parameter on a GET, so the
+// example can show both ways of calling `functions.invoke`. The response echoes
+// back how it was called (method and a custom header) so the app can display it.
+
+Deno.serve(async (req) => {
+ const url = new URL(req.url);
+
+ let name = url.searchParams.get("name") ?? "";
+ let excited = url.searchParams.get("excited") === "true";
+ if (req.method === "POST") {
+ const body = await req.json().catch(() => ({}));
+ if (typeof body.name === "string") name = body.name;
+ if (typeof body.excited === "boolean") excited = body.excited;
+ }
+ name = name.trim() || "world";
+
+ const message = `Hello, ${name}${excited ? "!!!" : "."}`;
+ const payload = {
+ message,
+ method: req.method,
+ // Echoing a custom header shows that headers set on the client reach here.
+ source: req.headers.get("x-greeting-source") ?? "unknown",
+ };
+
+ return new Response(JSON.stringify(payload), {
+ headers: { "Content-Type": "application/json" },
+ });
+});
diff --git a/examples/supabase/functions/shout/index.ts b/examples/supabase/functions/shout/index.ts
new file mode 100644
index 000000000..6305ed9dd
--- /dev/null
+++ b/examples/supabase/functions/shout/index.ts
@@ -0,0 +1,12 @@
+// Text-transform Edge Function for the edge_functions example.
+//
+// Reads the raw request body as text and returns it uppercased with a
+// `text/plain` content type, so the example can show a function that responds
+// with plain text (surfaced as a Dart String) rather than JSON.
+
+Deno.serve(async (req) => {
+ const text = await req.text();
+ return new Response(text.toUpperCase(), {
+ headers: { "Content-Type": "text/plain" },
+ });
+});
diff --git a/examples/supabase/functions/word-count/index.ts b/examples/supabase/functions/word-count/index.ts
new file mode 100644
index 000000000..ec348c288
--- /dev/null
+++ b/examples/supabase/functions/word-count/index.ts
@@ -0,0 +1,30 @@
+// Validation Edge Function for the edge_functions example.
+//
+// Counts the words and characters in the posted text. When the text is missing
+// or empty it responds with a 400 and a JSON error body, so the example can show
+// how a non-2xx response surfaces in Dart as a FunctionException whose `details`
+// carry that body.
+
+Deno.serve(async (req) => {
+ const body = await req.json().catch(() => ({}));
+ const rawText = typeof body.text === "string" ? body.text : "";
+ const text = rawText.trim();
+
+ if (text.length === 0) {
+ return new Response(
+ JSON.stringify({ error: "Provide some text to count." }),
+ { status: 400, headers: { "Content-Type": "application/json" } },
+ );
+ }
+
+ const payload = {
+ words: text.split(/\s+/).length,
+ // Count from the raw text so surrounding spaces are kept, and with
+ // Array.from so an emoji counts as one character rather than two UTF-16
+ // units.
+ characters: Array.from(rawText).length,
+ };
+ return new Response(JSON.stringify(payload), {
+ headers: { "Content-Type": "application/json" },
+ });
+});
diff --git a/examples/supabase/migrations/20240601000000_crud_example.sql b/examples/supabase/migrations/20240601000000_crud_example.sql
new file mode 100644
index 000000000..aed88c228
--- /dev/null
+++ b/examples/supabase/migrations/20240601000000_crud_example.sql
@@ -0,0 +1,41 @@
+-- Schema for the "database CRUD with PostgREST" example.
+--
+-- Two related tables (projects 1:N tasks) so the example can show selects with
+-- filters and ordering, inserts, updates, deletes and a join from a task to its
+-- project.
+
+create table public.projects (
+ id uuid primary key default gen_random_uuid(),
+ name text not null,
+ created_at timestamptz not null default now()
+);
+
+create table public.tasks (
+ id uuid primary key default gen_random_uuid(),
+ project_id uuid not null references public.projects (id) on delete cascade,
+ title text not null,
+ is_complete boolean not null default false,
+ priority int not null default 1,
+ created_at timestamptz not null default now()
+);
+
+create index tasks_project_id_idx on public.tasks (project_id);
+
+-- The example runs unauthenticated with the publishable (anon) key. Enable row
+-- level security and add permissive policies so the demo can read and write
+-- without signing in. A real app would scope these policies to the signed in
+-- user instead.
+alter table public.projects enable row level security;
+alter table public.tasks enable row level security;
+
+create policy "Anyone can read projects"
+ on public.projects for select using (true);
+
+create policy "Anyone can manage tasks"
+ on public.tasks for all using (true) with check (true);
+
+-- Row level security decides which rows a role may touch, but the role still
+-- needs table privileges. Grant the publishable (anon) key read access to
+-- projects and full access to tasks so the demo works without signing in.
+grant select on public.projects to anon, authenticated;
+grant all on public.tasks to anon, authenticated;
diff --git a/examples/supabase/migrations/20240603000000_storage_transforms_example.sql b/examples/supabase/migrations/20240603000000_storage_transforms_example.sql
new file mode 100644
index 000000000..e1f98c8b4
--- /dev/null
+++ b/examples/supabase/migrations/20240603000000_storage_transforms_example.sql
@@ -0,0 +1,32 @@
+-- Schema for the "Storage: uploads and image transformations" example.
+--
+-- A single public bucket that the example uploads generated PNGs to and reads
+-- back from, applying image transformations on the way out.
+
+insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+values (
+ 'images',
+ 'images',
+ true,
+ 1048576,
+ array['image/png', 'image/jpeg', 'image/webp']
+)
+on conflict (id) do nothing;
+
+-- The example runs unauthenticated with the publishable (anon) key. A public
+-- bucket already serves reads without a policy; these policies additionally let
+-- the demo list, upload, overwrite and delete objects in the `images` bucket
+-- without signing in. A real app would scope these policies to the signed in
+-- user instead.
+create policy "Anyone can view images"
+ on storage.objects for select using (bucket_id = 'images');
+
+create policy "Anyone can upload images"
+ on storage.objects for insert with check (bucket_id = 'images');
+
+create policy "Anyone can update images"
+ on storage.objects for update
+ using (bucket_id = 'images') with check (bucket_id = 'images');
+
+create policy "Anyone can delete images"
+ on storage.objects for delete using (bucket_id = 'images');
diff --git a/examples/supabase/seed.sql b/examples/supabase/seed.sql
new file mode 100644
index 000000000..3df90668b
--- /dev/null
+++ b/examples/supabase/seed.sql
@@ -0,0 +1,15 @@
+-- Sample data for the database CRUD example. Runs when the local stack is first
+-- created (`supabase start`) or reset (`supabase db reset`).
+
+insert into public.projects (id, name) values
+ ('00000000-0000-0000-0000-000000000001', 'Website redesign'),
+ ('00000000-0000-0000-0000-000000000002', 'Mobile app'),
+ ('00000000-0000-0000-0000-000000000003', 'Personal');
+
+insert into public.tasks (project_id, title, is_complete, priority) values
+ ('00000000-0000-0000-0000-000000000001', 'Draft the landing page copy', false, 3),
+ ('00000000-0000-0000-0000-000000000001', 'Pick a color palette', true, 1),
+ ('00000000-0000-0000-0000-000000000002', 'Set up push notifications', false, 2),
+ ('00000000-0000-0000-0000-000000000002', 'Fix login on Android', false, 3),
+ ('00000000-0000-0000-0000-000000000003', 'Book flights', false, 2),
+ ('00000000-0000-0000-0000-000000000003', 'Water the plants', true, 1);
diff --git a/packages/functions_client/CHANGELOG.md b/packages/functions_client/CHANGELOG.md
index 3e75faaa7..2f9bbf090 100644
--- a/packages/functions_client/CHANGELOG.md
+++ b/packages/functions_client/CHANGELOG.md
@@ -1,3 +1,9 @@
+## 2.7.0
+
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **FEAT**(functions): add request cancellation to invoke via abortSignal ([#1593](https://github.com/supabase/supabase-flutter/issues/1593)). ([b994dabd](https://github.com/supabase/supabase-flutter/commit/b994dabdb23aa43540bef6953d739c48521bb532))
+ - **FEAT**(functions): expose FunctionsFetchException/FunctionsRelayException/FunctionsHttpException subtypes ([#1545](https://github.com/supabase/supabase-flutter/issues/1545)). ([2d3a7202](https://github.com/supabase/supabase-flutter/commit/2d3a720215837722e25c51507d6be8c6f8f3edb7))
+
## 2.6.4
- **FIX**(functions): don't lose error status on a non-JSON body labeled JSON ([#1507](https://github.com/supabase/supabase-flutter/issues/1507)). ([925b46e1](https://github.com/supabase/supabase-flutter/commit/925b46e118e62884dd65505e7a753e0f51958b4c))
diff --git a/packages/functions_client/README.md b/packages/functions_client/README.md
index c980a81d3..d2f938af2 100644
--- a/packages/functions_client/README.md
+++ b/packages/functions_client/README.md
@@ -1,4 +1,28 @@
-# `functions-dart`
+
+
+
+
+
+
+
functions_client
+
+
+ Dart client library to interact with Supabase Edge Functions.
+
+
+
+ Guides
+ ·
+ Reference Docs
+
+
+
+
+
+[](https://pub.dev/packages/functions_client)
+[](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest)
+
+
## Docs
diff --git a/packages/functions_client/example/functions_dart_example.dart b/packages/functions_client/example/functions_dart_example.dart
index ab73b3a23..de3920aeb 100644
--- a/packages/functions_client/example/functions_dart_example.dart
+++ b/packages/functions_client/example/functions_dart_example.dart
@@ -1 +1,26 @@
-void main() {}
+// ignore_for_file: avoid_print
+
+import 'package:functions_client/functions_client.dart';
+
+/// Example to use with Supabase Edge Functions https://supabase.com/
+Future main() async {
+ const supabaseUrl = '';
+ const supabaseKey = '';
+ final client = FunctionsClient(
+ '$supabaseUrl/functions/v1',
+ {
+ 'Authorization': 'Bearer $supabaseKey',
+ },
+ );
+
+ try {
+ final response = await client.invoke(
+ 'get_countries',
+ body: {'name': 'The Shire'},
+ );
+ print('status: ${response.status}');
+ print('data: ${response.data}');
+ } on FunctionException catch (error) {
+ print('Function error: ${error.status} ${error.details}');
+ }
+}
diff --git a/packages/functions_client/lib/functions_client.dart b/packages/functions_client/lib/functions_client.dart
index eb5aac5d3..a79601b06 100644
--- a/packages/functions_client/lib/functions_client.dart
+++ b/packages/functions_client/lib/functions_client.dart
@@ -1,7 +1,8 @@
/// A dart client library for Supabase Edge Functions.
library;
-export 'package:http/http.dart' show ByteStream, MultipartFile;
+export 'package:http/http.dart'
+ show ByteStream, MultipartFile, RequestAbortedException;
export 'src/functions_client.dart';
export 'src/types.dart';
diff --git a/packages/functions_client/lib/src/constants.dart b/packages/functions_client/lib/src/constants.dart
index a747a768d..3b32d1435 100644
--- a/packages/functions_client/lib/src/constants.dart
+++ b/packages/functions_client/lib/src/constants.dart
@@ -1,7 +1,8 @@
import 'package:functions_client/src/version.dart';
+import 'package:supabase_common/supabase_common.dart';
class Constants {
- static const defaultHeaders = {
- 'X-Client-Info': 'functions-dart/$version',
+ static final defaultHeaders = {
+ 'X-Client-Info': buildClientInfoHeader('functions-dart', version),
};
}
diff --git a/packages/functions_client/lib/src/functions_client.dart b/packages/functions_client/lib/src/functions_client.dart
index 9aa6d6b48..ebefa6cab 100644
--- a/packages/functions_client/lib/src/functions_client.dart
+++ b/packages/functions_client/lib/src/functions_client.dart
@@ -7,6 +7,7 @@ import 'package:functions_client/src/version.dart';
import 'package:http/http.dart' as http;
import 'package:http/http.dart' show MultipartRequest;
import 'package:logging/logging.dart';
+import 'package:supabase_common/supabase_common.dart';
import 'package:yet_another_json_isolate/yet_another_json_isolate.dart';
class FunctionsClient {
@@ -65,6 +66,32 @@ class FunctionsClient {
/// [region] optionally specify the region to invoke the function in.
/// When specified, adds both `x-region` header and `forceFunctionRegion` query parameter.
///
+ /// [abortSignal] cancels the in-flight request when the provided [Future]
+ /// completes. It must not complete with an error. On abort, a
+ /// [RequestAbortedException] is thrown. This is useful for cancelling a
+ /// request in response to an event or for setting a request timeout:
+ ///
+ /// ```dart
+ /// // Event based
+ /// final abortSignal = Completer();
+ /// abortSignal.complete(); // Call in some event handler to abort the request
+ ///
+ /// try {
+ /// final response = await supabase.functions.invoke(
+ /// 'hello-world',
+ /// abortSignal: abortSignal.future,
+ /// );
+ /// } on RequestAbortedException catch (error) {
+ /// print('Request was aborted: $error');
+ /// }
+ ///
+ /// // Timer based
+ /// final response = await supabase.functions.invoke(
+ /// 'hello-world',
+ /// abortSignal: Future.delayed(Duration(seconds: 5)),
+ /// );
+ /// ```
+ ///
/// ```dart
/// // Call a standard function
/// final response = await supabase.functions.invoke('hello-world');
@@ -97,6 +124,7 @@ class FunctionsClient {
Map? queryParameters,
HttpMethod method = HttpMethod.post,
String? region,
+ Future? abortSignal,
}) async {
final effectiveRegion = region ?? _region;
@@ -136,11 +164,20 @@ class FunctionsClient {
);
final fields = (body as Map?)?.cast();
- request = http.MultipartRequest(method.name.toUpperCase(), uri)
- ..fields.addAll(fields ?? {})
- ..files.addAll(files);
+ request =
+ http.AbortableMultipartRequest(
+ method.name.toUpperCase(),
+ uri,
+ abortTrigger: abortSignal,
+ )
+ ..fields.addAll(fields ?? {})
+ ..files.addAll(files);
} else {
- final bodyRequest = http.Request(method.name.toUpperCase(), uri);
+ final bodyRequest = http.AbortableRequest(
+ method.name.toUpperCase(),
+ uri,
+ abortTrigger: abortSignal,
+ );
if (body == null) {
// No body to set
@@ -161,7 +198,14 @@ class FunctionsClient {
_log.finest('Request: ${request.method} ${request.url} ${request.headers}');
- final response = await (_httpClient?.send(request) ?? request.send());
+ final http.StreamedResponse response;
+ try {
+ response = await (_httpClient?.send(request) ?? request.send());
+ } on http.RequestAbortedException {
+ rethrow;
+ } catch (error) {
+ throw FunctionsFetchException(details: error);
+ }
final responseType =
(response.headers['Content-Type'] ??
response.headers['content-type'] ??
@@ -170,8 +214,9 @@ class FunctionsClient {
.trim()
.toLowerCase();
+ final isRelayError = response.headers['x-relay-error'] == 'true';
final isSuccessStatus =
- 200 <= response.statusCode && response.statusCode < 300;
+ isSuccessStatusCode(response.statusCode) && !isRelayError;
final dynamic data;
@@ -210,7 +255,14 @@ class FunctionsClient {
if (isSuccessStatus) {
return FunctionResponse(data: data, status: response.statusCode);
}
- throw FunctionException(
+ if (isRelayError) {
+ throw FunctionsRelayException(
+ status: response.statusCode,
+ details: data,
+ reasonPhrase: response.reasonPhrase,
+ );
+ }
+ throw FunctionsHttpException(
status: response.statusCode,
details: data,
reasonPhrase: response.reasonPhrase,
diff --git a/packages/functions_client/lib/src/types.dart b/packages/functions_client/lib/src/types.dart
index 0e20f8894..01d57912a 100644
--- a/packages/functions_client/lib/src/types.dart
+++ b/packages/functions_client/lib/src/types.dart
@@ -39,5 +39,36 @@ class FunctionException implements Exception {
@override
String toString() =>
- 'FunctionException(status: $status, details: $details, reasonPhrase: $reasonPhrase)';
+ '$runtimeType(status: $status, details: $details, reasonPhrase: $reasonPhrase)';
+}
+
+/// Thrown when the request to the Edge Function could not be sent, for example
+/// because of a network or transport failure, before any response was received.
+///
+/// The originating error is available in [details] and [status] is `0` since no
+/// response reached the client.
+class FunctionsFetchException extends FunctionException {
+ const FunctionsFetchException({
+ super.details,
+ super.reasonPhrase,
+ }) : super(status: 0);
+}
+
+/// Thrown when the Supabase relay returns an error while invoking the Edge
+/// Function, indicated by the `x-relay-error` response header.
+class FunctionsRelayException extends FunctionException {
+ const FunctionsRelayException({
+ required super.status,
+ super.details,
+ super.reasonPhrase,
+ });
+}
+
+/// Thrown when the Edge Function itself responds with a non-2xx status code.
+class FunctionsHttpException extends FunctionException {
+ const FunctionsHttpException({
+ required super.status,
+ super.details,
+ super.reasonPhrase,
+ });
}
diff --git a/packages/functions_client/lib/src/version.dart b/packages/functions_client/lib/src/version.dart
index 3b8461390..71c30ec03 100644
--- a/packages/functions_client/lib/src/version.dart
+++ b/packages/functions_client/lib/src/version.dart
@@ -1 +1 @@
-const version = '2.6.4';
+const version = '2.7.0';
diff --git a/packages/functions_client/pubspec.yaml b/packages/functions_client/pubspec.yaml
index 214f1ffdb..38ffc6231 100644
--- a/packages/functions_client/pubspec.yaml
+++ b/packages/functions_client/pubspec.yaml
@@ -1,9 +1,16 @@
name: functions_client
description: A dart client library for the Supabase functions.
-version: 2.6.4
+version: 2.7.0
homepage: 'https://supabase.com'
repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/functions_client'
+issue_tracker: 'https://github.com/supabase/supabase-flutter/issues'
documentation: 'https://supabase.com/docs/reference/dart/functions-invoke'
+topics:
+ - supabase
+ - functions
+ - edge-functions
+ - backend
+ - api
environment:
sdk: '>=3.9.0 <4.0.0'
@@ -11,10 +18,11 @@ environment:
resolution: workspace
dependencies:
- http: ^1.2.2
- logging: ^1.2.0
+ http: ^1.6.0
+ logging: ^1.3.0
+ supabase_common: 0.1.1
yet_another_json_isolate: 2.1.1
dev_dependencies:
- supabase_lints: ^0.1.0
- test: ^1.16.4
+ supabase_lints: ^0.1.1
+ test: ^1.25.0
diff --git a/packages/functions_client/test/custom_http_client.dart b/packages/functions_client/test/custom_http_client.dart
index bc4df08a6..bb27434f0 100644
--- a/packages/functions_client/test/custom_http_client.dart
+++ b/packages/functions_client/test/custom_http_client.dart
@@ -11,11 +11,39 @@ class CustomHttpClient extends BaseClient {
@override
Future send(BaseRequest request) async {
- // Add request to receivedRequests list.
- receivedRequests = receivedRequests..add(request);
+ receivedRequests.add(request);
request.finalize();
- if (request.url.path.endsWith("error-function")) {
+ if (request.url.path.endsWith('slow')) {
+ // Hangs until the request is aborted, mirroring how a real client
+ // surfaces abortion. Without an abort trigger it responds immediately so
+ // non-abort tests don't stall.
+ final abortTrigger = request is Abortable ? request.abortTrigger : null;
+ if (abortTrigger != null) {
+ await abortTrigger;
+ throw RequestAbortedException(request.url);
+ }
+ return StreamedResponse(
+ Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))),
+ 200,
+ request: request,
+ headers: {"Content-Type": "application/json"},
+ );
+ } else if (request.url.path.endsWith('network-error')) {
+ // Simulate a transport failure before any response is received.
+ throw ClientException('Connection failed', request.url);
+ } else if (request.url.path.endsWith('relay-error')) {
+ return StreamedResponse(
+ Stream.value(utf8.encode(jsonEncode({"error": "relay down"}))),
+ 500,
+ request: request,
+ headers: {
+ "Content-Type": "application/json",
+ "x-relay-error": "true",
+ },
+ reasonPhrase: "Internal Server Error",
+ );
+ } else if (request.url.path.endsWith("error-function")) {
//Return custom status code to check for usage of this client.
return StreamedResponse(
Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))),
diff --git a/packages/functions_client/test/functions_dart_test.dart b/packages/functions_client/test/functions_dart_test.dart
index 93f7ed37a..5d73f5f93 100644
--- a/packages/functions_client/test/functions_dart_test.dart
+++ b/packages/functions_client/test/functions_dart_test.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
@@ -24,20 +25,69 @@ void main() {
});
test('function throws', () async {
await expectLater(
- () => functionsCustomHttpClient.invoke('error-function'),
+ functionsCustomHttpClient.invoke('error-function'),
throwsA(
- isA().having((e) => e.status, 'status', 420),
+ isA().having((e) => e.status, 'status', 420),
),
);
});
+ test('a non-2xx response throws a FunctionsHttpException', () async {
+ await expectLater(
+ functionsCustomHttpClient.invoke('error-function'),
+ throwsA(
+ isA()
+ .having((e) => e.status, 'status', 420)
+ .having(
+ (e) => e.reasonPhrase,
+ 'reasonPhrase',
+ 'Enhance Your Calm',
+ )
+ .having((e) => e.details, 'details', {'key': 'Hello World'}),
+ ),
+ );
+ });
+
+ test('a relay error throws a FunctionsRelayException', () async {
+ await expectLater(
+ functionsCustomHttpClient.invoke('relay-error'),
+ throwsA(
+ isA()
+ .having((e) => e.status, 'status', 500)
+ .having((e) => e.details, 'details', {'error': 'relay down'}),
+ ),
+ );
+ });
+
+ test('a transport failure throws a FunctionsFetchException', () async {
+ await expectLater(
+ functionsCustomHttpClient.invoke('network-error'),
+ throwsA(
+ isA()
+ .having((e) => e.status, 'status', 0)
+ .having((e) => e.details, 'details', isA()),
+ ),
+ );
+ });
+
+ test('the subtypes remain catchable as FunctionException', () async {
+ await expectLater(
+ functionsCustomHttpClient.invoke('relay-error'),
+ throwsA(isA()),
+ );
+ await expectLater(
+ functionsCustomHttpClient.invoke('network-error'),
+ throwsA(isA()),
+ );
+ });
+
test(
'error response with a streaming content type exposes the body',
() async {
// The error body must be drained and decoded into `details` rather than
// handed back as an unconsumed stream (which also leaks the connection).
await expectLater(
- () => functionsCustomHttpClient.invoke('error-sse'),
+ functionsCustomHttpClient.invoke('error-sse'),
throwsA(
isA()
.having((e) => e.status, 'status', 500)
@@ -51,7 +101,7 @@ void main() {
'error response labeled JSON with a non-JSON body reports the status',
() async {
await expectLater(
- () => functionsCustomHttpClient.invoke('invalid-json-error'),
+ functionsCustomHttpClient.invoke('invalid-json-error'),
throwsA(
isA()
.having((e) => e.status, 'status', 500)
@@ -72,7 +122,7 @@ void main() {
// doesn't parse is a real anomaly, so the FormatException must surface
// rather than silently degrading to a raw String.
await expectLater(
- () => functionsCustomHttpClient.invoke('success-invalid-json'),
+ functionsCustomHttpClient.invoke('success-invalid-json'),
throwsA(isA()),
);
},
@@ -81,24 +131,26 @@ void main() {
test(
'an upper-cased application/JSON content type is parsed as JSON',
() async {
- final res = await functionsCustomHttpClient.invoke('uppercase-json');
- expect(res.data, {'key': 'Hello World'});
- expect(res.status, 200);
+ final response = await functionsCustomHttpClient.invoke(
+ 'uppercase-json',
+ );
+ expect(response.data, {'key': 'Hello World'});
+ expect(response.status, 200);
},
);
test('function call', () async {
- final res = await functionsCustomHttpClient.invoke('function');
+ final response = await functionsCustomHttpClient.invoke('function');
expect(
customHttpClient.receivedRequests.last.headers["Content-Type"],
null,
);
- expect(res.data, {'key': 'Hello World'});
- expect(res.status, 200);
+ expect(response.data, {'key': 'Hello World'});
+ expect(response.status, 200);
});
test('function call with query parameters', () async {
- final res = await functionsCustomHttpClient.invoke(
+ final response = await functionsCustomHttpClient.invoke(
'function',
queryParameters: {'key': 'value'},
);
@@ -106,14 +158,14 @@ void main() {
final request = customHttpClient.receivedRequests.last;
expect(request.url.queryParameters, {'key': 'value'});
- expect(res.data, {'key': 'Hello World'});
- expect(res.status, 200);
+ expect(response.data, {'key': 'Hello World'});
+ expect(response.status, 200);
});
test('function call with files', () async {
final fileName = "file.txt";
final fileContent = "Hello World";
- final res = await functionsCustomHttpClient.invoke(
+ final response = await functionsCustomHttpClient.invoke(
'function',
queryParameters: {'key': 'value'},
files: [
@@ -125,10 +177,10 @@ void main() {
expect(request.url.queryParameters, {'key': 'value'});
expect(request.headers['Content-Type'], contains('multipart/form-data'));
- expect(res.data, [
+ expect(response.data, [
{'name': fileName, 'content': fileContent},
]);
- expect(res.status, 200);
+ expect(response.status, 200);
});
test('dispose isolate', () async {
@@ -145,14 +197,14 @@ void main() {
);
await client.dispose();
- final res = await client.invoke('function');
- expect(res.data, {'key': 'Hello World'});
+ final response = await client.invoke('function');
+ expect(response.data, {'key': 'Hello World'});
});
test('Listen to SSE event', () async {
- final res = await functionsCustomHttpClient.invoke('sse');
+ final response = await functionsCustomHttpClient.invoke('sse');
expect(
- res.data.transform(const Utf8Decoder()),
+ response.data.transform(const Utf8Decoder()),
emitsInOrder(
['a', 'b', 'c'],
),
@@ -163,45 +215,45 @@ void main() {
test('integer properly encoded', () async {
await functionsCustomHttpClient.invoke('function', body: 42);
- final req = customHttpClient.receivedRequests.last;
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(request, isA());
- req as Request;
- expect(req.body, '42');
- expect(req.headers["Content-Type"], contains("application/json"));
+ request as Request;
+ expect(request.body, '42');
+ expect(request.headers["Content-Type"], contains("application/json"));
});
test('double is properly encoded', () async {
await functionsCustomHttpClient.invoke('function', body: 42.9);
- final req = customHttpClient.receivedRequests.last;
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(request, isA());
- req as Request;
- expect(req.body, '42.9');
- expect(req.headers["Content-Type"], contains("application/json"));
+ request as Request;
+ expect(request.body, '42.9');
+ expect(request.headers["Content-Type"], contains("application/json"));
});
test('string is properly encoded', () async {
await functionsCustomHttpClient.invoke('function', body: 'ExampleText');
- final req = customHttpClient.receivedRequests.last;
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(request, isA());
- req as Request;
- expect(req.body, 'ExampleText');
- expect(req.headers["Content-Type"], contains("text/plain"));
+ request as Request;
+ expect(request.body, 'ExampleText');
+ expect(request.headers["Content-Type"], contains("text/plain"));
});
test('list is properly encoded', () async {
await functionsCustomHttpClient.invoke('function', body: [1, 2, 3]);
- final req = customHttpClient.receivedRequests.last;
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(request, isA());
- req as Request;
- expect(req.body, '[1,2,3]');
- expect(req.headers["Content-Type"], contains("application/json"));
+ request as Request;
+ expect(request.body, '[1,2,3]');
+ expect(request.headers["Content-Type"], contains("application/json"));
});
test('map is properly encoded', () async {
@@ -210,35 +262,38 @@ void main() {
body: {'thekey': 'thevalue'},
);
- final req = customHttpClient.receivedRequests.last;
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(request, isA());
- req as Request;
- expect(req.body, '{"thekey":"thevalue"}');
- expect(req.headers["Content-Type"], contains("application/json"));
+ request as Request;
+ expect(request.body, '{"thekey":"thevalue"}');
+ expect(request.headers["Content-Type"], contains("application/json"));
});
test('Uint8List is properly encoded as binary data', () async {
final binaryData = Uint8List.fromList([1, 2, 3, 4, 5]);
await functionsCustomHttpClient.invoke('function', body: binaryData);
- final req = customHttpClient.receivedRequests.last;
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(request, isA());
- req as Request;
- expect(req.bodyBytes, equals(binaryData));
- expect(req.headers["Content-Type"], equals("application/octet-stream"));
+ request as Request;
+ expect(request.bodyBytes, equals(binaryData));
+ expect(
+ request.headers["Content-Type"],
+ equals("application/octet-stream"),
+ );
});
test('null body sends no content-type', () async {
await functionsCustomHttpClient.invoke('function');
- final req = customHttpClient.receivedRequests.last;
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(request, isA());
- req as Request;
- expect(req.body, '');
- expect(req.headers.containsKey("Content-Type"), isFalse);
+ request as Request;
+ expect(request.body, '');
+ expect(request.headers.containsKey("Content-Type"), isFalse);
});
});
@@ -249,8 +304,8 @@ void main() {
method: HttpMethod.get,
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.method, 'GET');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.method, 'GET');
});
test('PUT method', () async {
@@ -259,8 +314,8 @@ void main() {
method: HttpMethod.put,
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.method, 'PUT');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.method, 'PUT');
});
test('DELETE method', () async {
@@ -269,8 +324,8 @@ void main() {
method: HttpMethod.delete,
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.method, 'DELETE');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.method, 'DELETE');
});
test('PATCH method', () async {
@@ -279,8 +334,8 @@ void main() {
method: HttpMethod.patch,
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.method, 'PATCH');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.method, 'PATCH');
});
});
@@ -290,8 +345,8 @@ void main() {
await functionsCustomHttpClient.invoke('function');
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['Authorization'], 'Bearer new-token');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers['Authorization'], 'Bearer new-token');
});
test('headers getter returns current headers', () {
@@ -308,8 +363,8 @@ void main() {
headers: {'Content-Type': 'custom/type'},
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['Content-Type'], 'custom/type');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers['Content-Type'], 'custom/type');
});
test('custom lowercase content-type header overrides defaults', () async {
@@ -319,8 +374,8 @@ void main() {
headers: {'content-type': 'application/custom+json'},
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['content-type'], 'application/custom+json');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers['content-type'], 'application/custom+json');
});
test('custom headers merge with defaults', () async {
@@ -329,9 +384,9 @@ void main() {
headers: {'X-Custom': 'value'},
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['X-Custom'], 'value');
- expect(req.headers, contains('X-Client-Info'));
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers['X-Custom'], 'value');
+ expect(request.headers, contains('X-Client-Info'));
});
});
@@ -344,9 +399,12 @@ void main() {
region: 'us-west-1',
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['x-region'], 'us-west-1');
- expect(req.url.queryParameters['forceFunctionRegion'], 'us-west-1');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers['x-region'], 'us-west-1');
+ expect(
+ request.url.queryParameters['forceFunctionRegion'],
+ 'us-west-1',
+ );
},
);
@@ -356,10 +414,10 @@ void main() {
region: 'any',
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers.containsKey('x-region'), isFalse);
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers.containsKey('x-region'), isFalse);
expect(
- req.url.queryParameters.containsKey('forceFunctionRegion'),
+ request.url.queryParameters.containsKey('forceFunctionRegion'),
isFalse,
);
});
@@ -376,9 +434,12 @@ void main() {
await client.invoke('function');
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['x-region'], 'eu-west-1');
- expect(req.url.queryParameters['forceFunctionRegion'], 'eu-west-1');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers['x-region'], 'eu-west-1');
+ expect(
+ request.url.queryParameters['forceFunctionRegion'],
+ 'eu-west-1',
+ );
},
);
@@ -392,9 +453,9 @@ void main() {
await client.invoke('function', region: 'us-east-1');
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['x-region'], 'us-east-1');
- expect(req.url.queryParameters['forceFunctionRegion'], 'us-east-1');
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers['x-region'], 'us-east-1');
+ expect(request.url.queryParameters['forceFunctionRegion'], 'us-east-1');
});
test('region works with other query parameters', () async {
@@ -404,9 +465,9 @@ void main() {
queryParameters: {'key': 'value', 'foo': 'bar'},
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['x-region'], 'ap-south-1');
- expect(req.url.queryParameters, {
+ final request = customHttpClient.receivedRequests.last;
+ expect(request.headers['x-region'], 'ap-south-1');
+ expect(request.url.queryParameters, {
'key': 'value',
'foo': 'bar',
'forceFunctionRegion': 'ap-south-1',
@@ -447,9 +508,12 @@ void main() {
],
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['Content-Type'], contains('multipart/form-data'));
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(
+ request.headers['Content-Type'],
+ contains('multipart/form-data'),
+ );
+ expect(request, isA());
});
test('multipart with only files', () async {
@@ -458,41 +522,85 @@ void main() {
files: [MultipartFile.fromString('file', 'content')],
);
- final req = customHttpClient.receivedRequests.last;
- expect(req.headers['Content-Type'], contains('multipart/form-data'));
- expect(req, isA());
+ final request = customHttpClient.receivedRequests.last;
+ expect(
+ request.headers['Content-Type'],
+ contains('multipart/form-data'),
+ );
+ expect(request, isA());
+ });
+ });
+
+ group('Request cancellation', () {
+ test('aborts an in-flight request via abortSignal', () async {
+ final abortSignal = Completer();
+ Timer(const Duration(milliseconds: 50), abortSignal.complete);
+
+ await expectLater(
+ functionsCustomHttpClient.invoke(
+ 'slow',
+ abortSignal: abortSignal.future,
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test('aborts a multipart request via abortSignal', () async {
+ final abortSignal = Completer();
+ Timer(const Duration(milliseconds: 50), abortSignal.complete);
+
+ await expectLater(
+ functionsCustomHttpClient.invoke(
+ 'slow',
+ files: [MultipartFile.fromString('file', 'content')],
+ abortSignal: abortSignal.future,
+ ),
+ throwsA(isA()),
+ );
+ });
+
+ test('completes normally when the signal never fires', () async {
+ final abortSignal = Completer();
+
+ final response = await functionsCustomHttpClient.invoke(
+ 'function',
+ abortSignal: abortSignal.future,
+ );
+
+ expect(response.status, 200);
+ expect(response.data, {'key': 'Hello World'});
});
});
group('Response content types', () {
test('handles application/octet-stream response', () async {
- final res = await functionsCustomHttpClient.invoke('binary');
+ final response = await functionsCustomHttpClient.invoke('binary');
- expect(res.data, isA());
- expect(res.data, equals(Uint8List.fromList([1, 2, 3, 4, 5])));
- expect(res.status, 200);
+ expect(response.data, isA());
+ expect(response.data, equals(Uint8List.fromList([1, 2, 3, 4, 5])));
+ expect(response.status, 200);
});
test('handles text/plain response', () async {
- final res = await functionsCustomHttpClient.invoke('text');
+ final response = await functionsCustomHttpClient.invoke('text');
- expect(res.data, isA());
- expect(res.data, 'Hello World');
- expect(res.status, 200);
+ expect(response.data, isA());
+ expect(response.data, 'Hello World');
+ expect(response.status, 200);
});
test('handles empty JSON response', () async {
- final res = await functionsCustomHttpClient.invoke('empty-json');
+ final response = await functionsCustomHttpClient.invoke('empty-json');
- expect(res.data, '');
- expect(res.status, 200);
+ expect(response.data, '');
+ expect(response.status, 200);
});
});
group('Error handling', () {
test('FunctionException contains all error details', () async {
await expectLater(
- () => functionsCustomHttpClient.invoke('error-function'),
+ functionsCustomHttpClient.invoke('error-function'),
throwsA(
isA()
.having((e) => e.status, 'status', 420)
diff --git a/packages/gotrue/CHANGELOG.md b/packages/gotrue/CHANGELOG.md
index a19b3d351..afc5558c2 100644
--- a/packages/gotrue/CHANGELOG.md
+++ b/packages/gotrue/CHANGELOG.md
@@ -1,3 +1,29 @@
+## 2.27.0
+
+ - **REFACTOR**: modernize dart syntax across client packages ([#1574](https://github.com/supabase/supabase-flutter/issues/1574)). ([b74fdfee](https://github.com/supabase/supabase-flutter/commit/b74fdfee05c90d40dd3f0676ca86bd92e6013c65))
+ - **REFACTOR**: extract shared code into supabase_common package ([#1573](https://github.com/supabase/supabase-flutter/issues/1573)). ([46601bbb](https://github.com/supabase/supabase-flutter/commit/46601bbb80ca2f52929f8e0c2a6e5456d3e32360))
+ - **REFACTOR**: drop retry dependency in favor of an in-house helper ([#1571](https://github.com/supabase/supabase-flutter/issues/1571)). ([b81d2121](https://github.com/supabase/supabase-flutter/commit/b81d212159f15bf4d9515acf75430550a715664d))
+ - **REFACTOR**(gotrue): drop rxdart dependency ([#1566](https://github.com/supabase/supabase-flutter/issues/1566)). ([3f70d5ed](https://github.com/supabase/supabase-flutter/commit/3f70d5edcea9e000156742a29f2a0ae3fb1d112b))
+ - **REFACTOR**(gotrue): derive OAuth client enum values from the snakeCase extension ([#1562](https://github.com/supabase/supabase-flutter/issues/1562)). ([ed50706f](https://github.com/supabase/supabase-flutter/commit/ed50706f0b026a3c30ece0b64f5e9e925a2262ca))
+ - **REFACTOR**(gotrue): drop jwt_decode dependency in favor of dart_jsonwebtoken ([#1565](https://github.com/supabase/supabase-flutter/issues/1565)). ([b1157efa](https://github.com/supabase/supabase-flutter/commit/b1157efaed65b8127dae0afbd61043d39f9ea2b0))
+ - **REFACTOR**(auth): use OAuthProvider.name and cover custom OIDC providers ([#1555](https://github.com/supabase/supabase-flutter/issues/1555)). ([ebb7564d](https://github.com/supabase/supabase-flutter/commit/ebb7564d7112e208b8d38f0279f949b733afca31))
+ - **FEAT**(auth): send PKCE code_challenge in updateUser when changing email ([#1601](https://github.com/supabase/supabase-flutter/issues/1601)). ([59d349e4](https://github.com/supabase/supabase-flutter/commit/59d349e43f873389ed3aec6b87a3f4ae4e85c8df))
+ - **FEAT**(auth): support friendlyName and user.name fallback for WebAuthn passkey enrollment ([#1603](https://github.com/supabase/supabase-flutter/issues/1603)). ([558b0346](https://github.com/supabase/supabase-flutter/commit/558b03461441d340b1656606d1086382481611f8))
+ - **FEAT**(auth): add signInWithWeb3 for Web3 wallet authentication ([#1590](https://github.com/supabase/supabase-flutter/issues/1590)). ([20a82beb](https://github.com/supabase/supabase-flutter/commit/20a82beb2eed6bc2dca86f14aefe0d58e0796c4f))
+ - **FEAT**(postgrest): configurable auto-retry and request timeout ([#1560](https://github.com/supabase/supabase-flutter/issues/1560)). ([59d4b3af](https://github.com/supabase/supabase-flutter/commit/59d4b3af227e15a53208146a2930981526a39599))
+ - **FEAT**(auth): add OAuth server listGrants and revokeGrant methods ([#1561](https://github.com/supabase/supabase-flutter/issues/1561)). ([67fed239](https://github.com/supabase/supabase-flutter/commit/67fed239e52e0f02213bee7ae71cb043473efe28))
+ - **FEAT**(auth): add async getSession() with on-demand refresh ([#1563](https://github.com/supabase/supabase-flutter/issues/1563)). ([0b1d9b6b](https://github.com/supabase/supabase-flutter/commit/0b1d9b6b61adf9f66ed6716032b1a25f096604ce))
+ - **FEAT**(gotrue): add channel parameter to mfa.challenge() ([#1547](https://github.com/supabase/supabase-flutter/issues/1547)). ([3bde140e](https://github.com/supabase/supabase-flutter/commit/3bde140e214f6e2928108ec496eb0e96bb9ac258))
+ - **FEAT**(gotrue): add currentPassword to UserAttributes ([#1554](https://github.com/supabase/supabase-flutter/issues/1554)). ([b04c6419](https://github.com/supabase/supabase-flutter/commit/b04c6419785842c9d2af1337206b572adcb367c1))
+
+## 2.26.0
+
+> Note: This release has breaking changes.
+
+ - **FIX**(gotrue): OAuth server throws FormatException instead of ArgumentE… ([#1535](https://github.com/supabase/supabase-flutter/issues/1535)). ([da0f6e02](https://github.com/supabase/supabase-flutter/commit/da0f6e0236f52cd8dfb087257dbedd4a9de97533))
+ - **FEAT**(gotrue): add custom OAuth providers admin API with custom_claims_allowlist ([#1519](https://github.com/supabase/supabase-flutter/issues/1519)). ([122e5cf2](https://github.com/supabase/supabase-flutter/commit/122e5cf2036877c32ce92f807d5a9077d991a7e7))
+ - **BREAKING** **FIX**(gotrue): handle already-consented OAuth authorization responses ([#1536](https://github.com/supabase/supabase-flutter/issues/1536)). ([22d41234](https://github.com/supabase/supabase-flutter/commit/22d412346a748d282b39004771f175e37c504a5a))
+
## 2.25.0
- **FEAT**(gotrue): add shouldSoftDelete option to admin deleteUser ([#1513](https://github.com/supabase/supabase-flutter/issues/1513)). ([30e9f22f](https://github.com/supabase/supabase-flutter/commit/30e9f22f8572bb8a0d734978b011dc1f8950211c))
diff --git a/packages/gotrue/README.md b/packages/gotrue/README.md
index 095f5af11..477400b12 100644
--- a/packages/gotrue/README.md
+++ b/packages/gotrue/README.md
@@ -1,9 +1,28 @@
-# gotrue
+
+
+
+
+
-Dart client for the [GoTrue](https://github.com/supabase/gotrue) API.
+
gotrue
+
+
+ Dart client library for Supabase Auth.
+
+
+
+ Guides
+ ·
+ Reference Docs
+
+
+
+
[](https://pub.dev/packages/gotrue)
-[](https://github.com/supabase/gotrue-dart/actions?query=workflow%3ATest)
+[](https://github.com/supabase/supabase-flutter/actions?query=workflow%3ATest)
+
+
## Docs
diff --git a/packages/gotrue/example/main.dart b/packages/gotrue/example/main.dart
index 958b6270e..aef001b14 100644
--- a/packages/gotrue/example/main.dart
+++ b/packages/gotrue/example/main.dart
@@ -1,13 +1,16 @@
+// ignore_for_file: avoid_print
+
import 'package:gotrue/gotrue.dart';
-Future main(List arguments) async {
+/// Example to use with Supabase Auth https://supabase.com/
+Future main() async {
const gotrueUrl = 'http://localhost:9999';
- const annonToken = '';
+ const supabaseKey = '';
final client = GoTrueClient(
url: gotrueUrl,
headers: {
- 'Authorization': 'Bearer $annonToken',
- 'apikey': annonToken,
+ 'Authorization': 'Bearer $supabaseKey',
+ 'apikey': supabaseKey,
},
);
@@ -16,12 +19,11 @@ Future main(List arguments) async {
email: 'email',
password: '12345',
);
- print('Logged in, uid: ${login.session!.user.id}');
+ print('Logged in, user id: ${login.session!.user.id}');
} on AuthException catch (error) {
- print('Sign in Error: ${error.message}');
+ print('Sign in error: ${error.message}');
}
await client.signOut();
print('Logged out!');
- return true;
}
diff --git a/packages/gotrue/lib/gotrue.dart b/packages/gotrue/lib/gotrue.dart
index 37dda78b4..2c8c5ec0f 100644
--- a/packages/gotrue/lib/gotrue.dart
+++ b/packages/gotrue/lib/gotrue.dart
@@ -7,7 +7,7 @@ export 'src/gotrue_admin_api.dart';
export 'src/gotrue_client.dart';
export 'src/helper.dart' show decodeJwt, validateExp;
export 'src/types/auth_exception.dart';
-export 'src/types/auth_response.dart' hide ToSnakeCase;
+export 'src/types/auth_response.dart';
export 'src/types/auth_state.dart';
export 'src/types/custom_oauth_provider.dart';
export 'src/types/error_code.dart';
diff --git a/packages/gotrue/lib/src/constants.dart b/packages/gotrue/lib/src/constants.dart
index 5d265a97d..e3193577e 100644
--- a/packages/gotrue/lib/src/constants.dart
+++ b/packages/gotrue/lib/src/constants.dart
@@ -1,11 +1,11 @@
import 'package:gotrue/src/types/api_version.dart';
-import 'package:gotrue/src/types/auth_response.dart';
import 'package:gotrue/src/version.dart';
+import 'package:supabase_common/supabase_common.dart';
class Constants {
static const String defaultGotrueUrl = 'http://localhost:9999';
- static const Map defaultHeaders = {
- 'X-Client-Info': 'gotrue-dart/$version',
+ static final Map defaultHeaders = {
+ 'X-Client-Info': buildClientInfoHeader('gotrue-dart', version),
};
/// storage key prefix to store code verifiers
@@ -99,6 +99,12 @@ enum OtpChannel {
whatsapp,
}
+/// The blockchain used to sign in with a Web3 wallet.
+enum Web3Chain {
+ ethereum,
+ solana,
+}
+
/// Determines which sessions should be logged out.
enum SignOutScope {
/// All sessions by this account will be signed out.
diff --git a/packages/gotrue/lib/src/fetch.dart b/packages/gotrue/lib/src/fetch.dart
index b9db20446..d855f8865 100644
--- a/packages/gotrue/lib/src/fetch.dart
+++ b/packages/gotrue/lib/src/fetch.dart
@@ -7,6 +7,7 @@ import 'package:gotrue/src/types/auth_exception.dart';
import 'package:gotrue/src/types/error_code.dart';
import 'package:gotrue/src/types/fetch_options.dart';
import 'package:http/http.dart';
+import 'package:supabase_common/supabase_common.dart';
enum RequestMethodType { get, post, put, patch, delete }
@@ -15,10 +16,6 @@ class GotrueFetch {
const GotrueFetch([this.httpClient]);
- bool isSuccessStatusCode(int code) {
- return code >= 200 && code <= 299;
- }
-
String _getErrorMessage(dynamic error) {
if (error is Map) {
return error['msg'] ??
@@ -162,43 +159,32 @@ class GotrueFetch {
}
Response response;
try {
- switch (method) {
- case RequestMethodType.get:
- response = await (httpClient?.get ?? get)(
- uri,
- headers: headers,
- );
-
- break;
- case RequestMethodType.post:
- response = await (httpClient?.post ?? post)(
- uri,
- headers: headers,
- body: bodyStr,
- );
- break;
- case RequestMethodType.put:
- response = await (httpClient?.put ?? put)(
- uri,
- headers: headers,
- body: bodyStr,
- );
- break;
- case RequestMethodType.patch:
- response = await (httpClient?.patch ?? patch)(
- uri,
- headers: headers,
- body: bodyStr,
- );
- break;
- case RequestMethodType.delete:
- response = await (httpClient?.delete ?? delete)(
- uri,
- headers: headers,
- body: bodyStr,
- );
- break;
- }
+ response = await switch (method) {
+ RequestMethodType.get => (httpClient?.get ?? get)(
+ uri,
+ headers: headers,
+ ),
+ RequestMethodType.post => (httpClient?.post ?? post)(
+ uri,
+ headers: headers,
+ body: bodyStr,
+ ),
+ RequestMethodType.put => (httpClient?.put ?? put)(
+ uri,
+ headers: headers,
+ body: bodyStr,
+ ),
+ RequestMethodType.patch => (httpClient?.patch ?? patch)(
+ uri,
+ headers: headers,
+ body: bodyStr,
+ ),
+ RequestMethodType.delete => (httpClient?.delete ?? delete)(
+ uri,
+ headers: headers,
+ body: bodyStr,
+ ),
+ };
} catch (e) {
// fetch failed, likely due to a network or CORS error
throw AuthRetryableFetchException(message: e.toString());
diff --git a/packages/gotrue/lib/src/gotrue_admin_api.dart b/packages/gotrue/lib/src/gotrue_admin_api.dart
index d371e82ef..edb1a6caf 100644
--- a/packages/gotrue/lib/src/gotrue_admin_api.dart
+++ b/packages/gotrue/lib/src/gotrue_admin_api.dart
@@ -1,9 +1,8 @@
import 'package:gotrue/gotrue.dart';
import 'package:gotrue/src/fetch.dart';
-import 'package:gotrue/src/helper.dart';
-import 'package:gotrue/src/types/auth_response.dart';
import 'package:gotrue/src/types/fetch_options.dart';
import 'package:http/http.dart';
+import 'package:supabase_common/supabase_common.dart';
import 'package:meta/meta.dart';
diff --git a/packages/gotrue/lib/src/gotrue_admin_custom_providers_api.dart b/packages/gotrue/lib/src/gotrue_admin_custom_providers_api.dart
index 0cac8a8d8..9187fe1b3 100644
--- a/packages/gotrue/lib/src/gotrue_admin_custom_providers_api.dart
+++ b/packages/gotrue/lib/src/gotrue_admin_custom_providers_api.dart
@@ -33,7 +33,7 @@ class GoTrueAdminCustomProvidersApi {
options: GotrueRequestOptions(
headers: _headers,
query: {
- if (type != null) 'type': type.name,
+ 'type': ?type?.name,
},
),
);
diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart
index cd4eaa3ed..225f928ac 100644
--- a/packages/gotrue/lib/src/gotrue_client.dart
+++ b/packages/gotrue/lib/src/gotrue_client.dart
@@ -8,14 +8,11 @@ import 'package:gotrue/gotrue.dart';
import 'package:gotrue/src/constants.dart';
import 'package:gotrue/src/fetch.dart';
import 'package:gotrue/src/helper.dart';
-import 'package:gotrue/src/types/auth_response.dart';
import 'package:gotrue/src/types/fetch_options.dart';
import 'package:http/http.dart';
-import 'package:jwt_decode/jwt_decode.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
-import 'package:retry/retry.dart';
-import 'package:rxdart/subjects.dart';
+import 'package:supabase_common/supabase_common.dart';
import 'broadcast_stub.dart'
if (dart.library.js_interop) './broadcast_web.dart'
@@ -94,8 +91,8 @@ class GoTrueClient {
JWKSet? _jwks;
DateTime? _jwksCachedAt;
- final _onAuthStateChangeController = BehaviorSubject();
- final _onAuthStateChangeControllerSync = BehaviorSubject(
+ final _onAuthStateChangeController = ReplaySubject();
+ final _onAuthStateChangeControllerSync = ReplaySubject(
sync: true,
);
@@ -146,7 +143,7 @@ class GoTrueClient {
/// Proxy to the web BroadcastChannel API. Should be null on non-web platforms.
BroadcastChannel? _broadcastChannel;
- StreamSubscription? _broadcastChannelSubscription;
+ StreamSubscription? _broadcastChannelSubscription;
/// {@macro gotrue_client}
GoTrueClient({
@@ -192,6 +189,50 @@ class GoTrueClient {
/// Returns the current session, if any;
Session? get currentSession => _currentSession;
+ /// Returns the current session, refreshing it on demand when the access
+ /// token has expired.
+ ///
+ /// Where the synchronous [currentSession] getter returns whatever session is
+ /// stored, even one whose access token has already expired, this returns a
+ /// session whose access token is guaranteed to be valid when it resolves: a
+ /// still-valid session is returned as-is, while an expired one is refreshed
+ /// first. If a refresh is already in flight, the expired session waits for it
+ /// to settle instead of starting another one.
+ ///
+ /// Returns `null` when there is no session. Throws an [AuthException] when an
+ /// expired session cannot be refreshed, unless its access token is still
+ /// within its real validity window, in which case the still-valid session is
+ /// returned.
+ Future getSession() async {
+ final session = _currentSession;
+ if (session == null) {
+ return null;
+ }
+
+ if (!session.isExpired) {
+ return session;
+ }
+
+ final refreshToken = session.refreshToken;
+ if (refreshToken == null) {
+ return session;
+ }
+
+ try {
+ // Concurrent callers share a single refresh through the same
+ // de-duplication used by [refreshSession], so an expired session's
+ // refresh token is only spent once.
+ final response = await _callRefreshToken(refreshToken);
+ return response.session;
+ } on AuthException {
+ final current = _currentSession;
+ if (current != null && !current.isExpiredWithoutMargin) {
+ return current;
+ }
+ rethrow;
+ }
+ }
+
/// Creates a new anonymous user.
///
/// Returns An `AuthResponse` with a session where the `is_anonymous` claim
@@ -472,7 +513,7 @@ class GoTrueClient {
options: GotrueRequestOptions(
headers: _headers,
body: {
- 'provider': provider.snakeCase,
+ 'provider': provider.name,
'id_token': idToken,
'nonce': nonce,
'gotrue_meta_security': {'captcha_token': captchaToken},
@@ -494,6 +535,54 @@ class GoTrueClient {
return authResponse;
}
+ /// Signs in a user by verifying a message signed with their Web3 wallet.
+ ///
+ /// Supports Ethereum (Sign-In with Ethereum) and Solana (Sign-In with
+ /// Solana), both of which derive from the EIP-4361 standard.
+ ///
+ /// Wallet interaction and message signing are performed by the caller using
+ /// their wallet library of choice. Provide the signed [message] together with
+ /// its [signature]. For [Web3Chain.ethereum] the signature is a hex encoded
+ /// string, for [Web3Chain.solana] it is a base64url encoded string.
+ ///
+ /// [captchaToken] is the verification token received when the user
+ /// completes the captcha on the app.
+ ///
+ /// See also https://eips.ethereum.org/EIPS/eip-4361
+ Future signInWithWeb3({
+ required Web3Chain chain,
+ required String message,
+ required String signature,
+ String? captchaToken,
+ }) async {
+ final response = await _fetch.request(
+ '$_url/token',
+ RequestMethodType.post,
+ options: GotrueRequestOptions(
+ headers: _headers,
+ body: {
+ 'chain': chain.name,
+ 'message': message,
+ 'signature': signature,
+ if (captchaToken != null)
+ 'gotrue_meta_security': {'captcha_token': captchaToken},
+ },
+ query: {'grant_type': 'web3'},
+ ),
+ );
+
+ final authResponse = AuthResponse.fromJson(response);
+
+ if (authResponse.session == null) {
+ throw AuthException('An error occurred on token verification.');
+ }
+
+ _saveSession(authResponse.session!);
+ notifyAllSubscribers(AuthChangeEvent.signedIn);
+
+ return authResponse;
+ }
+
/// Log in a user using magiclink or a one-time password (OTP).
///
/// If the `{{ .ConfirmationURL }}` variable is specified in the email template, a magiclink will be sent.
@@ -808,7 +897,17 @@ class GoTrueClient {
throw AuthSessionMissingException();
}
- final body = attributes.toJson();
+ final codeChallenge = attributes.email != null
+ ? await _generatePKCECodeChallenge()
+ : null;
+
+ final body = {
+ ...attributes.toJson(),
+ if (attributes.email != null) ...{
+ 'code_challenge': codeChallenge,
+ 'code_challenge_method': codeChallenge != null ? 's256' : null,
+ },
+ };
final options = GotrueRequestOptions(
headers: _headers,
body: body,
@@ -1071,7 +1170,7 @@ class GoTrueClient {
headers: _headers,
jwt: _currentSession?.accessToken,
body: {
- 'provider': provider.snakeCase,
+ 'provider': provider.name,
'id_token': idToken,
'nonce': nonce,
'gotrue_meta_security': {'captcha_token': captchaToken},
@@ -1338,27 +1437,19 @@ class GoTrueClient {
required Map? queryParams,
bool skipBrowserRedirect = false,
}) async {
- final urlParams = {'provider': provider.snakeCase};
- if (scopes != null) {
- urlParams['scopes'] = scopes;
- }
- if (redirectTo != null) {
- urlParams['redirect_to'] = redirectTo;
- }
- if (queryParams != null) {
- urlParams.addAll(queryParams);
- }
final codeChallenge = await _generatePKCECodeChallenge();
- if (codeChallenge != null) {
- urlParams.addAll({
+ final urlParams = {
+ 'provider': provider.name,
+ 'scopes': ?scopes,
+ 'redirect_to': ?redirectTo,
+ ...?queryParams,
+ if (codeChallenge != null) ...{
'flow_type': _flowType.name,
'code_challenge': codeChallenge,
'code_challenge_method': 's256',
- });
- }
- if (skipBrowserRedirect) {
- urlParams['skip_http_redirect'] = 'true';
- }
+ },
+ if (skipBrowserRedirect) 'skip_http_redirect': 'true',
+ };
final oauthUrl = '$url?${Uri(queryParameters: urlParams).query}';
return OAuthResponse(provider: provider, url: oauthUrl);
}
diff --git a/packages/gotrue/lib/src/gotrue_mfa_api.dart b/packages/gotrue/lib/src/gotrue_mfa_api.dart
index c9f944321..73388d4af 100644
--- a/packages/gotrue/lib/src/gotrue_mfa_api.dart
+++ b/packages/gotrue/lib/src/gotrue_mfa_api.dart
@@ -132,8 +132,12 @@ class GoTrueMFAApi {
/// Prepares a challenge used to verify that a user has access to a MFA factor.
///
/// [factorId] System assigned identifier for authenticator device as returned by enroll
+ ///
+ /// [channel] Messaging channel to use for phone factors (e.g. whatsapp or sms).
+ /// Defaults to the server's behavior (sms) when omitted.
Future challenge({
required String factorId,
+ OtpChannel? channel,
}) async {
final session = _client.currentSession;
@@ -142,6 +146,7 @@ class GoTrueMFAApi {
RequestMethodType.post,
options: GotrueRequestOptions(
headers: _client._headers,
+ body: channel == null ? null : {'channel': channel.name},
jwt: session?.accessToken,
),
);
@@ -214,7 +219,7 @@ class GoTrueMFAApi {
currentAuthenticationMethods: [],
);
}
- final payload = Jwt.parseJwt(session.accessToken);
+ final payload = decodeJwtPayload(session.accessToken).claims;
final currentLevel = AuthenticatorAssuranceLevels.values.firstWhereOrNull(
(level) => level.name == payload['aal'],
diff --git a/packages/gotrue/lib/src/gotrue_oauth_api.dart b/packages/gotrue/lib/src/gotrue_oauth_api.dart
index af9769b53..02064ce7a 100644
--- a/packages/gotrue/lib/src/gotrue_oauth_api.dart
+++ b/packages/gotrue/lib/src/gotrue_oauth_api.dart
@@ -11,24 +11,88 @@ class OAuthAuthorizedClient {
/// Human-readable name of the OAuth client
final String? clientName;
+ /// URI of the OAuth client's website
+ final String? clientUri;
+
+ /// URI of the OAuth client's logo
+ final String? logoUri;
+
const OAuthAuthorizedClient({
required this.clientId,
this.clientName,
+ this.clientUri,
+ this.logoUri,
});
factory OAuthAuthorizedClient.fromJson(Map json) {
return OAuthAuthorizedClient(
clientId: json['id'] as String,
clientName: json['name'] as String?,
+ clientUri: json['uri'] as String?,
+ logoUri: json['logo_uri'] as String?,
+ );
+ }
+}
+
+/// An OAuth grant representing a user's authorization of an OAuth client.
+///
+/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
+class OAuthGrant {
+ /// The OAuth client the grant was issued to.
+ final OAuthAuthorizedClient client;
+
+ /// The scopes granted to the client.
+ final List scopes;
+
+ /// The moment the grant was created.
+ final DateTime grantedAt;
+
+ const OAuthGrant({
+ required this.client,
+ required this.scopes,
+ required this.grantedAt,
+ });
+
+ factory OAuthGrant.fromJson(Map json) {
+ return OAuthGrant(
+ client: OAuthAuthorizedClient.fromJson(json['client']),
+ scopes: (json['scopes'] as List?)?.cast() ?? const [],
+ grantedAt: DateTime.parse(json['granted_at'] as String),
);
}
}
+/// Result returned by [GoTrueOAuthApi.getAuthorizationDetails].
+///
+/// The OAuth 2.1 server responds in one of two ways, depending on whether the
+/// signed-in user has already granted consent to the requesting client:
+///
+/// * [OAuthAuthorizationDetailsResponse] — consent is still required, so the
+/// requesting client, user and requested scopes are returned for display in
+/// a consent screen.
+/// * [OAuthAuthorizationRedirectResponse] — the user already granted consent
+/// to this client, so the server short-circuits the consent flow and returns
+/// only the redirect URL the caller should navigate to.
+///
+/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
+sealed class OAuthAuthorizationResponse {
+ const OAuthAuthorizationResponse();
+
+ factory OAuthAuthorizationResponse.fromJson(Map json) {
+ // When consent was already granted, the server short-circuits the flow and
+ // returns a redirect-only body ({"redirect_url": ...}) with no client or
+ // user information.
+ return json.containsKey('redirect_url')
+ ? OAuthAuthorizationRedirectResponse.fromJson(json)
+ : OAuthAuthorizationDetailsResponse.fromJson(json);
+ }
+}
+
/// Response type representing the details of a pending OAuth authorization
-/// request.
+/// request that still requires the user's consent.
///
/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
-class OAuthAuthorizationDetailsResponse {
+class OAuthAuthorizationDetailsResponse extends OAuthAuthorizationResponse {
/// The unique identifier for this authorization request.
final String authorizationId;
@@ -74,6 +138,29 @@ class OAuthAuthorizationDetailsResponse {
}
}
+/// Response type returned when the signed-in user has already granted consent
+/// to the requesting client.
+///
+/// The OAuth 2.1 server short-circuits the consent flow and only provides the
+/// redirect URL the caller should navigate to in order to complete the request.
+///
+/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
+class OAuthAuthorizationRedirectResponse extends OAuthAuthorizationResponse {
+ /// The URL the caller should redirect the user to in order to complete the
+ /// already-approved authorization request.
+ final String redirectUrl;
+
+ const OAuthAuthorizationRedirectResponse({required this.redirectUrl});
+
+ factory OAuthAuthorizationRedirectResponse.fromJson(
+ Map json,
+ ) {
+ return OAuthAuthorizationRedirectResponse(
+ redirectUrl: json['redirect_url'] as String,
+ );
+ }
+}
+
/// Response type for an OAuth authorization consent decision (approve or deny).
///
/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
@@ -134,10 +221,13 @@ class GoTrueOAuthApi {
/// Retrieves details about an OAuth authorization request.
/// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
///
- /// Returns authorization details including client info, scopes, and user information.
- /// If the response includes only a redirect_url field, it means consent was already given - the caller
- /// should handle the redirect manually if needed.
- Future getAuthorizationDetails(
+ /// Returns an [OAuthAuthorizationDetailsResponse] with the client info,
+ /// scopes and user information when the user still has to consent. When the
+ /// user already granted consent to this client, the server short-circuits the
+ /// flow and an [OAuthAuthorizationRedirectResponse] carrying the redirect URL
+ /// is returned instead. Switch on the sealed [OAuthAuthorizationResponse] to
+ /// handle both cases.
+ Future getAuthorizationDetails(
String authorizationId,
) async {
final session = _client.currentSession;
@@ -151,7 +241,7 @@ class GoTrueOAuthApi {
),
);
- return OAuthAuthorizationDetailsResponse.fromJson(data);
+ return OAuthAuthorizationResponse.fromJson(data);
}
/// Approves a pending OAuth authorization request.
@@ -205,4 +295,41 @@ class GoTrueOAuthApi {
return OAuthConsentResponse.fromJson(data);
}
+
+ /// Lists all OAuth grants that the authenticated user has authorized.
+ ///
+ /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
+ Future> listGrants() async {
+ final session = _client.currentSession;
+
+ final data = await _fetch.request(
+ '${_client._url}/user/oauth/grants',
+ RequestMethodType.get,
+ options: GotrueRequestOptions(
+ headers: _client._headers,
+ jwt: session?.accessToken,
+ ),
+ );
+
+ return (data as List).map((grant) => OAuthGrant.fromJson(grant)).toList();
+ }
+
+ /// Revokes the authenticated user's OAuth grant for the client identified by
+ /// [clientId].
+ ///
+ /// Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
+ Future revokeGrant(String clientId) async {
+ final session = _client.currentSession;
+
+ await _fetch.request(
+ '${_client._url}/user/oauth/grants',
+ RequestMethodType.delete,
+ options: GotrueRequestOptions(
+ headers: _client._headers,
+ jwt: session?.accessToken,
+ query: {'client_id': clientId},
+ noResolveJson: true,
+ ),
+ );
+ }
}
diff --git a/packages/gotrue/lib/src/gotrue_passkey_api.dart b/packages/gotrue/lib/src/gotrue_passkey_api.dart
index 84b97bafe..618162739 100644
--- a/packages/gotrue/lib/src/gotrue_passkey_api.dart
+++ b/packages/gotrue/lib/src/gotrue_passkey_api.dart
@@ -54,9 +54,17 @@ class GoTruePasskeyApi {
/// platform's passkey API to create a credential, then complete the
/// registration with [verifyRegistration].
///
+ /// [friendlyName] is the account label the authenticator shows for the
+ /// passkey. When the server does not provide a `user.name` in the options
+ /// (for example when the user has neither an email nor a phone), it is filled
+ /// with [friendlyName] if given, or a generic default otherwise, so the
+ /// platform ceremony always has a name to display.
+ ///
/// Requires a signed in (non-anonymous) user. If the user has verified MFA
/// factors, the session has to be at `aal2` to manage passkeys.
- Future startRegistration() async {
+ Future startRegistration({
+ String? friendlyName,
+ }) async {
final session = _client.currentSession;
final data = await _fetch.request(
@@ -69,7 +77,31 @@ class GoTruePasskeyApi {
),
);
- return PasskeyRegistrationOptionsResponse.fromJson(data);
+ final response = PasskeyRegistrationOptionsResponse.fromJson(data);
+ _applyUserNameFallback(response.options, friendlyName);
+ return response;
+ }
+
+ static const _defaultPasskeyName = 'Passkey';
+
+ void _applyUserNameFallback(
+ Map options,
+ String? friendlyName,
+ ) {
+ final user = options['user'];
+ if (user is! Map) {
+ return;
+ }
+
+ final name = user['name'];
+ if (name is! String || name.isEmpty) {
+ user['name'] = friendlyName ?? _defaultPasskeyName;
+ }
+
+ final displayName = user['displayName'];
+ if (displayName is! String || displayName.isEmpty) {
+ user['displayName'] = user['name'];
+ }
}
/// Completes the registration of a new passkey.
diff --git a/packages/gotrue/lib/src/helper.dart b/packages/gotrue/lib/src/helper.dart
index 39c0a1346..d919911f2 100644
--- a/packages/gotrue/lib/src/helper.dart
+++ b/packages/gotrue/lib/src/helper.dart
@@ -1,35 +1,12 @@
import 'dart:convert';
-import 'dart:math';
-import 'package:crypto/crypto.dart';
-import 'package:gotrue/src/base64url.dart';
import 'package:gotrue/src/types/auth_exception.dart';
import 'package:gotrue/src/types/jwt.dart';
+import 'package:meta/meta.dart';
+import 'package:supabase_common/supabase_common.dart';
-/// Generates a random code verifier
-String generatePKCEVerifier() {
- const verifierLength = 56;
- final random = Random.secure();
- return base64UrlEncode(
- List.generate(verifierLength, (_) => random.nextInt(256)),
- ).split('=')[0];
-}
-
-String generatePKCEChallenge(String verifier) {
- return base64UrlEncode(
- sha256.convert(ascii.encode(verifier)).bytes,
- ).split('=')[0];
-}
-
-final uuidRegex = RegExp(
- r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
-);
-
-void validateUuid(String id) {
- if (!uuidRegex.hasMatch(id)) {
- throw ArgumentError('Invalid id: $id, must be a valid UUID');
- }
-}
+export 'package:supabase_common/supabase_common.dart'
+ show generatePKCEVerifier, generatePKCEChallenge, uuidRegex, validateUuid;
/// Decodes a JWT token without performing validation
///
@@ -75,6 +52,29 @@ DecodedJwt decodeJwt(String token) {
}
}
+/// Decodes only the payload of a JWT without validating the header or signature.
+///
+/// Useful where just the claims are needed and the token may not carry a
+/// well-formed header or signature. Throws [AuthInvalidJwtException] if the
+/// structure or payload is invalid.
+@internal
+JwtPayload decodeJwtPayload(String token) {
+ final parts = token.split('.');
+ if (parts.length != 3) {
+ throw AuthInvalidJwtException('Invalid JWT structure');
+ }
+
+ try {
+ final payloadJson = Base64Url.decodeToString(parts[1]);
+ return JwtPayload.fromJson(json.decode(payloadJson));
+ } catch (e) {
+ if (e is AuthInvalidJwtException) {
+ rethrow;
+ }
+ throw AuthInvalidJwtException('Failed to decode JWT: $e');
+ }
+}
+
/// Validates the expiration time of a JWT
///
/// Throws [AuthException] if the exp claim is missing or the JWT has expired.
diff --git a/packages/gotrue/lib/src/types/auth_response.dart b/packages/gotrue/lib/src/types/auth_response.dart
index 92e34b213..095f4e515 100644
--- a/packages/gotrue/lib/src/types/auth_response.dart
+++ b/packages/gotrue/lib/src/types/auth_response.dart
@@ -102,22 +102,3 @@ class GenerateLinkProperties {
json['verification_type'],
);
}
-
-extension ToSnakeCase on Enum {
- String get snakeCase {
- final a = 'a'.codeUnitAt(0), z = 'z'.codeUnitAt(0);
- final A = 'A'.codeUnitAt(0), Z = 'Z'.codeUnitAt(0);
- final result = StringBuffer()..write(name[0].toLowerCase());
- for (var i = 1; i < name.length; i++) {
- final char = name.codeUnitAt(i);
- if (A <= char && char <= Z) {
- final pChar = name.codeUnitAt(i - 1);
- if (a <= pChar && pChar <= z) {
- result.write('_');
- }
- }
- result.write(name[i].toLowerCase());
- }
- return result.toString();
- }
-}
diff --git a/packages/gotrue/lib/src/types/custom_oauth_provider.dart b/packages/gotrue/lib/src/types/custom_oauth_provider.dart
index 784899045..245be1827 100644
--- a/packages/gotrue/lib/src/types/custom_oauth_provider.dart
+++ b/packages/gotrue/lib/src/types/custom_oauth_provider.dart
@@ -193,8 +193,7 @@ class CustomOAuthProvider {
customClaimsAllowlist: (json['custom_claims_allowlist'] as List?)?.cast(),
pkceEnabled: json['pkce_enabled'] as bool?,
attributeMapping: json['attribute_mapping'] as Map?,
- authorizationParams: (json['authorization_params'] as Map?)
- ?.cast(),
+ authorizationParams: (json['authorization_params'] as Map?)?.cast(),
enabled: json['enabled'] as bool?,
emailOptional: json['email_optional'] as bool?,
issuer: json['issuer'] as String?,
diff --git a/packages/gotrue/lib/src/types/fetch_options.dart b/packages/gotrue/lib/src/types/fetch_options.dart
index 8cc8f6387..733f33ef3 100644
--- a/packages/gotrue/lib/src/types/fetch_options.dart
+++ b/packages/gotrue/lib/src/types/fetch_options.dart
@@ -1,13 +1,6 @@
-class FetchOptions {
- final Map headers;
- final bool noResolveJson;
+import 'package:supabase_common/supabase_common.dart';
- const FetchOptions(
- Map? headers, {
- bool? noResolveJson,
- }) : headers = headers ?? const {},
- noResolveJson = noResolveJson ?? false;
-}
+export 'package:supabase_common/supabase_common.dart' show FetchOptions;
class GotrueRequestOptions extends FetchOptions {
final String? jwt;
diff --git a/packages/gotrue/lib/src/types/jwt.dart b/packages/gotrue/lib/src/types/jwt.dart
index ce62c0573..80f87648d 100644
--- a/packages/gotrue/lib/src/types/jwt.dart
+++ b/packages/gotrue/lib/src/types/jwt.dart
@@ -1,7 +1,7 @@
-import 'dart:convert';
import 'dart:typed_data';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
+import 'package:supabase_common/supabase_common.dart';
/// JWT Header structure
class JwtHeader {
@@ -238,35 +238,23 @@ class JWK {
}
/// Allows accessing additional properties using operator[].
- dynamic operator [](String key) {
- switch (key) {
- case 'kty':
- return kty;
- case 'key_ops':
- return keyOps;
- case 'alg':
- return alg;
- case 'kid':
- return kid;
- default:
- return _additionalProperties[key];
- }
- }
+ dynamic operator [](String key) => switch (key) {
+ 'kty' => kty,
+ 'key_ops' => keyOps,
+ 'alg' => alg,
+ 'kid' => kid,
+ _ => _additionalProperties[key],
+ };
/// Converts this [JWK] to a JSON map.
Map toJson() {
- final Map json = {
+ return {
'kty': kty,
'key_ops': keyOps,
+ 'alg': ?alg,
+ 'kid': ?kid,
..._additionalProperties,
};
- if (alg != null) {
- json['alg'] = alg;
- }
- if (kid != null) {
- json['kid'] = kid;
- }
- return json;
}
/// Builds the RSA public key for verifying RS256 JWTs from this JWK's
@@ -281,8 +269,8 @@ class JWK {
// clock 1.1.2 and so allows dart_jsonwebtoken 3.x. fromJWK also adds EC
// (ES256) support.
RSAPublicKey get publicKey {
- final modulus = _base64UrlToBytes(this['n'] as String);
- final exponent = _base64UrlToBytes(this['e'] as String);
+ final modulus = Base64Url.decodeToBytes(this['n'] as String);
+ final exponent = Base64Url.decodeToBytes(this['e'] as String);
final der = _derSequence([
_derInteger(modulus),
_derInteger(exponent),
@@ -291,10 +279,6 @@ class JWK {
}
}
-List _base64UrlToBytes(String input) {
- return base64Url.decode(base64Url.normalize(input));
-}
-
/// DER-encodes an unsigned big-endian integer (prefixing a zero byte when the
/// high bit is set so it is not interpreted as negative).
List _derInteger(List bytes) {
diff --git a/packages/gotrue/lib/src/types/mfa.dart b/packages/gotrue/lib/src/types/mfa.dart
index 6793b6546..e1bcdf3c3 100644
--- a/packages/gotrue/lib/src/types/mfa.dart
+++ b/packages/gotrue/lib/src/types/mfa.dart
@@ -81,18 +81,15 @@ class PhoneEnrollment {
);
}
- factory PhoneEnrollment._fromJsonValue(dynamic value) {
- if (value is String) {
- // Server returns phone number as a string directly
- return PhoneEnrollment(phone: value);
- } else if (value is Map) {
- // Server returns phone data as an object
- return PhoneEnrollment.fromJson(value);
- }
- throw ArgumentError(
+ factory PhoneEnrollment._fromJsonValue(dynamic value) => switch (value) {
+ // Server returns phone number as a string directly
+ String() => PhoneEnrollment(phone: value),
+ // Server returns phone data as an object
+ Map() => PhoneEnrollment.fromJson(value),
+ _ => throw ArgumentError(
'Invalid phone enrollment data type: ${value.runtimeType}',
- );
- }
+ ),
+ };
}
class AuthMFAChallengeResponse {
diff --git a/packages/gotrue/lib/src/types/session.dart b/packages/gotrue/lib/src/types/session.dart
index 9818bfa18..7ef93b027 100644
--- a/packages/gotrue/lib/src/types/session.dart
+++ b/packages/gotrue/lib/src/types/session.dart
@@ -1,6 +1,7 @@
import 'package:gotrue/src/constants.dart';
+import 'package:gotrue/src/helper.dart';
import 'package:gotrue/src/types/user.dart';
-import 'package:jwt_decode/jwt_decode.dart';
+import 'package:meta/meta.dart';
class Session {
final String? providerToken;
@@ -77,8 +78,7 @@ class Session {
int? get _expiresAt {
try {
- final payload = Jwt.parseJwt(accessToken);
- return payload['exp'] as int;
+ return decodeJwtPayload(accessToken).exp;
} catch (_) {
return null;
}
@@ -96,6 +96,16 @@ class Session {
);
}
+ /// Returns `true` if the token is expired right now, without applying the
+ /// [Constants.expiryMargin] buffer used by [isExpired].
+ @internal
+ bool get isExpiredWithoutMargin {
+ if (expiresAt == null) return false;
+ return DateTime.now().isAfter(
+ DateTime.fromMillisecondsSinceEpoch(expiresAt! * 1000),
+ );
+ }
+
Session copyWith({
String? accessToken,
int? expiresIn,
diff --git a/packages/gotrue/lib/src/types/types.dart b/packages/gotrue/lib/src/types/types.dart
index 3508da3d0..c295a7bb2 100644
--- a/packages/gotrue/lib/src/types/types.dart
+++ b/packages/gotrue/lib/src/types/types.dart
@@ -1,6 +1,8 @@
+import 'package:supabase_common/supabase_common.dart';
+
typedef BroadcastChannel = ({
Stream