1
0
forked from firka/firka

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.
This commit is contained in:
Horváth Gergely
2025-11-24 04:43:14 +01:00
committed by 4831c0
parent c21ff3e15f
commit 768d0904a8
6 changed files with 498 additions and 19 deletions

View File

@@ -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<void> handleEnabledChange(bool enabled) async {
/// Called from settings toggle callback
static Future<void> 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<void> 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<bool?> _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<void> onTokenExpired() async {
if (!Platform.isIOS) return;

View File

@@ -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,

View File

@@ -78,6 +78,7 @@ class AppInitialization {
UpdateNotifier settingsUpdateNotifier = UpdateNotifier();
UpdateNotifier profilePictureUpdateNotifier = UpdateNotifier();
AppLocalizations l10n;
final GlobalKey<NavigatorState> 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<void> 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<AppInitialization> initializeApp() async {
tokens: tokens,
settings: SettingsStore(AppLocalizationsHu()),
l10n: AppLocalizationsHu(),
navigatorKey: navigatorKey,
);
if (Platform.isIOS) {

View File

@@ -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<HomeScreen> {
prefetch();
_preloadImages();
if (Platform.isIOS) {
Future.delayed(Duration(seconds: 2), () async {
await LiveActivityService.showConsentScreenIfNeeded();
});
}
}
void settingsUpdateListener() {

View File

@@ -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<FullPrivacyPolicyScreen> createState() => _FullPrivacyPolicyScreenState();
}
class _FullPrivacyPolicyScreenState extends FirkaState<FullPrivacyPolicyScreen> {
@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),
),
],
),
);
}
}

View File

@@ -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<LiveActivityConsentScreen> createState() =>
_LiveActivityConsentScreenState();
}
class _LiveActivityConsentScreenState
extends FirkaState<LiveActivityConsentScreen> {
@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),
),
],
),
),
],
),
);
}
}