From b2ec88ffa7e7d22858b27a6c394efeed33ad6fc6 Mon Sep 17 00:00:00 2001 From: Brion Date: Thu, 16 Jul 2026 18:06:14 +0530 Subject: [PATCH] Implement federated options --- android/build.gradle | 9 +- .../flutter/ThunderIDFlutterPlugin.kt | 70 ++- .../flutter/ThunderIDMethodHandler.kt | 64 ++- ios/Classes/ThunderIDMethodHandler.swift | 115 +++- lib/src/models/thunderid_error.dart | 6 +- lib/src/thunderid_client.dart | 35 ++ lib/src/widgets/adapters/facebook_button.dart | 71 +++ lib/src/widgets/adapters/github_button.dart | 73 +++ lib/src/widgets/adapters/google_button.dart | 86 +++ lib/src/widgets/adapters/linkedin_button.dart | 66 +++ .../widgets/adapters/microsoft_button.dart | 81 +++ .../adapters/outlined_trigger_button.dart | 85 +++ lib/src/widgets/adapters/svg_icon_path.dart | 497 ++++++++++++++++++ lib/src/widgets/flow_form.dart | 259 ++++++++- lib/src/widgets/sign_in.dart | 44 +- pubspec.yaml | 1 + samples/quickstart/android/build.gradle.kts | 1 + samples/quickstart/ios/Podfile | 2 +- test/adapters_test.dart | 74 +++ test/flow_form_test.dart | 295 +++++++++++ test/thunderid_client_test.dart | 47 ++ 21 files changed, 1939 insertions(+), 42 deletions(-) create mode 100644 lib/src/widgets/adapters/facebook_button.dart create mode 100644 lib/src/widgets/adapters/github_button.dart create mode 100644 lib/src/widgets/adapters/google_button.dart create mode 100644 lib/src/widgets/adapters/linkedin_button.dart create mode 100644 lib/src/widgets/adapters/microsoft_button.dart create mode 100644 lib/src/widgets/adapters/outlined_trigger_button.dart create mode 100644 lib/src/widgets/adapters/svg_icon_path.dart create mode 100644 test/adapters_test.dart create mode 100644 test/flow_form_test.dart diff --git a/android/build.gradle b/android/build.gradle index a697c70..e72bf5c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -62,6 +62,13 @@ def flutterEngineVersion = new File(flutterSdkPath(), 'bin/cache/engine.stamp'). dependencies { compileOnly "io.flutter:flutter_embedding_debug:1.0.0-$flutterEngineVersion" - implementation 'com.github.thunder-id:android-sdks:v0.1.0' + // Pinned to the merge commit of https://github.com/thunder-id/android-sdks/pull/12 (fixed a + // `publishing` block that never attached the compiled AAR to its Maven publication, which had + // made every ref/commit/tag resolve to an empty POM shell via JitPack). Pinned to a commit + // (not "main-SNAPSHOT"/a branch) so JitPack's build cache is permanent and CI doesn't hit the + // branch-snapshot cold-start 404 on every new push. Uses the full 40-char SHA — JitPack's + // short-SHA cache entry for this commit got stuck on a transient build infra error, so the + // full SHA is used here as a distinct, successfully-built cache key. + implementation 'com.github.thunder-id:android-sdks:b069ba42c8685a578506c4162b19716378659481' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' } diff --git a/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDFlutterPlugin.kt b/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDFlutterPlugin.kt index 7d38d7f..18bb878 100644 --- a/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDFlutterPlugin.kt +++ b/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDFlutterPlugin.kt @@ -1,6 +1,13 @@ package dev.thunderid.flutter +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.os.Bundle +import dev.thunderid.android.auth.FederatedAuthSession import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler @@ -9,10 +16,27 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class ThunderIDFlutterPlugin : FlutterPlugin, MethodCallHandler { +class ThunderIDFlutterPlugin : FlutterPlugin, ActivityAware, MethodCallHandler { private lateinit var channel: MethodChannel private lateinit var handler: ThunderIDMethodHandler private val scope = CoroutineScope(Dispatchers.Main) + private var activity: Activity? = null + + // Cancels any in-flight federated-auth Custom Tab session when the host Activity resumes + // without having received the provider's redirect (e.g. the user pressed back in the tab). + // Safe to call unconditionally: FederatedAuthSession.cancelIfPending() is a no-op once the + // session already completed via onNewIntent. + private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityResumed(resumedActivity: Activity) { + if (resumedActivity === activity) FederatedAuthSession.cancelIfPending() + } + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivityStarted(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + } override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { handler = ThunderIDMethodHandler(binding.applicationContext) @@ -30,4 +54,48 @@ class ThunderIDFlutterPlugin : FlutterPlugin, MethodCallHandler { handler.handle(call.method, args, result) } } + + // ── ActivityAware — required so `continueFederatedAuth` can launch a Custom Tab from an + // Activity context, and so the provider's redirect (delivered via onNewIntent to the host + // app's registered callback scheme) reaches FederatedAuthSession.onRedirect ── + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + attachActivity(binding) + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + attachActivity(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + detachActivity() + } + + override fun onDetachedFromActivity() { + // Unlike onDetachedFromActivityForConfigChanges, this is a permanent detach (e.g. the + // host Activity finished while the Custom Tab was open) — nothing will resume to call + // cancelIfPending() via onActivityResumed, so the suspended method-channel request would + // otherwise hang indefinitely. + FederatedAuthSession.cancelIfPending() + detachActivity() + } + + private fun attachActivity(binding: ActivityPluginBinding) { + activity = binding.activity + handler.activity = binding.activity + binding.activity.application.registerActivityLifecycleCallbacks(lifecycleCallbacks) + binding.addOnNewIntentListener { intent -> handleNewIntent(intent) } + } + + private fun detachActivity() { + activity?.application?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + activity = null + handler.activity = null + } + + private fun handleNewIntent(intent: Intent): Boolean { + val data = intent.data + if (data != null) FederatedAuthSession.onRedirect(data) + return false + } } diff --git a/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt b/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt index 2d6a77d..9afdd5e 100644 --- a/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt +++ b/android/src/main/kotlin/dev/thunderid/flutter/ThunderIDMethodHandler.kt @@ -1,9 +1,11 @@ package dev.thunderid.flutter +import android.app.Activity import android.content.Context import io.flutter.plugin.common.MethodChannel.Result import dev.thunderid.android.* -import dev.thunderid.android.auth.PKCEManager +import dev.thunderid.android.auth.FederatedAuthSession +import kotlinx.coroutines.CancellationException /** * Routes Flutter method channel calls to the native Android ThunderIDClient (spec §7.1). @@ -12,6 +14,13 @@ import dev.thunderid.android.auth.PKCEManager class ThunderIDMethodHandler(private val context: Context) { private val client = ThunderIDClient() + /** + * Set by [ThunderIDFlutterPlugin] from its `ActivityAware` callbacks. Required for + * `continueFederatedAuth` since launching a Custom Tab needs an Activity context; falls back + * to the application context (set at construction) if no Activity is currently attached. + */ + var activity: Activity? = null + suspend fun handle(method: String, args: Map, result: Result) { try { when (method) { @@ -34,6 +43,28 @@ class ThunderIDMethodHandler(private val context: Context) { val response = client.signIn(buildPayload(payloadMap), buildFlowRequest(requestMap)) result.success(encodeFlowResponse(response)) } + "continueFederatedAuth" -> { + val redirectUrl = args["redirectUrl"] as? String ?: "" + val actionId = args["actionId"] as? String ?: "" + val applicationId = args["applicationId"] as? String ?: "" + val flowId = args["flowId"] as? String + val challengeToken = args["challengeToken"] as? String + val callbackUri = FederatedAuthSession.launch(activity ?: context, redirectUrl) + val code = callbackUri.getQueryParameter("code") + ?: throw IAMException( + ThunderIDErrorCode.INVALID_GRANT, + "Federated sign-in did not return an authorization code" + ) + val payload = EmbeddedSignInPayload( + flowId = flowId, + actionId = actionId, + inputs = mapOf("code" to code), + challengeToken = challengeToken + ) + val request = EmbeddedFlowRequestConfig(applicationId = applicationId) + val response = client.signIn(payload, request) + result.success(encodeFlowResponse(response)) + } "buildSignInUrl" -> { result.success(client.buildSignInUrl()) } @@ -97,6 +128,10 @@ class ThunderIDMethodHandler(private val context: Context) { } } catch (e: IAMException) { result.error(e.code.value, e.message, null) + } catch (e: CancellationException) { + // User dismissed the Custom Tab without completing federated sign-in — surfaced as a + // typed cancellation so the Dart layer can reset state silently instead of erroring. + result.error("FEDERATED_AUTH_CANCELLED", "User cancelled federated sign-in", null) } catch (e: Exception) { result.error("UNKNOWN_ERROR", e.message, null) } @@ -166,11 +201,14 @@ class ThunderIDMethodHandler(private val context: Context) { private fun encodeFlowStepData(data: FlowStepData) = mapOf( "actions" to data.actions?.map { action -> mapOf( - "id" to action.id.ifEmpty { action.ref ?: action.nextNode ?: "submit" }, + "id" to (action.id?.ifEmpty { null } ?: action.ref ?: action.nextNode ?: "submit"), "ref" to action.ref, "nextNode" to action.nextNode, "type" to action.type, - "label" to action.label + "label" to action.label, + "eventType" to action.eventType, + "variant" to action.variant, + "icon" to action.icon ) }, "inputs" to data.inputs?.map { input -> @@ -180,7 +218,25 @@ class ThunderIDMethodHandler(private val context: Context) { "required" to input.required ) }, - "meta" to data.meta + "meta" to data.meta?.let { encodeFlowMeta(it) } + ) + + private fun encodeFlowMeta(meta: FlowMeta) = mapOf( + "components" to meta.components?.map { encodeFlowComponent(it) } + ) + + private fun encodeFlowComponent(comp: FlowComponent): Map = mapOf( + "id" to comp.id, + "ref" to comp.ref, + "type" to comp.type, + "category" to comp.category, + "label" to comp.label, + "placeholder" to comp.placeholder, + "variant" to comp.variant, + "eventType" to comp.eventType, + "align" to comp.align, + "icon" to comp.icon, + "components" to comp.components?.map { encodeFlowComponent(it) } ) private fun encodeTokenResponse(r: TokenResponse) = mapOf( diff --git a/ios/Classes/ThunderIDMethodHandler.swift b/ios/Classes/ThunderIDMethodHandler.swift index ade0b84..d8d6ec9 100644 --- a/ios/Classes/ThunderIDMethodHandler.swift +++ b/ios/Classes/ThunderIDMethodHandler.swift @@ -6,6 +6,7 @@ import ThunderID @MainActor final class ThunderIDMethodHandler { private let client = ThunderIDClient() + private let federatedAuthSession = FederatedAuthSession() func handle(method: String, args: [String: Any], result: @escaping FlutterResult) async { do { @@ -30,6 +31,9 @@ final class ThunderIDMethodHandler { let response = try await client.signIn(payload: payload, request: request) result(encodeFlowResponse(response)) + case "continueFederatedAuth": + await handleContinueFederatedAuth(args, result: result) + case "buildSignInUrl": let url = try client.buildSignInURL() result(url.absoluteString) @@ -107,6 +111,76 @@ final class ThunderIDMethodHandler { } } + // MARK: - Federated auth (TRIGGER actions) + + /// Resumes a TRIGGER action after the server responded `type: "REDIRECTION"`: opens + /// `redirectUrl` in an `ASWebAuthenticationSession`, extracts the `code` query parameter + /// from the provider's callback, and resubmits the flow with `inputs: {"code": code}` — + /// mirroring `BaseSignIn.handleRedirection` in `ThunderIDSwiftUI` (spec §6.1 federated + /// sign-in extension). Handles its own errors and always calls `result` itself so a + /// cancelled browser session can be reported with a dedicated `FEDERATED_AUTH_CANCELLED` + /// code instead of falling into the generic `UNKNOWN_ERROR` path. + private func handleContinueFederatedAuth(_ args: [String: Any], result: @escaping FlutterResult) async { + let redirectUrlStr = args["redirectUrl"] as? String ?? "" + guard let url = URL(string: redirectUrlStr) else { + result(FlutterError(code: "INVALID_REDIRECT_URI", message: "Invalid redirect URL", details: nil)) + return + } + guard let scheme = callbackURLScheme(from: url) else { + result(FlutterError( + code: "INVALID_CONFIGURATION", + message: "Unable to determine callback URL scheme from the authorization URL's " + + "redirect_uri or afterSignInUrl", + details: nil + )) + return + } + do { + let callbackURL = try await federatedAuthSession.authenticate(url: url, callbackURLScheme: scheme) + guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "code" })?.value else { + result(FlutterError( + code: "INVALID_GRANT", + message: "Authorization code missing from callback URL", + details: nil + )) + return + } + let payload = EmbeddedSignInPayload( + flowId: args["flowId"] as? String, + actionId: args["actionId"] as? String ?? "", + inputs: ["code": code], + challengeToken: args["challengeToken"] as? String + ) + let request = buildFlowRequestConfig(from: ["applicationId": args["applicationId"] as? String ?? ""]) + let response = try await client.signIn(payload: payload, request: request) + result(encodeFlowResponse(response)) + } catch is FederatedAuthSession.CancelledError { + result(FlutterError(code: "FEDERATED_AUTH_CANCELLED", message: "User cancelled federated sign-in", details: nil)) + } catch let error as ThunderIDError { + result(FlutterError(code: error.code.rawValue, message: error.message, details: nil)) + } catch { + result(FlutterError(code: "UNKNOWN_ERROR", message: error.localizedDescription, details: nil)) + } + } + + /// Resolves the scheme `ASWebAuthenticationSession` should watch for. `afterSignInUrl` is + /// optional on `ThunderIDConfig` (Android and the Dart API impose no equivalent requirement), + /// so prefer the `redirect_uri` query parameter already present on every standard OAuth2 + /// authorization URL, falling back to `afterSignInUrl` only if that's absent. + private func callbackURLScheme(from redirectUrl: URL) -> String? { + if let redirectUri = URLComponents(url: redirectUrl, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "redirect_uri" })?.value, + let scheme = URLComponents(string: redirectUri)?.scheme { + return scheme + } + guard let afterSignInUrl = try? client.getConfiguration().afterSignInUrl, + let scheme = URLComponents(string: afterSignInUrl)?.scheme else { + return nil + } + return scheme + } + // MARK: - Builders private func buildConfig(from args: [String: Any]) throws -> ThunderIDConfig { @@ -187,13 +261,46 @@ final class ThunderIDMethodHandler { "ref": $0.ref as Any, "nextNode": $0.nextNode as Any, "label": $0.label as Any, + "eventType": $0.eventType as Any, + "type": $0.type as Any, + "variant": $0.variant as Any, + "icon": $0.icon as Any, ] } } - if let meta = d.meta, - let encoded = try? JSONEncoder().encode(meta), - let obj = try? JSONSerialization.jsonObject(with: encoded) { - result["meta"] = obj + if let meta = d.meta { + result["meta"] = encodeFlowMeta(meta) + } + result["redirectURL"] = d.redirectURL as Any + if let additionalData = d.additionalData { + result["additionalData"] = additionalData.mapValues { $0.value } + } + return result + } + + private func encodeFlowMeta(_ meta: FlowMeta) -> [String: Any] { + var result: [String: Any] = [:] + if let components = meta.components { + result["components"] = components.map { encodeFlowComponent($0) } + } + return result + } + + private func encodeFlowComponent(_ c: FlowComponent) -> [String: Any] { + var result: [String: Any] = [ + "id": c.id as Any, + "ref": c.ref as Any, + "type": c.type as Any, + "category": c.category as Any, + "label": c.label as Any, + "placeholder": c.placeholder as Any, + "variant": c.variant as Any, + "eventType": c.eventType as Any, + "align": c.align as Any, + "icon": c.icon as Any, + ] + if let components = c.components { + result["components"] = components.map { encodeFlowComponent($0) } } return result } diff --git a/lib/src/models/thunderid_error.dart b/lib/src/models/thunderid_error.dart index 197a916..4829fda 100644 --- a/lib/src/models/thunderid_error.dart +++ b/lib/src/models/thunderid_error.dart @@ -50,7 +50,10 @@ enum ThunderIDErrorCode { networkError, requestTimeout, serverError, - unknownError; + unknownError, + + // Federated / social login (TRIGGER actions) + federatedAuthCancelled; static const _codeMap = { 'SDK_NOT_INITIALIZED': ThunderIDErrorCode.sdkNotInitialized, @@ -77,6 +80,7 @@ enum ThunderIDErrorCode { 'REQUEST_TIMEOUT': ThunderIDErrorCode.requestTimeout, 'SERVER_ERROR': ThunderIDErrorCode.serverError, 'UNKNOWN_ERROR': ThunderIDErrorCode.unknownError, + 'FEDERATED_AUTH_CANCELLED': ThunderIDErrorCode.federatedAuthCancelled, }; static ThunderIDErrorCode fromString(String code) => diff --git a/lib/src/thunderid_client.dart b/lib/src/thunderid_client.dart index 4f59f95..3ce79f7 100644 --- a/lib/src/thunderid_client.dart +++ b/lib/src/thunderid_client.dart @@ -79,6 +79,41 @@ class ThunderIDClient { } } + /// Resumes a TRIGGER action (federated/social login) after the server + /// responded with `type: "REDIRECTION"` (spec §6.1 embedded flow, federated + /// sign-in extension). The native layer opens [redirectUrl] in a system + /// browser (Custom Tabs on Android, `ASWebAuthenticationSession` on iOS), + /// waits for the provider to redirect back to the app's registered + /// callback scheme, extracts the `code` query parameter, and resubmits the + /// same flow with `inputs: {"code": code}` — mirroring the exact resubmit + /// pattern used by the native Android/iOS SDKs. + /// + /// Throws [IAMException] with [ThunderIDErrorCode.federatedAuthCancelled] + /// if the user dismisses the browser without completing sign-in; callers + /// should treat this as a silent reset rather than an error. + Future continueFederatedAuth({ + required String redirectUrl, + required String actionId, + required String applicationId, + String? flowId, + String? challengeToken, + }) async { + _requireInitialized(); + _isLoading = true; + try { + final result = await _channel.invokeMap('continueFederatedAuth', { + 'redirectUrl': redirectUrl, + 'actionId': actionId, + 'applicationId': applicationId, + if (flowId != null) 'flowId': flowId, + if (challengeToken != null) 'challengeToken': challengeToken, + }); + return EmbeddedFlowResponse.fromMap(result); + } finally { + _isLoading = false; + } + } + /// Builds the redirect-based sign-in URL. Open this in an in-app browser or /// custom tab, then call [handleRedirectCallback] with the callback URL. Future buildSignInUrl({SignInOptions? options}) async { diff --git a/lib/src/widgets/adapters/facebook_button.dart b/lib/src/widgets/adapters/facebook_button.dart new file mode 100644 index 0000000..44812f7 --- /dev/null +++ b/lib/src/widgets/adapters/facebook_button.dart @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'package:flutter/material.dart'; + +import 'outlined_trigger_button.dart'; +import 'svg_icon_path.dart'; + +const Size _viewBox = Size(512, 512); + +/// Facebook "f" glyph, ported from the SVG path data used by the web SDK's `FacebookButton` +/// icon adapter (viewBox 512 x 512). +class _FacebookGlyph extends StatelessWidget { + const _FacebookGlyph(); + + @override + Widget build(BuildContext context) => const MultiColorSvgIcon( + viewBox: _viewBox, + size: 18, + paths: [ + SvgPathSpec( + pathData: + 'M448,0H64C28.704,0,0,28.704,0,64v384c0,35.296,28.704,64,64,64h384c35.296,0,64-28.704,64-64V64C512,28.704,483.296,0,448,0z', + color: Color(0xFF1976D2), + ), + SvgPathSpec( + pathData: + 'M432,256h-80v-64c0-17.664,14.336-16,32-16h32V96h-64l0,0c-53.024,0-96,42.976-96,96v64h-64v80h64v176h96V336h48L432,256z', + color: Color(0xFFFAFAFA), + ), + ], + ); +} + +/// "Continue with Facebook" federated sign-in trigger, styled to match the outlined action +/// buttons rendered below a SignIn form's "Or" divider. +class FacebookButton extends StatelessWidget { + final String label; + final bool isLoading; + final VoidCallback onPressed; + + const FacebookButton({ + super.key, + required this.onPressed, + this.label = 'Continue with Facebook', + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) => OutlinedTriggerButton( + label: label, + isLoading: isLoading, + onPressed: onPressed, + icon: const _FacebookGlyph(), + ); +} diff --git a/lib/src/widgets/adapters/github_button.dart b/lib/src/widgets/adapters/github_button.dart new file mode 100644 index 0000000..7700138 --- /dev/null +++ b/lib/src/widgets/adapters/github_button.dart @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'package:flutter/material.dart'; + +import 'outlined_trigger_button.dart'; +import 'svg_icon_path.dart'; + +const Size _viewBox = Size(67.91, 66.233); + +/// GitHub "Octocat" mark, ported from the SVG path data used by the web SDK's `GitHubButton` +/// icon adapter (viewBox 67.91 x 66.233). Matches the iOS `GitHubGlyph` (`GitHubButton.swift`) +/// and Android `gitHubLogo()` (`GitHubButton.kt`) ports. +class _GitHubGlyph extends StatelessWidget { + const _GitHubGlyph(); + + @override + Widget build(BuildContext context) => MultiColorSvgIcon( + viewBox: _viewBox, + size: 18, + paths: [ + SvgPathSpec( + pathData: + 'M420.915,-658.072a33.956,33.956,0,0,0,-33.955,33.955,33.963,33.963,0,0,0,23.221,32.22c1.7,.314,2.32,-.737,2.32,-1.633,0,-.81,-.031,-3.484,-.046,-6.322,-9.446,2.054,-11.44,-4.006,-11.44,-4.006,-1.545,-3.925,-3.77,-4.968,-3.77,-4.968,-3.081,-2.107,.232,-2.064,.232,-2.064,3.41,.239,5.205,3.5,5.205,3.5,3.028,5.19,7.943,3.69,9.881,2.822a7.23,7.23,0,0,1,2.156,-4.54c-7.542,-.859,-15.47,-3.77,-15.47,-16.781a13.141,13.141,0,0,1,3.5,-9.114,12.2,12.2,0,0,1,.329,-8.986s2.851,-.913,9.34,3.48a32.545,32.545,0,0,1,8.5,-1.143,32.629,32.629,0,0,1,8.506,1.143c6.481,-4.393,9.328,-3.48,9.328,-3.48a12.185,12.185,0,0,1,.333,8.986,13.115,13.115,0,0,1,3.495,9.114c0,13.042,-7.943,15.913,-15.5,16.754,1.218,1.054,2.3,3.12,2.3,6.288,0,4.543,-.039,8.2,-.039,9.318,0,.9,.611,1.962,2.332,1.629a33.959,33.959,0,0,0,23.2,-32.215,33.955,33.955,0,0,0,-33.955,-33.955', + // The upstream "Octocat" mark ships as a single white-fill path meant to sit on a + // dark badge background. This button's chrome is a transparent/outlined surface + // (see OutlinedTriggerButton), so a hardcoded white fill would be invisible in + // light mode. Use the theme's foreground color instead so the glyph stays visible + // in both light and dark mode. + color: Theme.of(context).colorScheme.onSurface, + translate: const Offset(-386.96, 658.072), + ), + ], + ); +} + +/// "Continue with GitHub" federated sign-in trigger, styled to match the outlined action +/// buttons rendered below a SignIn form's "Or" divider. +class GitHubButton extends StatelessWidget { + final String label; + final bool isLoading; + final VoidCallback onPressed; + + const GitHubButton({ + super.key, + required this.onPressed, + this.label = 'Continue with GitHub', + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) => OutlinedTriggerButton( + label: label, + isLoading: isLoading, + onPressed: onPressed, + icon: const _GitHubGlyph(), + ); +} diff --git a/lib/src/widgets/adapters/google_button.dart b/lib/src/widgets/adapters/google_button.dart new file mode 100644 index 0000000..daade01 --- /dev/null +++ b/lib/src/widgets/adapters/google_button.dart @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'package:flutter/material.dart'; + +import 'outlined_trigger_button.dart'; +import 'svg_icon_path.dart'; + +const Size _viewBox = Size(67.91, 67.901); + +/// Multi-color Google "G" glyph, ported from the SVG path data used by the web SDK's +/// `GoogleButton` icon adapter (viewBox 67.91 x 67.901). Matches the iOS `GoogleGlyph` +/// (`GoogleButton.swift`) and Android `googleLogo()` (`GoogleButton.kt`) ports. +class _GoogleGlyph extends StatelessWidget { + const _GoogleGlyph(); + + @override + Widget build(BuildContext context) => const MultiColorSvgIcon( + viewBox: _viewBox, + size: 18, + paths: [ + SvgPathSpec( + pathData: + 'M15.049,160.965l-2.364,8.824-8.639.183a34.011,34.011,0,0,1,-.25,-31.7h0l7.691,1.41,3.369,7.645a20.262,20.262,0,0,0,.19,13.642Z', + color: Color(0xFFFBBB00), + translate: Offset(0, -119.93), + ), + SvgPathSpec( + pathData: + 'M294.24,208.176A33.939,33.939,0,0,1,282.137,241h0l-9.687,-.494,-1.371,-8.559a20.235,20.235,0,0,0,8.706,-10.333H261.628V208.176Z', + color: Color(0xFF518EF8), + translate: Offset(-226.93, -180.567), + ), + SvgPathSpec( + pathData: + 'M81.668,328.8h0a33.962,33.962,0,0,1,-51.161,-10.387l11,-9.006a20.192,20.192,0,0,0,29.1,10.338Z', + color: Color(0xFF28B446), + translate: Offset(-26.463, -268.374), + ), + SvgPathSpec( + pathData: + 'M80.451,7.816l-11,9A20.19,20.19,0,0,0,39.686,27.393l-11.06,-9.055h0A33.959,33.959,0,0,1,80.451,7.816Z', + color: Color(0xFFF14336), + translate: Offset(-24.828, 0), + ), + ], + ); +} + +/// "Continue with Google" federated sign-in trigger, styled to match the outlined action +/// buttons rendered below a SignIn form's "Or" divider. +class GoogleButton extends StatelessWidget { + final String label; + final bool isLoading; + final VoidCallback onPressed; + + const GoogleButton({ + super.key, + required this.onPressed, + this.label = 'Continue with Google', + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) => OutlinedTriggerButton( + label: label, + isLoading: isLoading, + onPressed: onPressed, + icon: const _GoogleGlyph(), + ); +} diff --git a/lib/src/widgets/adapters/linkedin_button.dart b/lib/src/widgets/adapters/linkedin_button.dart new file mode 100644 index 0000000..59f81fe --- /dev/null +++ b/lib/src/widgets/adapters/linkedin_button.dart @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'package:flutter/material.dart'; + +import 'outlined_trigger_button.dart'; +import 'svg_icon_path.dart'; + +const Size _viewBox = Size(24, 24); + +/// LinkedIn "in" glyph, ported from the SVG path data used by the web SDK's `LinkedInButton` +/// icon adapter (viewBox 24 x 24). +class _LinkedInGlyph extends StatelessWidget { + const _LinkedInGlyph(); + + @override + Widget build(BuildContext context) => const MultiColorSvgIcon( + viewBox: _viewBox, + size: 18, + paths: [ + SvgPathSpec( + pathData: + 'M20.447,20.452h-3.554v-5.569c0-1.328,-.027,-3.037,-1.852,-3.037,-1.853,0,-2.136,1.445,-2.136,2.939v5.667H9.351V9h3.414v1.561h.046c.477,-.9,1.637,-1.85,3.37,-1.85,3.601,0,4.267,2.37,4.267,5.455v6.286zM5.337,7.433c-1.144,0,-2.063,-.926,-2.063,-2.065,0,-1.138,.92,-2.063,2.063,-2.063,1.14,0,2.064,.925,2.064,2.063,0,1.139,-.925,2.065,-2.064,2.065zm1.782,13.019H3.555V9h3.564v11.452zM22.225,0H1.771C.792,0,0,.774,0,1.729v20.542C0,23.227,.792,24,1.771,24h20.451C23.2,24,24,23.227,24,22.271V1.729C24,.774,23.2,0,22.222,0h.003z', + color: Color(0xFF0077B5), + ), + ], + ); +} + +/// "Continue with LinkedIn" federated sign-in trigger, styled to match the outlined action +/// buttons rendered below a SignIn form's "Or" divider. +class LinkedInButton extends StatelessWidget { + final String label; + final bool isLoading; + final VoidCallback onPressed; + + const LinkedInButton({ + super.key, + required this.onPressed, + this.label = 'Continue with LinkedIn', + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) => OutlinedTriggerButton( + label: label, + isLoading: isLoading, + onPressed: onPressed, + icon: const _LinkedInGlyph(), + ); +} diff --git a/lib/src/widgets/adapters/microsoft_button.dart b/lib/src/widgets/adapters/microsoft_button.dart new file mode 100644 index 0000000..e9ed370 --- /dev/null +++ b/lib/src/widgets/adapters/microsoft_button.dart @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'package:flutter/material.dart'; + +import 'outlined_trigger_button.dart'; +import 'svg_icon_path.dart'; + +const Size _viewBox = Size(23, 23); + +/// Microsoft four-square glyph, ported from the SVG path data used by the web SDK's +/// `MicrosoftButton` icon adapter (viewBox 23 x 23). +class _MicrosoftGlyph extends StatelessWidget { + const _MicrosoftGlyph(); + + @override + Widget build(BuildContext context) => const MultiColorSvgIcon( + viewBox: _viewBox, + size: 18, + paths: [ + SvgPathSpec( + pathData: 'M0,0h23v23h-23z', + color: Color(0xFFF3F3F3), + ), + SvgPathSpec( + pathData: 'M1,1h10v10h-10z', + color: Color(0xFFF35325), + ), + SvgPathSpec( + pathData: 'M12,1h10v10h-10z', + color: Color(0xFF81BC06), + ), + SvgPathSpec( + pathData: 'M1,12h10v10h-10z', + color: Color(0xFF05A6F0), + ), + SvgPathSpec( + pathData: 'M12,12h10v10h-10z', + color: Color(0xFFFFBA08), + ), + ], + ); +} + +/// "Continue with Microsoft" federated sign-in trigger, styled to match the outlined action +/// buttons rendered below a SignIn form's "Or" divider. +class MicrosoftButton extends StatelessWidget { + final String label; + final bool isLoading; + final VoidCallback onPressed; + + const MicrosoftButton({ + super.key, + required this.onPressed, + this.label = 'Continue with Microsoft', + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) => OutlinedTriggerButton( + label: label, + isLoading: isLoading, + onPressed: onPressed, + icon: const _MicrosoftGlyph(), + ); +} diff --git a/lib/src/widgets/adapters/outlined_trigger_button.dart b/lib/src/widgets/adapters/outlined_trigger_button.dart new file mode 100644 index 0000000..9810ab7 --- /dev/null +++ b/lib/src/widgets/adapters/outlined_trigger_button.dart @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'package:flutter/material.dart'; + +/// Shared outlined-button chrome for `eventType: TRIGGER` (federated login) actions: an +/// optional icon slot, a label, and a rounded-rect stroke — matching the "Continue with X" +/// buttons rendered below a SignIn form's "Or" divider. Mirrors `TriggerButtonStyle` (iOS) / +/// `OutlinedTriggerButton` (Android). +class OutlinedTriggerButton extends StatelessWidget { + final String label; + final bool isLoading; + final VoidCallback onPressed; + final Widget? icon; + + const OutlinedTriggerButton({ + super.key, + required this.label, + required this.onPressed, + this.isLoading = false, + this.icon, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics( + label: label, + button: true, + child: SizedBox( + height: 48, + child: OutlinedButton( + onPressed: isLoading ? null : onPressed, + style: OutlinedButton.styleFrom( + side: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.4), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + minimumSize: const Size.fromHeight(44), + ), + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[icon!, const SizedBox(width: 10)], + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/adapters/svg_icon_path.dart b/lib/src/widgets/adapters/svg_icon_path.dart new file mode 100644 index 0000000..7ed5d3d --- /dev/null +++ b/lib/src/widgets/adapters/svg_icon_path.dart @@ -0,0 +1,497 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// A single colored `` from a brand icon's SVG source, ported into a Flutter +/// [Path] and scaled into a shared [viewBox]. Mirrors the `SVGIconPath`/`PathParser` ports used +/// by the iOS (`SVGIconPath.swift`) and Android (`GoogleButton.kt`/`GitHubButton.kt`) SDKs, so +/// the same brand glyph renders identically across platforms. +@immutable +class SvgPathSpec { + /// The raw SVG path `d` attribute data. Supports the M/L/H/V/C/A/Z commands (absolute and + /// relative) used by the Google/GitHub logos ported from the web SDK's icon adapters. + final String pathData; + + /// Fill color for this path. + final Color color; + + /// Translation applied in the SVG's own coordinate space, matching a + /// `` wrapper around the source `` (e.g. Google's + /// four-color glyph groups). + final Offset translate; + + const SvgPathSpec({ + required this.pathData, + required this.color, + this.translate = Offset.zero, + }); +} + +/// Renders one or more [SvgPathSpec]s scaled to fit a square icon of [size], matching the +/// `MultiColorSvgIcon`-style composition used by the Google/GitHub brand marks. +class MultiColorSvgIcon extends StatelessWidget { + final Size viewBox; + final List paths; + final double size; + + const MultiColorSvgIcon({ + super.key, + required this.viewBox, + required this.paths, + this.size = 18, + }); + + @override + Widget build(BuildContext context) => SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _SvgPathPainter(viewBox: viewBox, paths: paths), + ), + ); +} + +class _SvgPathPainter extends CustomPainter { + final Size viewBox; + final List paths; + + const _SvgPathPainter({required this.viewBox, required this.paths}); + + @override + void paint(Canvas canvas, Size size) { + final scaleX = size.width / viewBox.width; + final scaleY = size.height / viewBox.height; + for (final spec in paths) { + Offset map(Offset point) => Offset( + (point.dx + spec.translate.dx) * scaleX, + (point.dy + spec.translate.dy) * scaleY, + ); + final path = _SvgPathParser(spec.pathData).parseToPath(map); + canvas.drawPath(path, Paint()..color = spec.color); + } + } + + @override + bool shouldRepaint(covariant _SvgPathPainter oldDelegate) => + oldDelegate.viewBox != viewBox || oldDelegate.paths != paths; +} + +/// A parsed drawing instruction in the SVG path's own coordinate space (pre-scaling). +abstract class _Seg { + void apply(Path path, Offset Function(Offset) map); +} + +class _MoveSeg extends _Seg { + final Offset point; + _MoveSeg(this.point); + @override + void apply(Path path, Offset Function(Offset) map) => + path.moveTo(map(point).dx, map(point).dy); +} + +class _LineSeg extends _Seg { + final Offset point; + _LineSeg(this.point); + @override + void apply(Path path, Offset Function(Offset) map) => + path.lineTo(map(point).dx, map(point).dy); +} + +class _CurveSeg extends _Seg { + final Offset control1; + final Offset control2; + final Offset end; + _CurveSeg(this.control1, this.control2, this.end); + @override + void apply(Path path, Offset Function(Offset) map) { + final c1 = map(control1); + final c2 = map(control2); + final e = map(end); + path.cubicTo(c1.dx, c1.dy, c2.dx, c2.dy, e.dx, e.dy); + } +} + +class _CloseSeg extends _Seg { + @override + void apply(Path path, Offset Function(Offset) map) => path.close(); +} + +/// Minimal SVG path-data ("d" attribute) parser producing [_Seg]s, ported from the iOS SDK's +/// `SVGPathParser` (`SVGIconPath.swift`). +class _SvgPathParser { + final String _data; + int _idx = 0; + Offset _current = Offset.zero; + Offset _subpathStart = Offset.zero; + String _command = 'M'; + // The reflection of the previous C/S command's second control point, used by S/s per the SVG + // spec. Reset to null after any other command, in which case S/s uses the current point itself + // as its (unreflected) first control point. + Offset? _lastCubicControl; + + _SvgPathParser(this._data); + + Path parseToPath(Offset Function(Offset) map) { + final path = Path(); + for (final seg in _parse()) { + seg.apply(path, map); + } + return path; + } + + List<_Seg> _parse() { + final segments = <_Seg>[]; + while (_idx < _data.length) { + _skipSeparators(); + if (_idx >= _data.length) break; + final ch = _data[_idx]; + if (_isLetter(ch)) { + _command = ch; + _idx++; + } else if (_command == 'M') { + _command = 'L'; + } else if (_command == 'm') { + _command = 'l'; + } + final numbers = _readNumbers(_command); + if (numbers == null) continue; + _apply(_command, numbers, segments); + } + return segments; + } + + List? _readNumbers(String command) { + final count = _argumentCount(command); + if (count == 0) return const []; + final values = []; + for (var i = 0; i < count; i++) { + final value = _parseNumber(); + if (value == null) return values.isEmpty ? null : values; + values.add(value); + } + return values; + } + + int _argumentCount(String command) { + switch (command.toLowerCase()) { + case 'm': + case 'l': + case 't': + return 2; + case 'h': + case 'v': + return 1; + case 'c': + return 6; + case 's': + case 'q': + return 4; + case 'a': + return 7; + case 'z': + return 0; + default: + return 0; + } + } + + void _apply(String command, List values, List<_Seg> segments) { + final isRelative = command.toLowerCase() == command && _isLetter(command); + Offset resolved(double deltaX, double deltaY) => isRelative + ? Offset(_current.dx + deltaX, _current.dy + deltaY) + : Offset(deltaX, deltaY); + + switch (command.toLowerCase()) { + case 'm': + final point = resolved(values[0], values[1]); + segments.add(_MoveSeg(point)); + _current = point; + _subpathStart = point; + break; + case 'l': + final point = resolved(values[0], values[1]); + segments.add(_LineSeg(point)); + _current = point; + break; + case 'h': + final point = Offset( + isRelative ? _current.dx + values[0] : values[0], + _current.dy, + ); + segments.add(_LineSeg(point)); + _current = point; + break; + case 'v': + final point = Offset( + _current.dx, + isRelative ? _current.dy + values[0] : values[0], + ); + segments.add(_LineSeg(point)); + _current = point; + break; + case 'c': + final control1 = resolved(values[0], values[1]); + final control2 = resolved(values[2], values[3]); + final end = resolved(values[4], values[5]); + segments.add(_CurveSeg(control1, control2, end)); + _current = end; + _lastCubicControl = _reflect(control2, end); + return; + case 's': + final control1 = _lastCubicControl ?? _current; + final control2 = resolved(values[0], values[1]); + final end = resolved(values[2], values[3]); + segments.add(_CurveSeg(control1, control2, end)); + _current = end; + _lastCubicControl = _reflect(control2, end); + return; + case 'a': + final end = resolved(values[5], values[6]); + final arc = _EllipticalArc( + start: _current, + end: end, + radiusX: values[0], + radiusY: values[1], + rotationDegrees: values[2], + largeArc: values[3] != 0, + sweep: values[4] != 0, + ); + segments.addAll(arc.bezierSegments()); + _current = end; + break; + case 'z': + segments.add(_CloseSeg()); + _current = _subpathStart; + break; + default: + break; + } + _lastCubicControl = null; + } + + /// Reflects [controlPoint] through [end], per the SVG spec's rule for S/s and T/t: the implicit + /// first control point is the reflection of the previous curve's final control point about the + /// current point. + Offset _reflect(Offset controlPoint, Offset end) => + Offset(2 * end.dx - controlPoint.dx, 2 * end.dy - controlPoint.dy); + + void _skipSeparators() { + while (_idx < _data.length && + (_data[_idx] == ',' || _data[_idx].trim().isEmpty)) { + _idx++; + } + } + + bool _isLetter(String s) => s.isNotEmpty && RegExp(r'[A-Za-z]').hasMatch(s); + + bool _isDigit(String s) => s.isNotEmpty && RegExp(r'[0-9]').hasMatch(s); + + double? _parseNumber() { + _skipSeparators(); + if (_idx >= _data.length) return null; + final sb = StringBuffer(); + if (_data[_idx] == '-' || _data[_idx] == '+') { + sb.write(_data[_idx]); + _idx++; + } + var sawDot = false; + while (_idx < _data.length && + (_isDigit(_data[_idx]) || (_data[_idx] == '.' && !sawDot))) { + if (_data[_idx] == '.') sawDot = true; + sb.write(_data[_idx]); + _idx++; + } + if (_idx < _data.length && (_data[_idx] == 'e' || _data[_idx] == 'E')) { + final expSb = StringBuffer(_data[_idx]); + var lookahead = _idx + 1; + if (lookahead < _data.length && + (_data[lookahead] == '+' || _data[lookahead] == '-')) { + expSb.write(_data[lookahead]); + lookahead++; + } + while (lookahead < _data.length && _isDigit(_data[lookahead])) { + expSb.write(_data[lookahead]); + lookahead++; + } + sb.write(expSb.toString()); + _idx = lookahead; + } + if (sb.isEmpty) return null; + return double.tryParse(sb.toString()); + } +} + +/// An SVG elliptical arc ("A"/"a" command), convertible to cubic-bezier segments per the +/// SVG 1.1 spec's endpoint-to-center-parameterization (Appendix F.6.5). Ported from the iOS +/// SDK's `EllipticalArc` (`SVGIconPath.swift`). +class _EllipticalArc { + final Offset start; + final Offset end; + final double radiusX; + final double radiusY; + final double rotationDegrees; + final bool largeArc; + final bool sweep; + + const _EllipticalArc({ + required this.start, + required this.end, + required this.radiusX, + required this.radiusY, + required this.rotationDegrees, + required this.largeArc, + required this.sweep, + }); + + List<_Seg> bezierSegments() { + final form = _centerForm(); + if (radiusX == 0 || radiusY == 0 || start == end || form == null) { + return [_LineSeg(end)]; + } + final segmentCount = + math.max(1, (form.sweepAngle.abs() / (math.pi / 2)).ceil()); + final step = form.sweepAngle / segmentCount; + var angle = form.startAngle; + final segments = <_Seg>[]; + for (var i = 0; i < segmentCount; i++) { + final nextAngle = angle + step; + segments.add(_bezierSegment(angle, nextAngle, form)); + angle = nextAngle; + } + return segments; + } + + _CenterForm? _centerForm() { + var radiusX = this.radiusX.abs(); + var radiusY = this.radiusY.abs(); + final rotation = rotationDegrees * math.pi / 180; + final cosRotation = math.cos(rotation); + final sinRotation = math.sin(rotation); + final halfDeltaX = (start.dx - end.dx) / 2; + final halfDeltaY = (start.dy - end.dy) / 2; + final rotatedX = cosRotation * halfDeltaX + sinRotation * halfDeltaY; + final rotatedY = -sinRotation * halfDeltaX + cosRotation * halfDeltaY; + + final scaleCheck = (rotatedX * rotatedX) / (radiusX * radiusX) + + (rotatedY * rotatedY) / (radiusY * radiusY); + if (scaleCheck > 1) { + final scale = math.sqrt(scaleCheck); + radiusX *= scale; + radiusY *= scale; + } + + final sign = largeArc != sweep ? 1.0 : -1.0; + final numerator = radiusX * radiusX * radiusY * radiusY - + radiusX * radiusX * rotatedY * rotatedY - + radiusY * radiusY * rotatedX * rotatedX; + final denominator = radiusX * radiusX * rotatedY * rotatedY + + radiusY * radiusY * rotatedX * rotatedX; + final coefficient = denominator == 0 + ? 0.0 + : sign * math.sqrt(math.max(0, numerator / denominator)); + final centerRotatedX = coefficient * (radiusX * rotatedY) / radiusY; + final centerRotatedY = coefficient * -(radiusY * rotatedX) / radiusX; + + final centerX = cosRotation * centerRotatedX - + sinRotation * centerRotatedY + + (start.dx + end.dx) / 2; + final centerY = sinRotation * centerRotatedX + + cosRotation * centerRotatedY + + (start.dy + end.dy) / 2; + + final startVectorX = (rotatedX - centerRotatedX) / radiusX; + final startVectorY = (rotatedY - centerRotatedY) / radiusY; + final endVectorX = (-rotatedX - centerRotatedX) / radiusX; + final endVectorY = (-rotatedY - centerRotatedY) / radiusY; + final startAngle = _angleBetween(1, 0, startVectorX, startVectorY); + var sweepAngle = + _angleBetween(startVectorX, startVectorY, endVectorX, endVectorY); + if (!sweep && sweepAngle > 0) sweepAngle -= 2 * math.pi; + if (sweep && sweepAngle < 0) sweepAngle += 2 * math.pi; + + return _CenterForm( + center: Offset(centerX, centerY), + radiusX: radiusX, + radiusY: radiusY, + cosRotation: cosRotation, + sinRotation: sinRotation, + startAngle: startAngle, + sweepAngle: sweepAngle, + ); + } + + _Seg _bezierSegment(double angle, double nextAngle, _CenterForm form) { + final kappa = 4.0 / 3.0 * math.tan((nextAngle - angle) / 4.0); + final cosStart = math.cos(angle), sinStart = math.sin(angle); + final cosEnd = math.cos(nextAngle), sinEnd = math.sin(nextAngle); + final control1 = _mapUnitPoint( + cosStart - kappa * sinStart, + sinStart + kappa * cosStart, + form, + ); + final control2 = _mapUnitPoint( + cosEnd + kappa * sinEnd, + sinEnd - kappa * cosEnd, + form, + ); + final segmentEnd = _mapUnitPoint(cosEnd, sinEnd, form); + return _CurveSeg(control1, control2, segmentEnd); + } + + Offset _mapUnitPoint(double unitX, double unitY, _CenterForm form) { + final scaledX = form.radiusX * unitX; + final scaledY = form.radiusY * unitY; + return Offset( + form.cosRotation * scaledX - form.sinRotation * scaledY + form.center.dx, + form.sinRotation * scaledX + form.cosRotation * scaledY + form.center.dy, + ); + } + + double _angleBetween(double fromX, double fromY, double toX, double toY) { + final dot = fromX * toX + fromY * toY; + final magnitude = + math.sqrt((fromX * fromX + fromY * fromY) * (toX * toX + toY * toY)); + var angle = math.acos((dot / magnitude).clamp(-1.0, 1.0)); + if (fromX * toY - fromY * toX < 0) angle = -angle; + return angle; + } +} + +/// Center-parameterization values derived from the endpoint form of an elliptical arc. +class _CenterForm { + final Offset center; + final double radiusX; + final double radiusY; + final double cosRotation; + final double sinRotation; + final double startAngle; + final double sweepAngle; + + const _CenterForm({ + required this.center, + required this.radiusX, + required this.radiusY, + required this.cosRotation, + required this.sinRotation, + required this.startAngle, + required this.sweepAngle, + }); +} diff --git a/lib/src/widgets/flow_form.dart b/lib/src/widgets/flow_form.dart index 0bf4f2a..95de04b 100644 --- a/lib/src/widgets/flow_form.dart +++ b/lib/src/widgets/flow_form.dart @@ -16,10 +16,18 @@ * under the License. */ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../flow_template_resolver.dart'; import '../models/flow_models.dart'; +import 'adapters/facebook_button.dart'; +import 'adapters/github_button.dart'; +import 'adapters/google_button.dart'; +import 'adapters/linkedin_button.dart'; +import 'adapters/microsoft_button.dart'; +import 'adapters/outlined_trigger_button.dart'; import 'thunderid_provider.dart'; /// Internal widget used by [ThunderIDSignIn] and [ThunderIDSignUp] to render a @@ -30,7 +38,8 @@ class FlowForm extends StatefulWidget { final EmbeddedFlowResponse? currentStep; final bool isLoading; final String? error; - final Future Function(String actionId, Map inputs) submit; + final Future Function(String actionId, Map inputs) + submit; final String submitLabel; const FlowForm({ @@ -49,6 +58,7 @@ class FlowForm extends StatefulWidget { class _FlowFormState extends State { final _controllers = {}; + final _linkRecognizers = []; FlowTemplateResolver? _resolver; @override @@ -62,8 +72,13 @@ class _FlowFormState extends State { @override void dispose() { for (final c in _controllers.values) { + { c.dispose(); } + for (final r in _linkRecognizers) { + r.dispose(); + } + } super.dispose(); } @@ -88,10 +103,18 @@ class _FlowFormState extends State { return const Center(child: CircularProgressIndicator()); } + // _renderRichText appends a fresh TapGestureRecognizer per link on every build; dispose the + // previous build's recognizers first so they don't accumulate across rebuilds. + for (final r in _linkRecognizers) { + r.dispose(); + } + _linkRecognizers.clear(); + final data = step?.data; final rawMeta = data?['meta']; - final components = - rawMeta is Map ? _readList(rawMeta['components']) : const >[]; + final components = rawMeta is Map + ? _readList(rawMeta['components']) + : const >[]; final inputs = _readList(data?['inputs']); final actions = _readList(data?['actions']); @@ -105,11 +128,13 @@ class _FlowFormState extends State { if (!_hasActionComponent(components) && actions.isNotEmpty) ...actions.map((a) => _renderAction(context, a, actions)), ] else ...[ - ...inputs.map((i) => _renderField( - context, - {'ref': _inputRef(i), 'label': '', 'type': i['type']}, - _str(i['type']), - ),), + ...inputs.map( + (i) => _renderField( + context, + {'ref': _inputRef(i), 'label': '', 'type': i['type']}, + _str(i['type']), + ), + ), if (actions.isNotEmpty) ...actions.map((a) => _renderAction(context, a, actions)) else @@ -124,7 +149,9 @@ class _FlowFormState extends State { Text( widget.error!, style: TextStyle( - color: Theme.of(context).colorScheme.error, fontSize: 13,), + color: Theme.of(context).colorScheme.error, + fontSize: 13, + ), ), ], ], @@ -154,9 +181,12 @@ class _FlowFormState extends State { final category = _str(comp['category']); if (category.isNotEmpty) return category; switch (_str(comp['type'])) { + case 'DIVIDER': + return 'DIVIDER'; + case 'RICH_TEXT': + return 'RICH_TEXT'; case 'TEXT': case 'IMAGE': - case 'RICH_TEXT': return 'DISPLAY'; case 'BLOCK': return 'BLOCK'; @@ -180,6 +210,10 @@ class _FlowFormState extends State { switch (_effectiveCategory(comp)) { case 'DISPLAY': return _renderDisplay(context, comp); + case 'DIVIDER': + return _renderDivider(context, comp); + case 'RICH_TEXT': + return _renderRichText(context, comp); case 'BLOCK': return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -191,6 +225,15 @@ class _FlowFormState extends State { case 'FIELD': return _renderField(context, comp, _str(comp['type'])); case 'ACTION': + final nested = _readList(comp['components']); + if (nested.isNotEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: + nested.map((c) => _renderComponent(context, c, actions)).toList(), + ); + } return _renderAction(context, comp, actions); default: return const SizedBox.shrink(); @@ -199,6 +242,16 @@ class _FlowFormState extends State { Widget _renderDisplay(BuildContext context, Map comp) { final type = _str(comp['type']); + // The real backend sends an explicit `category: "DISPLAY"` on DIVIDER + // and RICH_TEXT components too, so `_effectiveCategory` short-circuits + // to 'DISPLAY' before ever consulting `type`. Dispatch on `type` here + // so those components still render correctly instead of being dropped. + if (type == 'DIVIDER') { + return _renderDivider(context, comp); + } + if (type == 'RICH_TEXT') { + return _renderRichText(context, comp); + } if (type == 'TEXT') { final label = _resolve(comp['label']); if (label.isEmpty) return const SizedBox.shrink(); @@ -208,9 +261,8 @@ class _FlowFormState extends State { .titleMedium ?.copyWith(fontWeight: FontWeight.bold) : Theme.of(context).textTheme.bodyMedium; - final align = _str(comp['align']) == 'center' - ? TextAlign.center - : TextAlign.start; + final align = + _str(comp['align']) == 'center' ? TextAlign.center : TextAlign.start; return Padding( padding: const EdgeInsets.only(bottom: 16), child: Text(label, style: style, textAlign: align), @@ -228,6 +280,83 @@ class _FlowFormState extends State { return const SizedBox.shrink(); } + Widget _renderDivider(BuildContext context, Map comp) { + final label = _resolve(comp['label'], fallback: 'Or'); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text(label, style: Theme.of(context).textTheme.bodySmall), + ), + const Expanded(child: Divider()), + ], + ), + ); + } + + Widget _renderRichText(BuildContext context, Map comp) { + final html = _resolve(comp['label']); + if (html.isEmpty) return const SizedBox.shrink(); + + final linkPattern = RegExp( + r']*href="([^"]*)"[^>]*>(.*?)', + dotAll: true, + ); + final spans = []; + var cursor = 0; + for (final match in linkPattern.allMatches(html)) { + if (match.start > cursor) { + final plain = _stripTags(html.substring(cursor, match.start)); + if (plain.trim().isNotEmpty) spans.add(TextSpan(text: plain)); + } + final href = match.group(1) ?? ''; + final linkText = _stripTags(match.group(2) ?? '').trim(); + final recognizer = TapGestureRecognizer()..onTap = () => _openLink(href); + _linkRecognizers.add(recognizer); + spans.add( + TextSpan( + text: linkText, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: recognizer, + ), + ); + cursor = match.end; + } + if (cursor < html.length) { + final plain = _stripTags(html.substring(cursor)); + if (plain.trim().isNotEmpty) spans.add(TextSpan(text: plain)); + } + if (spans.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text.rich( + TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: spans, + ), + ), + ); + } + + String _stripTags(String value) => value.replaceAll(RegExp(r'<[^>]+>'), ''); + + Future _openLink(String url) async { + if (url.isEmpty || url.startsWith('{{')) return; + final uri = Uri.tryParse(url); + if (uri == null) return; + if (uri.scheme.toLowerCase() != 'http' && uri.scheme.toLowerCase() != 'https') { + return; + } + await launchUrl(uri); + } + Widget _renderField( BuildContext context, Map comp, @@ -245,7 +374,7 @@ class _FlowFormState extends State { controller: _controllers[ref], decoration: InputDecoration( labelText: label, - hintText: _capitalize(ref), + hintText: _resolve(comp['placeholder'], fallback: _capitalize(ref)), floatingLabelBehavior: FloatingLabelBehavior.always, border: const OutlineInputBorder(), ), @@ -266,6 +395,60 @@ class _FlowFormState extends State { final label = _resolve(comp['label'], fallback: widget.submitLabel); final metaActionId = _str(comp['ref'], fallback: _str(comp['id'])); final actionId = _findActionId(metaActionId, actions); + final eventType = _str( + comp['eventType'], + fallback: _str(_actionForId(metaActionId, actions)?['eventType']), + ); + + if (eventType.toUpperCase() == 'TRIGGER') { + void onPressed() => widget.submit( + actionId, + _controllers.map((k, v) => MapEntry(k, v.text)), + ); + final hint = + '$metaActionId $label ${_str(comp['icon'])}'.toLowerCase(); + if (hint.contains('google')) { + return GoogleButton( + label: label, + isLoading: widget.isLoading, + onPressed: onPressed, + ); + } + if (hint.contains('github')) { + return GitHubButton( + label: label, + isLoading: widget.isLoading, + onPressed: onPressed, + ); + } + if (hint.contains('facebook')) { + return FacebookButton( + label: label, + isLoading: widget.isLoading, + onPressed: onPressed, + ); + } + if (hint.contains('microsoft')) { + return MicrosoftButton( + label: label, + isLoading: widget.isLoading, + onPressed: onPressed, + ); + } + if (hint.contains('linkedin')) { + return LinkedInButton( + label: label, + isLoading: widget.isLoading, + onPressed: onPressed, + ); + } + return OutlinedTriggerButton( + label: label, + isLoading: widget.isLoading, + onPressed: onPressed, + ); + } + return Padding( padding: const EdgeInsets.only(top: 8), child: FilledButton( @@ -287,8 +470,22 @@ class _FlowFormState extends State { ); } + Map? _actionForId( + String metaActionId, + List> actions, + ) { + for (final a in actions) { + if (_str(a['ref']) == metaActionId || _str(a['id']) == metaActionId) { + return a; + } + } + return null; + } + String _findActionId( - String metaActionId, List> actions,) { + String metaActionId, + List> actions, + ) { if (actions.isEmpty) return 'submit'; final byRef = actions.firstWhere( (a) => _str(a['ref']) == metaActionId, @@ -307,9 +504,13 @@ class _FlowFormState extends State { return _actionSubmitId(actions.first); } - String _actionSubmitId(Map a) => _str(a['ref'], - fallback: _str(a['id'], - fallback: _str(a['nextNode'], fallback: 'submit'),),); + String _actionSubmitId(Map a) => _str( + a['ref'], + fallback: _str( + a['id'], + fallback: _str(a['nextNode'], fallback: 'submit'), + ), + ); int? _actionIndex(String id) { if (!id.startsWith('action_')) return null; @@ -328,6 +529,7 @@ class _FlowFormState extends State { walk(_readList(c['components'])); } } + walk(comps); return refs; } @@ -344,6 +546,7 @@ class _FlowFormState extends State { if (found) return; } } + walk(comps); return found; } @@ -362,13 +565,21 @@ class _FlowFormState extends State { String _capitalize(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); - String _fieldRef(Map comp) => _str(comp['ref'], - fallback: _str(comp['identifier'], - fallback: _str(comp['name'], fallback: _str(comp['id'])),),); + String _fieldRef(Map comp) => _str( + comp['ref'], + fallback: _str( + comp['identifier'], + fallback: _str(comp['name'], fallback: _str(comp['id'])), + ), + ); - String _inputRef(Map input) => _str(input['name'], - fallback: _str(input['identifier'], - fallback: _str(input['ref'], fallback: _str(input['id'])),),); + String _inputRef(Map input) => _str( + input['name'], + fallback: _str( + input['identifier'], + fallback: _str(input['ref'], fallback: _str(input['id'])), + ), + ); String _resolve(dynamic value, {String fallback = ''}) { final s = value is String ? value.trim() : ''; diff --git a/lib/src/widgets/sign_in.dart b/lib/src/widgets/sign_in.dart index b8f6305..108b7a0 100644 --- a/lib/src/widgets/sign_in.dart +++ b/lib/src/widgets/sign_in.dart @@ -20,6 +20,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../models/flow_models.dart'; +import '../models/thunderid_error.dart'; import '../models/token_exchange_config.dart'; import '../models/user.dart'; import 'flow_form.dart'; @@ -98,6 +99,10 @@ class _BaseSignInState extends State { String? _error; bool _autoAdvancing = false; + /// Debug log tag scoped to the configured [ThunderIDConfig.vendor] rather than a hardcoded + /// brand literal, since a consuming app may white-label the SDK. + String get _logTag => '[${ThunderIDProvider.of(context).widget.config.vendor}SignIn]'; + @override void initState() { super.initState(); @@ -115,7 +120,7 @@ class _BaseSignInState extends State { if (kDebugMode) { final inputList = (response.data?['inputs'] as List?) ?? const []; final actionList = (response.data?['actions'] as List?) ?? const []; - debugPrint('[ThunderIDSignIn] init response flowStatus=${response.flowStatus} inputs=$inputList actions=$actionList'); + debugPrint('$_logTag init response flowStatus=${response.flowStatus} inputs=$inputList actions=$actionList'); } if (mounted) setState(() { _currentStep = response; _error = null; }); } catch (e) { @@ -128,7 +133,7 @@ class _BaseSignInState extends State { Future _submit(String actionId, Map inputs) async { final flowId = _currentStep?.flowId; - debugPrint('[ThunderIDSignIn] _submit flowId=$flowId actionId=$actionId inputs=${inputs.keys.toList()}'); + debugPrint('$_logTag _submit flowId=$flowId actionId=$actionId inputs=${inputs.keys.toList()}'); if (flowId == null) return; setState(() => _isLoading = true); try { @@ -138,12 +143,39 @@ class _BaseSignInState extends State { request: EmbeddedFlowRequestConfig(applicationId: widget.applicationId), ); + if (response.type == 'REDIRECTION') { + if (kDebugMode) { + debugPrint('$_logTag REDIRECTION for actionId=$actionId, delegating to continueFederatedAuth'); + } + final redirectUrl = response.data?['redirectURL'] as String?; + if (redirectUrl == null || redirectUrl.isEmpty) { + if (mounted) setState(() => _error = 'Federated sign-in did not return a redirect URL'); + widget.onError?.call(); + return; + } + try { + response = await state.client.continueFederatedAuth( + redirectUrl: redirectUrl, + actionId: actionId, + applicationId: widget.applicationId, + flowId: response.flowId ?? flowId, + challengeToken: response.challengeToken ?? _currentStep?.challengeToken, + ); + } on IAMException catch (e) { + if (e.code == ThunderIDErrorCode.federatedAuthCancelled) { + // User dismissed the browser without completing sign-in — reset silently. + return; + } + rethrow; + } + } + if (_shouldAutoAdvance(response)) { _autoAdvancing = true; final nextActionId = _nextActionId(response); if (nextActionId.isNotEmpty && response.flowId != null) { if (kDebugMode) { - debugPrint('[ThunderIDSignIn] auto-advancing actionId=$nextActionId'); + debugPrint('$_logTag auto-advancing actionId=$nextActionId'); } response = await state.client.signIn( payload: EmbeddedSignInPayload( @@ -163,8 +195,8 @@ class _BaseSignInState extends State { final actionCount = (response.data?['actions'] as List?)?.length ?? 0; final inputList = (response.data?['inputs'] as List?) ?? const []; final actionList = (response.data?['actions'] as List?) ?? const []; - debugPrint('[ThunderIDSignIn] submit response flowStatus=${response.flowStatus} hasAssertion=$hasAssertion inputs=$inputCount actions=$actionCount failureReason=${response.failureReason}'); - debugPrint('[ThunderIDSignIn] submit response inputData=$inputList actionData=$actionList'); + debugPrint('$_logTag submit response flowStatus=${response.flowStatus} hasAssertion=$hasAssertion inputs=$inputCount actions=$actionCount failureReason=${response.failureReason}'); + debugPrint('$_logTag submit response inputData=$inputList actionData=$actionList'); } final isComplete = response.flowStatus == FlowStatus.complete || (response.assertion?.isNotEmpty ?? false); @@ -204,7 +236,7 @@ class _BaseSignInState extends State { if (mounted) setState(() { _currentStep = response; _error = null; }); } } catch (e, st) { - debugPrint('[ThunderIDSignIn] _submit error: $e\n$st'); + debugPrint('$_logTag _submit error: $e\n$st'); if (mounted) setState(() => _error = e.toString()); widget.onError?.call(); } finally { diff --git a/pubspec.yaml b/pubspec.yaml index 6a00387..43ab50b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: flutter: sdk: flutter plugin_platform_interface: ^2.1.7 + url_launcher: ^6.2.5 dev_dependencies: build_runner: ^2.4.8 diff --git a/samples/quickstart/android/build.gradle.kts b/samples/quickstart/android/build.gradle.kts index dbee657..d08ad63 100644 --- a/samples/quickstart/android/build.gradle.kts +++ b/samples/quickstart/android/build.gradle.kts @@ -2,6 +2,7 @@ allprojects { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } } diff --git a/samples/quickstart/ios/Podfile b/samples/quickstart/ios/Podfile index 25d398e..d649ddf 100644 --- a/samples/quickstart/ios/Podfile +++ b/samples/quickstart/ios/Podfile @@ -29,7 +29,7 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! - pod 'ThunderID', :git => 'https://github.com/thunder-id/ios-sdks' + pod 'ThunderID', :git => 'https://github.com/thunder-id/ios-sdks', :branch => 'main' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) target 'RunnerTests' do diff --git a/test/adapters_test.dart b/test/adapters_test.dart new file mode 100644 index 0000000..4c11fc9 --- /dev/null +++ b/test/adapters_test.dart @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thunderid_flutter/src/widgets/adapters/github_button.dart'; +import 'package:thunderid_flutter/src/widgets/adapters/google_button.dart'; +import 'package:thunderid_flutter/src/widgets/adapters/outlined_trigger_button.dart'; + +void main() { + testWidgets('GoogleButton renders its brand glyph and label without throwing', + (tester) async { + var tapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GoogleButton(onPressed: () => tapped = true), + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.text('Continue with Google'), findsOneWidget); + + await tester.tap(find.byType(OutlinedButton)); + expect(tapped, true); + }); + + testWidgets('GitHubButton renders its brand glyph and label without throwing', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: GitHubButton(onPressed: () {}), + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.text('Continue with GitHub'), findsOneWidget); + }); + + testWidgets( + 'OutlinedTriggerButton falls back to the schema label when loading', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: OutlinedTriggerButton( + label: 'Continue with Acme', + isLoading: true, + onPressed: () {}, + ), + ), + ), + ); + expect(tester.takeException(), isNull); + final button = tester.widget(find.byType(OutlinedButton)); + expect(button.onPressed, isNull); + }); +} diff --git a/test/flow_form_test.dart b/test/flow_form_test.dart new file mode 100644 index 0000000..52e821a --- /dev/null +++ b/test/flow_form_test.dart @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:thunderid_flutter/src/models/flow_models.dart'; +import 'package:thunderid_flutter/src/models/thunderid_config.dart'; +import 'package:thunderid_flutter/src/widgets/adapters/github_button.dart'; +import 'package:thunderid_flutter/src/widgets/adapters/google_button.dart'; +import 'package:thunderid_flutter/src/widgets/flow_form.dart'; +import 'package:thunderid_flutter/src/widgets/thunderid_provider.dart'; + +const _config = + ThunderIDConfig(baseUrl: 'https://localhost:8090', clientId: 'test'); +const _sdkChannel = MethodChannel('dev.thunderid/sdk'); + +final Map _flowMeta = { + 'i18n': { + 'translations': { + 'signin': { + 'forms.credentials.fields.username.label': 'Username', + 'forms.credentials.fields.username.placeholder': 'Enter your username', + 'forms.credentials.fields.password.label': 'Password', + 'forms.credentials.fields.password.placeholder': 'Enter your password', + 'forms.credentials.links.forgot_password.prefix': '', + 'forms.credentials.links.forgot_password.label': 'Forgot password?', + 'forms.credentials.links.sign_up.prefix': "Don't have an account?", + 'forms.credentials.links.sign_up.label': 'Sign up', + }, + }, + }, + 'application': { + 'forgot_password_url': 'https://example.com/forgot-password', + 'sign_up_url': 'https://example.com/sign-up', + }, +}; + +final Map _stepData = { + 'inputs': [ + { + 'ref': 'input_001', + 'identifier': 'username', + 'type': 'TEXT_INPUT', + 'required': true, + }, + { + 'ref': 'input_002', + 'identifier': 'password', + 'type': 'PASSWORD_INPUT', + 'required': true, + }, + ], + 'actions': [ + {'ref': 'action_001', 'nextNode': 'credentials_auth'}, + {'ref': 'action_xoc0', 'nextNode': 'ID_0e5o'}, + {'ref': 'action_zeye', 'nextNode': 'ID_dbxc'}, + ], + 'meta': { + 'components': [ + { + 'align': 'center', + 'category': 'DISPLAY', + 'id': 'text_001', + 'label': 'Login', + 'resourceType': 'ELEMENT', + 'type': 'TEXT', + 'variant': 'HEADING_1', + }, + { + 'category': 'BLOCK', + 'components': [ + { + 'category': 'FIELD', + 'hint': '', + 'id': 'input_001', + 'inputType': 'text', + 'label': '{{ t(signin:forms.credentials.fields.username.label) }}', + 'placeholder': + '{{ t(signin:forms.credentials.fields.username.placeholder) }}', + 'ref': 'username', + 'required': true, + 'resourceType': 'ELEMENT', + 'type': 'TEXT_INPUT', + }, + { + 'category': 'FIELD', + 'hint': '', + 'id': 'input_002', + 'inputType': 'text', + 'label': '{{ t(signin:forms.credentials.fields.password.label) }}', + 'placeholder': + '{{ t(signin:forms.credentials.fields.password.placeholder) }}', + 'ref': 'password', + 'required': true, + 'resourceType': 'ELEMENT', + 'type': 'PASSWORD_INPUT', + }, + { + 'category': 'DISPLAY', + 'id': 'rich_text_forgot_password', + 'label': + '

{{ t(signin:forms.credentials.links.forgot_password.prefix) }} {{ t(signin:forms.credentials.links.forgot_password.label) }}

', + 'resourceType': 'ELEMENT', + 'type': 'RICH_TEXT', + }, + { + 'category': 'ACTION', + 'eventType': 'SUBMIT', + 'id': 'action_001', + 'label': '{{ t(signin:forms.credentials.actions.submit.label) }}', + 'resourceType': 'ELEMENT', + 'type': 'ACTION', + 'variant': 'PRIMARY', + }, + { + 'category': 'DISPLAY', + 'id': 'rich_text_signup', + 'label': + '

{{ t(signin:forms.credentials.links.sign_up.prefix) }} {{ t(signin:forms.credentials.links.sign_up.label) }}

', + 'resourceType': 'ELEMENT', + 'type': 'RICH_TEXT', + }, + ], + 'id': 'block_001', + 'resourceType': 'ELEMENT', + 'type': 'BLOCK', + }, + { + 'category': 'DISPLAY', + 'id': 'display_tfae', + 'label': 'Or', + 'resourceType': 'ELEMENT', + 'type': 'DIVIDER', + 'variant': 'HORIZONTAL', + }, + { + 'category': 'ACTION', + 'components': [ + { + 'category': 'ACTION', + 'eventType': 'TRIGGER', + 'id': 'action_xoc0', + 'image': 'assets/images/icons/google.svg', + 'label': 'Continue with Google', + 'resourceType': 'ELEMENT', + 'type': 'ACTION', + 'variant': 'OUTLINED', + }, + ], + 'eventType': 'TRIGGER', + 'id': 'block_per3', + 'resourceType': 'ELEMENT', + 'type': 'BLOCK', + }, + { + 'category': 'ACTION', + 'components': [ + { + 'category': 'ACTION', + 'eventType': 'TRIGGER', + 'id': 'action_zeye', + 'image': 'assets/images/icons/github.svg', + 'label': 'Continue with GitHub', + 'resourceType': 'ELEMENT', + 'type': 'ACTION', + 'variant': 'OUTLINED', + }, + ], + 'eventType': 'TRIGGER', + 'id': 'block_dzuu', + 'resourceType': 'ELEMENT', + 'type': 'BLOCK', + }, + ], + }, +}; + +final _step = EmbeddedFlowResponse( + flowStatus: FlowStatus.promptOnly, + data: _stepData, + challengeToken: + 'efb3706d1a8db1a291da65cd49e213af6e63fe6379006dfbd96d545d3a920119', +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_sdkChannel, null); + }); + + Widget buildForm() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_sdkChannel, (call) async { + switch (call.method) { + case 'initialize': + return true; + case 'isSignedIn': + return false; + case 'getFlowMeta': + return _flowMeta; + default: + return null; + } + }); + + return MaterialApp( + home: Scaffold( + body: ThunderIDProvider( + config: _config, + child: FlowForm( + applicationId: 'app-1', + currentStep: _step, + isLoading: false, + error: null, + submit: (actionId, inputs) async {}, + ), + ), + ), + ); + } + + group('FlowForm meta.components rendering', () { + testWidgets('renders a Divider for DIVIDER components', (tester) async { + await tester.pumpWidget(buildForm()); + await tester.pumpAndSettle(); + + expect(find.byType(Divider), findsWidgets); + }); + + testWidgets('renders tappable rich-text links for RICH_TEXT components', + (tester) async { + await tester.pumpWidget(buildForm()); + await tester.pumpAndSettle(); + + final richTextFinder = find.byWidgetPredicate((widget) { + if (widget is! Text) return false; + final span = widget.textSpan; + if (span == null) return false; + var hasLink = false; + span.visitChildren((child) { + if (child is TextSpan && child.recognizer is TapGestureRecognizer) { + hasLink = true; + } + return true; + }); + return hasLink; + }); + expect(richTextFinder, findsWidgets); + }); + + testWidgets('resolves the field placeholder instead of the capitalized ref', + (tester) async { + await tester.pumpWidget(buildForm()); + await tester.pumpAndSettle(); + + final field = tester.widget(find.byType(TextField).first); + final hintText = field.decoration?.hintText; + expect(hintText, isNotNull); + expect(hintText, isNot(contains('{{'))); + expect(hintText, 'Enter your username'); + }); + + testWidgets( + 'renders branded buttons for ACTION components nested inside a ' + 'category:ACTION wrapper block', (tester) async { + await tester.pumpWidget(buildForm()); + await tester.pumpAndSettle(); + + expect(find.text('Continue with Google'), findsOneWidget); + expect(find.text('Continue with GitHub'), findsOneWidget); + expect(find.byType(GoogleButton), findsOneWidget); + expect(find.byType(GitHubButton), findsOneWidget); + }); + }); +} diff --git a/test/thunderid_client_test.dart b/test/thunderid_client_test.dart index 7d562c0..160a0c4 100644 --- a/test/thunderid_client_test.dart +++ b/test/thunderid_client_test.dart @@ -18,6 +18,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:thunderid_flutter/src/models/flow_models.dart'; import 'package:thunderid_flutter/src/models/thunderid_config.dart'; import 'package:thunderid_flutter/src/models/thunderid_error.dart'; import 'package:thunderid_flutter/src/thunderid_client.dart'; @@ -44,6 +45,16 @@ void main() { return '/'; case 'getAccessToken': return 'mock-access-token'; + case 'continueFederatedAuth': + final args = call.arguments as Map; + if (args['redirectUrl'] == 'https://cancel.example') { + throw PlatformException(code: 'FEDERATED_AUTH_CANCELLED', message: 'cancelled'); + } + return { + 'flowId': 'flow-1', + 'flowStatus': 'COMPLETE', + 'assertion': 'mock-assertion', + }; default: return null; } @@ -148,4 +159,40 @@ void main() { expect(token, 'mock-access-token'); }); }); + + group('continueFederatedAuth', () { + test('resubmits the flow with the native-extracted code and returns the response', () async { + const config = ThunderIDConfig(baseUrl: 'https://localhost:8090', clientId: 'test'); + await client.initialize(config); + final response = await client.continueFederatedAuth( + redirectUrl: 'https://idp.example/authorize?state=abc', + actionId: 'action_2', + applicationId: 'app-1', + flowId: 'flow-1', + challengeToken: 'challenge-1', + ); + expect(response.flowStatus, FlowStatus.complete); + expect(response.assertion, 'mock-assertion'); + final call = log.firstWhere((c) => c.method == 'continueFederatedAuth'); + final args = call.arguments as Map; + expect(args['redirectUrl'], 'https://idp.example/authorize?state=abc'); + expect(args['actionId'], 'action_2'); + expect(args['applicationId'], 'app-1'); + expect(args['flowId'], 'flow-1'); + expect(args['challengeToken'], 'challenge-1'); + }); + + test('surfaces user cancellation as federatedAuthCancelled', () async { + const config = ThunderIDConfig(baseUrl: 'https://localhost:8090', clientId: 'test'); + await client.initialize(config); + expect( + () => client.continueFederatedAuth( + redirectUrl: 'https://cancel.example', + actionId: 'action_2', + applicationId: 'app-1', + ), + throwsA(isA().having((e) => e.code, 'code', ThunderIDErrorCode.federatedAuthCancelled)), + ); + }); + }); }