age_range_signals 0.7.0 copy "age_range_signals: ^0.7.0" to clipboard
age_range_signals: ^0.7.0 copied to clipboard

Flutter plugin for age verification supporting Google Play Age Signals API (Android) and Apple's Age Range (DeclaredAgeRange) API (iOS 26+).

example/lib/main.dart

import 'dart:async';
import 'dart:io';

import 'package:age_range_signals/age_range_signals.dart';
import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Age Range Signals Demo',
      theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
      home: const AgeSignalsDemo(),
    );
  }
}

class AgeSignalsDemo extends StatefulWidget {
  const AgeSignalsDemo({super.key});

  @override
  State<AgeSignalsDemo> createState() => _AgeSignalsDemoState();
}

class _AgeSignalsDemoState extends State<AgeSignalsDemo> {
  AgeSignalsResult? _result;
  String? _error;
  bool _isLoading = false;
  bool _isInitialized = false;
  final bool _isIos = Platform.isIOS;
  String _currentScenario = 'Default (Supervised 13-15)';
  String? _regulatoryOutcome;
  String? _acknowledgmentOutcome;

  final List<int> _ageGates = [13, 16, 18];

  @override
  void initState() {
    super.initState();
    _initializePlugin();
  }

  Future<void> _initializePlugin({AgeSignalsMockData? mockData}) async {
    try {
      await AgeRangeSignals.instance.initialize(
        ageGates: _ageGates,
        // useMockData: Android only - uses Google's FakeAgeSignalsManager
        // On iOS, this is ignored and the real DeclaredAgeRange API is always used
        useMockData: true, // Set to true for testing, false for production
        mockData: mockData, // Android only - custom mock data for testing
      );
      setState(() {
        _isInitialized = true;
      });
    } catch (e) {
      setState(() {
        _error = 'Initialization failed: $e';
      });
    }
  }

  Future<void> _reinitializeWithScenario(
    String scenarioName,
    AgeSignalsMockData mockData,
  ) async {
    setState(() {
      _currentScenario = scenarioName;
      _result = null;
      _error = null;
    });
    await _initializePlugin(mockData: mockData);
  }

  Future<void> _checkAgeSignals() async {
    setState(() {
      _isLoading = true;
      _error = null;
      _result = null;
    });

    try {
      final result = await AgeRangeSignals.instance.checkAgeSignals();
      setState(() {
        _result = result;
        _isLoading = false;
      });
    } on ApiNotAvailableException catch (e) {
      setState(() {
        _error = 'API Not Available: ${e.message}';
        _isLoading = false;
      });
    } on UnsupportedPlatformException catch (e) {
      setState(() {
        _error = 'Unsupported Platform: ${e.message}';
        _isLoading = false;
      });
    } on NotInitializedException catch (e) {
      setState(() {
        _error = 'Not Initialized: ${e.message}';
        _isLoading = false;
      });
    } on MissingEntitlementException catch (e) {
      setState(() {
        _error = 'Missing Entitlement: ${e.message}';
        _isLoading = false;
      });
    } on AgeSignalsException catch (e) {
      setState(() {
        _error = 'Error: ${e.message}';
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _error = 'Unexpected error: $e';
        _isLoading = false;
      });
    }
  }

  Future<void> _getRegulatoryFeatures() async {
    setState(() {
      _regulatoryOutcome = 'Checking...';
    });
    final stopwatch = Stopwatch()..start();
    try {
      final features = await AgeRangeSignals.instance
          .getRequiredRegulatoryFeatures();
      stopwatch.stop();
      setState(() {
        _regulatoryOutcome = features.isEmpty
            ? 'Empty set (Apple reports nothing required) '
                  'in ${stopwatch.elapsedMilliseconds} ms'
            : '${features.map((f) => f.name).join(', ')} '
                  'in ${stopwatch.elapsedMilliseconds} ms';
      });
    } on AgeSignalsException catch (e) {
      stopwatch.stop();
      setState(() {
        _regulatoryOutcome =
            '${e.runtimeType}: ${e.message} '
            '(${stopwatch.elapsedMilliseconds} ms)';
      });
    }
  }

