sayari_oauth_flutter_sdk 0.2.1
sayari_oauth_flutter_sdk: ^0.2.1 copied to clipboard
Reusable Sayari OAuth SDK for Flutter apps with PKCE, deep-link callbacks, and backend exchange helpers.
example/lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sayari_oauth_flutter_sdk/sayari_oauth_flutter_sdk.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const SayariSdkExampleApp());
}
class SayariSdkExampleApp extends StatelessWidget {
const SayariSdkExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sayari OAuth Flutter SDK Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1800AD)),
useMaterial3: true,
),
home: const _ExampleHomePage(),
);
}
}
class _ExampleHomePage extends StatefulWidget {
const _ExampleHomePage();
@override
State<_ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<_ExampleHomePage> {
final _appLinks = AppLinks();
final _loginHintController = TextEditingController();
SayariOAuthFlutterSdk? _sdk;
SayariOAuthTransaction? _transaction;
SayariOAuthConfig? _config;
String? _configError;
String? _callbackText;
String? _exchangeText;
String? _lastAction;
bool _loading = true;
bool _launching = false;
@override
void initState() {
super.initState();
_loadConfig();
_listenForCallbacks();
}
@override
void dispose() {
_loginHintController.dispose();
super.dispose();
}
Future<void> _loadConfig() async {
try {
final raw = await rootBundle.loadString('assets/config/sayari-oauth-config.json');
final decoded = jsonDecode(raw) as Map<String, dynamic>;
final config = _parseConfig(decoded);
final sdk = SayariOAuthFlutterSdk(config);
final pkce = sdk.createPkce();
setState(() {
_config = config;
_sdk = sdk;
_transaction = sdk.createTransaction(pkce: pkce);
_loading = false;
_lastAction = 'Loaded config and created a PKCE transaction.';
});
} catch (error) {
setState(() {
_configError = error.toString();
_loading = false;
});
}
}
void _listenForCallbacks() {
_appLinks.uriLinkStream.listen((uri) {
_handleIncomingUri(uri);
});
_appLinks.getInitialLink().then((uri) {
if (uri != null) {
_handleIncomingUri(uri);
}
});
}
void _handleIncomingUri(Uri uri) {
final sdk = _sdk;
final transaction = _transaction;
if (sdk == null || transaction == null) {
return;
}
final callback = sdk.parseCallback(uri);
if (!transaction.validateCallback(callback)) {
setState(() {
_callbackText = 'Ignored callback: state mismatch or missing code.';
_lastAction = 'Received a callback that did not match the transaction state.';
});
return;
}
setState(() {
_callbackText = const JsonEncoder.withIndent(' ').convert({
'code': callback.code,
'state': callback.state,
'error': callback.error,
'errorDescription': callback.errorDescription,
'redirectUri': callback.redirectUri,
});
_lastAction = 'Received a valid callback from Sayari.';
});
final code = callback.code;
if (code != null) {
unawaited(_exchangeCode(code, sdk, transaction, _config!));
}
}
Future<void> _openAuthorize() async {
final sdk = _sdk;
final transaction = _transaction;
final config = _config;
if (sdk == null || transaction == null || config == null) {
return;
}
setState(() {
_launching = true;
_lastAction = 'Launching the Sayari authorization flow.';
});
try {
await sdk.launchAuthorize(
pkce: transaction.pkce,
redirectUri: config.redirectUri,
appName: config.appName,
appIcon: config.appIcon,
parentOrigin: _deriveParentOrigin(config.redirectUri),
loginHint: _loginHint(),
);
} catch (error) {
setState(() {
_lastAction = error.toString();
});
} finally {
if (mounted) {
setState(() {
_launching = false;
});
}
}
}
void _copyAuthorizeUri() {
final sdk = _sdk;
final transaction = _transaction;
final config = _config;
if (sdk == null || transaction == null || config == null) {
return;
}
final uri = sdk.buildAuthorizeUri(
pkce: transaction.pkce,
redirectUri: config.redirectUri,
appName: config.appName,
appIcon: config.appIcon,
parentOrigin: _deriveParentOrigin(config.redirectUri),
loginHint: _loginHint(),
);
Clipboard.setData(ClipboardData(text: uri.toString()));
setState(() {
_lastAction = 'Authorize URL copied to clipboard.';
});
}
SayariOAuthConfig _parseConfig(Map<String, dynamic> json) {
final authApp = json['sayariAuthApp'] as Map<String, dynamic>;
return SayariOAuthConfig(
appId: authApp['appId'] as String,
authorizeUrl: authApp['authorizeUrl'] as String,
tokenUrl: authApp['tokenUrl'] as String,
redirectUri: authApp['primaryRedirectUri'] as String,
providerOrigin: authApp['providerOrigin'] as String,
appName: json['appName'] as String,
appIcon: authApp['appIcon'] as String?,
backendBaseUrl: authApp['backendBaseUrl'] as String?,
);
}
String? _loginHint() {
final value = _loginHintController.text.trim();
return value.isEmpty ? null : value;
}
Future<void> _exchangeCode(
String code,
SayariOAuthFlutterSdk sdk,
SayariOAuthTransaction transaction,
SayariOAuthConfig config,
) async {
final backendUrl = config.backendExchangeUrl;
if (backendUrl == null || backendUrl.isEmpty) {
setState(() {
_exchangeText = 'Backend exchange URL is not configured.';
});
return;
}
try {
final response = await sdk.client.exchangeWithBackend(
client: http.Client(),
code: code,
pkce: transaction.pkce,
backendExchangeUrl: backendUrl,
redirectUri: config.redirectUri,
);
setState(() {
_exchangeText = _formatExchangeResponse(response);
_lastAction = 'Sent authorization code to backend.';
});
} catch (error) {
setState(() {
_exchangeText = error.toString();
_lastAction = 'Backend exchange failed.';
});
}
}
String _formatExchangeResponse(http.Response response) {
final parsed = _tryParseJson(response.body);
if (parsed is Map<String, dynamic>) {
final session = parsed['session'];
final sayari = session is Map<String, dynamic> ? session['sayari'] : null;
return const JsonEncoder.withIndent(' ').convert({
'ok': parsed['ok'],
'message': parsed['message'],
'statusCode': response.statusCode,
'sessionUserName': session is Map<String, dynamic> ? session['userName'] : null,
'sessionEmail': sayari is Map<String, dynamic> ? sayari['email'] : null,
'sessionFullName': sayari is Map<String, dynamic> ? sayari['fullName'] : null,
'sessionPhone': sayari is Map<String, dynamic> ? sayari['phoneNumber'] : null,
});
}
return const JsonEncoder.withIndent(' ').convert({
'statusCode': response.statusCode,
'body': response.body,
});
}
Object? _tryParseJson(String value) {
try {
return jsonDecode(value);
} catch (_) {
return null;
}
}
String _deriveParentOrigin(String redirectUri) {
final uri = Uri.parse(redirectUri);
return Uri(scheme: uri.scheme, host: uri.host).toString();
}
@override
Widget build(BuildContext context) {
final config = _config;
final transaction = _transaction;
return Scaffold(
appBar: AppBar(
title: const Text('Sayari OAuth Flutter SDK Example'),
),
body: SafeArea(
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(20),
children: [
if (_configError != null) _ErrorCard(message: _configError!),
if (config != null && transaction != null) ...[
_SectionCard(
title: 'Loaded config',
children: [
_Row(label: 'App name', value: config.appName!),
_Row(label: 'App ID', value: config.appId),
_Row(label: 'Authorize URL', value: config.authorizeUrl),
_Row(label: 'Redirect URI', value: config.redirectUri),
_Row(label: 'Provider origin', value: config.providerOrigin),
],
),
const SizedBox(height: 16),
_SectionCard(
title: 'PKCE transaction',
children: [
_Row(label: 'State', value: transaction.state),
_Row(label: 'Code verifier', value: transaction.codeVerifier),
_Row(label: 'Code challenge', value: transaction.codeChallenge),
_Row(label: 'Challenge method', value: transaction.pkce.challengeMethod),
],
),
const SizedBox(height: 16),
TextField(
controller: _loginHintController,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.username, AutofillHints.email],
decoration: const InputDecoration(
labelText: 'Login hint (optional)',
helperText: 'Prefills the Sayari login identity when the browser opens the login page.',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: _launching ? null : _openAuthorize,
icon: _launching
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.open_in_browser),
label: Text(_launching ? 'Opening...' : 'Open Sayari authorization'),
),
OutlinedButton.icon(
onPressed: _copyAuthorizeUri,
icon: const Icon(Icons.copy),
label: const Text('Copy authorize URL'),
),
],
),
],
const SizedBox(height: 16),
_SectionCard(
title: 'Callback result',
children: [
Text(_callbackText ?? 'Waiting for a deep-link callback from Sayari.'),
const SizedBox(height: 12),
Text(_lastAction ?? 'Ready.'),
],
),
if (_exchangeText != null) ...[
const SizedBox(height: 16),
_SectionCard(
title: 'Backend exchange response',
children: [SelectableText(_exchangeText!)],
),
],
const SizedBox(height: 16),
const _SectionCard(
title: 'What this example shows',
children: [
Text('This app uses the reusable SDK package directly.'),
SizedBox(height: 8),
Text('It loads the example JSON asset, creates a PKCE transaction, opens the Sayari flow, and validates the deep-link callback.'),
SizedBox(height: 8),
Text('In a real app, the next step after callback is to send the authorization code to your own backend.'),
],
),
],
),
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
...children,
],
),
),
);
}
}
class _Row extends StatelessWidget {
const _Row({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
SelectableText(value),
],
),
);
}
}
class _ErrorCard extends StatelessWidget {
const _ErrorCard({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Card(
color: const Color(0xFFFFF1F0),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(message, style: const TextStyle(color: Color(0xFFB42318))),
),
);
}
}