consentera_consent 2.0.0 copy "consentera_consent: ^2.0.0" to clipboard
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))),
          ),
      ],
    );
  }
}
0
likes
160
points
--
downloads

Documentation

Documentation
API reference

Publisher

verified publisherconsentera.in

Weekly Downloads

Consentera Consent SDK for Flutter — DPDP Act 2023 compliant consent for mobile apps.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, http, meta, url_launcher

More

Packages that depend on consentera_consent