diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1d878aa..9fed0ee 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,6 +14,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -22,7 +23,7 @@ android { defaultConfig { applicationId = "com.example.shooter_app" - minSdk = 26 // ✅ From earlier fix + minSdk = 26 targetSdk = 33 versionCode = flutter.versionCode versionName = flutter.versionName @@ -31,12 +32,18 @@ android { buildTypes { release { signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = false + isShrinkResources = false } } } +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} + flutter { source = "../.." } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 494833b..86e922f 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -16,3 +16,13 @@ # Keep your YOLO model loader class if needed -keep class your.package.name.YOLODetect { *; } + +# Flutter Local Notifications +-keep class com.dexterous.** { *; } +-dontwarn com.dexterous.** + +# Timezone +-keep class com.jakewharton.threetenabp.** { *; } +-dontwarn com.jakewharton.threetenabp.** +-keep class org.threeten.** { *; } +-dontwarn org.threeten.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ef27da9..c3fadf5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index a439442..11662c3 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false } include(":app") diff --git a/lib/main.dart b/lib/main.dart index 6b7c050..2893833 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; // Import for SystemChrome import 'screens/home_screen.dart'; +import 'services/notification_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + try { + await NotificationService().initialize(); + } catch (_) {} // Lock orientation to portrait only await SystemChrome.setPreferredOrientations([ diff --git a/lib/screens/calendar_screen.dart b/lib/screens/calendar_screen.dart index f09af2c..e25db66 100644 --- a/lib/screens/calendar_screen.dart +++ b/lib/screens/calendar_screen.dart @@ -5,6 +5,7 @@ import 'package:intl/date_symbol_data_local.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/google_calendar_service.dart'; +import '../services/notification_service.dart'; import '../utils/translations.dart'; import '../utils/target_colors.dart'; @@ -234,6 +235,7 @@ class _CalendarScreenState extends State { _events = filteredEvents; }); } + NotificationService().scheduleEventNotifications(filteredEvents); } catch (e) { if (mounted) { setState(() { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d512163..c6ba113 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -133,7 +133,7 @@ class _HomeScreenState extends State { padding: const EdgeInsets.only(right: 16.0), child: Center( child: Text( - 'V1.0.3', + 'V1.1', style: TextStyle( color: Colors.grey[400], fontSize: 12, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bf00e2b..e9995ff 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../utils/translations.dart'; import '../utils/target_colors.dart'; +import '../services/notification_service.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -43,6 +44,13 @@ class _SettingsScreenState extends State { 'Grey', ]; + int _notificationHours = 1; + final List _notificationHourOptions = [1, 2, 3, 6, 12, 24, 48]; + + bool _notifyDezuranje = true; + bool _notifySestanek = true; + bool _notifyTekma = true; + String _selectedLanguage = 'English'; final List> _languages = [ {'code': 'en', 'name': 'English', 'flag': '🇬🇧'}, @@ -96,6 +104,7 @@ class _SettingsScreenState extends State { _loadBulletColor(); _loadLanguage(); _loadSelectedTarget(); + _loadNotificationSettings(); } @@ -205,6 +214,28 @@ String _getTargetDisplayName(String targetPath) { return _t(targetName); } + Future _loadNotificationSettings() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _notificationHours = prefs.getInt('notificationHours_single') ?? 1; + _notifyDezuranje = prefs.getBool('notifyDezuranje') ?? true; + _notifySestanek = prefs.getBool('notifySestanek') ?? true; + _notifyTekma = prefs.getBool('notifyTekma') ?? true; + }); + } + + Future _saveNotificationHours(int hours) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('notificationHours_single', hours); + await prefs.setStringList('notificationHours', [hours.toString()]); + setState(() => _notificationHours = hours); + } + + Future _saveNotifyType(String key, bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(key, value); + } + Future _loadCameraIPs() async { final prefs = await SharedPreferences.getInstance(); @@ -388,6 +419,8 @@ String _getTargetDisplayName(String targetPath) { _buildAppearanceSelector(), const SizedBox(height: 16), + _buildNotificationSettings(), + const SizedBox(height: 16), _buildCameraList(), ], ), @@ -1096,4 +1129,180 @@ void _showLanguagePopup() { ); } + Widget _buildNotifyToggle(String label, bool value, ValueChanged onChanged) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.black87, fontSize: 14)), + Switch( + value: value, + onChanged: onChanged, + activeThumbColor: _getColorFromName(_selectedBulletColor), + ), + ], + ), + ); + } + + Widget _buildNotificationSettings() { + return Container( + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 0), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _t('Notifications'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + GestureDetector( + onTap: () async { + await NotificationService().scheduleTestNotification(); + }, + child: const Icon(Icons.notifications_active_outlined, color: Colors.white, size: 20), + ), + ], + ), + ), + // Hours before picker + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + _t('Event types'), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + _buildNotifyToggle(_t('Dežuranje'), _notifyDezuranje, (v) { + setState(() => _notifyDezuranje = v); + _saveNotifyType('notifyDezuranje', v); + }), + _buildNotifyToggle(_t('Sestanek'), _notifySestanek, (v) { + setState(() => _notifySestanek = v); + _saveNotifyType('notifySestanek', v); + }), + _buildNotifyToggle(_t('Tekma'), _notifyTekma, (v) { + setState(() => _notifyTekma = v); + _saveNotifyType('notifyTekma', v); + }), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _t('Notify before event'), + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.black87), + ), + Text( + _notificationHours == 1 + ? '1 ${_t('hour')}' + : '$_notificationHours ${_t('hours')}', + style: TextStyle( + color: _getColorFromName(_selectedBulletColor), + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Row( + children: _notificationHourOptions.asMap().entries.map((entry) { + final i = entry.key; + final hours = entry.value; + final isSelected = hours == _notificationHours; + final label = '${hours}h'; + final isFirst = i == 0; + final isLast = i == _notificationHourOptions.length - 1; + return Expanded( + child: GestureDetector( + onTap: () => _saveNotificationHours(hours), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? _getColorFromName(_selectedBulletColor) + : Colors.grey[700], + border: Border( + right: isLast + ? BorderSide.none + : BorderSide(color: Colors.grey[600]!, width: 1), + ), + borderRadius: isFirst + ? const BorderRadius.only(topLeft: Radius.circular(10), bottomLeft: Radius.circular(10)) + : isLast + ? const BorderRadius.only(topRight: Radius.circular(10), bottomRight: Radius.circular(10)) + : BorderRadius.zero, + ), + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ], + ), + ); + } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart new file mode 100644 index 0000000..442ddc1 --- /dev/null +++ b/lib/services/notification_service.dart @@ -0,0 +1,169 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:timezone/data/latest.dart' as tz; +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/google_calendar_service.dart'; + +class NotificationService { + static final NotificationService _instance = NotificationService._internal(); + factory NotificationService() => _instance; + NotificationService._internal(); + + final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin(); + bool _initialized = false; + + static const _channel = AndroidNotificationChannel( + 'shooting_app_events', + 'Event Reminders', + description: 'Notifications for upcoming shooting events', + importance: Importance.max, + playSound: true, + enableVibration: true, + ); + + NotificationDetails get _notifDetails => const NotificationDetails( + android: AndroidNotificationDetails( + 'shooting_app_events', + 'Event Reminders', + channelDescription: 'Notifications for upcoming shooting events', + importance: Importance.max, + priority: Priority.high, + icon: '@drawable/ic_stat_icon_white', + ), + iOS: DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ), + ); + + Future initialize() async { + if (_initialized) return; + _initialized = true; // set early to prevent re-entry + + try { + tz.initializeTimeZones(); + final String timezoneName = await FlutterTimezone.getLocalTimezone(); + tz.setLocalLocation(tz.getLocation(timezoneName)); + } catch (_) {} + + try { + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + const initSettings = InitializationSettings(android: androidSettings, iOS: iosSettings); + await _plugin.initialize(initSettings); + + final androidPlugin = _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + await androidPlugin?.createNotificationChannel(_channel); + await requestPermissions(); + } catch (_) {} + } + + Future requestPermissions() async { + final android = _plugin.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + final ios = _plugin.resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>(); + + bool granted = false; + if (android != null) { + granted = await android.requestNotificationsPermission() ?? false; + } + if (ios != null) { + granted = await ios.requestPermissions(alert: true, badge: true, sound: true) ?? false; + } + return granted; + } + + Future cancelAll() async { + await _plugin.cancelAll(); + } + + Future scheduleEventNotifications(List events) async { + try { + await initialize(); + + final prefs = await SharedPreferences.getInstance(); + final hours = prefs.getInt('notificationHours_single') ?? 1; + final notifyDezuranje = prefs.getBool('notifyDezuranje') ?? true; + final notifySestanek = prefs.getBool('notifySestanek') ?? true; + final notifyTekma = prefs.getBool('notifyTekma') ?? true; + final isSlovenian = (prefs.getString('language') ?? 'English') == 'Slovenian'; + final now = DateTime.now(); + + await _plugin.cancelAll(); + + int notifId = 0; + for (final event in events) { + if (event.startTime == null) continue; + + final summary = (event.summary ?? '').toLowerCase(); + final isCompetition = summary.contains('liga') || summary.contains('tekmovanje'); + final isSestanek = summary.contains('sestanek'); + final isDezuranje = !isCompetition && !isSestanek; + + if (isCompetition && !notifyTekma) continue; + if (isSestanek && !notifySestanek) continue; + if (isDezuranje && !notifyDezuranje) continue; + + final notifyAt = event.startTime!.subtract(Duration(hours: hours)); + if (notifyAt.isAfter(now)) { + await _scheduleNotification( + id: notifId++, + title: event.summary ?? 'Event', + body: isSlovenian + ? (hours == 1 ? 'Začne se čez 1 uro' : 'Začne se čez $hours ur') + : (hours == 1 ? 'Starts in 1 hour' : 'Starts in $hours hours'), + scheduledDate: notifyAt, + ); + } + } + } catch (_) {} + } + + Future scheduleTestNotification() async { + try { + await initialize(); + final prefs = await SharedPreferences.getInstance(); + final isSlovenian = (prefs.getString('language') ?? 'English') == 'Slovenian'; + await _plugin.show( + 9999, + isSlovenian ? 'Testno obvestilo' : 'Test Notification', + isSlovenian ? 'Obvestila delujejo!' : 'Notifications are working!', + _notifDetails, + ); + final in10s = DateTime.now().add(const Duration(seconds: 10)); + await _scheduleNotification( + id: 9998, + title: isSlovenian ? 'Načrtovano testno obvestilo' : 'Scheduled Test', + body: isSlovenian ? 'Načrtovana obvestila delujejo!' : 'Scheduled notifications are working!', + scheduledDate: in10s, + ); + } catch (_) {} + } + + Future _scheduleNotification({ + required int id, + required String title, + required String body, + required DateTime scheduledDate, + }) async { + final tzDate = tz.TZDateTime.from(scheduledDate, tz.local); + await _plugin.zonedSchedule( + id, + title, + body, + tzDate, + _notifDetails, + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + ); + } +} diff --git a/lib/utils/translations.dart b/lib/utils/translations.dart index 6664f9f..111aed2 100644 --- a/lib/utils/translations.dart +++ b/lib/utils/translations.dart @@ -846,6 +846,56 @@ class AppTranslations { 'English': 'Points', 'Slovenian': 'Točke', }, + + // Notifications + 'Notifications': { + 'English': 'Notifications', + 'Slovenian': 'Obvestila', + }, + 'Event Reminders': { + 'English': 'Event Reminders', + 'Slovenian': 'Opomniki za dogodke', + }, + 'Notify before event': { + 'English': 'Notify before event', + 'Slovenian': 'Obvesti pred dogodkom', + }, + 'Hours before': { + 'English': 'Hours before', + 'Slovenian': 'Ur pred dogodkom', + }, + 'hour': { + 'English': 'hour', + 'Slovenian': 'ura', + }, + 'hours': { + 'English': 'hours', + 'Slovenian': 'ur', + }, + 'Notifications enabled': { + 'English': 'Notifications enabled', + 'Slovenian': 'Obvestila vključena', + }, + 'Notifications disabled': { + 'English': 'Notifications disabled', + 'Slovenian': 'Obvestila izključena', + }, + 'Event types': { + 'English': 'Event types', + 'Slovenian': 'Vrste dogodkov', + }, + 'Dežuranje': { + 'English': 'Duty shifts', + 'Slovenian': 'Dežuranje', + }, + 'Sestanek': { + 'English': 'Meetings', + 'Slovenian': 'Sestanki', + }, + 'Tekma': { + 'English': 'Competitions', + 'Slovenian': 'Tekme', + }, }; static String translate(String key, String language) { diff --git a/pubspec.lock b/pubspec.lock index 2e884c5..892f3e9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -246,6 +246,30 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" flutter_mjpeg: dependency: "direct main" description: @@ -275,6 +299,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: ea53c61c9152f271a5e30624a624184804947b6a733ff2b64186bb2579446892 + url: "https://pub.dev" + source: hosted + version: "3.0.1" flutter_web_plugins: dependency: transitive description: flutter @@ -484,18 +516,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -777,10 +809,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" tflite_flutter: dependency: "direct main" description: @@ -789,6 +821,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.0" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" typed_data: dependency: transitive description: @@ -998,5 +1038,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0-0 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index c966c98..cca3bd4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: test1 description: A new Flutter project. publish_to: 'none' # This prevents publishing to pub.dev -version: 1.0.3+3 +version: 1.1.0+4 environment: sdk: '>=3.3.0 <4.0.0' @@ -35,6 +35,9 @@ dependencies: image_picker: ^1.0.4 wakelock_plus: ^1.2.11 url_launcher: ^6.1.0 + flutter_local_notifications: ^18.0.1 + timezone: ^0.9.4 + flutter_timezone: ^3.0.0 dependency_overrides: http: ^0.13.6