Add consent/privacy screen at startup and license display
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 <noreply@anthropic.com>
This commit is contained in:
parent
e86d30e75a
commit
c6190dc31b
5 changed files with 393 additions and 2 deletions
36
lib/app.dart
36
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
67
lib/state/consent_provider.dart
Normal file
67
lib/state/consent_provider.dart
Normal file
|
|
@ -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<ConsentNotifier, ConsentState>(() {
|
||||
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<ConsentState> {
|
||||
@override
|
||||
ConsentState build() {
|
||||
_initialize();
|
||||
return const ConsentState(hasAccepted: false, isLoading: true);
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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<void> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
188
lib/widgets/dialogs/consent_dialog.dart
Normal file
188
lib/widgets/dialogs/consent_dialog.dart
Normal file
|
|
@ -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<ConsentDialog> createState() => _ConsentDialogState();
|
||||
}
|
||||
|
||||
class _ConsentDialogState extends ConsumerState<ConsentDialog> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SettingsDialog> {
|
|||
: 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<SettingsDialog> {
|
|||
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<SettingsDialog> {
|
|||
_tabBody(_styleTab(profiles, dropdownValue)),
|
||||
_tabBody(_colorsTab()),
|
||||
_tabBody(_logoTab()),
|
||||
_tabBody(_privacyTab()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1623,4 +1629,83 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
);
|
||||
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<void>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue