/// Flutter Consent SDK /// /// DSGVO/TTDSG-konformes Consent Management fuer Flutter Apps. /// /// Nutzung: /// 1. In main() mit ConsentManager.configure() initialisieren /// 2. App mit ConsentProvider wrappen /// 3. Mit ConsentGate Inhalte schuetzen /// /// Copyright (c) 2025 BreakPilot /// Apache License 2.0 library consent_sdk; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:provider/provider.dart'; // ============================================================================= // Consent Categories // ============================================================================= /// Standard-Consent-Kategorien nach IAB TCF 2.2 enum ConsentCategory { essential, // Technisch notwendig functional, // Personalisierung analytics, // Nutzungsanalyse marketing, // Werbung social, // Social Media } // ============================================================================= // Consent State // ============================================================================= /// Aktueller Consent-Zustand class ConsentState { final Map categories; final Map vendors; final DateTime timestamp; final String version; final String? consentId; final DateTime? expiresAt; final String? tcfString; const ConsentState({ required this.categories, this.vendors = const {}, required this.timestamp, this.version = '1.0.0', this.consentId, this.expiresAt, this.tcfString, }); /// Default State mit nur essential = true factory ConsentState.defaultState() { return ConsentState( categories: { ConsentCategory.essential: true, ConsentCategory.functional: false, ConsentCategory.analytics: false, ConsentCategory.marketing: false, ConsentCategory.social: false, }, timestamp: DateTime.now(), ); } ConsentState copyWith({ Map? categories, Map? vendors, DateTime? timestamp, String? version, String? consentId, DateTime? expiresAt, String? tcfString, }) { return ConsentState( categories: categories ?? this.categories, vendors: vendors ?? this.vendors, timestamp: timestamp ?? this.timestamp, version: version ?? this.version, consentId: consentId ?? this.consentId, expiresAt: expiresAt ?? this.expiresAt, tcfString: tcfString ?? this.tcfString, ); } Map toJson() => { 'categories': categories.map((k, v) => MapEntry(k.name, v)), 'vendors': vendors, 'timestamp': timestamp.toIso8601String(), 'version': version, 'consentId': consentId, 'expiresAt': expiresAt?.toIso8601String(), 'tcfString': tcfString, }; factory ConsentState.fromJson(Map json) { return ConsentState( categories: (json['categories'] as Map).map( (k, v) => MapEntry( ConsentCategory.values.firstWhere((e) => e.name == k), v as bool, ), ), vendors: Map.from(json['vendors'] ?? {}), timestamp: DateTime.parse(json['timestamp']), version: json['version'] ?? '1.0.0', consentId: json['consentId'], expiresAt: json['expiresAt'] != null ? DateTime.parse(json['expiresAt']) : null, tcfString: json['tcfString'], ); } } // ============================================================================= // Configuration // ============================================================================= /// SDK-Konfiguration class ConsentConfig { final String apiEndpoint; final String siteId; final String language; final bool showRejectAll; final bool showAcceptAll; final bool granularControl; final int rememberDays; final bool debug; const ConsentConfig({ required this.apiEndpoint, required this.siteId, this.language = 'en', this.showRejectAll = true, this.showAcceptAll = true, this.granularControl = true, this.rememberDays = 365, this.debug = false, }); } // ============================================================================= // Consent Manager // ============================================================================= /// Haupt-Manager fuer Consent-Verwaltung class ConsentManager extends ChangeNotifier { // Singleton static ConsentManager? _instance; static ConsentManager get instance => _instance!; // State ConsentState _consent = ConsentState.defaultState(); bool _isInitialized = false; bool _isLoading = true; bool _isBannerVisible = false; bool _isSettingsVisible = false; // Private ConsentConfig? _config; late ConsentStorage _storage; late ConsentApiClient _apiClient; // Getters ConsentState get consent => _consent; bool get isInitialized => _isInitialized; bool get isLoading => _isLoading; bool get isBannerVisible => _isBannerVisible; bool get isSettingsVisible => _isSettingsVisible; bool get needsConsent => _consent.consentId == null; // Private constructor ConsentManager._(); /// Konfiguriert den ConsentManager /// Sollte in main() vor runApp() aufgerufen werden static Future configure(ConsentConfig config) async { _instance = ConsentManager._(); _instance!._config = config; _instance!._storage = ConsentStorage(); _instance!._apiClient = ConsentApiClient( baseUrl: config.apiEndpoint, siteId: config.siteId, ); if (config.debug) { debugPrint('[ConsentSDK] Configured with siteId: ${config.siteId}'); } await _instance!._initialize(); } // ========================================================================== // Initialization // ========================================================================== Future _initialize() async { try { // Lokalen Consent laden final stored = await _storage.load(); if (stored != null) { // Pruefen ob abgelaufen if (stored.expiresAt != null && DateTime.now().isAfter(stored.expiresAt!)) { _consent = ConsentState.defaultState(); await _storage.clear(); } else { _consent = stored; } } // Vom Server synchronisieren try { final fingerprint = await DeviceFingerprint.generate(); final serverConsent = await _apiClient.getConsent(fingerprint); if (serverConsent != null) { _consent = serverConsent; await _storage.save(_consent); } } catch (e) { if (_config?.debug == true) { debugPrint('[ConsentSDK] Failed to sync consent: $e'); } } _isInitialized = true; // Banner anzeigen falls noetig if (needsConsent) { showBanner(); } } finally { _isLoading = false; notifyListeners(); } } // ========================================================================== // Public API // ========================================================================== /// Prueft ob Consent fuer Kategorie erteilt wurde bool hasConsent(ConsentCategory category) { // Essential ist immer erlaubt if (category == ConsentCategory.essential) return true; return _consent.categories[category] ?? false; } /// Alle Kategorien akzeptieren Future acceptAll() async { final newCategories = { for (var cat in ConsentCategory.values) cat: true }; final newConsent = _consent.copyWith( categories: newCategories, timestamp: DateTime.now(), ); await _saveConsent(newConsent); hideBanner(); } /// Alle nicht-essentiellen Kategorien ablehnen Future rejectAll() async { final newCategories = { for (var cat in ConsentCategory.values) cat: cat == ConsentCategory.essential }; final newConsent = _consent.copyWith( categories: newCategories, timestamp: DateTime.now(), ); await _saveConsent(newConsent); hideBanner(); } /// Auswahl speichern Future saveSelection(Map categories) async { final updated = Map.from(categories); updated[ConsentCategory.essential] = true; // Essential immer true final newConsent = _consent.copyWith( categories: updated, timestamp: DateTime.now(), ); await _saveConsent(newConsent); hideBanner(); } // ========================================================================== // UI Control // ========================================================================== void showBanner() { _isBannerVisible = true; notifyListeners(); } void hideBanner() { _isBannerVisible = false; notifyListeners(); } void showSettings() { _isSettingsVisible = true; notifyListeners(); } void hideSettings() { _isSettingsVisible = false; notifyListeners(); } // ========================================================================== // Private Methods // ========================================================================== Future _saveConsent(ConsentState newConsent) async { // Lokal speichern await _storage.save(newConsent); // An Server senden try { final fingerprint = await DeviceFingerprint.generate(); final response = await _apiClient.saveConsent(newConsent, fingerprint); final updated = newConsent.copyWith( consentId: response['consentId'], expiresAt: DateTime.parse(response['expiresAt']), ); _consent = updated; await _storage.save(updated); } catch (e) { // Lokal speichern auch bei Fehler _consent = newConsent; if (_config?.debug == true) { debugPrint('[ConsentSDK] Failed to sync consent: $e'); } } notifyListeners(); } } // ============================================================================= // Storage // ============================================================================= /// Sichere Speicherung mit flutter_secure_storage class ConsentStorage { final _storage = const FlutterSecureStorage(); static const _key = 'breakpilot_consent_state'; Future load() async { final data = await _storage.read(key: _key); if (data == null) return null; try { return ConsentState.fromJson(jsonDecode(data)); } catch (e) { return null; } } Future save(ConsentState consent) async { final data = jsonEncode(consent.toJson()); await _storage.write(key: _key, value: data); } Future clear() async { await _storage.delete(key: _key); } } // ============================================================================= // API Client // ============================================================================= /// API Client fuer Backend-Kommunikation class ConsentApiClient { final String baseUrl; final String siteId; ConsentApiClient({ required this.baseUrl, required this.siteId, }); Future getConsent(String fingerprint) async { final response = await http.get( Uri.parse('$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint'), ); if (response.statusCode != 200) return null; return ConsentState.fromJson(jsonDecode(response.body)); } Future> saveConsent( ConsentState consent, String fingerprint, ) async { final response = await http.post( Uri.parse('$baseUrl/banner/consent'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'site_id': siteId, 'device_fingerprint': fingerprint, 'categories': consent.categories.map((k, v) => MapEntry(k.name, v)), 'vendors': consent.vendors, 'platform': Platform.isIOS ? 'ios' : 'android', 'app_version': '1.0.0', // TODO: Get from package_info_plus }), ); return jsonDecode(response.body); } } // ============================================================================= // Device Fingerprint // ============================================================================= /// Privacy-konformer Device Fingerprint class DeviceFingerprint { static Future generate() async { final deviceInfo = DeviceInfoPlugin(); String rawId; if (Platform.isIOS) { final iosInfo = await deviceInfo.iosInfo; rawId = '${iosInfo.identifierForVendor}-${iosInfo.model}-${iosInfo.systemVersion}'; } else if (Platform.isAndroid) { final androidInfo = await deviceInfo.androidInfo; rawId = '${androidInfo.id}-${androidInfo.model}-${androidInfo.version.sdkInt}'; } else { rawId = DateTime.now().millisecondsSinceEpoch.toString(); } // SHA-256 Hash final bytes = utf8.encode(rawId); final digest = sha256.convert(bytes); return digest.toString(); } } // ============================================================================= // Flutter Widgets // ============================================================================= /// Consent Provider - Wraps the app class ConsentProvider extends StatelessWidget { final Widget child; const ConsentProvider({ super.key, required this.child, }); @override Widget build(BuildContext context) { return ChangeNotifierProvider.value( value: ConsentManager.instance, child: child, ); } } /// Consent Gate - Zeigt Inhalt nur bei Consent class ConsentGate extends StatelessWidget { final ConsentCategory category; final Widget child; final Widget? placeholder; final Widget? loading; const ConsentGate({ super.key, required this.category, required this.child, this.placeholder, this.loading, }); @override Widget build(BuildContext context) { return Consumer( builder: (context, consent, _) { if (consent.isLoading) { return loading ?? const SizedBox.shrink(); } if (!consent.hasConsent(category)) { return placeholder ?? const SizedBox.shrink(); } return child; }, ); } } /// Consent Banner - Default Banner UI class ConsentBanner extends StatelessWidget { final String? title; final String? description; final String? acceptAllText; final String? rejectAllText; final String? settingsText; const ConsentBanner({ super.key, this.title, this.description, this.acceptAllText, this.rejectAllText, this.settingsText, }); @override Widget build(BuildContext context) { return Consumer( builder: (context, consent, _) { if (!consent.isBannerVisible) { return const SizedBox.shrink(); } return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Theme.of(context).cardColor, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 20, offset: const Offset(0, -5), ), ], ), child: SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( title ?? 'Datenschutzeinstellungen', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), Text( description ?? 'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), const SizedBox(height: 20), Row( children: [ Expanded( child: OutlinedButton( onPressed: consent.rejectAll, child: Text(rejectAllText ?? 'Alle ablehnen'), ), ), const SizedBox(width: 8), Expanded( child: OutlinedButton( onPressed: consent.showSettings, child: Text(settingsText ?? 'Einstellungen'), ), ), const SizedBox(width: 8), Expanded( child: FilledButton( onPressed: consent.acceptAll, child: Text(acceptAllText ?? 'Alle akzeptieren'), ), ), ], ), ], ), ), ); }, ); } } /// Consent Placeholder - Placeholder fuer blockierten Inhalt class ConsentPlaceholder extends StatelessWidget { final ConsentCategory category; final String? message; final String? buttonText; const ConsentPlaceholder({ super.key, required this.category, this.message, this.buttonText, }); String get _categoryName { switch (category) { case ConsentCategory.essential: return 'Essentielle Cookies'; case ConsentCategory.functional: return 'Funktionale Cookies'; case ConsentCategory.analytics: return 'Statistik-Cookies'; case ConsentCategory.marketing: return 'Marketing-Cookies'; case ConsentCategory.social: return 'Social Media-Cookies'; } } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceVariant, borderRadius: BorderRadius.circular(12), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( message ?? 'Dieser Inhalt erfordert $_categoryName.', textAlign: TextAlign.center, ), const SizedBox(height: 12), OutlinedButton( onPressed: ConsentManager.instance.showSettings, child: Text(buttonText ?? 'Cookie-Einstellungen öffnen'), ), ], ), ); } } // ============================================================================= // Extension for easy context access // ============================================================================= extension ConsentExtension on BuildContext { ConsentManager get consent => Provider.of(this, listen: false); bool hasConsent(ConsentCategory category) => consent.hasConsent(category); }