biometric_guard 1.0.1 copy "biometric_guard: ^1.0.1" to clipboard
biometric_guard: ^1.0.1 copied to clipboard

Flutter plugin for Android biometric and device-credential authentication with session management, widget/navigation-level guards, and reactive UI state.

example/lib/main.dart

import 'dart:async';
import 'package:biometric_guard/biometric_guard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';

void main() {
  runApp(const BiometricDemoApp());
}

// ─── Design tokens ─────────────────────────────────────────────────────────
// Warm parchment light theme — editorial, refined, developer-friendly.
const _bg = Color(0xFFF5F2EE); // warm parchment
const _surface = Color(0xFFFFFFFF); // pure white
const _surfaceAlt = Color(0xFFF0EDE8); // tinted alt
const _border = Color(0xFFE2DDD7); // warm grey
const _borderMid = Color(0xFFCBC5BD); // stronger border
const _ink = Color(0xFF1C1917); // near-black
const _inkMid = Color(0xFF6B6560); // medium grey
const _inkLight = Color(0xFFABA5A0); // light grey
const _accent = Color(0xFFD4500A); // burnt orange
const _accentSoft = Color(0xFFFBEFE8); // tinted accent bg
const _green = Color(0xFF267A50); // forest green
const _greenSoft = Color(0xFFEAF5EF); // success bg
const _amber = Color(0xFFB06A00); // warm amber
const _amberSoft = Color(0xFFFAF3E6); // warning bg
const _red = Color(0xFFC4292A); // deep red
const _redSoft = Color(0xFFFCEEEE); // error bg
const _blue = Color(0xFF1A5CA8); // cobalt blue

class BiometricDemoApp extends StatelessWidget {
  const BiometricDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Biometric Guard',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light().copyWith(
        scaffoldBackgroundColor: _bg,
        colorScheme: const ColorScheme.light(
          surface: _surface,
          primary: _accent,
          error: _red,
        ),
        textTheme: GoogleFonts.dmSansTextTheme(ThemeData.light().textTheme),
      ),
      home: const _AppShell(),
    );
  }
}

// ─── App Shell ──────────────────────────────────────────────────────────────
class _AppShell extends StatefulWidget {
  const _AppShell();
  @override
  State<_AppShell> createState() => _AppShellState();
}

class _AppShellState extends State<_AppShell>
    with SingleTickerProviderStateMixin {
  late final TabController _tab;

  @override
  void initState() {
    super.initState();
    _tab = TabController(length: 3, vsync: this);
    _tab.addListener(() => setState(() {}));
  }

  @override
  void dispose() {
    _tab.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: _bg,
      body: Column(
        children: [
          _AppBar(tab: _tab),
          Expanded(
            child: TabBarView(
              controller: _tab,
              physics: const NeverScrollableScrollPhysics(),
              children: const [GuardTab(), RouteTab(), NativeTab()],
            ),
          ),
        ],
      ),
    );
  }
}

// ─── App Bar ────────────────────────────────────────────────────────────────
class _AppBar extends StatelessWidget {
  const _AppBar({required this.tab});
  final TabController tab;

  @override
  Widget build(BuildContext context) {
    return Container(
      color: _surface,
      child: SafeArea(
        bottom: false,
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
              child: Row(
                children: [
                  // Logo
                  Container(
                    width: 38,
                    height: 38,
                    decoration: BoxDecoration(
                      color: _accent,
                      borderRadius: BorderRadius.circular(11),
                    ),
                    child: const Icon(
                      Icons.fingerprint,
                      color: Colors.white,
                      size: 22,
                    ),
                  ),
                  const SizedBox(width: 11),
                  Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'biometric_guard',
                        style: GoogleFonts.jetBrainsMono(
                          color: _ink,
                          fontSize: 13,
                          fontWeight: FontWeight.w700,
                        ),
                      ),
                      Text(
                        'Flutter Plugin Demo',
                        style: GoogleFonts.dmSans(
                          color: _inkLight,
                          fontSize: 10,
                        ),
                      ),
                    ],
                  ),
                  const Spacer(),
                  // Live session chip
                  SessionStateBuilder(
                    sessionTimeout: const Duration(minutes: 5),
                    builder: (_, state) => _SessionChip(state: state),
                  ),
                ],
              ),
            ),
            const SizedBox(height: 14),
            // Pill-style tab bar
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 20),
              child: Container(
                height: 46,
                decoration: BoxDecoration(
                  color: _surfaceAlt,
                  borderRadius: BorderRadius.circular(13),
                  border: Border.all(color: _border),
                ),
                child: TabBar(
                  controller: tab,
                  padding: const EdgeInsets.all(4),
                  indicator: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(10),
                    boxShadow: [
                      BoxShadow(
                        color: Colors.black.withOpacity(0.07),
                        blurRadius: 6,
                        offset: const Offset(0, 2),
                      ),
                    ],
                  ),
                  indicatorSize: TabBarIndicatorSize.tab,
                  dividerColor: Colors.transparent,
                  labelColor: _accent,
                  unselectedLabelColor: _inkMid,
                  labelStyle: GoogleFonts.dmSans(
                    fontSize: 12,
                    fontWeight: FontWeight.w700,
                  ),
                  unselectedLabelStyle: GoogleFonts.dmSans(
                    fontSize: 12,
                    fontWeight: FontWeight.w500,
                  ),
                  tabs: const [
                    Tab(
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Icon(Icons.shield_outlined, size: 14),
                          SizedBox(width: 5),
                          Text('Guard'),
                        ],
                      ),
                    ),
                    Tab(
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Icon(Icons.route_outlined, size: 14),
                          SizedBox(width: 5),
                          Text('Route'),
                        ],
                      ),
                    ),
                    Tab(
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          Icon(Icons.api_outlined, size: 14),
                          SizedBox(width: 5),
                          Text('Native'),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 14),
            Container(height: 1, color: _border),
          ],
        ),
      ),
    );
  }
}

// ─── Session chip ─────────────────────────────────────────────────────────
class _SessionChip extends StatelessWidget {
  const _SessionChip({required this.state});
  final SessionState state;