  Future<void> _showSignificantUpdate() async {
    setState(() {
      _acknowledgmentOutcome = 'Presenting...';
    });
    try {
      await AgeRangeSignals.instance.showSignificantUpdateAcknowledgment(
        updateDescription:
            'We added social features and public profiles to this app.',
      );
      setState(() {
        _acknowledgmentOutcome = 'Acknowledged';
      });
    } on AgeSignalsException catch (e) {
      setState(() {
        _acknowledgmentOutcome = '${e.runtimeType}: ${e.message}';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Age Range Signals'), elevation: 2),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            _buildInfoCard(),
            if (_isIos) ...[const SizedBox(height: 12), _buildIosWarningCard()],
            if (!_isIos) ...[const SizedBox(height: 12), _buildScenarioCard()],
            const SizedBox(height: 16),
            _buildCheckButton(),
            const SizedBox(height: 12),
            _buildRegulatoryCard(),
            const SizedBox(height: 24),
            if (_isLoading) _buildLoadingIndicator(),
            if (_error != null) _buildErrorCard(),
            if (_result != null) _buildResultCard(),
          ],
        ),
      ),
    );
  }

  Widget _buildInfoCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Platform: ${Platform.isAndroid ? 'Android' : 'iOS'}',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),
            if (Platform.isIOS) ...[
              Text(
                'Age Gates: ${_ageGates.join(", ")}',
                style: Theme.of(context).textTheme.bodyMedium,
              ),
              const SizedBox(height: 4),
              Text(
                'Status: ${_isInitialized ? "Initialized" : "Not initialized"}',
                style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                  color: _isInitialized ? Colors.green : Colors.orange,
                ),
              ),
            ],
            const SizedBox(height: 8),
            const Divider(),
            const SizedBox(height: 8),
            Text(
              Platform.isAndroid
                  ? 'Note: This example uses mock data (useMockData: true) for testing. You can test different scenarios using the chips below.'
                  : 'Note: DeclaredAgeRange requires iOS 26.0 or later. On older iOS versions, you will receive an UnsupportedPlatformException.',
              style: Theme.of(context).textTheme.bodySmall?.copyWith(
                fontStyle: FontStyle.italic,
                color: Colors.grey[600],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildScenarioCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Test Scenarios',
              style: Theme.of(
                context,
              ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 8),
            Text(
              'Current: $_currentScenario',
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                color: Colors.blue[700],
                fontWeight: FontWeight.w600,
              ),
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _buildScenarioChip(
                  'Default (Supervised 13-15)',
                  AgeSignalsMockData(
                    status: AgeSignalsStatus.supervised,
                    ageLower: 13,
                    ageUpper: 15,
                    installId: 'test_install_id_12345',
                  ),
                ),
                _buildScenarioChip(
                  'Supervised 16-17',
                  AgeSignalsMockData(
                    status: AgeSignalsStatus.supervised,
                    ageLower: 16,
                    ageUpper: 17,
                    installId: 'test_install_id_12345',
                  ),
                ),
                _buildScenarioChip(
                  'Verified (18+)',
                  const AgeSignalsMockData(status: AgeSignalsStatus.verified),
                ),
                _buildScenarioChip(
                  'Approval Pending',
                  AgeSignalsMockData(
                    status: AgeSignalsStatus.supervisedApprovalPending,
                    ageLower: 13,
                    ageUpper: 15,
                    installId: 'test_install_id_12345',
                  ),
                ),
                _buildScenarioChip(
                  'Approval Denied',
                  AgeSignalsMockData(
                    status: AgeSignalsStatus.supervisedApprovalDenied,
                    ageLower: 13,
                    ageUpper: 15,
                    installId: 'test_install_id_12345',
                  ),
                ),
                _buildScenarioChip(
                  'Declared Age',
                  const AgeSignalsMockData(
                    status: AgeSignalsStatus.declared,
                    ageLower: 13,
                    ageUpper: 15,
                  ),
                ),
                _buildScenarioChip(
                  'Unknown',
                  const AgeSignalsMockData(status: AgeSignalsStatus.unknown),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildScenarioChip(String label, AgeSignalsMockData mockData) {
    return ActionChip(
      label: Text(label),
      onPressed: () => _reinitializeWithScenario(label, mockData),
    );
  }

  Widget _buildCheckButton() {
    return FilledButton.icon(
      onPressed: _isLoading || !_isInitialized ? null : _checkAgeSignals,
      icon: const Icon(Icons.verified_user),
      label: const Text('Check Age Signals'),
      style: FilledButton.styleFrom(
        padding: const EdgeInsets.symmetric(vertical: 16),
      ),
    );
  }

  Widget _buildRegulatoryCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(
              'Regulatory Features (iOS 26.4+)',
              style: Theme.of(
                context,
              ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 12),
            OutlinedButton.icon(
              onPressed: _getRegulatoryFeatures,
              icon: const Icon(Icons.policy_outlined),
              label: const Text('Get Required Regulatory Features'),
            ),
            if (_regulatoryOutcome != null) ...[
              const SizedBox(height: 8),
              Text(
                _regulatoryOutcome!,
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
            const SizedBox(height: 12),
            OutlinedButton.icon(
              onPressed: _showSignificantUpdate,
              icon: const Icon(Icons.campaign_outlined),
              label: const Text('Show Significant Update Sheet'),
            ),
            if (_acknowledgmentOutcome != null) ...[
              const SizedBox(height: 8),
              Text(
                _acknowledgmentOutcome!,
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildIosWarningCard() {
    return Card(
      color: Colors.orange[50],
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Icon(Icons.info_outline, color: Colors.orange[700]),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'iOS real-device test',
                    style: Theme.of(context).textTheme.titleMedium?.copyWith(
                      color: Colors.orange[800],
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 6),
                  Text(
                    'Runs the real DeclaredAgeRange API (iOS 26.2+). The project references Runner.entitlements, so device signing requires the com.apple.developer.declared-age-range capability registered on your App ID; register it in the developer portal or point the bundle ID at an App ID that has it. To exercise real responses, sign into a Sandbox Apple Account set to an applicable region (Settings > Developer) and pick a scenario under Age Assurance. Prefer release builds for this: debug builds have been seen to stall on the regulatory features call (the plugin times out after 10 seconds). See the iOS Testing section of the README.',
                    style: Theme.of(context).textTheme.bodyMedium,
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildLoadingIndicator() {
    return const Center(
      child: Padding(
        padding: EdgeInsets.all(24),
        child: CircularProgressIndicator(),
      ),
    );
  }

  Widget _buildErrorCard() {
    return Card(
      color: Colors.red[50],
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(Icons.error_outline, color: Colors.red[700]),
                const SizedBox(width: 8),
                Text(
                  'Error',
                  style: Theme.of(context).textTheme.titleMedium?.copyWith(
                    color: Colors.red[700],
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Text(_error ?? '', style: TextStyle(color: Colors.red[900])),
          ],
        ),
      ),
    );
  }

  Widget _buildResultCard() {
    return Card(
      color: Colors.green[50],
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                Icon(Icons.check_circle_outline, color: Colors.green[700]),
                const SizedBox(width: 8),
                Text(
                  'Result',
                  style: Theme.of(context).textTheme.titleMedium?.copyWith(
                    color: Colors.green[700],
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            _buildResultRow('Status', _getStatusText(_result!.status)),
            if (_result!.ageLower != null)
              _buildResultRow('Age Lower Bound', _result!.ageLower.toString()),
            if (_result!.ageUpper != null)
              _buildResultRow('Age Upper Bound', _result!.ageUpper.toString()),
            if (_result!.source != null)
              _buildResultRow('Source', _getSourceText(_result!.source!)),
            if (_result!.installId != null)
              _buildResultRow('Install ID', _result!.installId!),
          ],
        ),
      ),
    );
  }

  Widget _buildResultRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 120,
            child: Text(
              '$label:',
              style: const TextStyle(fontWeight: FontWeight.w600),
            ),
          ),
          Expanded(child: Text(value)),
        ],
      ),
    );
  }

  String _getStatusText(AgeSignalsStatus status) {
    switch (status) {
      case AgeSignalsStatus.verified:
        return 'Verified (User is above age threshold)';
      case AgeSignalsStatus.supervised:
        return 'Supervised (User may be under age threshold)';
      case AgeSignalsStatus.supervisedApprovalPending:
        return 'Supervised (Awaiting guardian approval)';
      case AgeSignalsStatus.supervisedApprovalDenied:
        return 'Supervised (Guardian denied approval)';
      case AgeSignalsStatus.declared:
        return 'Declared (User has declared their age)';
      case AgeSignalsStatus.declined:
        return 'Declined (User chose not to share)';
      case AgeSignalsStatus.unknown:
        return 'Unknown (Age information not available)';
    }
  }

  String _getSourceText(AgeDeclarationSource source) {
    switch (source) {
      case AgeDeclarationSource.selfDeclared:
        return 'Self Declared';
      case AgeDeclarationSource.guardianDeclared:
        return 'Guardian Declared';
    }
  }
}
16
likes
160
points
8.81k
downloads

Documentation

API reference

Publisher

verified publisherpovhe.si

Weekly Downloads

Flutter plugin for age verification supporting Google Play Age Signals API (Android) and Apple's Age Range (DeclaredAgeRange) API (iOS 26+).

Repository (GitHub)
View/report issues

Topics

#age-signals #age-range #age-verification #android #ios

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on age_range_signals

Packages that implement age_range_signals