Notifications added to the app -> updated version to V1.1.0

This commit is contained in:
2026-05-30 18:00:30 +02:00
parent c58ca5cf15
commit cd3398df99
18 changed files with 559 additions and 13 deletions
+4
View File
@@ -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([
+2
View File
@@ -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<CalendarScreen> {
_events = filteredEvents;
});
}
NotificationService().scheduleEventNotifications(filteredEvents);
} catch (e) {
if (mounted) {
setState(() {
+1 -1
View File
@@ -133,7 +133,7 @@ class _HomeScreenState extends State<HomeScreen> {
padding: const EdgeInsets.only(right: 16.0),
child: Center(
child: Text(
'V1.0.3',
'V1.1',
style: TextStyle(
color: Colors.grey[400],
fontSize: 12,
+209
View File
@@ -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<SettingsScreen> {
'Grey',
];
int _notificationHours = 1;
final List<int> _notificationHourOptions = [1, 2, 3, 6, 12, 24, 48];
bool _notifyDezuranje = true;
bool _notifySestanek = true;
bool _notifyTekma = true;
String _selectedLanguage = 'English';
final List<Map<String, String>> _languages = [
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
@@ -96,6 +104,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadBulletColor();
_loadLanguage();
_loadSelectedTarget();
_loadNotificationSettings();
}
@@ -205,6 +214,28 @@ String _getTargetDisplayName(String targetPath) {
return _t(targetName);
}
Future<void> _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<void> _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<void> _saveNotifyType(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, value);
}
Future<void> _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<bool> 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(),
),
),
],
),
),
],
),
);
}
}
+169
View File
@@ -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<void> 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<bool> 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<void> cancelAll() async {
await _plugin.cancelAll();
}
Future<void> scheduleEventNotifications(List<CalendarEvent> 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<void> 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<void> _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,
);
}
}
+50
View File
@@ -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) {