  @override
  Widget build(BuildContext context) {
    final (bg, fg, dot, label) = switch (state) {
      SessionActive() => (_greenSoft, _green, _green, 'LIVE'),
      SessionExpired() => (_surfaceAlt, _inkMid, _inkLight, 'EXPIRED'),
      SessionAuthenticating() => (_amberSoft, _amber, _amber, 'AUTH…'),
      SessionFailed() => (_redSoft, _red, _red, 'FAILED'),
    };
    return AnimatedContainer(
      duration: const Duration(milliseconds: 250),
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
      decoration: BoxDecoration(
        color: bg,
        borderRadius: BorderRadius.circular(20),
        border: Border.all(color: fg.withOpacity(0.25)),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 6,
            height: 6,
            decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
          ),
          const SizedBox(width: 6),
          Text(
            label,
            style: GoogleFonts.jetBrainsMono(
              fontSize: 9,
              color: fg,
              fontWeight: FontWeight.w700,
              letterSpacing: 0.5,
            ),
          ),
        ],
      ),
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════════
// TAB 1 — BiometricGuard
// ═════════════════════════════════════════════════════════════════════════════

class GuardTab extends StatefulWidget {
  const GuardTab({super.key});
  @override
  State<GuardTab> createState() => _GuardTabState();
}

class _GuardTabState extends State<GuardTab> {
  AuthIntent _intent = AuthIntent.secure;
  Duration _timeout = const Duration(minutes: 2);
  bool _bioOnly = false;
  bool _resumeFg = false;
  bool _guardActive = false;
  _ResultData? _result;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.fromLTRB(20, 22, 20, 40),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _ConceptBanner(
            icon: Icons.shield_outlined,
            color: _accent,
            title: 'BiometricGuard',
            body:
                'Wraps any widget and checks the global session before deciding to prompt. '
                'If the session is still valid, onSuccess fires immediately — no native prompt shown.',
          ),
          const SizedBox(height: 20),

