diff --git a/firka/lib/helpers/live_activity_service.dart b/firka/lib/helpers/live_activity_service.dart index 51bb930a..4fe0f398 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 1754e184..e44f20ee 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 38158001..f32b8f7c 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 e8da3217..19837ef2 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 00000000..baef8d2a --- /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 00000000..fe979170 --- /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), + ), + ], + ), + ), + ], + ), + ); + } +}