biometric_guard 1.0.0
biometric_guard: ^1.0.0 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,
),
),
],
),
),
),
],
),
),
),
],
),
);
}
}