          // Config
          _SectionCard(
            label: 'CONFIGURATION',
            child: Column(
              children: [
                _IntentRow(
                  value: _intent,
                  onChanged: (v) => setState(() {
                    _intent = v;
                    _guardActive = false;
                  }),
                ),
                const SizedBox(height: 14),
                _TimeoutRow(
                  value: _timeout,
                  onChanged: (v) => setState(() {
                    _timeout = v;
                    _guardActive = false;
                  }),
                ),
                const SizedBox(height: 14),
                Row(
                  children: [
                    Expanded(
                      child: _Toggle(
                        label: 'Biometric Only',
                        sublabel: 'Disables PIN fallback',
                        value: _bioOnly,
                        onChanged: (v) => setState(() {
                          _bioOnly = v;
                          _guardActive = false;
                        }),
                      ),
                    ),
                    const SizedBox(width: 10),
                    Expanded(
                      child: _Toggle(
                        label: 'Resume on FG',
                        sublabel: 'Re-prompts after background',
                        value: _resumeFg,
                        onChanged: (v) => setState(() {
                          _resumeFg = v;
                          _guardActive = false;
                        }),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
          const SizedBox(height: 14),

          // Live code preview
          _CodeBlock(code: _guardCode()),
          const SizedBox(height: 18),

          // Trigger area
          if (_guardActive)
            _GuardWidget(
              intent: _intent,
              timeout: _timeout,
              bioOnly: _bioOnly,
              resumeFg: _resumeFg,
              onDone: (r) => setState(() {
                _guardActive = false;
                _result = r;
              }),
            )
          else ...[
            if (_result != null) ...[
              _ResultCard(data: _result!),
              const SizedBox(height: 12),
            ],
            _PrimaryButton(
              label: 'Trigger BiometricGuard',
              icon: Icons.shield_outlined,
              onPressed: () => setState(() {
                _guardActive = true;
                _result = null;
              }),
            ),
            const SizedBox(height: 10),
            _OutlineButton(
              label: 'Invalidate Session',
              icon: Icons.lock_reset_outlined,
              onPressed: () {
                BiometricSessionManager.instance.invalidateSession();
                setState(
                  () => _result = _ResultData(
                    headline: 'Session Invalidated',
                    detail:
                        'The global session was cleared. The next guard trigger will prompt.',
                    code: 'INVALIDATED',
                    type: _ResultType.neutral,
                  ),
                );
              },
            ),
          ],

          const SizedBox(height: 22),
          _LearnPanel(
            color: _accent,
            items: const [
              (
                'Global session',
                'One shared timestamp — all guards on all screens use it.',
              ),
              (
                'Per-guard timeout',
                'Each BiometricGuard sets its own sessionTimeout.',
              ),
              (
                'No-prompt path',
                'If session is valid, onSuccess fires with zero native UI.',
              ),
              (
                'You control retry',
                'onFailure just hands back control — retry is your choice.',
              ),
            ],
          ),
        ],
      ),
    );
  }

  String _guardCode() {
    final t = _timeout.inSeconds < 60
        ? 'seconds: ${_timeout.inSeconds}'
        : 'minutes: ${_timeout.inMinutes}';
    return '''BiometricGuard(
  intent: AuthIntent.${_intent.name},
  sessionTimeout: Duration($t),
  options: AuthOptions(
    biometricOnly: $_bioOnly,
    resumeOnForeground: $_resumeFg,
  ),
  onSuccess: () => Navigator.push(context, ...),
  onFailure: () => showRetryDialog(context),
  child: YourScreen(),
)''';
  }
}

// Inline guard widget that mounts the real BiometricGuard
class _GuardWidget extends StatelessWidget {
  const _GuardWidget({
    required this.intent,
    required this.timeout,
    required this.bioOnly,
    required this.resumeFg,
    required this.onDone,
  });
  final AuthIntent intent;
  final Duration timeout;
  final bool bioOnly, resumeFg;
  final void Function(_ResultData) onDone;

  @override
  Widget build(BuildContext context) {
    final sessionWasValid = BiometricSessionManager.instance.isSessionValid(
      timeout,
    );
    return BiometricGuard(
      intent: intent,
      sessionTimeout: timeout,
      options: AuthOptions(
        biometricOnly: bioOnly,
        resumeOnForeground: resumeFg,
      ),
      onSuccess: () => onDone(
        _ResultData(
          headline: sessionWasValid
              ? 'Session Valid — No Prompt'
              : 'Authenticated',
          detail: sessionWasValid
              ? 'The session was still within the timeout window. onSuccess fired immediately — no native prompt was shown.'
              : 'Biometric authentication succeeded. The global session timestamp has been updated.',
          code: 'SUCCESS',
          type: _ResultType.success,
          chips: [
            if (sessionWasValid)
              ('session was valid', _green)
            else
              ('session was expired', _amber),
            ('type: biometric', _blue),
          ],
        ),
      ),
      onFailure: () {
        final state = BiometricSessionManager.instance.currentState();
        final r = state is SessionFailed ? state.result : null;
        onDone(
          _ResultData(
            headline: r?.isLockedOut == true
                ? 'Sensor Locked Out'
                : 'Authentication Failed',
            detail:
                r?.message ??
                'The user cancelled or authentication was not completed.',
            code: r?.code.name ?? 'FAILED',
            type: r?.isLockedOut == true
                ? _ResultType.warning
                : _ResultType.failure,
          ),
        );
      },
      loadingBuilder: (_) => const _PromptingCard(),
      lockedBuilder: (_, result) {
        onDone(
          _ResultData(
            headline: 'Sensor Locked Out',
            detail: result.message,
            code: 'LOCKED_OUT',
            type: _ResultType.warning,
          ),
        );
        return const SizedBox.shrink();
      },
      child: const _PromptingCard(),
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════════
// TAB 2 — BiometricRoute
// ═════════════════════════════════════════════════════════════════════════════

class RouteTab extends StatefulWidget {
  const RouteTab({super.key});
  @override
  State<RouteTab> createState() => _RouteTabState();
}

class _RouteTabState extends State<RouteTab> {
  AuthIntent _intent = AuthIntent.payment;
  bool _bioOnly = false;
  _ResultData? _result;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.fromLTRB(20, 22, 20, 40),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _ConceptBanner(
            icon: Icons.route_outlined,
            color: _blue,
            title: 'BiometricRoute',
            body:
                'A PageRoute that authenticates exactly once before showing the destination. '
                'It has NO session timeout — it always prompts. On failure it pops itself, '
                'returning an AuthResult to the caller.',
          ),
          const SizedBox(height: 20),

          _SectionCard(
            label: 'CONFIGURATION',
            child: Column(
              children: [
                _IntentRow(
                  value: _intent,
                  onChanged: (v) => setState(() => _intent = v),
                ),
                const SizedBox(height: 14),
                _Toggle(
                  label: 'Biometric Only',
                  sublabel: 'No PIN / password fallback',
                  value: _bioOnly,
                  onChanged: (v) => setState(() => _bioOnly = v),
                ),
              ],
            ),
          ),
          const SizedBox(height: 14),

          _CodeBlock(
            code:
                '''final result = await Navigator.push<AuthResult>(
  context,
  BiometricRoute(
    intent: AuthIntent.${_intent.name},
    options: AuthOptions(biometricOnly: $_bioOnly),
    builder: (_) => ProtectedScreen(),
    onAuthFailure: (result) {
      // called BEFORE auto-pop on failure
      if (result.isLockedOut) showLockoutBanner(context);
    },
  ),
);

// result == null  →  user returned normally  ✓
// result != null  →  auth failed, inspect result.code''',
          ),
          const SizedBox(height: 18),

          if (_result != null) ...[
            _ResultCard(data: _result!),
            const SizedBox(height: 12),
          ],

          _PrimaryButton(
            label: 'Push BiometricRoute',
            icon: Icons.route_outlined,
            color: _blue,
            onPressed: () => _push(_intent),
          ),
          const SizedBox(height: 10),
          Row(
            children: [
              Expanded(
                child: _OutlineButton(
                  label: 'Payment',
                  icon: Icons.payment_outlined,
                  onPressed: () => _push(AuthIntent.payment),
                ),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: _OutlineButton(
                  label: 'Credentials',
                  icon: Icons.key_outlined,
                  onPressed: () => _push(AuthIntent.credentials),
                ),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: _OutlineButton(
                  label: 'Secure',
                  icon: Icons.shield_outlined,
                  onPressed: () => _push(AuthIntent.secure),
                ),
              ),
            ],
          ),

          const SizedBox(height: 22),
          _LearnPanel(
            color: _blue,
            items: const [
              (
                'Always prompts',
                'No session check. Every Navigator.push triggers auth.',
              ),
              (
                'Auto-pop on failure',
                'The route removes itself — you never see the destination.',
              ),
              (
                'null = success',
                'A null pop result means the user navigated back normally.',
              ),
              (
                'AuthResult = failure',
                'Non-null result tells you exactly what went wrong.',
              ),
            ],
          ),
        ],
      ),
    );
  }

  Future<void> _push(AuthIntent intent) async {
    setState(() => _result = null);
    final result = await Navigator.of(context).push<AuthResult>(
      BiometricRoute(
        intent: intent,
        options: AuthOptions(biometricOnly: _bioOnly),
        builder: (_) => _ProtectedScreen(intent: intent),
        onAuthFailure: (r) {
          if (!mounted) return;
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(
                r.isLockedOut
                    ? 'Biometric sensor locked. Try again later.'
                    : 'Auth failed: ${r.code.name}',
              ),
              backgroundColor: r.isLockedOut ? _amber : _red,
              behavior: SnackBarBehavior.floating,
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(10),
              ),
              margin: const EdgeInsets.all(16),
            ),
          );
        },
      ),
    );
    if (!mounted) return;
    setState(() {
      if (result == null) {
        _result = _ResultData(
          headline: 'Authenticated Successfully',
          detail:
              'The user completed authentication and returned from the protected screen. '
              'Navigator.pop() returned null, confirming normal completion.',
          code: 'SUCCESS',
          type: _ResultType.success,
          chips: [('result == null', _green), ('returned normally ✓', _green)],
        );
      } else {
        final isLocked = result.isLockedOut;
        _result = _ResultData(
          headline: isLocked ? 'Sensor Locked Out' : 'Authentication Failed',
          detail:
              'BiometricRoute auto-popped and returned an AuthResult. '
              'Message: "${result.message}"',
          code: result.code.name,
          type: isLocked ? _ResultType.warning : _ResultType.failure,
          chips: [
            ('result != null', _red),
            ('route auto-popped', _red),
            ('type: ${result.type.name}', _inkMid),
          ],
        );
      }
    });
  }
}

// Protected destination
class _ProtectedScreen extends StatelessWidget {
  const _ProtectedScreen({required this.intent});
  final AuthIntent intent;

