diff --git a/.gitignore b/.gitignore index 18bd7c47..8b346603 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,67 @@ .DS_Store .atom/ .idea/ +.vscode/ .packages .pub/ .dart_tool/ pubspec.lock +pubspec_overrides.local.yaml flutter_export_environment.sh coverage +coverage/ *.iml +*.ipr +*.iws .flutter-plugins .flutter-plugins-dependencies +devtools_options.yaml .project .classpath .settings .last_build_id +.metadata + +# Local planning notes +backlog.md +task-list.md +todo.md +todos.md + +# Dart / Flutter generated docs and build output +doc/api/ +build/ +.dart_tool/ + +# Flutter example generated platform files +packages/**/example/.metadata +packages/**/example/.flutter-plugins +packages/**/example/.flutter-plugins-dependencies +packages/**/example/devtools_options.yaml +packages/**/example/build/ +packages/**/example/ios/Flutter/.last_build_id +packages/**/example/ios/Flutter/Generated.xcconfig +packages/**/example/ios/Flutter/flutter_export_environment.sh +packages/**/example/ios/Flutter/ephemeral/ +packages/**/example/ios/Flutter/Flutter.framework +packages/**/example/ios/Flutter/Flutter.podspec +packages/**/example/ios/Flutter/App.framework +packages/**/example/ios/Flutter/flutter_assets/ +packages/**/example/ios/Pods/ +packages/**/example/ios/.symlinks/ +packages/**/example/ios/Runner/GeneratedPluginRegistrant.* +packages/**/example/ios/Podfile.lock +packages/**/example/android/.gradle/ +packages/**/example/android/local.properties +packages/**/example/android/app/debug/ +packages/**/example/android/app/profile/ +packages/**/example/android/app/release/ +packages/**/example/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +packages/**/example/android/app/src/main/kotlin/io/flutter/plugins/GeneratedPluginRegistrant.kt # Misc .env.local @@ -25,3 +69,7 @@ coverage .env.test.local .env.production.local .melos_tool + +# AI +docs/superpowers/ + diff --git a/.vscode/launch.json b/.vscode/launch.json index f075dbf0..1ab75806 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,14 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "nhost_flutter", + "cwd": "packages/nhost_flutter/example", + "program": "lib/main.dart", + "request": "launch", + "flutterMode": "debug", + "type": "dart" + }, { "name": "nhost_flutter_auth", "cwd": "packages/nhost_flutter_auth/example", diff --git a/packages/nhost_auth_dart/README.md b/packages/nhost_auth_dart/README.md index 8e1441aa..91618dbe 100644 --- a/packages/nhost_auth_dart/README.md +++ b/packages/nhost_auth_dart/README.md @@ -44,5 +44,5 @@ void main() async { ```yaml dependencies: - nhost_auth_dart: ^2.0.0 + nhost_auth_dart: ^2.6.1 ``` diff --git a/packages/nhost_auth_dart/lib/src/auth_client.dart b/packages/nhost_auth_dart/lib/src/auth_client.dart index 4f80b9bd..047dffba 100644 --- a/packages/nhost_auth_dart/lib/src/auth_client.dart +++ b/packages/nhost_auth_dart/lib/src/auth_client.dart @@ -805,6 +805,152 @@ class NhostAuthClient implements HasuraAuthClient { //#endregion + //#region PAT, fetchUser, verifyToken + + /// Signs in using a Personal Access Token (PAT). + @override + Future signInWithPat(String pat) async { + log.finer('Attempting sign in (PAT)'); + AuthResponse? res; + try { + res = await _apiClient.post( + '/signin/pat', + jsonBody: {'personalAccessToken': pat}, + responseDeserializer: AuthResponse.fromJson, + ); + } catch (e, st) { + log.finer('Sign in (PAT) failed', e, st); + await clearSession(); + rethrow; + } + log.finer('Sign in (PAT) successful'); + await setSession(res!.session!); + return res; + } + + /// Fetches the current user's profile from the server. + @override + Future fetchUser() async { + log.finer('Fetching current user'); + final user = await _apiClient.get( + '/user', + responseDeserializer: User.fromJson, + headers: _session.authenticationHeaders, + ); + _currentUser = user; + return user; + } + + /// Verifies whether [accessToken] is still valid on the server. + @override + Future verifyToken(String accessToken) async { + log.finer('Verifying token'); + try { + await _apiClient.post( + '/token/verify', + headers: {'Authorization': 'Bearer $accessToken'}, + ); + return true; + } on ApiException { + return false; + } + } + + //#endregion + + //#region WebAuthn — platform-dependent stubs + + @override + Future> signInWithWebAuthn() async { + return _apiClient.post( + '/signin/webauthn', + responseDeserializer: (json) => Map.from(json as Map), + ); + } + + @override + Future verifyWebAuthnSignIn( + Map assertionResponse, + ) async { + log.finer('Attempting WebAuthn sign-in verification'); + final res = await _apiClient.post( + '/signin/webauthn/verify', + jsonBody: assertionResponse, + responseDeserializer: AuthResponse.fromJson, + ); + await setSession(res.session!); + return res; + } + + @override + Future> signUpWithWebAuthn({String? email}) async { + return _apiClient.post( + '/signup/webauthn', + jsonBody: {if (email != null) 'email': email}, + responseDeserializer: (json) => Map.from(json as Map), + ); + } + + @override + Future verifyWebAuthnSignUp( + Map attestationResponse, + ) async { + log.finer('Attempting WebAuthn sign-up verification'); + final res = await _apiClient.post( + '/signup/webauthn/verify', + jsonBody: attestationResponse, + responseDeserializer: AuthResponse.fromJson, + ); + if (res.session != null) await setSession(res.session!); + return res; + } + + @override + Future> addWebAuthnCredential() async { + return _apiClient.post( + '/user/webauthn/add', + headers: _session.authenticationHeaders, + responseDeserializer: (json) => Map.from(json as Map), + ); + } + + @override + Future verifyAddWebAuthnCredential( + Map attestationResponse, + ) async { + await _apiClient.post( + '/user/webauthn/verify', + jsonBody: attestationResponse, + headers: _session.authenticationHeaders, + ); + } + + @override + Future> elevateWithWebAuthn() async { + return _apiClient.post( + '/elevate/webauthn', + headers: _session.authenticationHeaders, + responseDeserializer: (json) => Map.from(json as Map), + ); + } + + @override + Future verifyWebAuthnElevation( + Map assertionResponse, + ) async { + log.finer('Attempting WebAuthn elevation verification'); + final res = await _apiClient.post( + '/elevate/webauthn/verify', + jsonBody: assertionResponse, + headers: _session.authenticationHeaders, + responseDeserializer: AuthResponse.fromJson, + ); + if (res.session != null) await setSession(res.session!); + return res; + } + + //#endregion + //#region Token and session Handling Future _refreshSession([String? initRefreshToken]) async { @@ -857,6 +1003,12 @@ class NhostAuthClient implements HasuraAuthClient { if (e is ApiException && e.statusCode == unauthorizedStatus) { log.finest('Unauthorized refresh token. Forcing signout.'); await signOut(); + } else { + // Non-401 failure (e.g. network error, 5xx): clearSession is not called, + // so we must manually clear _loading to prevent authenticationState from + // remaining stuck at inProgress forever (issue #180). + _loading = false; + _onAuthStateChanged(authenticationState); } log.severe('Exception during token refresh', e, st); @@ -963,8 +1115,9 @@ class NhostAuthClient implements HasuraAuthClient { @override String toString() { return { - 'accessToken': accessToken, - 'refreshToken': _session.session?.refreshToken, + 'accessToken': accessToken == null ? null : '', + 'refreshToken': + _session.session?.refreshToken == null ? null : '', 'accessTokenExpiresIn': _session.session?.accessTokenExpiresIn, 'userEmail': _session.session?.user?.email, }.toString(); diff --git a/packages/nhost_auth_dart/pubspec.yaml b/packages/nhost_auth_dart/pubspec.yaml index 19badb3d..53982d2f 100644 --- a/packages/nhost_auth_dart/pubspec.yaml +++ b/packages/nhost_auth_dart/pubspec.yaml @@ -1,8 +1,8 @@ name: nhost_auth_dart -description: Nhost Dart Auth Service SDK +description: Dart client for Nhost Auth with email/password, passwordless, MFA, anonymous sign-in, and session management. version: 2.6.1 homepage: https://nhost.io -repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_sdk +repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_auth_dart issue_tracker: https://github.com/nhost/nhost-dart/issues environment: diff --git a/packages/nhost_auth_dart/test/auth_client_test.dart b/packages/nhost_auth_dart/test/auth_client_test.dart new file mode 100644 index 00000000..dd4e6756 --- /dev/null +++ b/packages/nhost_auth_dart/test/auth_client_test.dart @@ -0,0 +1,46 @@ +import 'package:nhost_auth_dart/nhost_auth_dart.dart'; +import 'package:nhost_sdk/nhost_sdk.dart'; +import 'package:test/test.dart'; + +const _accessToken = 'eyJhbGciOiJIUzI1NiJ9.' + 'eyJodHRwczovL2hhc3VyYS5pby9qd3QvY2xhaW1zIjp7IngtaGFzdXJhLWFsbG93ZWQtcm9sZXMiOlsidXNlciIsIm1lIl0sIngtaGFzdXJhLWRlZmF1bHQtcm9sZSI6InVzZXIiLCJ4LWhhc3VyYS11c2VyLWlkIjoiNzEwYTgyNjMtNTgyNi00NTYzLWE4YTUtNGUyNzJkNDQxYWVkIiwieC1oYXN1cmEtdXNlci1pc0Fub255bW91cyI6ImZhbHNlIn0sInN1YiI6IjcxMGE4MjYzLTU4MjYtNDU2My1hOGE1LTRlMjcyZDQ0MWFlZCIsImlzcyI6Imhhc3VyYS1hdXRoIiwiaWF0IjoxNjQzMzQ3NzgwLCJleHAiOjE2NDMzNDg2ODB9.' + 'xzsBH0p34ynPwaHnNs97gVL5tdrccFOrxosuqBra1iw'; +const _refreshToken = 'refresh-token-should-not-be-logged'; + +void main() { + group('NhostAuthClient', () { + test('does not expose session tokens in string output', () async { + final auth = NhostAuthClient(url: 'http://localhost'); + addTearDown(auth.close); + + await auth.setSession( + Session( + accessToken: _accessToken, + accessTokenExpiresIn: Duration(seconds: 900), + refreshToken: _refreshToken, + user: User( + id: '710a8263-5826-4563-a8a5-4e272d441aed', + displayName: 'Test User', + locale: 'en', + createdAt: DateTime.utc(2024), + isAnonymous: false, + defaultRole: 'user', + roles: const ['user'], + emailVerified: true, + phoneNumber: '', + phoneNumberVerified: false, + email: 'test@example.com', + ), + ), + ); + + final output = auth.toString(); + + expect(output, isNot(contains(_accessToken))); + expect(output, isNot(contains(_refreshToken))); + expect(output, contains('accessToken: ')); + expect(output, contains('refreshToken: ')); + expect(output, contains('test@example.com')); + }); + }); +} diff --git a/packages/nhost_dart/README.md b/packages/nhost_dart/README.md index ce620383..2a4fe8d0 100644 --- a/packages/nhost_dart/README.md +++ b/packages/nhost_dart/README.md @@ -23,37 +23,42 @@ import 'package:nhost_dart/nhost_dart.dart'; void main() async { final nhost = NhostClient( subdomain: Subdomain( - region: 'eu-central-1', - subdomain: 'backend-5e69d1d7', - ), - ); - - - // for self host or local host you may use ServiceUrls - /* - final nhost = NhostClient( - serviceUrls: ServiceUrls( - authUrl: '', - storageUrl: '', - functionsUrl: '', - graphqlUrl: '', - ), - ); - */ + region: 'eu-central-1', + subdomain: 'backend-5e69d1d7', + ), + ); + + // For self-hosted or local projects, use ServiceUrls instead. + /* + final nhost = NhostClient( + serviceUrls: ServiceUrls( + authUrl: 'http://localhost:1337/v1/auth', + storageUrl: 'http://localhost:1337/v1/storage', + functionsUrl: 'http://localhost:1337/v1/functions', + graphqlUrl: 'http://localhost:1337/v1/graphql', + ), + ); + */ // User registration - await nhost.auth.register(email: 'new-user@gmail.com', password: 'xxxxx'); + final authResponse = await nhost.auth.signUp( + email: 'new-user@gmail.com', + password: 'password-1', + ); // Upload a file - final currentUser = nhost.auth.currentUser; + final currentUser = authResponse.user ?? nhost.auth.currentUser; await nhost.storage.uploadBytes( - filePath: '/users/${currentUser.id}/image.jpg', - bytes: [/* ... */], - contentType: 'image/jpeg', - ), + fileName: '/users/${currentUser!.id}/image.jpg', + fileContents: [/* ... */], + mimeType: 'image/jpeg', + ); // Log out - await nhost.auth.logout(); + await nhost.auth.signOut(); + + // Release resources + nhost.close(); } ``` @@ -63,7 +68,7 @@ void main() async { ```yaml dependencies: - nhost_dart: ^1.0.1 + nhost_dart: ^2.2.0 ``` ## 🔥 More Dart & Flutter packages from Nhost diff --git a/packages/nhost_dart/lib/nhost_dart.dart b/packages/nhost_dart/lib/nhost_dart.dart index f5859555..0e750f5d 100644 --- a/packages/nhost_dart/lib/nhost_dart.dart +++ b/packages/nhost_dart/lib/nhost_dart.dart @@ -12,13 +12,18 @@ export 'package:nhost_sdk/nhost_sdk.dart' show ApiException, Session, + User, + AuthResponse, + MultiFactorAuthResponse, createNhostServiceEndpoint, ServiceUrls, Subdomain, AuthenticationState, AuthStateChangedCallback, UnsubscribeDelegate, - AuthStore; + AuthStore, + DeanonymizeOptions, + DeanonymizeSignInMethod; export 'package:nhost_storage_dart/nhost_storage_dart.dart' show NhostStorageClient, ImageCornerRadius, ImageTransform; export 'package:nhost_auth_dart/nhost_auth_dart.dart' show NhostAuthClient; diff --git a/packages/nhost_dart/pubspec.yaml b/packages/nhost_dart/pubspec.yaml index 388f66c0..eb062fb6 100644 --- a/packages/nhost_dart/pubspec.yaml +++ b/packages/nhost_dart/pubspec.yaml @@ -1,8 +1,8 @@ name: nhost_dart -description: Nhost Dart SDK +description: Dart client for Nhost Auth, Storage, Functions, and GraphQL helpers for apps using Nhost. version: 2.2.0 homepage: https://nhost.io -repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_sdk +repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_dart issue_tracker: https://github.com/nhost/nhost-dart/issues environment: diff --git a/packages/nhost_flutter/CHANGELOG.md b/packages/nhost_flutter/CHANGELOG.md new file mode 100644 index 00000000..cda7e885 --- /dev/null +++ b/packages/nhost_flutter/CHANGELOG.md @@ -0,0 +1,8 @@ +## 1.0.0 + +- Initial release. +- `Nhost.initialize()` / `Nhost.instance` / `Nhost.local()` singleton API. +- `SecureAuthStore` backed by `flutter_secure_storage` (default). +- Sealed `AuthState` hierarchy: `AuthStateLoading`, `AuthStateSignedIn`, `AuthStateSignedOut`. +- `authStateChanges` stream and `authStateListenable` on `NhostAuthClient`. +- Auth widgets: `NhostAuthGate`, `NhostAuthStateBuilder`, `NhostSignedIn`, `NhostSignedOut`, `NhostUserBuilder`. diff --git a/packages/nhost_flutter/analysis_options.yaml b/packages/nhost_flutter/analysis_options.yaml new file mode 100644 index 00000000..572dd239 --- /dev/null +++ b/packages/nhost_flutter/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/packages/nhost_flutter/example/.gitignore b/packages/nhost_flutter/example/.gitignore new file mode 100644 index 00000000..3820a95c --- /dev/null +++ b/packages/nhost_flutter/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/nhost_flutter/example/README.md b/packages/nhost_flutter/example/README.md new file mode 100644 index 00000000..237f8536 --- /dev/null +++ b/packages/nhost_flutter/example/README.md @@ -0,0 +1,3 @@ +# nhost_flutter_example + +A new Flutter project. diff --git a/packages/nhost_flutter/example/analysis_options.yaml b/packages/nhost_flutter/example/analysis_options.yaml new file mode 100644 index 00000000..f9b30346 --- /dev/null +++ b/packages/nhost_flutter/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/packages/nhost_flutter/example/ios/.gitignore b/packages/nhost_flutter/example/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/packages/nhost_flutter/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/nhost_flutter/example/ios/Flutter/AppFrameworkInfo.plist b/packages/nhost_flutter/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..391a902b --- /dev/null +++ b/packages/nhost_flutter/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/packages/nhost_flutter/example/ios/Flutter/Debug.xcconfig b/packages/nhost_flutter/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..ec97fc6f --- /dev/null +++ b/packages/nhost_flutter/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/nhost_flutter/example/ios/Flutter/Release.xcconfig b/packages/nhost_flutter/example/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..c4855bfe --- /dev/null +++ b/packages/nhost_flutter/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/nhost_flutter/example/ios/Podfile b/packages/nhost_flutter/example/ios/Podfile new file mode 100644 index 00000000..620e46eb --- /dev/null +++ b/packages/nhost_flutter/example/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..93fba73a --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,732 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + CFCA3411CCEFD031824BF762 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD338F30B45336939DE98143 /* Pods_Runner.framework */; }; + E07329A372348F3233D12B91 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 12454FDB7B0662B1D4241238 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 12454FDB7B0662B1D4241238 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1D95F44F251FCE215897879D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4CC4AF7CFED5E26CFEB0D2B2 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 5F80C02967755E7DB2A7A06E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8E7E5B137788D5306651503E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B0412D4587F4B2ABBBD773EA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + CD338F30B45336939DE98143 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FD6D7F31F881DE326CB185AF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 46A6AD4501C44E70B70EE62E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E07329A372348F3233D12B91 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CFCA3411CCEFD031824BF762 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 97C234F8D6B6D23BD91ECA5B /* Pods */, + EE675CBBA5E6CCBFF40BD63C /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 97C234F8D6B6D23BD91ECA5B /* Pods */ = { + isa = PBXGroup; + children = ( + 8E7E5B137788D5306651503E /* Pods-Runner.debug.xcconfig */, + 4CC4AF7CFED5E26CFEB0D2B2 /* Pods-Runner.release.xcconfig */, + 5F80C02967755E7DB2A7A06E /* Pods-Runner.profile.xcconfig */, + 1D95F44F251FCE215897879D /* Pods-RunnerTests.debug.xcconfig */, + B0412D4587F4B2ABBBD773EA /* Pods-RunnerTests.release.xcconfig */, + FD6D7F31F881DE326CB185AF /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + EE675CBBA5E6CCBFF40BD63C /* Frameworks */ = { + isa = PBXGroup; + children = ( + CD338F30B45336939DE98143 /* Pods_Runner.framework */, + 12454FDB7B0662B1D4241238 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + EF13CA40C14EF1B85B041795 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 46A6AD4501C44E70B70EE62E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + C80DBCC7863375EE011B8BAE /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + A8742DAF69B4CD9128879215 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + A8742DAF69B4CD9128879215 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + C80DBCC7863375EE011B8BAE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EF13CA40C14EF1B85B041795 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.nhostFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1D95F44F251FCE215897879D /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nhostFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B0412D4587F4B2ABBBD773EA /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nhostFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FD6D7F31F881DE326CB185AF /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.nhostFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.nhostFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.nhostFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/nhost_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/nhost_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/nhost_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/nhost_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/nhost_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/nhost_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/nhost_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/nhost_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/nhost_flutter/example/ios/Runner/AppDelegate.swift b/packages/nhost_flutter/example/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..c30b367e --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..7353c41e Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..6ed2d933 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cd7b009 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..fe730945 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..321773cd Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..502f463a Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..e9f5fea2 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..84ac32ae Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..8953cba0 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..0467bf12 Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/nhost_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/nhost_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/nhost_flutter/example/ios/Runner/Base.lproj/Main.storyboard b/packages/nhost_flutter/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/nhost_flutter/example/ios/Runner/Info.plist b/packages/nhost_flutter/example/ios/Runner/Info.plist new file mode 100644 index 00000000..b603bde0 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nhost Flutter Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + nhost_flutter_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/packages/nhost_flutter/example/ios/Runner/Runner-Bridging-Header.h b/packages/nhost_flutter/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/nhost_flutter/example/ios/Runner/SceneDelegate.swift b/packages/nhost_flutter/example/ios/Runner/SceneDelegate.swift new file mode 100644 index 00000000..b9ce8ea2 --- /dev/null +++ b/packages/nhost_flutter/example/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/packages/nhost_flutter/example/ios/RunnerTests/RunnerTests.swift b/packages/nhost_flutter/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/packages/nhost_flutter/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/nhost_flutter/example/lib/config.dart b/packages/nhost_flutter/example/lib/config.dart new file mode 100644 index 00000000..c41eecfb --- /dev/null +++ b/packages/nhost_flutter/example/lib/config.dart @@ -0,0 +1,4 @@ +// Replace with your Nhost project subdomain and region. +// For local development use Nhost.local() instead of Nhost.initialize(). +const subdomain = 'xnnmloqkoasdlwm'; +const region = 'eu-central-1'; diff --git a/packages/nhost_flutter/example/lib/main.dart b/packages/nhost_flutter/example/lib/main.dart new file mode 100644 index 00000000..2b86fcd1 --- /dev/null +++ b/packages/nhost_flutter/example/lib/main.dart @@ -0,0 +1,766 @@ +// nhost_flutter example app. +// Demonstrates every P1 Flutter Experience API: +// Nhost.initialize(), NhostAuthGate, NhostAuthStateBuilder, +// NhostSignedIn/Out, NhostUserBuilder, authStateChanges, authStateListenable +// sign-in, register, anonymous sign-in, forgot password, change password +import 'package:flutter/material.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; + +import 'config.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await Nhost.initialize( + subdomain: Subdomain(subdomain: subdomain, region: region), + // SecureAuthStore is used by default — tokens are persisted automatically. + // For local development, swap the line above with: + // await Nhost.local(); + ); + + runApp(const NhostExampleApp()); +} + +class NhostExampleApp extends StatelessWidget { + const NhostExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return NhostAuthProvider( + auth: Nhost.instance.auth, + child: MaterialApp( + title: 'nhost_flutter example', + home: NhostAuthGate( + loading: (_) => const _SplashScreen(), + signedOut: (_) => const _SignInScreen(), + signedIn: (_, user, __) => _HomeScreen(user: user), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Splash +// --------------------------------------------------------------------------- + +class _SplashScreen extends StatelessWidget { + const _SplashScreen(); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } +} + +// --------------------------------------------------------------------------- +// Sign-in +// --------------------------------------------------------------------------- + +class _SignInScreen extends StatefulWidget { + const _SignInScreen(); + + @override + State<_SignInScreen> createState() => _SignInScreenState(); +} + +class _SignInScreenState extends State<_SignInScreen> { + final _email = TextEditingController(text: 'user-1@nhost.io'); + final _password = TextEditingController(text: 'password-1'); + bool _loading = false; + + Future _signIn() async { + setState(() => _loading = true); + try { + await Nhost.instance.auth.signInEmailPassword( + email: _email.text, + password: _password.text, + ); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Sign in failed: ${e.responseBody}')), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _signInAnonymously() async { + setState(() => _loading = true); + try { + await Nhost.instance.auth.signInAnonymous(null, null, null); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Anonymous sign in failed: ${e.responseBody}')), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _goToRegister() => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const _RegisterScreen()), + ); + + void _goToForgotPassword() => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const _ForgotPasswordScreen()), + ); + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Sign in')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _email, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + TextField( + controller: _password, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _goToForgotPassword, + child: const Text('Forgot password?'), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading ? null : _signIn, + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign in'), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon(Icons.person_outline), + label: const Text('Continue as guest'), + onPressed: _loading ? null : _signInAnonymously, + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: _goToRegister, + child: const Text("Don't have an account? Register"), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Register +// --------------------------------------------------------------------------- + +class _RegisterScreen extends StatefulWidget { + const _RegisterScreen(); + + @override + State<_RegisterScreen> createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State<_RegisterScreen> { + final _displayName = TextEditingController(); + final _email = TextEditingController(); + final _password = TextEditingController(); + bool _loading = false; + + Future _register() async { + setState(() => _loading = true); + try { + await Nhost.instance.auth.signUp( + email: _email.text, + password: _password.text, + displayName: _displayName.text.isEmpty ? null : _displayName.text, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Account created! Check your email to verify.'), + ), + ); + Navigator.of(context).pop(); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Registration failed: ${e.responseBody}')), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + void dispose() { + _displayName.dispose(); + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Create account')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _displayName, + decoration: const InputDecoration( + labelText: 'Display name (optional)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _email, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + TextField( + controller: _password, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading ? null : _register, + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create account'), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Already have an account? Sign in'), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Forgot password — sends reset email +// --------------------------------------------------------------------------- + +class _ForgotPasswordScreen extends StatefulWidget { + const _ForgotPasswordScreen(); + + @override + State<_ForgotPasswordScreen> createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State<_ForgotPasswordScreen> { + final _email = TextEditingController(); + bool _loading = false; + bool _sent = false; + + Future _send() async { + setState(() => _loading = true); + try { + await Nhost.instance.auth.resetPassword(email: _email.text); + if (!mounted) return; + setState(() => _sent = true); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed: ${e.responseBody}')), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + void dispose() { + _email.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Forgot password')), + body: Padding( + padding: const EdgeInsets.all(24), + child: _sent + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.mark_email_read_outlined, + size: 64, color: Colors.green), + const SizedBox(height: 16), + Text( + 'Password reset email sent to ${_email.text}.\nCheck your inbox.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Back to sign in'), + ), + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Enter your email and we'll send you a link to reset your password.", + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextField( + controller: _email, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading ? null : _send, + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Send reset email'), + ), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Home — demonstrates all P1 widgets +// --------------------------------------------------------------------------- + +class _HomeScreen extends StatelessWidget { + const _HomeScreen({required this.user}); + final User user; + + @override + Widget build(BuildContext context) { + final isAnonymous = user.isAnonymous; + + return Scaffold( + appBar: AppBar( + title: const Text('nhost_flutter demo'), + actions: [ + if (isAnonymous) + TextButton.icon( + icon: const Icon(Icons.upgrade), + label: const Text('Upgrade'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const _ConvertAnonymousScreen(), + ), + ), + ), + IconButton( + icon: const Icon(Icons.lock_outline), + tooltip: 'Change password', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const _ChangePasswordScreen(), + ), + ), + ), + IconButton( + icon: const Icon(Icons.logout), + tooltip: 'Sign out', + onPressed: () => Nhost.instance.auth.signOut(), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (isAnonymous) + _InfoBanner( + message: + 'You are signed in as a guest. Upgrade your account to save your data.', + action: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const _ConvertAnonymousScreen(), + ), + ), + child: const Text('Upgrade now'), + ), + ), + _Section( + title: 'NhostUserBuilder', + child: NhostUserBuilder( + builder: (_, u) => Text( + 'Hello, ${u.displayName.isNotEmpty ? u.displayName : (u.email ?? 'guest')}!', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + _Section( + title: 'NhostSignedIn / NhostSignedOut', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NhostSignedIn( + child: Chip( + avatar: const Icon(Icons.check_circle, color: Colors.green), + label: const Text('Signed in'), + ), + ), + NhostSignedOut( + child: Chip( + avatar: const Icon(Icons.cancel, color: Colors.red), + label: const Text('Signed out'), + ), + ), + ], + ), + ), + _Section( + title: 'NhostAuthStateBuilder (sealed switch)', + child: NhostAuthStateBuilder( + builder: (_, state) => switch (state) { + AuthStateLoading() => const Text('Loading…'), + AuthStateSignedOut() => const Text('Not signed in'), + AuthStateSignedIn(:final user) => + Text('Signed in as ${user.email ?? 'guest'}'), + }, + ), + ), + _Section( + title: 'authStateChanges stream', + child: StreamBuilder( + stream: Nhost.instance.auth.authStateChanges, + builder: (_, snap) { + final state = snap.data; + return Text(switch (state) { + null => 'Waiting for first event…', + AuthStateLoading() => 'Loading…', + AuthStateSignedOut() => 'Stream: signed out', + AuthStateSignedIn(:final user) => + 'Stream: signed in as ${user.email ?? 'guest'}', + }); + }, + ), + ), + _Section( + title: 'authStateListenable', + child: ValueListenableBuilder( + valueListenable: Nhost.instance.auth.authStateListenable, + builder: (_, state, __) => Text(switch (state) { + AuthStateLoading() => 'Listenable: loading…', + AuthStateSignedOut() => 'Listenable: signed out', + AuthStateSignedIn(:final user) => + 'Listenable: signed in as ${user.email ?? 'guest'}', + }), + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Convert anonymous account → full account +// --------------------------------------------------------------------------- + +class _ConvertAnonymousScreen extends StatefulWidget { + const _ConvertAnonymousScreen(); + + @override + State<_ConvertAnonymousScreen> createState() => + _ConvertAnonymousScreenState(); +} + +class _ConvertAnonymousScreenState extends State<_ConvertAnonymousScreen> { + final _email = TextEditingController(); + final _password = TextEditingController(); + bool _loading = false; + + Future _convert() async { + setState(() => _loading = true); + try { + await Nhost.instance.auth.deanonymizeUser( + DeanonymizeOptions( + signInMethod: DeanonymizeSignInMethod.emailPassword, + email: _email.text, + password: _password.text, + ), + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Account upgraded! Check your email.')), + ); + Navigator.of(context).pop(); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Upgrade failed: ${e.responseBody}')), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Upgrade account')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Convert your guest account to a permanent account.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextField( + controller: _email, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + TextField( + controller: _password, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading ? null : _convert, + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Upgrade account'), + ), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Change password (for logged-in users) +// --------------------------------------------------------------------------- + +class _ChangePasswordScreen extends StatefulWidget { + const _ChangePasswordScreen(); + + @override + State<_ChangePasswordScreen> createState() => _ChangePasswordScreenState(); +} + +class _ChangePasswordScreenState extends State<_ChangePasswordScreen> { + final _newPassword = TextEditingController(); + bool _loading = false; + + Future _changePassword() async { + setState(() => _loading = true); + try { + await Nhost.instance.auth.changePassword( + newPassword: _newPassword.text, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password changed successfully.')), + ); + Navigator.of(context).pop(); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed: ${e.responseBody}')), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + void dispose() { + _newPassword.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Change password')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextField( + controller: _newPassword, + decoration: const InputDecoration( + labelText: 'New password', + border: OutlineInputBorder(), + ), + obscureText: true, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading ? null : _changePassword, + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Change password'), + ), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Shared widgets +// --------------------------------------------------------------------------- + +class _InfoBanner extends StatelessWidget { + const _InfoBanner({required this.message, required this.action}); + final String message; + final Widget action; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.info_outline), + const SizedBox(width: 8), + Expanded(child: Text(message)), + action, + ], + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({required this.title, required this.child}); + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith(color: Colors.grey)), + const SizedBox(height: 8), + child, + ], + ), + ), + ); + } +} diff --git a/packages/nhost_flutter/example/pubspec.yaml b/packages/nhost_flutter/example/pubspec.yaml new file mode 100644 index 00000000..5056f35a --- /dev/null +++ b/packages/nhost_flutter/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: nhost_flutter_example +description: Example app demonstrating nhost_flutter P1 Flutter Experience APIs. +publish_to: none +version: 1.0.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.10.0" + + +dependencies: + flutter: + sdk: flutter + nhost_flutter: + path: ../ + cupertino_icons: + +dev_dependencies: + flutter_test: + sdk: flutter + lints: ^5.1.1 diff --git a/packages/nhost_flutter/example/pubspec_overrides.yaml b/packages/nhost_flutter/example/pubspec_overrides.yaml new file mode 100644 index 00000000..6576a886 --- /dev/null +++ b/packages/nhost_flutter/example/pubspec_overrides.yaml @@ -0,0 +1,22 @@ +# melos_managed_dependency_overrides: nhost_auth_dart,nhost_dart,nhost_flutter,nhost_flutter_auth,nhost_flutter_graphql,nhost_functions_dart,nhost_gql_links,nhost_graphql_adapter,nhost_sdk,nhost_storage_dart +dependency_overrides: + nhost_auth_dart: + path: ../../nhost_auth_dart + nhost_dart: + path: ../../nhost_dart + nhost_flutter: + path: .. + nhost_flutter_auth: + path: ../../nhost_flutter_auth + nhost_flutter_graphql: + path: ../../nhost_flutter_graphql + nhost_functions_dart: + path: ../../nhost_functions_dart + nhost_gql_links: + path: ../../nhost_gql_links + nhost_graphql_adapter: + path: ../../nhost_graphql_adapter + nhost_sdk: + path: ../../nhost_sdk + nhost_storage_dart: + path: ../../nhost_storage_dart diff --git a/packages/nhost_flutter/lib/nhost_flutter.dart b/packages/nhost_flutter/lib/nhost_flutter.dart new file mode 100644 index 00000000..9df092f6 --- /dev/null +++ b/packages/nhost_flutter/lib/nhost_flutter.dart @@ -0,0 +1,13 @@ +export 'package:nhost_dart/nhost_dart.dart'; +export 'package:nhost_flutter_auth/nhost_flutter_auth.dart'; +export 'package:nhost_flutter_graphql/nhost_flutter_graphql.dart'; + +export 'src/auth_state.dart'; +export 'src/secure_auth_store.dart'; +export 'src/auth_client_extensions.dart'; +export 'src/nhost.dart'; +export 'src/widgets/nhost_auth_gate.dart'; +export 'src/widgets/nhost_auth_state_builder.dart'; +export 'src/widgets/nhost_signed_in.dart'; +export 'src/widgets/nhost_signed_out.dart'; +export 'src/widgets/nhost_user_builder.dart'; diff --git a/packages/nhost_flutter/lib/src/auth_client_extensions.dart b/packages/nhost_flutter/lib/src/auth_client_extensions.dart new file mode 100644 index 00000000..4ae83628 --- /dev/null +++ b/packages/nhost_flutter/lib/src/auth_client_extensions.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:nhost_dart/nhost_dart.dart'; + +import 'auth_state.dart'; + +extension NhostAuthClientFlutterX on NhostAuthClient { + static final _state = Expando<_AuthFlutterState>(); + + _AuthFlutterState get _flutterState => + _state[this] ??= _AuthFlutterState(this); + + /// A broadcast [Stream] that emits an [AuthState] on every authentication + /// state change. + Stream get authStateChanges => _flutterState.stream; + + /// A [ValueListenable] that always holds the current [AuthState]. + ValueListenable get authStateListenable => _flutterState.notifier; +} + +class _AuthFlutterState { + _AuthFlutterState(NhostAuthClient auth) : _auth = auth { + _notifier = ValueNotifier(_mapCurrentState()); + _controller = StreamController.broadcast(); + _unsubscribe = auth.addAuthStateChangedCallback((_) { + final mapped = _mapCurrentState(); + _notifier.value = mapped; + _controller.add(mapped); + }); + } + + final NhostAuthClient _auth; + late final StreamController _controller; + late final ValueNotifier _notifier; + late final UnsubscribeDelegate _unsubscribe; + + Stream get stream => _controller.stream; + ValueListenable get notifier => _notifier; + + AuthState _mapCurrentState() => switch (_auth.authenticationState) { + AuthenticationState.inProgress => const AuthStateLoading(), + AuthenticationState.signedOut => const AuthStateSignedOut(), + AuthenticationState.signedIn => AuthStateSignedIn( + user: _auth.currentUser!, + session: _auth.userSession.session!, + ), + }; + + void dispose() { + _unsubscribe(); + _controller.close(); + _notifier.dispose(); + } +} diff --git a/packages/nhost_flutter/lib/src/auth_state.dart b/packages/nhost_flutter/lib/src/auth_state.dart new file mode 100644 index 00000000..ba7db49d --- /dev/null +++ b/packages/nhost_flutter/lib/src/auth_state.dart @@ -0,0 +1,19 @@ +import 'package:nhost_sdk/nhost_sdk.dart'; + +sealed class AuthState { + const AuthState(); +} + +final class AuthStateLoading extends AuthState { + const AuthStateLoading(); +} + +final class AuthStateSignedOut extends AuthState { + const AuthStateSignedOut(); +} + +final class AuthStateSignedIn extends AuthState { + const AuthStateSignedIn({required this.user, required this.session}); + final User user; + final Session session; +} diff --git a/packages/nhost_flutter/lib/src/nhost.dart b/packages/nhost_flutter/lib/src/nhost.dart new file mode 100644 index 00000000..d7f387b2 --- /dev/null +++ b/packages/nhost_flutter/lib/src/nhost.dart @@ -0,0 +1,109 @@ +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:nhost_dart/nhost_dart.dart'; + +import 'secure_auth_store.dart'; + +/// Entry point for the Nhost Flutter SDK. +/// +/// Call [initialize] once in `main()` before [runApp], then access the client +/// anywhere via [instance]. +/// +/// ```dart +/// void main() async { +/// WidgetsFlutterBinding.ensureInitialized(); +/// await Nhost.initialize( +/// subdomain: Subdomain(subdomain: 'xxx', region: 'eu-central-1'), +/// ); +/// runApp(const MyApp()); +/// } +/// ``` +class Nhost { + Nhost._(); + + static NhostClient? _instance; + + /// Initializes the Nhost SDK and returns the configured [NhostClient]. + /// + /// Exactly one of [subdomain] or [serviceUrls] must be provided. + /// + /// Tokens are persisted via [SecureAuthStore] by default. Pass [authStore] + /// to override. Pass `restoreSession: false` to skip the automatic restore. + /// + /// Throws [StateError] if called more than once without [reset]. + static Future initialize({ + Subdomain? subdomain, + ServiceUrls? serviceUrls, + AuthStore? authStore, + Duration? tokenRefreshInterval, + http.Client? httpClientOverride, + bool restoreSession = true, + }) async { + if (_instance != null) { + throw StateError( + 'Nhost is already initialized. Call Nhost.reset() first.', + ); + } + + _instance = NhostClient( + subdomain: subdomain, + serviceUrls: serviceUrls, + authStore: authStore ?? const SecureAuthStore(), + tokenRefreshInterval: tokenRefreshInterval, + httpClientOverride: httpClientOverride, + ); + + if (restoreSession) { + await _instance!.auth + .signInWithStoredCredentials() + .catchError((_) => AuthResponse(session: null)); + } + + return _instance!; + } + + /// Convenience initializer for local Nhost development. + /// + /// Defaults to `http://localhost:1337` — the standard `nhost dev` port. + /// On Android emulators pass `authUrl: 'http://10.0.2.2:1337/v1/auth'` etc. + static Future local({ + String authUrl = 'http://localhost:1337/v1/auth', + String storageUrl = 'http://localhost:1337/v1/storage', + String functionsUrl = 'http://localhost:1337/v1/functions', + String graphqlUrl = 'http://localhost:1337/v1/graphql', + AuthStore? authStore, + http.Client? httpClientOverride, + bool restoreSession = true, + }) => + initialize( + serviceUrls: ServiceUrls( + authUrl: authUrl, + storageUrl: storageUrl, + functionsUrl: functionsUrl, + graphqlUrl: graphqlUrl, + ), + authStore: authStore, + httpClientOverride: httpClientOverride, + restoreSession: restoreSession, + ); + + /// The initialized [NhostClient]. + /// + /// Throws [StateError] if accessed before [initialize] is called. + static NhostClient get instance { + final inst = _instance; + if (inst == null) { + throw StateError( + 'Nhost has not been initialized. Call Nhost.initialize() first.', + ); + } + return inst; + } + + /// Clears the singleton. Intended for use in tests only. + @visibleForTesting + static void reset() { + _instance?.close(); + _instance = null; + } +} diff --git a/packages/nhost_flutter/lib/src/secure_auth_store.dart b/packages/nhost_flutter/lib/src/secure_auth_store.dart new file mode 100644 index 00000000..b9cd235d --- /dev/null +++ b/packages/nhost_flutter/lib/src/secure_auth_store.dart @@ -0,0 +1,39 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:nhost_sdk/nhost_sdk.dart'; + +/// An [AuthStore] backed by `flutter_secure_storage`. +/// +/// Used by default in [Nhost.initialize]. Swap it out by passing a custom +/// [AuthStore] to the `authStore` parameter. +/// +/// Secure defaults: +/// - Android: `encryptedSharedPreferences: true` +/// - iOS: `KeychainAccessibility.first_unlock` +class SecureAuthStore implements AuthStore { + const SecureAuthStore({ + this.androidOptions = const AndroidOptions( + encryptedSharedPreferences: true, + ), + this.iOSOptions = const IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + }); + + final AndroidOptions androidOptions; + final IOSOptions iOSOptions; + + FlutterSecureStorage get _storage => FlutterSecureStorage( + aOptions: androidOptions, + iOptions: iOSOptions, + ); + + @override + Future getString(String key) => _storage.read(key: key); + + @override + Future setString(String key, String value) => + _storage.write(key: key, value: value); + + @override + Future removeItem(String key) => _storage.delete(key: key); +} diff --git a/packages/nhost_flutter/lib/src/widgets/nhost_auth_gate.dart b/packages/nhost_flutter/lib/src/widgets/nhost_auth_gate.dart new file mode 100644 index 00000000..88850a09 --- /dev/null +++ b/packages/nhost_flutter/lib/src/widgets/nhost_auth_gate.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:nhost_flutter_auth/nhost_flutter_auth.dart'; + +/// Routes to different widgets based on the current authentication state. +/// +/// Must be a descendant of [NhostAuthProvider]. +/// +/// ```dart +/// NhostAuthGate( +/// loading: (context) => const SplashScreen(), +/// signedOut: (context) => const LoginScreen(), +/// signedIn: (context, user, session) => HomeScreen(user: user), +/// ) +/// ``` +class NhostAuthGate extends StatelessWidget { + const NhostAuthGate({ + super.key, + required this.signedOut, + required this.signedIn, + this.loading, + }); + + /// Builder shown while authentication state is being determined. + /// Defaults to [SizedBox.shrink]. + final WidgetBuilder? loading; + + /// Builder shown when no user is authenticated. + final WidgetBuilder signedOut; + + /// Builder shown when a user is authenticated. + final Widget Function(BuildContext context, User user, Session session) + signedIn; + + @override + Widget build(BuildContext context) { + final auth = NhostAuthProvider.of(context)!; + return switch (auth.authenticationState) { + AuthenticationState.inProgress => + loading?.call(context) ?? const SizedBox.shrink(), + AuthenticationState.signedOut => signedOut(context), + AuthenticationState.signedIn => + signedIn(context, auth.currentUser!, auth.userSession.session!), + }; + } +} diff --git a/packages/nhost_flutter/lib/src/widgets/nhost_auth_state_builder.dart b/packages/nhost_flutter/lib/src/widgets/nhost_auth_state_builder.dart new file mode 100644 index 00000000..8e819e64 --- /dev/null +++ b/packages/nhost_flutter/lib/src/widgets/nhost_auth_state_builder.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; +import 'package:nhost_flutter_auth/nhost_flutter_auth.dart'; + +import '../auth_state.dart'; + +/// Builds a widget tree based on the current [AuthState]. +/// +/// The [builder] receives a sealed [AuthState] — use an exhaustive `switch` +/// with no `default` case: +/// +/// ```dart +/// NhostAuthStateBuilder( +/// builder: (context, state) => switch (state) { +/// AuthStateLoading() => const SplashScreen(), +/// AuthStateSignedOut() => const LoginScreen(), +/// AuthStateSignedIn(:final user) => HomeScreen(user: user), +/// }, +/// ) +/// ``` +/// +/// Must be a descendant of [NhostAuthProvider]. +class NhostAuthStateBuilder extends StatelessWidget { + const NhostAuthStateBuilder({super.key, required this.builder}); + + final Widget Function(BuildContext context, AuthState state) builder; + + @override + Widget build(BuildContext context) { + final auth = NhostAuthProvider.of(context)!; + final state = switch (auth.authenticationState) { + AuthenticationState.inProgress => const AuthStateLoading(), + AuthenticationState.signedOut => const AuthStateSignedOut(), + AuthenticationState.signedIn => AuthStateSignedIn( + user: auth.currentUser!, + session: auth.userSession.session!, + ), + }; + return builder(context, state); + } +} diff --git a/packages/nhost_flutter/lib/src/widgets/nhost_signed_in.dart b/packages/nhost_flutter/lib/src/widgets/nhost_signed_in.dart new file mode 100644 index 00000000..5f9e2432 --- /dev/null +++ b/packages/nhost_flutter/lib/src/widgets/nhost_signed_in.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; +import 'package:nhost_flutter_auth/nhost_flutter_auth.dart'; + +/// Shows [child] only when a user is authenticated. Renders [orElse] +/// (default: [SizedBox.shrink]) when signed out or loading. +/// +/// Must be a descendant of [NhostAuthProvider]. +class NhostSignedIn extends StatelessWidget { + const NhostSignedIn({super.key, required this.child, this.orElse}); + + final Widget child; + final Widget? orElse; + + @override + Widget build(BuildContext context) { + final auth = NhostAuthProvider.of(context)!; + if (auth.authenticationState == AuthenticationState.signedIn) { + return child; + } + return orElse ?? const SizedBox.shrink(); + } +} diff --git a/packages/nhost_flutter/lib/src/widgets/nhost_signed_out.dart b/packages/nhost_flutter/lib/src/widgets/nhost_signed_out.dart new file mode 100644 index 00000000..2a46394f --- /dev/null +++ b/packages/nhost_flutter/lib/src/widgets/nhost_signed_out.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; +import 'package:nhost_flutter_auth/nhost_flutter_auth.dart'; + +/// Shows [child] only when no user is authenticated. Renders [orElse] +/// (default: [SizedBox.shrink]) when signed in or loading. +/// +/// Must be a descendant of [NhostAuthProvider]. +class NhostSignedOut extends StatelessWidget { + const NhostSignedOut({super.key, required this.child, this.orElse}); + + final Widget child; + final Widget? orElse; + + @override + Widget build(BuildContext context) { + final auth = NhostAuthProvider.of(context)!; + if (auth.authenticationState == AuthenticationState.signedOut) { + return child; + } + return orElse ?? const SizedBox.shrink(); + } +} diff --git a/packages/nhost_flutter/lib/src/widgets/nhost_user_builder.dart b/packages/nhost_flutter/lib/src/widgets/nhost_user_builder.dart new file mode 100644 index 00000000..31ff8c12 --- /dev/null +++ b/packages/nhost_flutter/lib/src/widgets/nhost_user_builder.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; +import 'package:nhost_flutter_auth/nhost_flutter_auth.dart'; + +/// Builds a widget using the currently authenticated [User]. +/// +/// Renders [orElse] (default: [SizedBox.shrink]) when the user is not signed +/// in or authentication is still loading. +/// +/// Must be a descendant of [NhostAuthProvider]. +class NhostUserBuilder extends StatelessWidget { + const NhostUserBuilder({super.key, required this.builder, this.orElse}); + + final Widget Function(BuildContext context, User user) builder; + final WidgetBuilder? orElse; + + @override + Widget build(BuildContext context) { + final auth = NhostAuthProvider.of(context)!; + final user = auth.currentUser; + if (auth.authenticationState == AuthenticationState.signedIn && + user != null) { + return builder(context, user); + } + return orElse?.call(context) ?? const SizedBox.shrink(); + } +} diff --git a/packages/nhost_flutter/pubspec.yaml b/packages/nhost_flutter/pubspec.yaml new file mode 100644 index 00000000..6f0dce54 --- /dev/null +++ b/packages/nhost_flutter/pubspec.yaml @@ -0,0 +1,28 @@ +name: nhost_flutter +description: > + First-class Flutter package for Nhost. Includes auth, storage, GraphQL, + secure persistence by default, and modern sealed auth state APIs. +version: 1.0.0 +homepage: https://nhost.io +repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_flutter +issue_tracker: https://github.com/nhost/nhost-dart/issues + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + nhost_dart: ^2.2.0 + nhost_flutter_auth: ^4.2.1 + nhost_flutter_graphql: ^3.1.2 + nhost_sdk: ^5.8.0 + flutter_secure_storage: ^9.0.0 + http: ^1.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + lints: ^5.1.1 + mockito: ^5.4.5 diff --git a/packages/nhost_flutter/pubspec_overrides.yaml b/packages/nhost_flutter/pubspec_overrides.yaml new file mode 100644 index 00000000..8412bb6d --- /dev/null +++ b/packages/nhost_flutter/pubspec_overrides.yaml @@ -0,0 +1,20 @@ +# melos_managed_dependency_overrides: nhost_auth_dart,nhost_dart,nhost_flutter_auth,nhost_flutter_graphql,nhost_functions_dart,nhost_gql_links,nhost_graphql_adapter,nhost_sdk,nhost_storage_dart +dependency_overrides: + nhost_auth_dart: + path: ../nhost_auth_dart + nhost_dart: + path: ../nhost_dart + nhost_flutter_auth: + path: ../nhost_flutter_auth + nhost_flutter_graphql: + path: ../nhost_flutter_graphql + nhost_functions_dart: + path: ../nhost_functions_dart + nhost_gql_links: + path: ../nhost_gql_links + nhost_graphql_adapter: + path: ../nhost_graphql_adapter + nhost_sdk: + path: ../nhost_sdk + nhost_storage_dart: + path: ../nhost_storage_dart diff --git a/packages/nhost_flutter/test/auth_client_extensions_test.dart b/packages/nhost_flutter/test/auth_client_extensions_test.dart new file mode 100644 index 00000000..fe62ccea --- /dev/null +++ b/packages/nhost_flutter/test/auth_client_extensions_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; + +import 'helpers/fake_auth_client.dart'; + +void main() { + group('NhostAuthClientFlutterX', () { + late FakeAuthClient auth; + + setUp(() { + auth = FakeAuthClient(AuthenticationState.signedOut); + }); + + group('authStateChanges stream', () { + test('emits AuthStateSignedIn when client fires signedIn callback', + () async { + final states = []; + final sub = auth.authStateChanges.listen(states.add); + addTearDown(sub.cancel); + + auth.simulateSignIn(makeUser(), makeSession()); + + await Future.microtask(() {}); + expect(states, [isA()]); + expect((states.first as AuthStateSignedIn).user.email, + 'test@example.com'); + }); + + test('emits AuthStateSignedOut when client fires signedOut callback', + () async { + auth = FakeAuthClient(AuthenticationState.signedIn) + ..simulateSignIn(makeUser(), makeSession()); + + final states = []; + final sub = auth.authStateChanges.listen(states.add); + addTearDown(sub.cancel); + + auth.simulateSignOut(); + + await Future.microtask(() {}); + expect(states, [isA()]); + }); + + test('is a broadcast stream — multiple listeners allowed', () async { + final sub1 = auth.authStateChanges.listen((_) {}); + final sub2 = auth.authStateChanges.listen((_) {}); + addTearDown(sub1.cancel); + addTearDown(sub2.cancel); + expect(() => auth.authStateChanges, returnsNormally); + }); + }); + + group('authStateListenable', () { + test('initial value reflects signedOut state', () { + expect(auth.authStateListenable.value, isA()); + }); + + test('notifies listeners on state change', () { + final notifier = + auth.authStateListenable as ValueNotifier; + var notified = false; + notifier.addListener(() => notified = true); + + auth.simulateSignIn(makeUser(), makeSession()); + + expect(notified, isTrue); + expect(notifier.value, isA()); + }); + }); + }); +} diff --git a/packages/nhost_flutter/test/auth_state_test.dart b/packages/nhost_flutter/test/auth_state_test.dart new file mode 100644 index 00000000..cb7cca78 --- /dev/null +++ b/packages/nhost_flutter/test/auth_state_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; +import 'package:nhost_sdk/nhost_sdk.dart'; + +void main() { + group('AuthState sealed classes', () { + test('AuthStateLoading is an AuthState', () { + const AuthState state = AuthStateLoading(); + expect(state, isA()); + }); + + test('AuthStateSignedOut is an AuthState', () { + const AuthState state = AuthStateSignedOut(); + expect(state, isA()); + }); + + test('AuthStateSignedIn carries user and session', () { + final user = makeTestUser(); + final session = makeTestSession(); + final AuthState state = AuthStateSignedIn(user: user, session: session); + + expect(state, isA()); + expect((state as AuthStateSignedIn).user.email, 'ada@example.com'); + expect(state.session.accessToken, 'tok'); + }); + + test('exhaustive switch compiles without default', () { + const AuthState state = AuthStateLoading(); + final label = switch (state) { + AuthStateLoading() => 'loading', + AuthStateSignedOut() => 'out', + AuthStateSignedIn() => 'in', + }; + expect(label, 'loading'); + }); + }); +} + +User makeTestUser() => User( + id: 'u1', + displayName: 'Ada', + locale: 'en', + createdAt: DateTime.utc(2024), + isAnonymous: false, + defaultRole: 'user', + roles: const ['user'], + emailVerified: true, + phoneNumber: '', + phoneNumberVerified: false, + email: 'ada@example.com', + ); + +Session makeTestSession() => Session( + accessToken: 'tok', + accessTokenExpiresIn: const Duration(seconds: 900), + refreshToken: 'ref', + user: makeTestUser(), + ); diff --git a/packages/nhost_flutter/test/helpers/fake_auth_client.dart b/packages/nhost_flutter/test/helpers/fake_auth_client.dart new file mode 100644 index 00000000..eb853673 --- /dev/null +++ b/packages/nhost_flutter/test/helpers/fake_auth_client.dart @@ -0,0 +1,243 @@ +import 'package:nhost_dart/nhost_dart.dart'; +import 'package:nhost_sdk/nhost_sdk.dart'; + +/// Minimal fake that drives auth state changes without a real server. +class FakeAuthClient implements NhostAuthClient { + FakeAuthClient(this._state); + + AuthenticationState _state; + User? _user; + // Cached UserSession — built once in simulateSignIn to avoid repeated + // JwtDecoder.decode calls that would fail on every userSession getter access. + UserSession _cachedSession = UserSession(); + + final List _listeners = []; + + void simulateSignIn(User user, Session session) { + _user = user; + _cachedSession = UserSession()..session = session; + _state = AuthenticationState.signedIn; + for (final l in List.of(_listeners)) { + l(AuthenticationState.signedIn); + } + } + + void simulateSignOut() { + _user = null; + _cachedSession = UserSession(); + _state = AuthenticationState.signedOut; + for (final l in List.of(_listeners)) { + l(AuthenticationState.signedOut); + } + } + + @override + AuthenticationState get authenticationState => _state; + + @override + User? get currentUser => _user; + + @override + UserSession get userSession => _cachedSession; + + @override + UnsubscribeDelegate addAuthStateChangedCallback( + AuthStateChangedCallback callback) { + _listeners.add(callback); + return () => _listeners.remove(callback); + } + + // --- unused stubs --- + @override + String? get accessToken => null; + @override + String? getClaim(String c) => null; + @override + void close() {} + @override + UnsubscribeDelegate addTokenChangedCallback(TokenChangedCallback cb) => + () {}; + @override + UnsubscribeDelegate addSessionRefreshFailedCallback( + SessionRefreshFailedCallback cb) => + () {}; + @override + Future signUp( + {required String email, + required String password, + String? locale, + String? defaultRole, + Map? metadata, + List? roles, + String? displayName, + String? redirectTo, + String? turnstileResponse}) => + throw UnimplementedError(); + @override + Future signInEmailPassword( + {required String email, required String password}) => + throw UnimplementedError(); + @override + Future signInIdToken( + {required String provider, + required String idToken, + String? nonce, + String? locale, + String? defaultRole, + Map? metadata, + List? roles, + String? displayName, + String? redirectTo}) => + throw UnimplementedError(); + @override + Future linkIdToken( + {required String provider, + required String idToken, + String? nonce}) => + throw UnimplementedError(); + @override + Future signInWithEmailPasswordless( + {required String email, + String? locale, + String? defaultRole, + Map? metadata, + List? roles, + String? displayName, + String? redirectTo}) => + throw UnimplementedError(); + @override + Future signInAnonymous(String? displayName, String? locale, + Map? metadata) => + throw UnimplementedError(); + @override + Future deanonymizeUser(DeanonymizeOptions options) => + throw UnimplementedError(); + @override + Future signInWithSmsPasswordless( + {required String phoneNumber, + String? locale, + String? defaultRole, + Map? metadata, + List? roles, + String? displayName, + String? redirectTo}) => + throw UnimplementedError(); + @override + Future completeSmsPasswordlessSignIn( + String phoneNumber, String otp) => + throw UnimplementedError(); + @override + Future signInEmailOTP( + {required String email, + String? locale, + String? defaultRole, + Map? metadata, + List? roles, + String? displayName, + String? redirectTo}) => + throw UnimplementedError(); + @override + Future verifyEmailOTP( + {required String email, required String otp}) => + throw UnimplementedError(); + @override + Future signInWithStoredCredentials() => + throw UnimplementedError(); + @override + Future signInWithRefreshToken(String refreshToken) => + throw UnimplementedError(); + @override + Future signOut({bool all = false}) => + throw UnimplementedError(); + @override + Future sendVerificationEmail( + {required String email, String? redirectTo}) => + throw UnimplementedError(); + @override + Future changeEmail(String newEmail) => throw UnimplementedError(); + @override + Future changePassword( + {required String newPassword, String? ticket}) => + throw UnimplementedError(); + @override + Future resetPassword({required String email, String? redirectTo}) => + throw UnimplementedError(); + @override + Future generateMfa() => throw UnimplementedError(); + @override + Future enableMfa(String totp) => throw UnimplementedError(); + @override + Future disableMfa(String code) => throw UnimplementedError(); + @override + Future completeMfaSignIn( + {required String otp, required String ticket}) => + throw UnimplementedError(); + @override + Future completeOAuthProviderSignIn(Uri redirectUrl) => + throw UnimplementedError(); + @override + Future signInWithPat(String pat) => throw UnimplementedError(); + @override + Future fetchUser() => throw UnimplementedError(); + @override + Future verifyToken(String accessToken) => throw UnimplementedError(); + @override + Future> signInWithWebAuthn() => + throw UnimplementedError(); + @override + Future verifyWebAuthnSignIn( + Map assertionResponse) => + throw UnimplementedError(); + @override + Future> signUpWithWebAuthn({String? email}) => + throw UnimplementedError(); + @override + Future verifyWebAuthnSignUp( + Map attestationResponse) => + throw UnimplementedError(); + @override + Future> addWebAuthnCredential() => + throw UnimplementedError(); + @override + Future verifyAddWebAuthnCredential( + Map attestationResponse) => + throw UnimplementedError(); + @override + Future> elevateWithWebAuthn() => + throw UnimplementedError(); + @override + Future verifyWebAuthnElevation( + Map assertionResponse) => + throw UnimplementedError(); + @override + Future setSession(Session session) => throw UnimplementedError(); + @override + Future clearSession() => throw UnimplementedError(); +} + +User makeUser({String email = 'test@example.com'}) => User( + id: 'u1', + displayName: 'Test', + locale: 'en', + createdAt: DateTime.utc(2024), + isAnonymous: false, + defaultRole: 'user', + roles: const ['user'], + emailVerified: true, + phoneNumber: '', + phoneNumberVerified: false, + email: email, + ); + +// A valid JWT is required because UserSession.session= calls JwtDecoder.decode. +const _validJwt = + 'eyJhbGciOiJIUzI1NiJ9' + '.eyJodHRwczovL2hhc3VyYS5pby9qd3QvY2xhaW1zIjp7IngtaGFzdXJhLWFsbG93ZWQtcm9sZXMiOlsidXNlciJdLCJ4LWhhc3VyYS1kZWZhdWx0LXJvbGUiOiJ1c2VyIiwieC1oYXN1cmEtdXNlci1pZCI6InUxIn0sInN1YiI6InUxIiwiaWF0IjoxNjAwMDAwMDAwLCJleHAiOjk5OTk5OTk5OTl9' + '.signature'; + +Session makeSession() => Session( + accessToken: _validJwt, + accessTokenExpiresIn: const Duration(seconds: 900), + refreshToken: 'ref', + user: makeUser(), + ); diff --git a/packages/nhost_flutter/test/nhost_singleton_test.dart b/packages/nhost_flutter/test/nhost_singleton_test.dart new file mode 100644 index 00000000..a9222e8e --- /dev/null +++ b/packages/nhost_flutter/test/nhost_singleton_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; + +/// Minimal in-memory [AuthStore] for tests — avoids a flutter_secure_storage +/// platform channel during unit tests. +class _MemoryAuthStore implements AuthStore { + final _store = {}; + @override + Future getString(String key) async => _store[key]; + @override + Future setString(String key, String value) async => _store[key] = value; + @override + Future removeItem(String key) async => _store.remove(key); +} + +void main() { + tearDown(() => Nhost.reset()); + + group('Nhost singleton', () { + test('initialize returns an NhostClient', () async { + final client = await Nhost.initialize( + subdomain: Subdomain(subdomain: 'test', region: 'us-east-1'), + authStore: _MemoryAuthStore(), + restoreSession: false, + ); + expect(client, isA()); + }); + + test('instance returns the same client after initialize', () async { + final client = await Nhost.initialize( + subdomain: Subdomain(subdomain: 'test', region: 'us-east-1'), + authStore: _MemoryAuthStore(), + restoreSession: false, + ); + expect(Nhost.instance, same(client)); + }); + + test('calling initialize twice throws StateError', () async { + await Nhost.initialize( + subdomain: Subdomain(subdomain: 'test', region: 'us-east-1'), + authStore: _MemoryAuthStore(), + restoreSession: false, + ); + expect( + () => Nhost.initialize( + subdomain: Subdomain(subdomain: 'test', region: 'us-east-1'), + authStore: _MemoryAuthStore(), + restoreSession: false, + ), + throwsA(isA()), + ); + }); + + test('accessing instance before initialize throws StateError', () { + expect(() => Nhost.instance, throwsA(isA())); + }); + + test('reset clears the singleton', () async { + await Nhost.initialize( + subdomain: Subdomain(subdomain: 'test', region: 'us-east-1'), + authStore: _MemoryAuthStore(), + restoreSession: false, + ); + Nhost.reset(); + expect(() => Nhost.instance, throwsA(isA())); + }); + + test('local() uses localhost service URLs', () async { + final client = await Nhost.local( + authStore: _MemoryAuthStore(), + restoreSession: false, + ); + expect(client.serviceUrls?.authUrl, contains('localhost:1337')); + }); + }); +} diff --git a/packages/nhost_flutter/test/secure_auth_store_test.dart b/packages/nhost_flutter/test/secure_auth_store_test.dart new file mode 100644 index 00000000..4c401728 --- /dev/null +++ b/packages/nhost_flutter/test/secure_auth_store_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; + +void main() { + group('SecureAuthStore', () { + test('implements AuthStore', () { + const AuthStore store = SecureAuthStore(); + expect(store, isA()); + }); + + test('default instance is const-constructible', () { + const a = SecureAuthStore(); + const b = SecureAuthStore(); + expect(identical(a, b), isTrue); + }); + }); +} diff --git a/packages/nhost_flutter/test/widgets/nhost_auth_gate_test.dart b/packages/nhost_flutter/test/widgets/nhost_auth_gate_test.dart new file mode 100644 index 00000000..a7b60bd4 --- /dev/null +++ b/packages/nhost_flutter/test/widgets/nhost_auth_gate_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; + +import '../helpers/fake_auth_client.dart'; + +Widget _wrap(FakeAuthClient auth, Widget child) => MaterialApp( + home: NhostAuthProvider(auth: auth, child: child), + ); + +void main() { + group('NhostAuthGate', () { + testWidgets('shows signedOut widget when signed out', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + await tester.pumpWidget(_wrap( + auth, + NhostAuthGate( + signedOut: (_) => const Text('login'), + signedIn: (_, __, ___) => const Text('home'), + ), + )); + expect(find.text('login'), findsOneWidget); + expect(find.text('home'), findsNothing); + }); + + testWidgets('shows signedIn widget with user when signed in', + (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedIn) + ..simulateSignIn(makeUser(), makeSession()); + await tester.pumpWidget(_wrap( + auth, + NhostAuthGate( + signedOut: (_) => const Text('login'), + signedIn: (_, user, __) => Text('home:${user.email}'), + ), + )); + expect(find.text('home:test@example.com'), findsOneWidget); + }); + + testWidgets('shows SizedBox.shrink when loading and no loading builder', + (tester) async { + final auth = FakeAuthClient(AuthenticationState.inProgress); + await tester.pumpWidget(_wrap( + auth, + NhostAuthGate( + signedOut: (_) => const Text('login'), + signedIn: (_, __, ___) => const Text('home'), + ), + )); + expect(find.text('login'), findsNothing); + expect(find.text('home'), findsNothing); + }); + + testWidgets('shows loading widget when provided and inProgress', + (tester) async { + final auth = FakeAuthClient(AuthenticationState.inProgress); + await tester.pumpWidget(_wrap( + auth, + NhostAuthGate( + loading: (_) => const Text('loading'), + signedOut: (_) => const Text('login'), + signedIn: (_, __, ___) => const Text('home'), + ), + )); + expect(find.text('loading'), findsOneWidget); + }); + + testWidgets('switches widget when auth state changes', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + await tester.pumpWidget(_wrap( + auth, + NhostAuthGate( + signedOut: (_) => const Text('login'), + signedIn: (_, __, ___) => const Text('home'), + ), + )); + expect(find.text('login'), findsOneWidget); + + auth.simulateSignIn(makeUser(), makeSession()); + await tester.pump(); + + expect(find.text('home'), findsOneWidget); + expect(find.text('login'), findsNothing); + }); + }); +} diff --git a/packages/nhost_flutter/test/widgets/nhost_auth_state_builder_test.dart b/packages/nhost_flutter/test/widgets/nhost_auth_state_builder_test.dart new file mode 100644 index 00000000..80e74669 --- /dev/null +++ b/packages/nhost_flutter/test/widgets/nhost_auth_state_builder_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; + +import '../helpers/fake_auth_client.dart'; + +Widget _wrap(FakeAuthClient auth, Widget child) => + MaterialApp(home: NhostAuthProvider(auth: auth, child: child)); + +void main() { + group('NhostAuthStateBuilder', () { + testWidgets('passes AuthStateSignedOut when signed out', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + AuthState? received; + await tester.pumpWidget(_wrap( + auth, + NhostAuthStateBuilder(builder: (_, state) { + received = state; + return const SizedBox(); + }), + )); + expect(received, isA()); + }); + + testWidgets('passes AuthStateLoading when inProgress', (tester) async { + final auth = FakeAuthClient(AuthenticationState.inProgress); + AuthState? received; + await tester.pumpWidget(_wrap( + auth, + NhostAuthStateBuilder(builder: (_, state) { + received = state; + return const SizedBox(); + }), + )); + expect(received, isA()); + }); + + testWidgets('passes AuthStateSignedIn with user when signed in', + (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedIn) + ..simulateSignIn(makeUser(email: 'x@y.com'), makeSession()); + AuthState? received; + await tester.pumpWidget(_wrap( + auth, + NhostAuthStateBuilder(builder: (_, state) { + received = state; + return const SizedBox(); + }), + )); + expect(received, isA()); + expect((received as AuthStateSignedIn).user.email, 'x@y.com'); + }); + + testWidgets('rebuilds when auth state changes', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + final states = []; + await tester.pumpWidget(_wrap( + auth, + NhostAuthStateBuilder(builder: (_, state) { + states.add(state); + return const SizedBox(); + }), + )); + + auth.simulateSignIn(makeUser(), makeSession()); + await tester.pump(); + + expect(states.length, 2); + expect(states[0], isA()); + expect(states[1], isA()); + }); + }); +} diff --git a/packages/nhost_flutter/test/widgets/nhost_signed_in_out_test.dart b/packages/nhost_flutter/test/widgets/nhost_signed_in_out_test.dart new file mode 100644 index 00000000..e895e188 --- /dev/null +++ b/packages/nhost_flutter/test/widgets/nhost_signed_in_out_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; + +import '../helpers/fake_auth_client.dart'; + +Widget _wrap(FakeAuthClient auth, Widget child) => + MaterialApp(home: NhostAuthProvider(auth: auth, child: child)); + +void main() { + group('NhostSignedIn', () { + testWidgets('shows child when signed in', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedIn) + ..simulateSignIn(makeUser(), makeSession()); + await tester.pumpWidget( + _wrap(auth, const NhostSignedIn(child: Text('admin')))); + expect(find.text('admin'), findsOneWidget); + }); + + testWidgets('hides child when signed out', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + await tester.pumpWidget( + _wrap(auth, const NhostSignedIn(child: Text('admin')))); + expect(find.text('admin'), findsNothing); + }); + + testWidgets('shows orElse when signed out and orElse provided', + (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + await tester.pumpWidget(_wrap( + auth, + const NhostSignedIn(child: Text('admin'), orElse: Text('guest')), + )); + expect(find.text('guest'), findsOneWidget); + expect(find.text('admin'), findsNothing); + }); + }); + + group('NhostSignedOut', () { + testWidgets('shows child when signed out', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + await tester.pumpWidget( + _wrap(auth, const NhostSignedOut(child: Text('login')))); + expect(find.text('login'), findsOneWidget); + }); + + testWidgets('hides child when signed in', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedIn) + ..simulateSignIn(makeUser(), makeSession()); + await tester.pumpWidget( + _wrap(auth, const NhostSignedOut(child: Text('login')))); + expect(find.text('login'), findsNothing); + }); + + testWidgets('shows orElse when signed in and orElse provided', + (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedIn) + ..simulateSignIn(makeUser(), makeSession()); + await tester.pumpWidget(_wrap( + auth, + const NhostSignedOut( + child: Text('login'), orElse: Text('dashboard')), + )); + expect(find.text('dashboard'), findsOneWidget); + expect(find.text('login'), findsNothing); + }); + }); +} diff --git a/packages/nhost_flutter/test/widgets/nhost_user_builder_test.dart b/packages/nhost_flutter/test/widgets/nhost_user_builder_test.dart new file mode 100644 index 00000000..9d97ff7f --- /dev/null +++ b/packages/nhost_flutter/test/widgets/nhost_user_builder_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nhost_flutter/nhost_flutter.dart'; + +import '../helpers/fake_auth_client.dart'; + +Widget _wrap(FakeAuthClient auth, Widget child) => + MaterialApp(home: NhostAuthProvider(auth: auth, child: child)); + +void main() { + group('NhostUserBuilder', () { + testWidgets('calls builder with current user when signed in', + (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedIn) + ..simulateSignIn(makeUser(email: 'ada@example.com'), makeSession()); + await tester.pumpWidget(_wrap( + auth, + NhostUserBuilder( + builder: (_, user) => Text('hi ${user.email}'), + ), + )); + expect(find.text('hi ada@example.com'), findsOneWidget); + }); + + testWidgets('renders SizedBox.shrink when signed out and no orElse', + (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + await tester.pumpWidget(_wrap( + auth, + NhostUserBuilder(builder: (_, user) => Text('hi ${user.email}')), + )); + expect(find.byType(Text), findsNothing); + }); + + testWidgets('renders orElse when signed out', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + await tester.pumpWidget(_wrap( + auth, + NhostUserBuilder( + builder: (_, user) => Text('hi ${user.email}'), + orElse: (_) => const Text('please sign in'), + ), + )); + expect(find.text('please sign in'), findsOneWidget); + }); + + testWidgets('rebuilds when user signs in', (tester) async { + final auth = FakeAuthClient(AuthenticationState.signedOut); + await tester.pumpWidget(_wrap( + auth, + NhostUserBuilder( + builder: (_, user) => Text('hi ${user.email}'), + orElse: (_) => const Text('signed out'), + ), + )); + expect(find.text('signed out'), findsOneWidget); + + auth.simulateSignIn(makeUser(email: 'b@c.com'), makeSession()); + await tester.pump(); + + expect(find.text('hi b@c.com'), findsOneWidget); + }); + }); +} diff --git a/packages/nhost_flutter_auth/.metadata b/packages/nhost_flutter_auth/.metadata deleted file mode 100644 index 933d7a89..00000000 --- a/packages/nhost_flutter_auth/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: b7d4806243a4e906bf061f79a0e314ba28111aa6 - channel: dev - -project_type: package diff --git a/packages/nhost_flutter_auth/README.md b/packages/nhost_flutter_auth/README.md index 956603ba..65a7299f 100644 --- a/packages/nhost_flutter_auth/README.md +++ b/packages/nhost_flutter_auth/README.md @@ -13,7 +13,7 @@ outs. ```yaml dependencies: - nhost_flutter_auth: ^3.0.0 + nhost_flutter_auth: ^4.2.1 ``` ## 🔥 More Dart & Flutter packages from Nhost diff --git a/packages/nhost_flutter_auth/example/.metadata b/packages/nhost_flutter_auth/example/.metadata deleted file mode 100644 index 3fdf5328..00000000 --- a/packages/nhost_flutter_auth/example/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled. - -version: - revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - channel: stable - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - base_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - - platform: android - create_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - base_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - - platform: ios - create_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - base_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - - platform: linux - create_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - base_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - - platform: macos - create_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - base_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - - platform: web - create_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - base_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - - platform: windows - create_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - base_revision: b8f7f1f9869bb2d116aa6a70dbeac61000b52849 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/nhost_flutter_auth/pubspec.yaml b/packages/nhost_flutter_auth/pubspec.yaml index 0dc3d282..42924402 100644 --- a/packages/nhost_flutter_auth/pubspec.yaml +++ b/packages/nhost_flutter_auth/pubspec.yaml @@ -1,7 +1,7 @@ name: nhost_flutter_auth description: > - Provides Nhost authentication state to your Flutter app, making it easy to set - up protected resources, and react to sign ins and sign outs. + Flutter widgets and listenables for exposing Nhost authentication state in + your app. version: 4.2.1 homepage: https://nhost.io repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_flutter_auth diff --git a/packages/nhost_flutter_graphql/.metadata b/packages/nhost_flutter_graphql/.metadata deleted file mode 100644 index 933d7a89..00000000 --- a/packages/nhost_flutter_graphql/.metadata +++ /dev/null @@ -1,10 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: b7d4806243a4e906bf061f79a0e314ba28111aa6 - channel: dev - -project_type: package diff --git a/packages/nhost_flutter_graphql/README.md b/packages/nhost_flutter_graphql/README.md index 7dd52dc6..573acdf8 100644 --- a/packages/nhost_flutter_graphql/README.md +++ b/packages/nhost_flutter_graphql/README.md @@ -12,7 +12,7 @@ to work with [Nhost](https://nhost.io). ```yaml dependencies: - nhost_flutter_graphql: ^3.0.0 + nhost_flutter_graphql: ^3.1.2 ``` ## 🔥 More Dart & Flutter packages from Nhost diff --git a/packages/nhost_flutter_graphql/example/.metadata b/packages/nhost_flutter_graphql/example/.metadata deleted file mode 100644 index 9e70ade3..00000000 --- a/packages/nhost_flutter_graphql/example/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled. - -version: - revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - channel: stable - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - base_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - - platform: android - create_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - base_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - - platform: ios - create_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - base_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - - platform: linux - create_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - base_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - - platform: macos - create_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - base_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - - platform: web - create_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - base_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - - platform: windows - create_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - base_revision: 52b3dc25f6471c27b2144594abb11c741cb88f57 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/nhost_flutter_graphql/pubspec.yaml b/packages/nhost_flutter_graphql/pubspec.yaml index 419c1c8d..a8a2f96d 100644 --- a/packages/nhost_flutter_graphql/pubspec.yaml +++ b/packages/nhost_flutter_graphql/pubspec.yaml @@ -1,7 +1,7 @@ name: nhost_flutter_graphql description: > - Provides GraphQL clients to your Flutter application, automatically configured - to work with Nhost + Flutter GraphQL provider for authenticated Nhost queries and subscriptions + with graphql_flutter. version: 3.1.2 homepage: https://nhost.io repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_flutter_graphql diff --git a/packages/nhost_functions_dart/README.md b/packages/nhost_functions_dart/README.md index 0def7731..67342264 100644 --- a/packages/nhost_functions_dart/README.md +++ b/packages/nhost_functions_dart/README.md @@ -29,5 +29,5 @@ void main() async { ```yaml dependencies: - nhost_functions_dart: ^1.0.0 + nhost_functions_dart: ^2.0.10 ``` diff --git a/packages/nhost_functions_dart/pubspec.yaml b/packages/nhost_functions_dart/pubspec.yaml index fb85b2f9..176c62c5 100644 --- a/packages/nhost_functions_dart/pubspec.yaml +++ b/packages/nhost_functions_dart/pubspec.yaml @@ -1,8 +1,8 @@ name: nhost_functions_dart -description: Nhost Dart Functions Service SDK +description: Dart client for calling Nhost serverless functions with authenticated HTTP requests. version: 2.0.10 homepage: https://nhost.io -repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_sdk +repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_functions_dart issue_tracker: https://github.com/nhost/nhost-dart/issues environment: diff --git a/packages/nhost_gql_links/README.md b/packages/nhost_gql_links/README.md index ea7af443..04b3630b 100644 --- a/packages/nhost_gql_links/README.md +++ b/packages/nhost_gql_links/README.md @@ -14,7 +14,7 @@ and generally isn't meant for use directly. ```yaml dependencies: - nhost_gql_links: ^3.0.0 + nhost_gql_links: ^4.0.11 ``` ## 🔥 More Dart & Flutter packages from Nhost diff --git a/packages/nhost_gql_links/pubspec.yaml b/packages/nhost_gql_links/pubspec.yaml index cae3a500..e1333fc0 100644 --- a/packages/nhost_gql_links/pubspec.yaml +++ b/packages/nhost_gql_links/pubspec.yaml @@ -1,6 +1,6 @@ name: nhost_gql_links version: 4.0.11 -description: Constructs GraphQL links for use with graphql and ferry packages +description: Low-level GraphQL links for Nhost authentication over HTTP and WebSocket transports. homepage: https://nhost.io repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_gql_links issue_tracker: https://github.com/nhost/nhost-dart/issues diff --git a/packages/nhost_graphql_adapter/README.md b/packages/nhost_graphql_adapter/README.md index 01c579e1..a96a6c60 100644 --- a/packages/nhost_graphql_adapter/README.md +++ b/packages/nhost_graphql_adapter/README.md @@ -17,7 +17,7 @@ for widgets that work great with ```yaml dependencies: - nhost_graphql_adapter: ^3.0.0 + nhost_graphql_adapter: ^4.0.10 ``` ## 🔥 More Dart & Flutter packages from Nhost diff --git a/packages/nhost_graphql_adapter/pubspec.yaml b/packages/nhost_graphql_adapter/pubspec.yaml index 0146295f..dd7ed062 100644 --- a/packages/nhost_graphql_adapter/pubspec.yaml +++ b/packages/nhost_graphql_adapter/pubspec.yaml @@ -1,6 +1,6 @@ name: nhost_graphql_adapter version: 4.0.10 -description: Easily connect to your Nhost.io GraphQL backend using the graphql package. +description: GraphQL client helpers for connecting package:graphql clients to an authenticated Nhost backend. homepage: https://nhost.io repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_graphql_adapter issue_tracker: https://github.com/nhost/nhost-dart/issues diff --git a/packages/nhost_sdk/lib/src/base/hasura_auth_client.dart b/packages/nhost_sdk/lib/src/base/hasura_auth_client.dart index c1faaa31..2f4d2cce 100644 --- a/packages/nhost_sdk/lib/src/base/hasura_auth_client.dart +++ b/packages/nhost_sdk/lib/src/base/hasura_auth_client.dart @@ -128,6 +128,66 @@ abstract class HasuraAuthClient { Future completeOAuthProviderSignIn(Uri redirectUrl); + /// Signs in using a Personal Access Token (PAT). + /// + /// PATs are long-lived tokens suited for CI/CD, scripts, and server-to-server + /// calls. Create a PAT in the Nhost dashboard → Account settings → PATs. + Future signInWithPat(String pat); + + /// Fetches the current user's profile directly from the server. + /// + /// Useful for forcing a user-data refresh without a full token cycle. + Future fetchUser(); + + /// Verifies whether [accessToken] is still valid on the server. + /// + /// Returns `true` if the token is valid, `false` otherwise. + Future verifyToken(String accessToken); + + // --------------------------------------------------------------------------- + // WebAuthn — platform-dependent + // The methods below require a FIDO2/WebAuthn platform integration. + // On Flutter, pair with a native plugin (e.g. flutter_web_auth_2). + // Call the server endpoints, but the challenge creation/assertion must be + // handled by the platform layer before calling these. + // --------------------------------------------------------------------------- + + /// Initiates WebAuthn sign-in. Returns the server challenge that the + /// platform layer must sign before calling [verifyWebAuthnSignIn]. + Future> signInWithWebAuthn(); + + /// Completes WebAuthn sign-in with the [assertionResponse] produced by the + /// platform authenticator. + Future verifyWebAuthnSignIn( + Map assertionResponse, + ); + + /// Initiates WebAuthn sign-up. Returns the server challenge that the + /// platform layer must use to create a new credential. + Future> signUpWithWebAuthn({String? email}); + + /// Completes WebAuthn sign-up with the [attestationResponse] produced by + /// the platform authenticator. + Future verifyWebAuthnSignUp( + Map attestationResponse, + ); + + /// Initiates adding a new WebAuthn credential to an existing account. + Future> addWebAuthnCredential(); + + /// Completes adding a WebAuthn credential with the [attestationResponse]. + Future verifyAddWebAuthnCredential( + Map attestationResponse, + ); + + /// Initiates a session-elevation flow via WebAuthn. + Future> elevateWithWebAuthn(); + + /// Completes session elevation with the [assertionResponse]. + Future verifyWebAuthnElevation( + Map assertionResponse, + ); + @visibleForTesting Future setSession(Session session); diff --git a/packages/nhost_sdk/pubspec.yaml b/packages/nhost_sdk/pubspec.yaml index 3e2665bf..6c2898fe 100644 --- a/packages/nhost_sdk/pubspec.yaml +++ b/packages/nhost_sdk/pubspec.yaml @@ -1,5 +1,5 @@ name: nhost_sdk -description: Nhost authentication and file storage/retrieval APIs for the Dart language. +description: Shared core types and low-level HTTP APIs used by the Nhost Dart and Flutter packages. version: 5.8.0 homepage: https://nhost.io repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_sdk diff --git a/packages/nhost_storage_dart/README.md b/packages/nhost_storage_dart/README.md index edf889f8..05321cae 100644 --- a/packages/nhost_storage_dart/README.md +++ b/packages/nhost_storage_dart/README.md @@ -58,6 +58,6 @@ void main() async { ```yaml dependencies: - nhost_auth_dart: ^2.0.0 - nhost_storage_dart: ^2.0.0 + nhost_auth_dart: ^2.6.1 + nhost_storage_dart: ^2.2.0 ``` diff --git a/packages/nhost_storage_dart/pubspec.yaml b/packages/nhost_storage_dart/pubspec.yaml index b757f26b..a51610e8 100644 --- a/packages/nhost_storage_dart/pubspec.yaml +++ b/packages/nhost_storage_dart/pubspec.yaml @@ -1,8 +1,8 @@ name: nhost_storage_dart -description: Nhost Dart Storage Service SDK +description: Dart client for Nhost Storage with file upload, download, deletion, presigned URLs, and image transforms. version: 2.2.0 homepage: https://nhost.io -repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_sdk +repository: https://github.com/nhost/nhost-dart/tree/main/packages/nhost_storage_dart issue_tracker: https://github.com/nhost/nhost-dart/issues environment: