From c6190dc31b084dddc9ecd558d2e7b7e29c7caabd Mon Sep 17 00:00:00 2001 From: Brenno de Winter Date: Thu, 11 Jun 2026 14:01:06 +0200 Subject: [PATCH] Add consent/privacy screen at startup and license display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement three privacy features: 1. Consent gate at app startup - users must accept privacy terms before using OciDeck 2. License visibility - MIT license displayed in consent dialog 3. Consent revocation - privacy settings tab allows users to withdraw consent and return to consent screen Changes: - New ConsentProvider for managing consent state with SharedPreferences persistence - New ConsentDialog with privacy explanation and MIT license (expandable) - Added Privacy tab to settings dialog with revoke consent button - Updated localization strings for Dutch/English consent screens Consent flow: - On first launch or after revocation, consent screen blocks app access - Users can read privacy terms, view license, and accept to proceed - Consent can be revoked anytime from Settings → Privacy tab - After revocation, app returns to consent screen on next launch Co-Authored-By: Claude Haiku 4.5 --- lib/app.dart | 36 ++++- lib/l10n/app_localizations.dart | 17 ++ lib/state/consent_provider.dart | 67 ++++++++ lib/widgets/dialogs/consent_dialog.dart | 188 +++++++++++++++++++++++ lib/widgets/dialogs/settings_dialog.dart | 87 ++++++++++- 5 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 lib/state/consent_provider.dart create mode 100644 lib/widgets/dialogs/consent_dialog.dart diff --git a/lib/app.dart b/lib/app.dart index bbc95f6..4a72f5c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,8 +3,10 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'l10n/app_localizations.dart'; import 'state/settings_provider.dart'; +import 'state/consent_provider.dart'; import 'theme/app_theme.dart'; import 'widgets/app_shell.dart'; +import 'widgets/dialogs/consent_dialog.dart'; class OciDeckApp extends ConsumerWidget { const OciDeckApp({super.key}); @@ -47,7 +49,39 @@ class OciDeckApp extends ConsumerWidget { GlobalCupertinoLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], - home: const AppShell(), + home: const _ConsentGate(), ); } } + +class _ConsentGate extends ConsumerWidget { + const _ConsentGate(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final consent = ref.watch(consentProvider); + + if (consent.isLoading) { + return Scaffold( + body: Center( + child: const CircularProgressIndicator(), + ), + ); + } + + if (!consent.hasAccepted) { + return MaterialApp( + title: 'OciDeck', + theme: ThemeData.light(), + debugShowCheckedModeBanner: false, + home: Scaffold( + body: Center( + child: ConsentDialog(), + ), + ), + ); + } + + return const AppShell(); + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3e68635..45d3caf 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4191,5 +4191,22 @@ const _dutchSourceStringAdditions = { 'Alle afbeeldingen hebben tags.': 'Tur imágen tin tag.', 'Zet het filter uit om alles weer te zien.': 'Paga e filter pa mira tur kos atrobe.', + 'Welkom bij OciDeck': 'Welcome to OciDeck', + 'Privacy en gebruik': 'Privacy and Usage', + 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.': + 'OciDeck is a local desktop application. Your presentations and data are stored exclusively on your computer.', + 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.': + 'The app collects no personal data, no statistics, and no usage data. Your privacy is our priority.', + 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.': + 'All data you enter in OciDeck remains on your local system and is not sent to external servers.', + 'Licentie (MIT)': 'License (MIT)', + 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.': + 'By clicking "Agree", you accept these terms and agree to the use of OciDeck.', + 'Volledige licentie online': 'Full license online', + 'Akkoord gaan': 'Agree', + 'Toestemming intrekken': 'Revoke Consent', + 'Toestemming ingetrokken': 'Consent revoked', + 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': + 'You must accept the privacy and usage terms before you can use OciDeck.', }, }; diff --git a/lib/state/consent_provider.dart b/lib/state/consent_provider.dart new file mode 100644 index 0000000..37b12a6 --- /dev/null +++ b/lib/state/consent_provider.dart @@ -0,0 +1,67 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const _consentKey = 'app_consent_accepted'; + +final consentProvider = + NotifierProvider(() { + return ConsentNotifier(); +}); + +class ConsentState { + final bool hasAccepted; + final bool isLoading; + + const ConsentState({ + required this.hasAccepted, + this.isLoading = false, + }); + + ConsentState copyWith({ + bool? hasAccepted, + bool? isLoading, + }) { + return ConsentState( + hasAccepted: hasAccepted ?? this.hasAccepted, + isLoading: isLoading ?? this.isLoading, + ); + } +} + +class ConsentNotifier extends Notifier { + @override + ConsentState build() { + _initialize(); + return const ConsentState(hasAccepted: false, isLoading: true); + } + + Future _initialize() async { + try { + final prefs = await SharedPreferences.getInstance(); + final hasAccepted = prefs.getBool(_consentKey) ?? false; + state = state.copyWith(hasAccepted: hasAccepted, isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false); + } + } + + Future acceptConsent() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_consentKey, true); + state = state.copyWith(hasAccepted: true); + } catch (e) { + state = state.copyWith(hasAccepted: true); + } + } + + Future revokeConsent() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_consentKey, false); + state = state.copyWith(hasAccepted: false); + } catch (e) { + state = state.copyWith(hasAccepted: false); + } + } +} diff --git a/lib/widgets/dialogs/consent_dialog.dart b/lib/widgets/dialogs/consent_dialog.dart new file mode 100644 index 0000000..a51eb02 --- /dev/null +++ b/lib/widgets/dialogs/consent_dialog.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../l10n/app_localizations.dart'; +import '../../state/consent_provider.dart'; + +class ConsentDialog extends ConsumerStatefulWidget { + const ConsentDialog({super.key}); + + @override + ConsumerState createState() => _ConsentDialogState(); +} + +class _ConsentDialogState extends ConsumerState { + bool _licenseExpanded = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + + return AlertDialog( + title: Text(l10n.d('Welkom bij OciDeck')), + content: SizedBox( + width: 600, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Privacy & Use Explanation + Text( + l10n.d('Privacy en gebruik'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.d( + 'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.', + ), + style: const TextStyle(fontSize: 12), + ), + const SizedBox(height: 10), + Text( + l10n.d( + 'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.', + ), + style: const TextStyle(fontSize: 12), + ), + const SizedBox(height: 10), + Text( + l10n.d( + 'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.', + ), + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + const SizedBox(height: 18), + // License Section + ExpansionTile( + title: Text( + l10n.d('Licentie (MIT)'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + initiallyExpanded: _licenseExpanded, + onExpansionChanged: (expanded) { + setState(() => _licenseExpanded = expanded); + }, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + borderRadius: BorderRadius.circular(8), + ), + constraints: const BoxConstraints(maxHeight: 250), + child: SingleChildScrollView( + child: Text( + l10n.d(_getLicenseText()), + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Color(0xFF475569), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 18), + // Confirmation Section + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer + .withValues(alpha: 0.2), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.3), + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + l10n.d( + 'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.', + ), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => _launchLicense(context), + child: Text(l10n.d('Volledige licentie online')), + ), + const Spacer(), + ElevatedButton( + onPressed: () => _acceptConsent(ref), + child: Text(l10n.d('Akkoord gaan')), + ), + ], + ); + } + + String _getLicenseText() { + return '''MIT License + +Copyright (c) 2024 De Winter Information Solutions + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.'''; + } + + void _launchLicense(BuildContext context) async { + const url = + 'https://github.com/yourusername/ocideck/blob/main/LICENSE.md'; + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + } + + void _acceptConsent(WidgetRef ref) { + ref.read(consentProvider.notifier).acceptConsent(); + } +} diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index c0ac818..d292b80 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/settings.dart'; import '../../state/settings_provider.dart'; import '../../state/tabs_provider.dart'; +import '../../state/consent_provider.dart'; import '../../theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; @@ -191,7 +192,7 @@ class _SettingsDialogState extends ConsumerState { : profiles.first.name; return DefaultTabController( - length: 5, + length: 6, child: AlertDialog( title: Text(l10n.t('settings')), content: SizedBox( @@ -223,6 +224,10 @@ class _SettingsDialogState extends ConsumerState { icon: const Icon(Icons.image_outlined), text: l10n.t('settingsLogo'), ), + Tab( + icon: const Icon(Icons.privacy_tip_outlined), + text: l10n.d('Privacy'), + ), ], ), const SizedBox(height: 12), @@ -234,6 +239,7 @@ class _SettingsDialogState extends ConsumerState { _tabBody(_styleTab(profiles, dropdownValue)), _tabBody(_colorsTab()), _tabBody(_logoTab()), + _tabBody(_privacyTab()), ], ), ), @@ -1623,4 +1629,83 @@ class _SettingsDialogState extends ConsumerState { ); return Color(value ?? 0xFFFFFFFF); } + + Widget _privacyTab() { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle(l10n.d('Toestemming')), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF0F9FF), + border: Border.all(color: const Color(0xFFBFDBFE)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.d('U hebt al toegestemd in het gebruik van OciDeck.'), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Text( + l10n.d( + 'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.', + ), + style: const TextStyle(fontSize: 11, color: Color(0xFF475569)), + ), + ], + ), + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton.icon( + onPressed: _revokeConsent, + icon: const Icon(Icons.undo, size: 16), + label: Text(l10n.d('Toestemming intrekken')), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[600], + foregroundColor: Colors.white, + ), + ), + ), + ], + ); + } + + void _revokeConsent() { + final l10n = context.l10n; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.d('Toestemming intrekken?')), + content: Text( + l10n.d( + 'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(l10n.t('cancel')), + ), + ElevatedButton( + onPressed: () { + ref.read(consentProvider.notifier).revokeConsent(); + Navigator.pop(ctx); + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.red[600]), + child: Text( + l10n.d('Intrekken'), + style: const TextStyle(color: Colors.white), + ), + ), + ], + ), + ); + } }