  @override
  Widget build(BuildContext context) {
    final (color, icon, title, sub) = switch (intent) {
      AuthIntent.payment => (
        _blue,
        Icons.payment_outlined,
        'Payment Screen',
        'Transaction authorised',
      ),
      AuthIntent.credentials => (
        _green,
        Icons.key_outlined,
        'Credential Vault',
        'Credentials unlocked',
      ),
      AuthIntent.secure => (
        _accent,
        Icons.shield_outlined,
        'Secure Area',
        'Identity verified',
      ),
      AuthIntent.custom => (
        _ink,
        Icons.lock_open_outlined,
        'Protected Screen',
        'Access granted',
      ),
    };

    return Scaffold(
      backgroundColor: _bg,
      appBar: AppBar(
        backgroundColor: _surface,
        elevation: 0,
        surfaceTintColor: Colors.transparent,
        leading: IconButton(
          icon: const Icon(Icons.arrow_back_ios_new, size: 16, color: _inkMid),
          onPressed: () => Navigator.of(context).pop(),
        ),
        title: Text(
          title,
          style: GoogleFonts.dmSans(
            color: _ink,
            fontSize: 15,
            fontWeight: FontWeight.w700,
          ),
        ),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(1),
          child: Container(height: 1, color: _border),
        ),
      ),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(32),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TweenAnimationBuilder<double>(
                tween: Tween(begin: 0, end: 1),
                duration: const Duration(milliseconds: 550),
                curve: Curves.easeOutBack,
                builder: (_, v, __) => Transform.scale(
                  scale: v,
                  child: Container(
                    width: 88,
                    height: 88,
                    decoration: BoxDecoration(
                      color: color.withOpacity(0.1),
                      shape: BoxShape.circle,
                      border: Border.all(
                        color: color.withOpacity(0.35),
                        width: 2,
                      ),
                    ),
                    child: Icon(icon, color: color, size: 38),
                  ),
                ),
              ),
              const SizedBox(height: 22),
              Text(
                title,
                style: GoogleFonts.dmSans(
                  color: _ink,
                  fontSize: 24,
                  fontWeight: FontWeight.w800,
                ),
              ),
              const SizedBox(height: 6),
              Text(
                sub,
                style: GoogleFonts.dmSans(
                  color: color,
                  fontSize: 14,
                  fontWeight: FontWeight.w600,
                ),
              ),
              const SizedBox(height: 32),
              SessionStateBuilder(
                builder: (_, state) {
                  final text = switch (state) {
                    SessionActive(authenticatedAt: final t, type: final tp) =>
                      'Session · ${tp.name} · ${_elapsed(t)} ago',
                    _ => 'No active session',
                  };
                  return Container(
                    padding: const EdgeInsets.symmetric(
                      horizontal: 18,
                      vertical: 10,
                    ),
                    decoration: BoxDecoration(
                      color: _surfaceAlt,
                      borderRadius: BorderRadius.circular(10),
                      border: Border.all(color: _border),
                    ),
                    child: Text(
                      text,
                      style: GoogleFonts.jetBrainsMono(
                        color: _inkMid,
                        fontSize: 11,
                      ),
                    ),
                  );
                },
              ),
            ],
          ),
        ),
      ),
    );
  }

  String _elapsed(DateTime t) {
    final d = DateTime.now().difference(t);
    return d.inSeconds < 60 ? '${d.inSeconds}s' : '${d.inMinutes}m';
  }
}

// ═════════════════════════════════════════════════════════════════════════════
// TAB 3 — Native API  (no terminal — rich result cards)
// ═════════════════════════════════════════════════════════════════════════════

class NativeTab extends StatefulWidget {
  const NativeTab({super.key});
  @override
  State<NativeTab> createState() => _NativeTabState();
}

class _NativeTabState extends State<NativeTab> {
  AuthIntent _intent = AuthIntent.secure;
  bool _bioOnly = false;
  bool _resumeFg = false;
  bool _loading = false;
  bool _streamOn = false;
  StreamSubscription<SessionState>? _sub;

