consentera_consent 2.0.0
consentera_consent: ^2.0.0 copied to clipboard
Consentera Consent SDK for Flutter — DPDP Act 2023 compliant consent for mobile apps.
example/lib/main.dart
import 'dart:async';
import 'package:consentera_consent/consentera_consent.dart';
import 'package:flutter/material.dart';
/// BNB Pay — Flutter demo of the Consentera mobile integration (archetype M1,
/// Flutter twin). Same proven flow as the native + RN demos:
/// createSession → hosted collect page in the system browser → deep-link
/// return (best-effort) + resume re-validate → ConsentGate flips trackers.
const kBackend = 'http://10.0.2.2:3072/api/consentera';
const kDataPrincipal = 'meera.flutter@bnbbank.in';
/// The lifecycle roads (validate/withdraw) name a person by the identifier
/// fields of the organisation's locked key — `data_principal_ref` is refused
/// outright, and the mobile atom is `mobile` on these roads and on create alike
/// (F015).
const _who = PrincipalRef.identifiedBy({'email': kDataPrincipal});
const kNotice = 'bnb_consent_v2';
const kPurpose = 'product_analytics';
const navy = Color(0xFF12263A);
const gold = Color(0xFFD4A72C);
const teal = Color(0xFF0E7C7B);
final session = ConsenteraSession(const SessionConfig(
backendBaseUrl: kBackend,
callbackScheme: 'bnbfl',
));
/// Simulated analytics tracker — the whole point of the gate: it can only
/// exist while consent is ALLOW, and is torn down (identifier cleared) on
/// withdraw.
class PayAnalytics {
static bool running = false;
static String? deviceId;
static void start() {
running = true;
deviceId = 'flt-${DateTime.now().millisecondsSinceEpoch}';
}
static void shutdown() {
running = false;
deviceId = null;
}
}
void main() => runApp(const BnbPayApp());
class BnbPayApp extends StatelessWidget {
const BnbPayApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'BNB Pay',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: navy, primary: navy, secondary: gold),
useMaterial3: true,
),
home: const LoginScreen(),
);
}
// ───────────────────────────── Login ─────────────────────────────
class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: navy,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: Column(
children: [
const SizedBox(height: 40),
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: gold, borderRadius: BorderRadius.circular(24)),
alignment: Alignment.center,
child: const Text('BNB',
style: TextStyle(
color: navy,
fontSize: 28,
fontWeight: FontWeight.w800)),
),
const SizedBox(height: 20),
const Text('BNB Pay',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w800)),
const SizedBox(height: 6),
const Text('UPI · Bills · Built on Flutter',
style: TextStyle(color: gold, fontSize: 16)),
const SizedBox(height: 36),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Welcome back',
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text('Sign in to Pay',
style: TextStyle(color: Colors.grey.shade600)),
const SizedBox(height: 16),
TextFormField(
initialValue: kDataPrincipal,
readOnly: true,
decoration: InputDecoration(
labelText: 'Customer ID / Email',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: navy,
padding: const EdgeInsets.symmetric(vertical: 16)),
onPressed: () => Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => const HomeShell())),
child: const Text('Continue',
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w700)),
),
),
],
),
),
const SizedBox(height: 24),
const Text(
'Your data is processed only with your consent under the DPDP Act 2023.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 14),
),
],
),
),
),
);
}
}
// ───────────────────────────── Shell ─────────────────────────────
class HomeShell extends StatefulWidget {
const HomeShell({super.key, this.consentSession});
/// Defaults to the example backend; injectable for lifecycle regression tests.
final ConsenteraSession? consentSession;
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
int tab = 0;
String status = 'Checking…';
bool allowed = false;
String? lastError;
int _refreshGeneration = 0;
ConsenteraSession get _session => widget.consentSession ?? session;
/// The nonce the last created session put on its callback URL.
///
/// In memory only, deliberately: if the app is killed while the browser is
/// open this is gone, and the resume-revalidate in
/// [didChangeAppLifecycleState] is the answer — the callback is a hint and
/// `validate` is the truth.
String? _lastCallbackState;
/// Hand an incoming deep link here (from app_links, uni_links or whatever
/// your app already uses).
///
/// It is CHECKED, not merely parsed: [ConsenteraSession.parseCallback]
/// compares scheme AND host AND path AND the `state` nonce, and throws
/// otherwise — a custom scheme is first-come on Android and undefined on iOS,
/// so another installed app can send this one a
/// `bnbpay://consent/callback?status=granted` of its own.
///
/// A rejected callback is not an error the person needs to see. Either way
/// the next thing that happens is a re-validate.
void handleIncomingLink(Uri uri) {
try {
final cb = _session.parseCallback(uri, expectedState: _lastCallbackState);
debugPrint(
'consent callback: ${cb.callbackStatus.name} (pending: ${cb.pending}) '
'for artifact ${cb.artifactId}');
} on ConsenteraCallbackRejected catch (e) {
debugPrint('ignored a callback that is not ours: ${e.message}');
}
_refresh();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_refresh();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Chrome may block the gesture-less deep-link redirect — ALWAYS re-validate
// when the user comes back to the app.
if (state == AppLifecycleState.resumed) _refresh();
}
Future<void> _refresh() async {
final generation = ++_refreshGeneration;
try {
final d = await _session.validate(_who, kPurpose);
// A delayed ALLOW must not restart analytics after a newer refusal or
// failed validation. A disposed screen must not apply a late answer.
if (!mounted || generation != _refreshGeneration) return;
setState(() {
allowed = d.allowed;
status = d.allowed
? 'Active'
: (d.reasonCode == 'CONSENT_WITHDRAWN'
? 'Withdrawn'
: 'Not granted');
lastError = null;
if (d.allowed && !PayAnalytics.running) PayAnalytics.start();
if (!d.allowed) PayAnalytics.shutdown();
});
} catch (e) {
if (!mounted || generation != _refreshGeneration) return;
// Failure to validate is not a recorded denial or withdrawal, but it
// must immediately stop processing and clear the simulated identifier.
PayAnalytics.shutdown();
setState(() {
allowed = false;
status = 'Not verified';
lastError = '$e';
});
}
}
Future<void> _review() async {
try {
// Keyed by THIS organisation's locked integration key (U58): the type
// comes from the field name, so there is no separate declared type.
final cs = await _session.createSession(
dataPrincipal: {'email': kDataPrincipal},
noticeInternalName: kNotice,
sessionRef: 'bnbfl-${DateTime.now().millisecondsSinceEpoch}');
// The SESSION overload, and keep its callback nonce: parseCallback
// refuses a deep link that does not carry it.
_lastCallbackState = cs.callbackState;
await _session.presentConsentSession(cs);
} catch (e) {
setState(() => lastError = 'Could not start consent: $e');
}
}
Future<void> _withdraw() async {
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Withdraw consent?'),
content: const Text(
'BNB Pay will stop analytics immediately and clear the analytics identifier. Services that need this purpose may be limited.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('Cancel')),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFB3261E)),
onPressed: () => Navigator.pop(c, true),
child: const Text('Withdraw')),
],
),
);
if (ok != true) return;
try {
await _session.withdraw(_who, [kPurpose]);
await _refresh();
} catch (e) {
setState(() => lastError = 'Withdraw failed: $e');
}
}
Future<void> _portal() async {
try {
await _session.openPortal({'email': kDataPrincipal});
} catch (e) {
setState(() => lastError = 'Portal failed: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
body: SafeArea(child: tab == 0 ? _home() : _privacy()),
bottomNavigationBar: NavigationBar(
selectedIndex: tab,
onDestinationSelected: (i) {
setState(() => tab = i);
if (i == 1) _refresh();
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home'),
NavigationDestination(
icon: Icon(Icons.privacy_tip_outlined),
selectedIcon: Icon(Icons.privacy_tip),
label: 'Privacy'),
],
),
);
}
Widget _home() {
final payees = [
('Aarav Sharma', '₹2,400', Icons.person),
('Electricity — BESCOM', '₹1,180', Icons.bolt),
('Mobile Recharge', '₹299', Icons.smartphone),
('DTH — TataPlay', '₹450', Icons.tv),
];
return ListView(
padding: const EdgeInsets.all(20),
children: [
Row(
children: [
const CircleAvatar(
backgroundColor: navy,
child: Text('M', style: TextStyle(color: Colors.white))),
const SizedBox(width: 12),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Good evening', style: TextStyle(color: Colors.grey)),
Text('Meera',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
],
),
const Spacer(),
IconButton(
onPressed: () {}, icon: const Icon(Icons.qr_code_scanner)),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [navy, Color(0xFF1D3A57)]),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('UPI Lite balance',
style: TextStyle(color: Colors.white70)),
const SizedBox(height: 6),
const Text('₹ 4,820.00',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w800)),
const SizedBox(height: 12),
Row(children: [
_chip('Scan & Pay', Icons.qr_code_scanner),
const SizedBox(width: 10),
_chip('To Mobile', Icons.send),
const SizedBox(width: 10),
_chip('Bills', Icons.receipt_long),
]),
],
),
),
const SizedBox(height: 20),
const Text('Recent payments',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
...payees.map((p) => Card(
elevation: 0,
color: Colors.white,
margin: const EdgeInsets.symmetric(vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFFEFF3F6),
child: Icon(p.$3, color: navy)),
title: Text(p.$1,
style: const TextStyle(fontWeight: FontWeight.w600)),
trailing: Text(p.$2,
style: const TextStyle(fontWeight: FontWeight.w700)),
),
)),
const SizedBox(height: 8),
Card(
elevation: 0,
color: const Color(0xFFE8F0E8),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: ListTile(
leading: const Icon(Icons.privacy_tip, color: teal),
title: const Text('Your privacy, your choice',
style: TextStyle(fontWeight: FontWeight.w700)),
subtitle: const Text(
'Review what BNB Pay can process — withdraw any time.'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
setState(() => tab = 1);
_refresh();
},
),
),
],
);
}
Widget _chip(String label, IconData ic) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withAlpha(31),
borderRadius: BorderRadius.circular(30)),
child: Row(children: [
Icon(ic, color: gold, size: 18),
const SizedBox(width: 6),
Text(label,
style: const TextStyle(color: Colors.white, fontSize: 13)),
]),
);
Widget _privacy() {
return ListView(
padding: const EdgeInsets.all(20),
children: [
const Text('Privacy & Consent',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800)),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: navy, borderRadius: BorderRadius.circular(16)),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('DPDP Act 2023 compliant',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w700)),
SizedBox(height: 4),
Text('Every choice is recorded immutably. Withdraw any time.',
style: TextStyle(color: Colors.white70)),
],
),
),
const SizedBox(height: 12),
Card(
elevation: 0,
color: Colors.white,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text('Product analytics',
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w700))),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: allowed
? const Color(0xFFE3F2E6)
: const Color(0xFFFBE9E7),
borderRadius: BorderRadius.circular(20),
),
child: Text(status,
style: TextStyle(
color: allowed
? const Color(0xFF1B5E20)
: const Color(0xFFB3261E),
fontWeight: FontWeight.w700)),
),
],
),
const SizedBox(height: 6),
Text(
'Understand how you use the app so we can improve journeys and fix drop-offs.',
style: TextStyle(color: Colors.grey.shade700)),
],
),
),
),
const SizedBox(height: 12),
Card(
elevation: 0,
color: Colors.white,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('App trackers — gated by your consent',
style:
TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 10),
Row(
children: [
Icon(Icons.circle,
size: 14,
color: PayAnalytics.running
? const Color(0xFF2E7D32)
: const Color(0xFFB3261E)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Payments Analytics SDK',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
Text(
PayAnalytics.running
? 'RUNNING · id ${PayAnalytics.deviceId ?? ''}'
: 'BLOCKED · no identifier, no collection',
style: TextStyle(
color: Colors.grey.shade700, fontSize: 13),
),
],
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: navy,
padding: const EdgeInsets.symmetric(vertical: 16)),
onPressed: _review,
child: const Text('Review & update my consent',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 10),
if (allowed)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFB3261E),
side: const BorderSide(color: Color(0xFFB3261E)),
padding: const EdgeInsets.symmetric(vertical: 16)),
onPressed: _withdraw,
child: const Text('Withdraw this consent',
style:
TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
),
),
),
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: navy,
padding: const EdgeInsets.symmetric(vertical: 16)),
onPressed: _portal,
child: const Text('Privacy Portal — rights, receipts & history',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: navy,
padding: const EdgeInsets.symmetric(vertical: 16)),
onPressed: _refresh,
child: const Text('Refresh status',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700)),
),
),
if (lastError != null)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(lastError!,
style: const TextStyle(color: Color(0xFFB3261E))),
),
],
);
}
}