From 768d0904a882185ceed7d3d5a1d8f4bfc038d146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Gergely?= Date: Mon, 24 Nov 2025 04:43:14 +0100 Subject: [PATCH] Add data privacy consent dialog for Live Activity A new data privacy consent dialog has been added to the Live Activity feature. Users must accept this dialog to use Live Activity. If they decline, all their data will be immediately deleted from the database, and cannot use the Live Activities feature. Additionally, users receive a detailed description explaining what data we store, for how long, and their GDPR rights. --- firka/lib/helpers/live_activity_service.dart | 113 ++++++-- firka/lib/helpers/settings.dart | 15 +- firka/lib/main.dart | 4 +- .../ui/phone/screens/home/home_screen.dart | 7 + .../full_privacy_policy_screen.dart | 130 +++++++++ .../live_activity_consent_screen.dart | 248 ++++++++++++++++++ 6 files changed, 498 insertions(+), 19 deletions(-) create mode 100644 firka/lib/ui/phone/screens/live_activity/full_privacy_policy_screen.dart create mode 100644 firka/lib/ui/phone/screens/live_activity/live_activity_consent_screen.dart diff --git a/firka/lib/helpers/live_activity_service.dart b/firka/lib/helpers/live_activity_service.dart index 51bb930..4fe0f39 100644 --- a/firka/lib/helpers/live_activity_service.dart +++ b/firka/lib/helpers/live_activity_service.dart @@ -4,8 +4,11 @@ import 'package:firka/helpers/api/client/kreta_client.dart'; import 'package:firka/helpers/api/client/live_activity_backend_client.dart'; import 'package:firka/helpers/api/model/generic.dart'; import 'package:firka/helpers/api/model/timetable.dart'; +import 'package:firka/helpers/db/models/app_settings_model.dart'; import 'package:firka/helpers/live_activity_manager.dart'; import 'package:firka/helpers/settings.dart'; +import 'package:firka/ui/phone/screens/live_activity/live_activity_consent_screen.dart'; +import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -127,31 +130,111 @@ class LiveActivityService { } /// Handle LiveActivity enabled state change - /// Called from settings toggle callback - does NOT save settings (already saved) - static Future handleEnabledChange(bool enabled) async { + /// Called from settings toggle callback + static Future handleEnabledChange(bool enabled, {bool isManual = false}) async { if (!Platform.isIOS) return; try { - if (!enabled) { - await LiveActivityManager.endAllActivities(); - _stopTimetableMonitoring(); - await _clearCache(); - _logger.info('LiveActivity disabled - all activities ended'); - } else { - final studentResp = await initData.client.getStudent(); - final studentName = studentResp.response?.name ?? initData.tokens.first.studentId ?? "Student"; + final enabledSetting = initData.settings + .group("settings") + .subGroup("application")["live_activity_enabled"] + as SettingsBoolean; - await onUserLogin( - client: initData.client, - studentName: studentName, - settingsStore: initData.settings, - ); + if (!enabled) { + await onUserLogout(); + enabledSetting.value = false; + await initData.isar.writeTxn(() async { + await enabledSetting.save(initData.isar.appSettingsModels); + }); + globalUpdate.update(); + _logger.info('LiveActivity disabled and user data cleared.'); + } else { + _logger.info('Showing privacy consent screen (manual: $isManual)'); + final bool? accepted = await _showPrivacyConsentScreen(); + + if (accepted == true) { + _logger.info('User accepted privacy policy'); + + enabledSetting.value = true; + await initData.isar.writeTxn(() async { + await enabledSetting.save(initData.isar.appSettingsModels); + }); + globalUpdate.update(); + + final studentResp = await initData.client.getStudent(); + final studentName = studentResp.response?.name ?? initData.tokens.first.studentId ?? "Student"; + + await onUserLogin( + client: initData.client, + studentName: studentName, + settingsStore: initData.settings, + ); + } else { + _logger.info('User declined privacy policy or swiped back'); + + final everDeclinedSetting = initData.settings + .group("settings") + .subGroup("application")["live_activity_privacy_ever_declined"] + as SettingsBoolean; + + enabledSetting.value = false; + everDeclinedSetting.value = true; + + await initData.isar.writeTxn(() async { + await enabledSetting.save(initData.isar.appSettingsModels); + await everDeclinedSetting.save(initData.isar.appSettingsModels); + }); + globalUpdate.update(); + } } } catch (e) { _logger.warning('Error handling LiveActivity enabled change: $e'); } } + /// Show privacy consent screen automatically on first use or user switch + /// Only shows if user hasn't declined before + static Future showConsentScreenIfNeeded() async { + if (!Platform.isIOS) return; + + try { + final enabledSetting = initData.settings + .group("settings") + .subGroup("application")["live_activity_enabled"] as SettingsBoolean; + + final everDeclinedSetting = initData.settings + .group("settings") + .subGroup("application")["live_activity_privacy_ever_declined"] as SettingsBoolean; + + if (!enabledSetting.value && !everDeclinedSetting.value) { + _logger.info('First use or new user - showing privacy consent automatically'); + await handleEnabledChange(true, isManual: false); + } + } catch (e) { + _logger.warning('Error checking if consent screen needed: $e'); + } + } + + /// Show privacy consent screen + static Future _showPrivacyConsentScreen() async { + final context = initData.navigatorKey.currentContext; + if (context == null) { + _logger.warning('No context available to show consent screen'); + return false; + } + + final bool? result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LiveActivityConsentScreen( + data: initData, + ), + ), + ); + + return result; + } + /// Handle token expiration - deactivate LiveActivity static Future onTokenExpired() async { if (!Platform.isIOS) return; diff --git a/firka/lib/helpers/settings.dart b/firka/lib/helpers/settings.dart index 1754e18..e44f20e 100644 --- a/firka/lib/helpers/settings.dart +++ b/firka/lib/helpers/settings.dart @@ -32,6 +32,7 @@ const developerOptsEnabled = 1016; const themeBrightness = 1017; const ttToastSubstitution = 1018; const liveActivityEnabled = 1019; +const liveActivityPrivacyEverDeclined = 1020; bool always() { return true; @@ -127,8 +128,8 @@ class SettingsStore { liveActivityEnabled, FirkaIconType.majesticons, Majesticon.clockSolid, - "LiveActivity", - true, + l10n.la_enable, + false, isIOS, () async { final setting = initData.settings @@ -136,8 +137,16 @@ class SettingsStore { .subGroup("application")["live_activity_enabled"] as SettingsBoolean; final enabled = setting.value; - await LiveActivityService.handleEnabledChange(enabled); + + await LiveActivityService.handleEnabledChange(enabled, isManual: true); }), + "live_activity_privacy_ever_declined": SettingsBoolean( + liveActivityPrivacyEverDeclined, + null, + null, + "Privacy Ever Declined", + false, + never), "test_notification": SettingsButton( 0, FirkaIconType.majesticons, diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 3815800..f32b8f7 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -78,6 +78,7 @@ class AppInitialization { UpdateNotifier settingsUpdateNotifier = UpdateNotifier(); UpdateNotifier profilePictureUpdateNotifier = UpdateNotifier(); AppLocalizations l10n; + final GlobalKey navigatorKey; AppInitialization({ required this.isar, @@ -87,6 +88,7 @@ class AppInitialization { required this.tokens, required this.settings, required this.l10n, + required this.navigatorKey, }); } @@ -145,7 +147,6 @@ Future initLang(AppInitialization data) async { break; } - // Update language preference on backend for Live Activity localization if (languageCode != null && Platform.isIOS) { try { await LiveActivityService.updateLanguagePreference(languageCode); @@ -272,6 +273,7 @@ Future initializeApp() async { tokens: tokens, settings: SettingsStore(AppLocalizationsHu()), l10n: AppLocalizationsHu(), + navigatorKey: navigatorKey, ); if (Platform.isIOS) { diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart index e8da321..19837ef 100644 --- a/firka/lib/ui/phone/screens/home/home_screen.dart +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:firka/helpers/api/client/kreta_stream.dart'; import 'package:firka/helpers/extensions.dart'; +import 'package:firka/helpers/live_activity_service.dart'; import 'package:firka/helpers/settings.dart'; import 'package:firka/helpers/update_notifier.dart'; import 'package:firka/main.dart'; @@ -246,6 +247,12 @@ class _HomeScreenState extends FirkaState { prefetch(); _preloadImages(); + + if (Platform.isIOS) { + Future.delayed(Duration(seconds: 2), () async { + await LiveActivityService.showConsentScreenIfNeeded(); + }); + } } void settingsUpdateListener() { diff --git a/firka/lib/ui/phone/screens/live_activity/full_privacy_policy_screen.dart b/firka/lib/ui/phone/screens/live_activity/full_privacy_policy_screen.dart new file mode 100644 index 0000000..baef8d2 --- /dev/null +++ b/firka/lib/ui/phone/screens/live_activity/full_privacy_policy_screen.dart @@ -0,0 +1,130 @@ +import 'package:firka/main.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:firka/ui/widget/firka_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:majesticons_flutter/majesticons_flutter.dart'; + +import '../../../../helpers/firka_state.dart'; + +class FullPrivacyPolicyScreen extends StatefulWidget { + final AppInitialization data; + + const FullPrivacyPolicyScreen({required this.data, super.key}); + + @override + State createState() => _FullPrivacyPolicyScreenState(); +} + +class _FullPrivacyPolicyScreenState extends FirkaState { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: appStyle.colors.background, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(20.0), + child: Row( + children: [ + GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: FirkaIconWidget( + FirkaIconType.majesticons, + Majesticon.chevronLeftLine, + color: appStyle.colors.textSecondary, + ), + ), + SizedBox(width: 8), + Expanded( + child: Text( + widget.data.l10n.la_privacy_header, + style: appStyle.fonts.H_H2 + .apply(color: appStyle.colors.textPrimary), + ), + ), + ], + ), + ), + + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.data.l10n.la_privacy_intro, + style: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textSecondary), + ), + SizedBox(height: 24), + _buildPrivacySection( + widget.data.l10n.la_privacy_section1_title, + widget.data.l10n.la_privacy_section1_body, + ), + _buildPrivacySection( + widget.data.l10n.la_privacy_section2_title, + widget.data.l10n.la_privacy_section2_body, + ), + _buildPrivacySection( + widget.data.l10n.la_privacy_section3_title, + widget.data.l10n.la_privacy_section3_body, + ), + _buildPrivacySection( + widget.data.l10n.la_privacy_section4_title, + widget.data.l10n.la_privacy_section4_body, + ), + _buildPrivacySection( + widget.data.l10n.la_privacy_section5_title, + widget.data.l10n.la_privacy_section5_body, + ), + SizedBox(height: 16), + Text( + widget.data.l10n.la_privacy_footer, + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.textTertiary), + ), + SizedBox(height: 8), + Text( + widget.data.l10n.la_privacy_contact, + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.textTertiary), + ), + SizedBox(height: 24), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPrivacySection(String title, String body) { + return Padding( + padding: const EdgeInsets.only(bottom: 20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: + appStyle.fonts.H_16px.apply(color: appStyle.colors.textPrimary), + ), + SizedBox(height: 8), + Text( + body, + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textSecondary), + ), + ], + ), + ); + } +} diff --git a/firka/lib/ui/phone/screens/live_activity/live_activity_consent_screen.dart b/firka/lib/ui/phone/screens/live_activity/live_activity_consent_screen.dart new file mode 100644 index 0000000..fe97917 --- /dev/null +++ b/firka/lib/ui/phone/screens/live_activity/live_activity_consent_screen.dart @@ -0,0 +1,248 @@ +import 'dart:typed_data'; + +import 'package:firka/helpers/ui/firka_button.dart'; +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/main.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:firka/ui/phone/screens/live_activity/full_privacy_policy_screen.dart'; +import 'package:firka/ui/widget/firka_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:majesticons_flutter/majesticons_flutter.dart'; + +import '../../../../helpers/firka_state.dart'; + +class LiveActivityConsentScreen extends StatefulWidget { + final AppInitialization data; + + const LiveActivityConsentScreen({ + required this.data, + super.key, + }); + + @override + State createState() => + _LiveActivityConsentScreenState(); +} + +class _LiveActivityConsentScreenState + extends FirkaState { + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: appStyle.colors.background, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FirkaIconWidget( + FirkaIconType.majesticons, + Majesticon.bellSolid, + color: appStyle.colors.accent, + size: 32, + ), + SizedBox(width: 12), + Expanded( + child: Text( + widget.data.l10n.la_title, + style: appStyle.fonts.H_H1 + .apply(color: appStyle.colors.textPrimary), + ), + ), + ], + ), + SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: Text( + widget.data.l10n.la_subtitle, + style: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textSecondary), + maxLines: null, + softWrap: true, + ), + ), + SizedBox(height: 24), + + Card( + color: appStyle.colors.warningCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FirkaIconWidget( + FirkaIconType.majesticons, + Majesticon.shieldSolid, + color: appStyle.colors.warningAccent, + size: 24, + ), + SizedBox(width: 8), + Expanded( + child: Text( + widget.data.l10n.la_privacy_title, + style: appStyle.fonts.H_16px + .apply(color: appStyle.colors.warningText), + ), + ), + ], + ), + SizedBox(height: 8), + Text( + widget.data.l10n.la_privacy_required, + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.warningText), + softWrap: true, + ), + ], + ), + ), + ), + SizedBox(height: 16), + + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.data.l10n.la_privacy_intro, + style: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textPrimary), + ), + SizedBox(height: 16), + _buildPrivacySummaryItem( + icon: Majesticon.editPen4Solid, + title: widget.data.l10n.la_privacy_section1_title, + description: widget.data.l10n.la_privacy_summary1, + ), + _buildPrivacySummaryItem( + icon: Majesticon.lockSolid, + title: widget.data.l10n.la_privacy_section2_title, + description: widget.data.l10n.la_privacy_summary2, + ), + _buildPrivacySummaryItem( + icon: Majesticon.clockSolid, + title: widget.data.l10n.la_privacy_section3_title, + description: widget.data.l10n.la_privacy_summary3, + ), + SizedBox(height: 16), + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + FullPrivacyPolicyScreen(data: widget.data))); + }, + child: FirkaCard( + left: [ + Text( + widget.data.l10n.la_learn_more, + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.accent), + ), + ], + right: [ + FirkaIconWidget( + FirkaIconType.majesticons, + Majesticon.chevronRightLine, + color: appStyle.colors.accent, + ), + ], + ), + ), + ], + ), + ), + ), + + SizedBox(height: 16), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: GestureDetector( + onTap: () { + Navigator.pop(context, false); + }, + child: FirkaButton( + text: widget.data.l10n.la_decline, + bgColor: appStyle.colors.buttonSecondaryFill, + fontStyle: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textSecondary), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: () { + Navigator.pop(context, true); + }, + child: FirkaButton( + text: widget.data.l10n.la_accept, + bgColor: appStyle.colors.accent, + fontStyle: appStyle.fonts.B_16R.apply( + color: appStyle.colors.textSecondaryLight), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildPrivacySummaryItem({ + required Uint8List icon, + required String title, + required String description, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FirkaIconWidget( + FirkaIconType.majesticons, + icon, + color: appStyle.colors.accent, + size: 20, + ), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: appStyle.fonts.B_14SB + .apply(color: appStyle.colors.textPrimary), + ), + SizedBox(height: 4), + Text( + description, + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textSecondary), + ), + ], + ), + ), + ], + ), + ); + } +}