  final _results = <_ResultData>[];

  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }

  Future<void> _run(Future<_ResultData> Function() fn) async {
    if (_loading) return;
    setState(() => _loading = true);
    try {
      final r = await fn();
      setState(() => _results.insert(0, r));
    } catch (e) {
      setState(
        () => _results.insert(
          0,
          _ResultData(
            headline: 'Unexpected Error',
            detail: e.toString(),
            code: 'EXCEPTION',
            type: _ResultType.failure,
          ),
        ),
      );
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.fromLTRB(20, 22, 20, 40),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _ConceptBanner(
            icon: Icons.api_outlined,
            color: _green,
            title: 'Native API',
            body:
                'Call BiometricSessionManager and the MethodChannel directly. '
                'Each result appears as a structured card below the actions — '
                'no terminal scrolling required.',
          ),
          const SizedBox(height: 20),

          // Options
          _SectionCard(
            label: 'OPTIONS',
            child: Column(
              children: [
                _IntentRow(
                  value: _intent,
                  onChanged: (v) => setState(() => _intent = v),
                ),
                const SizedBox(height: 14),
                Row(
                  children: [
                    Expanded(
                      child: _Toggle(
                        label: 'Biometric Only',
                        sublabel: 'No PIN fallback',
                        value: _bioOnly,
                        onChanged: (v) => setState(() => _bioOnly = v),
                      ),
                    ),
                    const SizedBox(width: 10),
                    Expanded(
                      child: _Toggle(
                        label: 'Resume on FG',
                        sublabel: 'Re-prompt on return',
                        value: _resumeFg,
                        onChanged: (v) => setState(() => _resumeFg = v),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
          const SizedBox(height: 14),

          // Actions grid
          _SectionCard(
            label: 'ACTIONS',
            child: Column(
              children: [
                _ActionRow(
                  children: [
                    _ActionBtn(
                      label: 'authenticate()',
                      icon: Icons.fingerprint,
                      color: _accent,
                      loading: _loading,
                      onTap: () => _run(() async {
                        final r = await BiometricSessionManager.instance
                            .authenticate(
                              intent: _intent,
                              options: AuthOptions(
                                biometricOnly: _bioOnly,
                                resumeOnForeground: _resumeFg,
                              ),
                            );
                        return _ResultData(
                          headline: r.isSuccess
                              ? 'Authentication Succeeded'
                              : 'Authentication Failed',
                          detail: r.message.isNotEmpty
                              ? r.message
                              : r.isSuccess
                              ? 'Biometric confirmed. Session timestamp updated.'
                              : 'Auth was not completed successfully.',
                          code: r.code.name,
                          type: r.isSuccess
                              ? _ResultType.success
                              : r.isLockedOut
                              ? _ResultType.warning
                              : _ResultType.failure,
                          chips: [
                            (
                              'type: ${r.type.name}',
                              r.isSuccess ? _green : _inkMid,
                            ),
                            if (r.isSuccess) ('session updated ✓', _green),
                          ],
                        );
                      }),
                    ),
                    _ActionBtn(
                      label: 'stopAuth()',
                      icon: Icons.stop_circle_outlined,
                      color: _amber,
                      loading: false,
                      onTap: () => _run(() async {
                        await BiometricSessionManager.instance
                            .cancelAuthentication();
                        return const _ResultData(
                          headline: 'Authentication Stopped',
                          detail:
                              'The native prompt was dismissed programmatically. '
                              'Any in-flight auth delivers USER_CANCELED.',
                          code: 'STOPPED',
                          type: _ResultType.neutral,
                        );
                      }),
                    ),
                  ],
                ),
                const SizedBox(height: 10),
                _ActionRow(
                  children: [
                    _ActionBtn(
                      label: 'isSupported()',
                      icon: Icons.devices_outlined,
                      color: _blue,
                      loading: false,
                      onTap: () => _run(() async {
                        final ok = await BiometricSessionManager.instance
                            .isDeviceSupported();
                        return _ResultData(
                          headline: ok ? 'Device Supported' : 'Not Supported',
                          detail: ok
                              ? 'This device has at least one usable auth method (biometric or screen lock).'
                              : 'No biometric hardware or screen lock was found on this device.',
                          code: ok ? 'SUPPORTED' : 'UNSUPPORTED',
                          type: ok ? _ResultType.success : _ResultType.warning,
                          chips: [('returns: $ok', ok ? _green : _amber)],
                        );
                      }),
                    ),
                    _ActionBtn(
                      label: 'getTypes()',
                      icon: Icons.category_outlined,
                      color: const Color(0xFF5B4FC4),
                      loading: false,
                      onTap: () => _run(() async {
                        final types = await BiometricSessionManager.instance
                            .getAvailableTypes();
                        return _ResultData(
                          headline: types.isEmpty
                              ? 'No Hardware Found'
                              : 'Hardware Detected',
                          detail: types.isEmpty
                              ? 'No biometric sensors were detected on this device.'
                              : 'Available sensors: ${types.join(", ")}',
                          code: types.isEmpty ? 'NONE' : types.first,
                          type: types.isNotEmpty
                              ? _ResultType.success
                              : _ResultType.neutral,
                          chips: types.map((t) => (t, _blue)).toList(),
                        );
                      }),
                    ),
                  ],
                ),
                const SizedBox(height: 10),
                _ActionRow(
                  children: [
                    _ActionBtn(
                      label: 'invalidate()',
                      icon: Icons.lock_reset_outlined,
                      color: _red,
                      loading: false,
                      onTap: () => _run(() async {
                        BiometricSessionManager.instance.invalidateSession();
                        return const _ResultData(
                          headline: 'Session Invalidated',
                          detail:
                              'The global session was cleared. Every guard will now '
                              'trigger a native prompt on its next check.',
                          code: 'INVALIDATED',
                          type: _ResultType.neutral,
                        );
                      }),
                    ),
                    _ActionBtn(
                      label: _streamOn ? 'Stop Stream' : 'Listen Stream',
                      icon: _streamOn
                          ? Icons.stop_rounded
                          : Icons.stream_outlined,
                      color: _streamOn ? _red : _green,
                      loading: false,
                      onTap: _toggleStream,
                    ),
                  ],
                ),
              ],
            ),
          ),
          const SizedBox(height: 14),

          // Session validity check
          _SectionCard(
            label: 'isSessionValid() — LIVE CHECK',
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  'Tap a duration to synchronously check if the current session qualifies.',
                  style: GoogleFonts.dmSans(
                    color: _inkMid,
                    fontSize: 12,
                    height: 1.5,
                  ),
                ),
                const SizedBox(height: 12),
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: [
                    for (final (label, secs) in [
                      ('30 sec', 30),
                      ('1 min', 60),
                      ('2 min', 120),
                      ('5 min', 300),
                      ('10 min', 600),
                    ])
                      _SessChip(
                        label: label,
                        onTap: () {
                          final valid = BiometricSessionManager.instance
                              .isSessionValid(Duration(seconds: secs));
                          setState(
                            () => _results.insert(
                              0,
                              _ResultData(
                                headline: valid
                                    ? 'Session is Valid'
                                    : 'Session Expired / Missing',
                                detail:
                                    'isSessionValid(Duration(seconds: $secs)) returned $valid.',
                                code: valid ? 'VALID' : 'EXPIRED',
                                type: valid
                                    ? _ResultType.success
                                    : _ResultType.warning,
                                chips: [
                                  ('timeout: $label', valid ? _green : _amber),
                                  ('synchronous — no await', _inkMid),
                                ],
                              ),
                            ),
                          );
                        },
                      ),
                  ],
                ),
              ],
            ),
          ),

          // Results
          if (_results.isNotEmpty) ...[
            const SizedBox(height: 20),
            Row(
              children: [
                Text(
                  'RESULTS',
                  style: GoogleFonts.dmSans(
                    color: _inkLight,
                    fontSize: 10,
                    fontWeight: FontWeight.w700,
                    letterSpacing: 1.3,
                  ),
                ),
                const Spacer(),
                GestureDetector(
                  onTap: () => setState(() => _results.clear()),
                  child: Text(
                    'CLEAR ALL',
                    style: GoogleFonts.dmSans(
                      color: _accent,
                      fontSize: 11,
                      fontWeight: FontWeight.w700,
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 10),
            ..._results.map(
              (r) => Padding(
                padding: const EdgeInsets.only(bottom: 8),
                child: _ResultCard(data: r, compact: true),
              ),
            ),
          ],

          const SizedBox(height: 22),
          _LearnPanel(
            color: _green,
            items: const [
              (
                'authenticate()',
                'Calls native biometric. Updates session on success.',
              ),
              (
                'Stream — optional',
                'Only subscribe when reactive UI needs live state.',
              ),
              (
                'invalidateSession()',
                'Forces every guard to re-prompt on next visit.',
              ),
              (
                'isSessionValid()',
                'Synchronous — no future, no await. Fast path.',
              ),
            ],
          ),
        ],
      ),
    );
  }

  void _toggleStream() {
    if (_streamOn) {
      _sub?.cancel();
      _sub = null;
      setState(() {
        _streamOn = false;
        _results.insert(
          0,
          const _ResultData(
            headline: 'Stream Stopped',
            detail: 'sessionStream subscription was cancelled.',
            code: 'CANCELLED',
            type: _ResultType.neutral,
          ),
        );
      });
    } else {
      _sub = BiometricSessionManager.instance.sessionStream.listen((state) {
        if (!mounted) return;
        final (label, type) = switch (state) {
          SessionActive(type: final tp) => (
            'SessionActive [${tp.name}]',
            _ResultType.success,
          ),
          SessionExpired() => ('SessionExpired', _ResultType.neutral),
          SessionAuthenticating(intent: final i) => (
            'Authenticating [${i.name}]',
            _ResultType.neutral,
          ),
          SessionFailed(result: final r) => (
            'SessionFailed: ${r.code.name}',
            _ResultType.failure,
          ),
        };
        setState(
          () => _results.insert(
            0,
            _ResultData(
              headline: '◈ Stream Event',
              detail: label,
              code: 'STREAM',
              type: type,
            ),
          ),
        );
      });
      setState(() {
        _streamOn = true;
        _results.insert(
          0,
          const _ResultData(
            headline: 'Stream Active',
            detail:
                'Listening to sessionStream. Any auth state change will appear here.',
            code: 'LISTENING',
            type: _ResultType.success,
          ),
        );
      });
    }
  }
}

// ═════════════════════════════════════════════════════════════════════════════
// Shared result model
// ═════════════════════════════════════════════════════════════════════════════

enum _ResultType { success, warning, failure, neutral }

class _ResultData {
  const _ResultData({
    required this.headline,
    required this.detail,
    required this.code,
    required this.type,
    this.chips = const [],
  });
  final String headline;
  final String detail;
  final String code;
  final _ResultType type;
  final List<(String, Color)> chips;
}

// ═════════════════════════════════════════════════════════════════════════════
// Shared widgets
// ═════════════════════════════════════════════════════════════════════════════

// ── Concept banner ───────────────────────────────────────────────────────────
class _ConceptBanner extends StatelessWidget {
  const _ConceptBanner({
    required this.icon,
    required this.color,
    required this.title,
    required this.body,
  });
  final IconData icon;
  final Color color;
  final String title, body;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(18),
      decoration: BoxDecoration(
        color: color.withOpacity(0.05),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: color.withOpacity(0.18)),
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            width: 44,
            height: 44,
            decoration: BoxDecoration(
              color: color.withOpacity(0.12),
              borderRadius: BorderRadius.circular(12),
            ),
            child: Icon(icon, color: color, size: 24),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  title,
                  style: GoogleFonts.dmSans(
                    color: color,
                    fontSize: 16,
                    fontWeight: FontWeight.w800,
                  ),
                ),
                const SizedBox(height: 4),
                Text(
                  body,
                  style: GoogleFonts.dmSans(
                    color: _inkMid,
                    fontSize: 12,
                    height: 1.55,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

// ── Section card ─────────────────────────────────────────────────────────────
class _SectionCard extends StatelessWidget {
  const _SectionCard({required this.label, required this.child});
  final String label;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: _border),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.03),
            blurRadius: 6,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
            child: Text(
              label,
              style: GoogleFonts.jetBrainsMono(
                color: _inkLight,
                fontSize: 9,
                fontWeight: FontWeight.w700,
                letterSpacing: 1.4,
              ),
            ),
          ),
          Container(
            height: 1,
            margin: const EdgeInsets.only(top: 11),
            color: _border,
          ),
          Padding(padding: const EdgeInsets.all(16), child: child),
        ],
      ),
    );
  }
}

