A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
659 lines
19 KiB
Dart
659 lines
19 KiB
Dart
/// 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<ConsentCategory, bool> categories;
|
|
final Map<String, bool> 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<ConsentCategory, bool>? categories,
|
|
Map<String, bool>? 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<String, dynamic> 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<String, dynamic> json) {
|
|
return ConsentState(
|
|
categories: (json['categories'] as Map<String, dynamic>).map(
|
|
(k, v) => MapEntry(
|
|
ConsentCategory.values.firstWhere((e) => e.name == k),
|
|
v as bool,
|
|
),
|
|
),
|
|
vendors: Map<String, bool>.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<void> 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<void> _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<void> 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<void> 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<void> saveSelection(Map<ConsentCategory, bool> categories) async {
|
|
final updated = Map<ConsentCategory, bool>.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<void> _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<ConsentState?> 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<void> save(ConsentState consent) async {
|
|
final data = jsonEncode(consent.toJson());
|
|
await _storage.write(key: _key, value: data);
|
|
}
|
|
|
|
Future<void> 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<ConsentState?> 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<Map<String, dynamic>> 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<String> 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<ConsentManager>(
|
|
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<ConsentManager>(
|
|
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<ConsentManager>(this, listen: false);
|
|
|
|
bool hasConsent(ConsentCategory category) => consent.hasConsent(category);
|
|
}
|