From 3caa3e676e5267095c44075184e7d44759b30e2a Mon Sep 17 00:00:00 2001 From: "r.mubarakzyanov" Date: Sat, 15 Aug 2026 12:00:28 +0300 Subject: [PATCH] push notifications --- android/app/build.gradle.kts | 10 ++ android/app/src/debug/AndroidManifest.xml | 5 + android/app/src/main/AndroidManifest.xml | 4 + android/settings.gradle.kts | 2 + lib/main.dart | 4 + lib/screens/account_page.dart | 86 ++++++++++ lib/screens/login_page.dart | 5 + lib/services/push_api.dart | 92 ++++++++++ lib/services/push_service.dart | 162 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 148 +++++++++++++++- pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 14 files changed, 526 insertions(+), 6 deletions(-) create mode 100644 lib/services/push_api.dart create mode 100644 lib/services/push_service.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 360feb0..a783471 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,6 +3,8 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + // Firebase Cloud Messaging: legge android/app/google-services.json. + id("com.google.gms.google-services") } android { @@ -11,6 +13,9 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + // Richiesto da flutter_local_notifications (notifiche mostrate quando + // l'app e' aperta). + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -42,3 +47,8 @@ android { flutter { source = "../.." } + +dependencies { + // Libreria di supporto richiesta dal desugaring (flutter_local_notifications). + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..21b3144 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,9 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0deed65..9884219 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + main() async { WidgetsFlutterBinding.ensureInitialized(); await initializeDateFormatting('it_IT'); + + await PushService.initFirebase(); + await PushService.enableForegroundNotifications(); runApp( ChangeNotifierProvider(create: (_) => AppState(), child: const MyApp()), ); diff --git a/lib/screens/account_page.dart b/lib/screens/account_page.dart index 1e55583..c138d4e 100644 --- a/lib/screens/account_page.dart +++ b/lib/screens/account_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../models/school.dart'; +import '../services/push_api.dart'; import '../services/vanguard_api.dart'; import '../widgets/yogibook_background.dart'; @@ -41,12 +42,54 @@ class _AccountPageState extends State { bool _obscurePw = true; bool _obscurePw2 = true; + bool? _pushEnabled; + bool _savingPush = false; + final _picker = ImagePicker(); @override void initState() { super.initState(); _loadProfile(); + _loadPushPreference(); + } + + Future _loadPushPreference() async { + try { + final attive = await PushApi.fetchPreference(token: widget.token); + if (!mounted) return; + setState(() => _pushEnabled = attive); + } catch (_) { + if (!mounted) return; + setState(() => _pushEnabled = null); + } + } + + Future _togglePush(bool valore) async { + final precedente = _pushEnabled; + + setState(() { + _pushEnabled = valore; + _savingPush = true; + }); + + try { + final salvato = await PushApi.updatePreference( + token: widget.token, + enabled: valore, + ); + if (!mounted) return; + setState(() => _pushEnabled = salvato); + _snack( + salvato ? 'Notifiche attivate.' : 'Notifiche disattivate.', + ); + } catch (e) { + if (!mounted) return; + setState(() => _pushEnabled = precedente); + _snack('Errore nel salvataggio: $e'); + } finally { + if (mounted) setState(() => _savingPush = false); + } } @override @@ -252,6 +295,8 @@ class _AccountPageState extends State { const SizedBox(height: 20), _profileCard(), const SizedBox(height: 16), + _notificationsCard(), + const SizedBox(height: 16), _passwordCard(), const SizedBox(height: 8), ], @@ -409,6 +454,47 @@ class _AccountPageState extends State { ); } + Widget _notificationsCard() { + if (_pushEnabled == null) { + return _card( + title: 'Notifiche', + children: const [ + Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Expanded(child: Text('Carico le impostazioni...')), + ], + ), + ], + ); + } + + return _card( + title: 'Notifiche', + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + activeThumbColor: kGreen, + value: _pushEnabled!, + onChanged: _savingPush ? null : _togglePush, + title: const Text( + 'Notifiche push', + style: TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: const Text( + 'Promemoria delle lezioni del giorno, scadenza abbonamento e ' + 'certificato medico.', + ), + ), + ], + ); + } + Widget _passwordCard() { return _card( title: 'Cambia password', diff --git a/lib/screens/login_page.dart b/lib/screens/login_page.dart index c9aa2a3..c7524c8 100644 --- a/lib/screens/login_page.dart +++ b/lib/screens/login_page.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import '../services/push_service.dart'; import '../services/vanguard_api.dart'; import '../models/school.dart'; import 'home_page.dart'; @@ -47,6 +50,8 @@ class _LoginPageState extends State { password: passwordController.text.trim(), ); + unawaited(PushService.registerAfterLogin(token)); + if (!mounted) return; // Bypass SelectSchoolPage: scuola unica, si va dritti alla Home. diff --git a/lib/services/push_api.dart b/lib/services/push_api.dart new file mode 100644 index 0000000..d8d8564 --- /dev/null +++ b/lib/services/push_api.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../config/api_config.dart'; + +class PushApi { + static Map _headers(String token) => { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }; + + static Future registerDevice({ + required String token, + required String deviceToken, + required String platform, + String? appVersion, + }) async { + final uri = Uri.parse('${ApiConfig.laravelApiBase}/register_device.php'); + + final res = await http.post( + uri, + headers: _headers(token), + body: jsonEncode({ + 'device_token': deviceToken, + 'platform': platform, + if (appVersion != null) 'app_version': appVersion, + }), + ); + + final data = _decode(res.body, res.statusCode); + + return (data['push_enabled'] ?? 'Y').toString(); + } + + static Future unregisterDevice({ + required String token, + required String deviceToken, + }) async { + final uri = Uri.parse('${ApiConfig.laravelApiBase}/register_device.php'); + + final res = await http.delete( + uri, + headers: _headers(token), + body: jsonEncode({'device_token': deviceToken}), + ); + + _decode(res.body, res.statusCode); + } + + static Future fetchPreference({required String token}) async { + final uri = Uri.parse('${ApiConfig.laravelApiBase}/push_preference.php'); + + final res = await http.get(uri, headers: _headers(token)); + final data = _decode(res.body, res.statusCode); + + return (data['push_enabled'] ?? 'Y').toString().toUpperCase() == 'Y'; + } + + static Future updatePreference({ + required String token, + required bool enabled, + }) async { + final uri = Uri.parse('${ApiConfig.laravelApiBase}/push_preference.php'); + + final res = await http.post( + uri, + headers: _headers(token), + body: jsonEncode({'enabled': enabled}), + ); + + final data = _decode(res.body, res.statusCode); + + return (data['push_enabled'] ?? 'Y').toString().toUpperCase() == 'Y'; + } + + static Map _decode(String body, int statusCode) { + Map data; + + try { + data = jsonDecode(body) as Map; + } catch (_) { + throw Exception('Risposta non valida ($statusCode): $body'); + } + + if (statusCode != 200 || data['success'] != true) { + throw Exception(data['error'] ?? data['message'] ?? 'Errore notifiche'); + } + + return data; + } +} diff --git a/lib/services/push_service.dart b/lib/services/push_service.dart new file mode 100644 index 0000000..f84b3f6 --- /dev/null +++ b/lib/services/push_service.dart @@ -0,0 +1,162 @@ +import 'dart:io' show Platform; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import 'push_api.dart'; + +class PushService { + static String? _deviceToken; + + static bool _firebaseReady = false; + + static final FlutterLocalNotificationsPlugin _locali = + FlutterLocalNotificationsPlugin(); + + static const AndroidNotificationChannel _canale = AndroidNotificationChannel( + 'yogibook_notifiche', + 'Notifiche YogiBook', + description: 'Promemoria delle lezioni, scadenze abbonamento e certificato', + importance: Importance.high, + ); + + static Future initFirebase() async { + if (_firebaseReady) return; + + try { + await Firebase.initializeApp(); + FirebaseMessaging.onBackgroundMessage(_backgroundHandler); + await _preparaNotificheLocali(); + _firebaseReady = true; + } catch (e) { + debugPrint('Firebase non inizializzato: $e'); + } + } + + static Future _preparaNotificheLocali() async { + const impostazioni = InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ), + ); + + await _locali.initialize(settings: impostazioni); + + await _locali + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(_canale); + + FirebaseMessaging.onMessage.listen(_mostraInPrimoPiano); + } + + static Future _mostraInPrimoPiano(RemoteMessage messaggio) async { + if (kIsWeb || !Platform.isAndroid) return; + + final notifica = messaggio.notification; + if (notifica == null) return; + + await _locali.show( + id: notifica.hashCode, + title: notifica.title, + body: notifica.body, + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + _canale.id, + _canale.name, + channelDescription: _canale.description, + importance: Importance.high, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + ), + ), + ); + } + + static Future registerAfterLogin(String authToken) async { + if (!_firebaseReady) return; + + try { + final messaging = FirebaseMessaging.instance; + + final settings = await messaging.requestPermission(); + + if (settings.authorizationStatus == AuthorizationStatus.denied) { + debugPrint('Permesso notifiche negato dall\'utente.'); + return; + } + + final deviceToken = await messaging.getToken(); + if (deviceToken == null) return; + + _deviceToken = deviceToken; + + await PushApi.registerDevice( + token: authToken, + deviceToken: deviceToken, + platform: _platformName(), + ); + + messaging.onTokenRefresh.listen((nuovo) async { + _deviceToken = nuovo; + try { + await PushApi.registerDevice( + token: authToken, + deviceToken: nuovo, + platform: _platformName(), + ); + } catch (e) { + debugPrint('Aggiornamento token fallito: $e'); + } + }); + } catch (e) { + debugPrint('Registrazione push fallita: $e'); + } + } + + static Future unregisterOnLogout(String authToken) async { + final deviceToken = _deviceToken; + if (deviceToken == null) return; + + try { + await PushApi.unregisterDevice( + token: authToken, + deviceToken: deviceToken, + ); + _deviceToken = null; + } catch (e) { + debugPrint('Disattivazione dispositivo fallita: $e'); + } + } + + static Future enableForegroundNotifications() async { + if (!_firebaseReady) return; + + try { + await FirebaseMessaging.instance + .setForegroundNotificationPresentationOptions( + alert: true, + badge: true, + sound: true, + ); + } catch (e) { + debugPrint('Impostazione notifiche in foreground fallita: $e'); + } + } + + static String _platformName() { + if (kIsWeb) return 'web'; + return Platform.isIOS ? 'ios' : 'android'; + } +} + +@pragma('vm:entry-point') +Future _backgroundHandler(RemoteMessage message) async { + debugPrint('Notifica in background: ${message.messageId}'); +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 793a31b..ec7651b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,9 @@ import Foundation import audioplayers_darwin import file_picker import file_selector_macos +import firebase_core +import firebase_messaging +import flutter_local_notifications import google_sign_in_ios import path_provider_foundation import shared_preferences_foundation @@ -17,6 +20,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 2f36b05..097e505 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "6727cf2ced9b104abca9daa278380be2eca2b98ce33d4b46f11708e387dc6b4d" + url: "https://pub.dev" + source: hosted + version: "1.3.76" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -121,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91" + url: "https://pub.dev" + source: hosted + version: "0.7.13" fake_async: dependency: transitive description: @@ -185,6 +209,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "9478ca6700c02d315c6aba37e206e612317f98bb4f335bb1cbd6e0ce67dcf764" + url: "https://pub.dev" + source: hosted + version: "4.13.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: e28f9afdcb5b0f0a8ea74ea3b322f5a7592c81cb45dd9d189913bdae08a2089a + url: "https://pub.dev" + source: hosted + version: "8.1.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: f471a288b0101a45567548322ac4a5ad31e3ecbf87a576dcc0e424e6a11e04ea + url: "https://pub.dev" + source: hosted + version: "3.10.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "55cf1000dd72d229ebff3ce6a399bd35705a7675275478b31308afd90ac85751" + url: "https://pub.dev" + source: hosted + version: "16.5.0" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: e5fbb62bd23a27c983825277877ac91fdfdb6bfb31c6e5399700963ae586e339 + url: "https://pub.dev" + source: hosted + version: "4.9.3" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "663d320e90a41fe57d651ec79df2bc831bf29e051bc0c2cd41faf3106eb412d9" + url: "https://pub.dev" + source: hosted + version: "4.2.4" fixnum: dependency: transitive description: @@ -206,6 +278,46 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" + url: "https://pub.dev" + source: hosted + version: "22.3.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b" + url: "https://pub.dev" + source: hosted + version: "8.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" + url: "https://pub.dev" + source: hosted + version: "12.2.0" + flutter_local_notifications_web: + dependency: transitive + description: + name: flutter_local_notifications_web + sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "6f43bdd03b171b7a90f22647506fea33e2bb12294b7c7c7a3d690e960a382945" + url: "https://pub.dev" + source: hosted + version: "3.1.1" flutter_localizations: dependency: "direct main" description: flutter @@ -493,6 +605,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -630,10 +750,18 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.12" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: @@ -718,10 +846,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -754,6 +882,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" sdks: - dart: ">=3.10.4 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index e746574..4f62b50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,9 @@ dependencies: mime: ^2.0.0 provider: ^6.1.5+1 shared_preferences: ^2.2.3 + firebase_core: ^4.13.0 + firebase_messaging: ^16.5.0 + flutter_local_notifications: ^22.3.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 6e77110..cb386cc 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 804cebd..8035f8b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,10 +5,12 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows + firebase_core url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows ) set(PLUGIN_BUNDLED_LIBRARIES)