// ── Code block ───────────────────────────────────────────────────────────────
class _CodeBlock extends StatelessWidget {
  const _CodeBlock({required this.code});
  final String code;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: const Color(0xFF1A1A24),
        borderRadius: BorderRadius.circular(13),
        border: Border.all(color: Colors.white.withOpacity(0.06)),
      ),
      child: Column(
        children: [
          // Header bar
          Container(
            height: 36,
            padding: const EdgeInsets.symmetric(horizontal: 14),
            decoration: BoxDecoration(
              border: Border(
                bottom: BorderSide(color: Colors.white.withOpacity(0.07)),
              ),
            ),
            child: Row(
              children: [
                for (final c in [
                  const Color(0xFFFF5F57),
                  const Color(0xFFFFBD2E),
                  const Color(0xFF28CA41),
                ])
                  Container(
                    width: 9,
                    height: 9,
                    margin: const EdgeInsets.only(right: 5),
                    decoration: BoxDecoration(color: c, shape: BoxShape.circle),
                  ),
                const SizedBox(width: 6),
                Text(
                  'Dart',
                  style: GoogleFonts.jetBrainsMono(
                    color: Colors.white30,
                    fontSize: 10,
                  ),
                ),
                const Spacer(),
                GestureDetector(
                  onTap: () => Clipboard.setData(ClipboardData(text: code)),
                  child: Row(
                    children: [
                      const Icon(
                        Icons.copy_rounded,
                        size: 12,
                        color: Colors.white38,
                      ),
                      const SizedBox(width: 4),
                      Text(
                        'Copy',
                        style: GoogleFonts.dmSans(
                          color: Colors.white38,
                          fontSize: 10,
                        ),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              code,
              style: GoogleFonts.jetBrainsMono(
                color: const Color(0xFFA8CCFF),
                fontSize: 11.5,
                height: 1.8,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ── Rich result card ──────────────────────────────────────────────────────────
class _ResultCard extends StatelessWidget {
  const _ResultCard({required this.data, this.compact = false});
  final _ResultData data;
  final bool compact;

  @override
  Widget build(BuildContext context) {
    final (bg, border, fg, icon) = switch (data.type) {
      _ResultType.success => (
        _greenSoft,
        _green.withOpacity(0.3),
        _green,
        Icons.check_circle_outline_rounded,
      ),
      _ResultType.warning => (
        _amberSoft,
        _amber.withOpacity(0.3),
        _amber,
        Icons.warning_amber_rounded,
      ),
      _ResultType.failure => (
        _redSoft,
        _red.withOpacity(0.3),
        _red,
        Icons.error_outline_rounded,
      ),
      _ResultType.neutral => (
        _surfaceAlt,
        _border,
        _inkMid,
        Icons.info_outline_rounded,
      ),
    };

    return Container(
      padding: EdgeInsets.all(compact ? 12 : 16),
      decoration: BoxDecoration(
        color: bg,
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: border),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(icon, color: fg, size: compact ? 16 : 18),
              const SizedBox(width: 8),
              Expanded(
                child: Text(
                  data.headline,
                  style: GoogleFonts.dmSans(
                    color: fg,
                    fontSize: compact ? 13 : 14,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
                decoration: BoxDecoration(
                  color: fg.withOpacity(0.1),
                  borderRadius: BorderRadius.circular(6),
                  border: Border.all(color: fg.withOpacity(0.2)),
                ),
                child: Text(
                  data.code,
                  style: GoogleFonts.jetBrainsMono(
                    color: fg,
                    fontSize: 9,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ),
            ],
          ),
          if (!compact) ...[
            const SizedBox(height: 8),
            Text(
              data.detail,
              style: GoogleFonts.dmSans(
                color: _inkMid,
                fontSize: 12,
                height: 1.5,
              ),
            ),
          ] else ...[
            const SizedBox(height: 4),
            Text(
              data.detail,
              style: GoogleFonts.dmSans(color: _inkMid, fontSize: 11),
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
          ],
          if (data.chips.isNotEmpty) ...[
            const SizedBox(height: 10),
            Wrap(
              spacing: 6,
              runSpacing: 6,
              children: data.chips
                  .map(
                    (c) => Container(
                      padding: const EdgeInsets.symmetric(
                        horizontal: 8,
                        vertical: 4,
                      ),
                      decoration: BoxDecoration(
                        color: c.$2.withOpacity(0.1),
                        borderRadius: BorderRadius.circular(6),
                        border: Border.all(color: c.$2.withOpacity(0.25)),
                      ),
                      child: Text(
                        c.$1,
                        style: GoogleFonts.dmSans(
                          color: c.$2,
                          fontSize: 10,
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                    ),
                  )
                  .toList(),
            ),
          ],
        ],
      ),
    );
  }
}

// ── Intent row ────────────────────────────────────────────────────────────────
class _IntentRow extends StatelessWidget {
  const _IntentRow({required this.value, required this.onChanged});
  final AuthIntent value;
  final ValueChanged<AuthIntent> onChanged;

  static const _colors = {
    AuthIntent.secure: _accent,
    AuthIntent.payment: _blue,
    AuthIntent.credentials: _green,
    AuthIntent.custom: _inkMid,
  };

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'INTENT',
          style: GoogleFonts.jetBrainsMono(
            color: _inkLight,
            fontSize: 9,
            fontWeight: FontWeight.w700,
            letterSpacing: 1.3,
          ),
        ),
        const SizedBox(height: 8),
        Row(
          children: AuthIntent.values.map((intent) {
            final sel = intent == value;
            final color = _colors[intent] ?? _inkMid;
            return Expanded(
              child: GestureDetector(
                onTap: () => onChanged(intent),
                child: AnimatedContainer(
                  duration: const Duration(milliseconds: 160),
                  margin: const EdgeInsets.only(right: 6),
                  padding: const EdgeInsets.symmetric(vertical: 9),
                  decoration: BoxDecoration(
                    color: sel ? color.withOpacity(0.1) : _surfaceAlt,
                    borderRadius: BorderRadius.circular(9),
                    border: Border.all(
                      color: sel ? color.withOpacity(0.5) : _border,
                    ),
                  ),
                  child: Text(
                    intent.name.toUpperCase(),
                    textAlign: TextAlign.center,
                    style: GoogleFonts.jetBrainsMono(
                      color: sel ? color : _inkMid,
                      fontSize: 8.5,
                      letterSpacing: 0.4,
                      fontWeight: sel ? FontWeight.w700 : FontWeight.w500,
                    ),
                  ),
                ),
              ),
            );
          }).toList(),
        ),
      ],
    );
  }
}

// ── Timeout row ───────────────────────────────────────────────────────────────
class _TimeoutRow extends StatelessWidget {
  const _TimeoutRow({required this.value, required this.onChanged});
  final Duration value;
  final ValueChanged<Duration> onChanged;

  static const _opts = [
    ('30s', Duration(seconds: 30)),
    ('1m', Duration(minutes: 1)),
    ('2m', Duration(minutes: 2)),
    ('5m', Duration(minutes: 5)),
  ];

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'SESSION TIMEOUT',
          style: GoogleFonts.jetBrainsMono(
            color: _inkLight,
            fontSize: 9,
            fontWeight: FontWeight.w700,
            letterSpacing: 1.3,
          ),
        ),
        const SizedBox(height: 8),
        Row(
          children: _opts.map((o) {
            final sel = o.$2 == value;
            return Expanded(
              child: GestureDetector(
                onTap: () => onChanged(o.$2),
                child: AnimatedContainer(
                  duration: const Duration(milliseconds: 160),
                  margin: const EdgeInsets.only(right: 6),
                  padding: const EdgeInsets.symmetric(vertical: 9),
                  decoration: BoxDecoration(
                    color: sel ? _accentSoft : _surfaceAlt,
                    borderRadius: BorderRadius.circular(9),
                    border: Border.all(
                      color: sel ? _accent.withOpacity(0.45) : _border,
                    ),
                  ),
                  child: Text(
                    o.$1,
                    textAlign: TextAlign.center,
                    style: GoogleFonts.dmSans(
                      color: sel ? _accent : _inkMid,
                      fontSize: 12,
                      fontWeight: sel ? FontWeight.w700 : FontWeight.w500,
                    ),
                  ),
                ),
              ),
            );
          }).toList(),
        ),
      ],
    );
  }
}

// ── Toggle tile ───────────────────────────────────────────────────────────────
class _Toggle extends StatelessWidget {
  const _Toggle({
    required this.label,
    required this.sublabel,
    required this.value,
    required this.onChanged,
  });
  final String label, sublabel;
  final bool value;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => onChanged(!value),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 170),
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: value ? _accentSoft : _surfaceAlt,
          borderRadius: BorderRadius.circular(10),
          border: Border.all(color: value ? _accent.withOpacity(0.4) : _border),
        ),
        child: Row(
          children: [
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    label,
                    style: GoogleFonts.dmSans(
                      color: value ? _accent : _ink,
                      fontSize: 11,
                      fontWeight: FontWeight.w700,
                    ),
                  ),
                  Text(
                    sublabel,
                    style: GoogleFonts.dmSans(color: _inkLight, fontSize: 9),
                  ),
                ],
              ),
            ),
            AnimatedContainer(
              duration: const Duration(milliseconds: 170),
              width: 30,
              height: 17,
              decoration: BoxDecoration(
                color: value ? _accent : _borderMid,
                borderRadius: BorderRadius.circular(9),
              ),
              child: AnimatedAlign(
                duration: const Duration(milliseconds: 170),
                alignment: value ? Alignment.centerRight : Alignment.centerLeft,
                child: Container(
                  margin: const EdgeInsets.all(2),
                  width: 13,
                  height: 13,
                  decoration: BoxDecoration(
                    color: Colors.white,
                    shape: BoxShape.circle,
                    boxShadow: [
                      BoxShadow(
                        color: Colors.black.withOpacity(0.15),
                        blurRadius: 3,
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Primary button ────────────────────────────────────────────────────────────
class _PrimaryButton extends StatelessWidget {
  const _PrimaryButton({
    required this.label,
    required this.icon,
    required this.onPressed,
    this.color = _accent,
  });
  final String label;
  final IconData icon;
  final VoidCallback onPressed;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onPressed,
      child: Container(
        width: double.infinity,
        padding: const EdgeInsets.symmetric(vertical: 15),
        decoration: BoxDecoration(
          color: color,
          borderRadius: BorderRadius.circular(12),
          boxShadow: [
            BoxShadow(
              color: color.withOpacity(0.32),
              blurRadius: 14,
              offset: const Offset(0, 5),
            ),
          ],
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(icon, color: Colors.white, size: 16),
            const SizedBox(width: 8),
            Text(
              label,
              style: GoogleFonts.dmSans(
                color: Colors.white,
                fontSize: 14,
                fontWeight: FontWeight.w700,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Outline button ────────────────────────────────────────────────────────────
class _OutlineButton extends StatelessWidget {
  const _OutlineButton({
    required this.label,
    required this.icon,
    required this.onPressed,
  });
  final String label;
  final IconData icon;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onPressed,
      child: Container(
        width: double.infinity,
        padding: const EdgeInsets.symmetric(vertical: 13),
        decoration: BoxDecoration(
          color: _surface,
          borderRadius: BorderRadius.circular(12),
          border: Border.all(color: _borderMid),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(icon, size: 14, color: _inkMid),
            const SizedBox(width: 6),
            Text(
              label,
              style: GoogleFonts.dmSans(
                color: _inkMid,
                fontSize: 12,
                fontWeight: FontWeight.w600,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Action grid row ───────────────────────────────────────────────────────────
class _ActionRow extends StatelessWidget {
  const _ActionRow({required this.children});
  final List<Widget> children;
  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        for (int i = 0; i < children.length; i++) ...[
          Expanded(child: children[i]),
          if (i < children.length - 1) const SizedBox(width: 10),
        ],
      ],
    );
  }
}

// ── Action button ─────────────────────────────────────────────────────────────
class _ActionBtn extends StatelessWidget {
  const _ActionBtn({
    required this.label,
    required this.icon,
    required this.color,
    required this.loading,
    required this.onTap,
  });
  final String label;
  final IconData icon;
  final Color color;
  final bool loading;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: loading ? null : onTap,
      child: AnimatedOpacity(
        duration: const Duration(milliseconds: 150),
        opacity: loading ? 0.5 : 1.0,
        child: Container(
          padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 10),
          decoration: BoxDecoration(
            color: color.withOpacity(0.07),
            borderRadius: BorderRadius.circular(10),
            border: Border.all(color: color.withOpacity(0.22)),
          ),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(icon, size: 14, color: color),
              const SizedBox(width: 6),
              Flexible(
                child: Text(
                  label,
                  overflow: TextOverflow.ellipsis,
                  style: GoogleFonts.dmSans(
                    color: color,
                    fontSize: 10,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

// ── Session chip ──────────────────────────────────────────────────────────────
class _SessChip extends StatelessWidget {
  const _SessChip({required this.label, required this.onTap});
  final String label;
  final VoidCallback onTap;
  @override
  Widget build(BuildContext context) => GestureDetector(
    onTap: onTap,
    child: Container(
      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(9),
        border: Border.all(color: _borderMid),
      ),
      child: Text(
        label,
        style: GoogleFonts.jetBrainsMono(color: _inkMid, fontSize: 11),
      ),
    ),
  );
}

// ── Prompting card (shown while BiometricGuard is waiting) ────────────────────
class _PromptingCard extends StatefulWidget {
  const _PromptingCard();
  @override
  State<_PromptingCard> createState() => _PromptingCardState();
}

class _PromptingCardState extends State<_PromptingCard>
    with SingleTickerProviderStateMixin {
  late final AnimationController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 950),
    )..repeat(reverse: true);
  }

  @override
  void dispose() {
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _ctrl,
      builder: (_, __) => Container(
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: _accentSoft,
          borderRadius: BorderRadius.circular(14),
          border: Border.all(
            color: _accent.withOpacity(0.25 + _ctrl.value * 0.25),
          ),
        ),
        child: Row(
          children: [
            Container(
              width: 46,
              height: 46,
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: _accent.withOpacity(0.08 + _ctrl.value * 0.12),
                border: Border.all(
                  color: _accent.withOpacity(0.35 + _ctrl.value * 0.45),
                  width: 1.5,
                ),
              ),
              child: Icon(Icons.fingerprint, color: _accent, size: 24),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'Biometric prompt is active',
                    style: GoogleFonts.dmSans(
                      color: _accent,
                      fontSize: 13,
                      fontWeight: FontWeight.w700,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    'Waiting for sensor or camera…',
                    style: GoogleFonts.dmSans(color: _inkMid, fontSize: 11),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// ── Learn panel ───────────────────────────────────────────────────────────────
class _LearnPanel extends StatelessWidget {
  const _LearnPanel({required this.color, required this.items});
  final Color color;
  final List<(String, String)> items;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(18),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
        border: Border.all(color: _border),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Icon(Icons.lightbulb_outline_rounded, size: 14, color: color),
              const SizedBox(width: 6),
              Text(
                'KEY CONCEPTS',
                style: GoogleFonts.jetBrainsMono(
                  color: color,
                  fontSize: 9,
                  fontWeight: FontWeight.w700,
                  letterSpacing: 1.3,
                ),
              ),
            ],
          ),
          const SizedBox(height: 14),
          ...items.map(
            (item) => Padding(
              padding: const EdgeInsets.only(bottom: 12),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Container(
                    width: 5,
                    height: 5,
                    margin: const EdgeInsets.only(top: 6, right: 10),
                    decoration: BoxDecoration(
                      color: color,
                      shape: BoxShape.circle,
                    ),
                  ),
                  Expanded(
                    child: RichText(
                      text: TextSpan(
                        children: [
                          TextSpan(
                            text: '${item.$1}  ',
                            style: GoogleFonts.jetBrainsMono(
                              color: color,
                              fontSize: 11,
                              fontWeight: FontWeight.w700,
                            ),
                          ),
                          TextSpan(
                            text: item.$2,
                            style: GoogleFonts.dmSans(
                              color: _inkMid,
                              fontSize: 12,
                              height: 1.45,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}
2
likes
0
points
122
downloads

Publisher

unverified uploader

Weekly Downloads

Flutter plugin for Android biometric and device-credential authentication with session management, widget/navigation-level guards, and reactive UI state.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter

More

Packages that depend on biometric_guard

Packages that implement biometric_guard