import 'dart:io'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/timezone.dart' as tz; import 'package:http/http.dart' as http; import 'package:alarm/alarm.dart'; import 'dart:convert'; // For trusting Let's Encrypt on older devices (SDK 24, Android 7) //import 'dart:io'; //import 'package:http/http.dart' as http; import 'package:flutter/services.dart' show rootBundle; /// TBD: /// - Add other languages (Spanish, Croatian) /// v1.1.0 /// - Move the switchtile back to its own row so that it handles titles better. /// v1.2.0 /// - Change sunset grabber to checkbox and retrieve it each alarm or open. /// v2.0.0 /// - Add sunrise section and change app name to Sunshine Alarm? /// :TBD void main() async { WidgetsFlutterBinding.ensureInitialized(); final certData = await rootBundle.load( 'media/certs/open-meteo-com_isrg-root-x1.pem', ); SecurityContext.defaultContext.setTrustedCertificatesBytes( certData.buffer.asUint8List(), ); tz.initializeTimeZones(); await Alarm.init(); runApp(const SunsetAlarmApp()); } const ANDROID_USE_NOTIF = false; const ANDROID_USE_ALARM = !ANDROID_USE_NOTIF; class SunsetAlarmApp extends StatelessWidget { const SunsetAlarmApp({super.key}); @override Widget build(BuildContext context) { const seedColor = Colors.deepOrange; return MaterialApp( title: 'Sunset Alarm', theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: seedColor, brightness: Brightness.light, ), ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: seedColor, brightness: Brightness.dark, ), ), themeMode: ThemeMode.system, home: const HomeScreen(), ); } } class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { final _latController = TextEditingController(); final _lngController = TextEditingController(); final _sunsetController = TextEditingController(text: "18:00"); final _minutesController = TextEditingController(text: "30"); final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); bool _loading = false; bool _alarmEnabled = false; int _minutesBefore = 30; String _plural = 's'; String? _sunsetErrorText; String? _minuteErrorText; bool _validTime = false; static const _alarmId = 1001; @override void initState() { super.initState(); _initNotifications(); _loadPrefs(); } Future _initNotifications() async { const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher'); const iosInit = DarwinInitializationSettings(); const linuxInit = LinuxInitializationSettings( defaultActionName: 'Sunset Alarm', ); await _notifications.initialize( settings: const InitializationSettings( android: androidInit, iOS: iosInit, linux: linuxInit, ), ); } Future _loadPrefs() async { final prefs = await SharedPreferences.getInstance(); setState(() { final lat = prefs.getDouble('lat').toString(); final lng = prefs.getDouble('lng').toString(); final sun = prefs.getString('sunset_time'); _latController.text = (lat == "null") ? "" : lat; _lngController.text = (lng == "null") ? "" : lng; _sunsetController.text = sun ?? ""; _minutesBefore = prefs.getInt('minutes_before') ?? 30; _minutesController.text = _minutesBefore.toString(); _alarmEnabled = prefs.getBool('alarm_enabled') ?? false; _validTime = prefs.getBool('valid_time') ?? false; _calcPlural(); }); if (_alarmEnabled && _sunsetController.text.isNotEmpty) { _scheduleAlarm(); } } Future _savePrefs() async { final prefs = await SharedPreferences.getInstance(); final lat = double.tryParse(_latController.text); final lng = double.tryParse(_lngController.text); if (lat != null) await prefs.setDouble('lat', lat); if (lng != null) await prefs.setDouble('lng', lng); if (_sunsetController.text.isNotEmpty) { await prefs.setString('sunset_time', _sunsetController.text); } await prefs.setBool('alarm_enabled', _alarmEnabled); await prefs.setInt('minutes_before', _minutesBefore); await prefs.setBool('valid_time', _validTime); } Future _getGpsLocation() async { try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { _showSnack('Location Services Disabled'); return; } LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { _showSnack('Location Permission Denied'); return; } } final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, ); setState(() { _latController.text = position.latitude.toString(); _lngController.text = position.longitude.toString(); }); await _savePrefs(); _showSnack('Location Updated.'); } catch (e) { _showSnack('GPS Error: $e'); } } Future _fetchSunset() async { final lat = double.tryParse(_latController.text); final lng = double.tryParse(_lngController.text); if (lat == null || lng == null) { _showSnack('Enter Valid Latitude & Longitude'); return; } setState(() => _loading = true); try { final url = Uri.parse( 'https://api.open-meteo.com/v1/forecast' '?latitude=$lat&longitude=$lng' '&daily=sunset&timezone=auto&forecast_days=1', ); final response = await http.get(url); if (response.statusCode == 200) { final data = jsonDecode(response.body); final sunsetStr = data['daily']['sunset'][0] as String; final timePart = sunsetStr.split('T')[1]; setState(() => _sunsetController.text = timePart); await _savePrefs(); _showSnack('Sunset Time Retrieved: $timePart'); } else { _showSnack('API Error: ${response.statusCode}'); } } catch (e) { _showSnack('Network Error: $e'); } finally { setState(() { _loading = false; _validTime = true; }); } if (_alarmEnabled) { _scheduleAlarm(); } } Timer? _alarmTimer; Future _scheduleAlarm() async { final sunsetText = _sunsetController.text.trim(); if (sunsetText.isEmpty) return; final now = DateTime.now(); final parts = sunsetText.split(':'); if (parts.length < 2) return; final h = int.parse(parts[0]); final m = int.parse(parts[1]); var alarmTime = DateTime(now.year, now.month, now.day, h, m); if (alarmTime.isBefore(now)) { alarmTime = alarmTime.add(const Duration(days: 1)); } alarmTime = alarmTime.subtract(Duration(minutes: _minutesBefore)); if (alarmTime.isBefore(now)) { _showSnack('Alarm Time In Past - Will Start Tomorrow'); alarmTime = alarmTime.add(const Duration(days: 1)); } if (Platform.isLinux) { // Linux: use a Timer (app must stay open) _alarmTimer?.cancel(); final delay = alarmTime.difference(now); _alarmTimer = Timer(delay, () { const linuxDetails = LinuxNotificationDetails( urgency: LinuxNotificationUrgency.critical, resident: true, timeout: LinuxNotificationTimeout.expiresNever(), ); _notifications.show( id: _alarmId, title: 'Sunset in ${_minutesBefore} min!', body: 'The sun sets at $sunsetText.', notificationDetails: const NotificationDetails(linux: linuxDetails), ); }); _showSnack( 'Alarm Enabled @ ${alarmTime.hour.toString().padLeft(2, '0')}:${alarmTime.minute.toString().padLeft(2, '0')} (App Must Stay Open!)', ); } else if (Platform.isAndroid) { // Get the Notifications permission: final android = _notifications .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); await android?.requestNotificationsPermission(); // Ensure that Android has the correct alarm permissions. final canSchedule = await android?.canScheduleExactNotifications(); if (canSchedule != true) { _showSnack('Permissions Need Granted, Requesting...'); await android?.requestExactAlarmsPermission(); } /*** Notification Method - Works ***/ if (ANDROID_USE_NOTIF) { // Android: use zonedSchedule (works in background) final tzAlarm = tz.TZDateTime.from(alarmTime, tz.local); const androidDetails = AndroidNotificationDetails( 'sunset_alarm', 'Sunset Alarm', channelDescription: 'Alarm X minutes before sunset', importance: Importance.max, priority: Priority.high, fullScreenIntent: true, category: AndroidNotificationCategory.alarm, ); await _notifications.zonedSchedule( id: _alarmId, title: 'Sunset in ${_minutesBefore} min', body: 'The sun sets at $sunsetText. Time to get ready!', scheduledDate: tzAlarm, notificationDetails: const NotificationDetails( android: androidDetails, ), androidScheduleMode: AndroidScheduleMode.alarmClock, ); _showSnack('Alarm scheduled!'); debugPrint('DEBUG: Alarm time: ${tzAlarm.toString()}'); debugPrint('DEBUG: Current time: ${DateTime.now().toUtc().toString()}'); debugPrint('DEBUG: ${_notifications.toString()}'); } // End Notification Method ***/ /*** Alarm Method - Works ***/ if (ANDROID_USE_ALARM) { // Schedule: final settings = AlarmSettings( id: _alarmId, dateTime: alarmTime, //assetAudioPath: 'assets/alarm.mp3', // use default rather than custom loopAudio: true, // keeps ringing until dismissed vibrate: true, androidFullScreenIntent: true, // wakes screen like clock app volumeSettings: VolumeSettings.fade( //volume: 0.8, // use default rather than custom fadeDuration: Duration(seconds: 60), ), notificationSettings: NotificationSettings( title: 'Sunset Alarm', body: 'Sun is setting in ${_minutesBefore} minute${_plural}!', stopButton: 'Dismiss', icon: 'ic_notification', ), ); await Alarm.set(alarmSettings: settings); } // End Alarm Method ***/ _showSnack('Alarm Scheduled @ ${alarmTime}'); } else { _showSnack('Platform Not Detected - Contact Developer'); } } Future _toggleAlarm(bool value) async { setState(() => _alarmEnabled = value); await _savePrefs(); if (value) { await _scheduleAlarm(); } else { _stopAlarm(); } } Future _stopAlarm() async { if (Platform.isLinux) { _alarmTimer?.cancel(); } else if (Platform.isAndroid) { // Notification Method if (ANDROID_USE_NOTIF) { await _notifications.cancel(id: _alarmId); } // Alarm Method if (ANDROID_USE_ALARM) { await Alarm.stop(_alarmId); } } _showSnack('Alarm Cancelled'); } void _showSnack(String msg) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } @override void dispose() { _savePrefs(); _latController.dispose(); _lngController.dispose(); _sunsetController.dispose(); _minutesController.dispose(); super.dispose(); } String? _validateTime(String? value) { setState(() { _validTime = false; }); if (value == null || !RegExp(r'^\d{2}:\d{2}$').hasMatch(value)) { return 'Use HH:MM Format'; } final h = int.parse(value.substring(0, 2)); final m = int.parse(value.substring(3, 5)); if (h > 23) return 'Hour Must Be 0–23'; if (m > 59) return 'Minute Must Be 0–59'; setState(() { _validTime = true; }); return null; // valid } void _calcPlural() { setState(() { _plural = (_minutesBefore == 1) ? '' : 's'; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Sunset Alarm')), resizeToAvoidBottomInset: true, // default, but be explicit body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // --- Location section --- const Text( 'Location', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: _latController, keyboardType: const TextInputType.numberWithOptions( signed: true, decimal: true, ), decoration: const InputDecoration( labelText: 'Latitude', border: OutlineInputBorder(), ), onChanged: (_) => _savePrefs(), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: _lngController, keyboardType: const TextInputType.numberWithOptions( signed: true, decimal: true, ), decoration: const InputDecoration( labelText: 'Longitude', border: OutlineInputBorder(), ), onChanged: (_) => _savePrefs(), ), ), const SizedBox(width: 8), IconButton.filledTonal( icon: const Icon(Icons.my_location), tooltip: 'Get GPS Location', onPressed: _getGpsLocation, ), ], ), const SizedBox(height: 16), // --- Sunset time section --- const Text( 'Sunset', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: _sunsetController, decoration: InputDecoration( labelText: 'Sunset Time (HH:MM)', border: OutlineInputBorder(), errorText: _sunsetErrorText, suffixIcon: IconButton( icon: Icon(Icons.access_time), onPressed: () async { final picked = await showTimePicker( context: context, initialTime: TimeOfDay.now(), ); if (picked != null) { _sunsetController.text = '${picked.hour.toString().padLeft(2, '0')}:' '${picked.minute.toString().padLeft(2, '0')}'; setState( () => _sunsetErrorText = _validateTime( _sunsetController.text, ), ); if (_alarmEnabled) { _scheduleAlarm(); } } }, ), ), onChanged: (v) { if (v == "") { _stopAlarm(); } final val = _validateTime(v); if (val == null) { _savePrefs(); if (_alarmEnabled) { _scheduleAlarm(); } } setState(() { _sunsetErrorText = val; }); }, ), ), const SizedBox(width: 8), IconButton.filledTonal( icon: _loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.cloud_done_outlined), tooltip: 'Fetch Open-Meteo Sunset Data', onPressed: () { if (!_loading) { _fetchSunset(); } }, ), ], ), const SizedBox(height: 16), const Text( 'Alert', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), // --- Minutes before --- TextField( controller: _minutesController, keyboardType: TextInputType.number, decoration: InputDecoration( border: OutlineInputBorder(), labelText: 'Minutes Before Sunset', errorText: _minuteErrorText, ), onChanged: (v) { final val = int.tryParse(v); setState(() { if (val != null) { _minuteErrorText = null; _minutesBefore = val; } else { _minuteErrorText = 'Invalid Minute Value'; } _calcPlural(); }); }, ), // --- Toggle --- SwitchListTile( title: Center( child: Text( _validTime ? _alarmEnabled ? "Sunset Alarm Enabled" : "Enable" : "Disabled", ), ), subtitle: Center( child: Text( _validTime ? _alarmEnabled ? "${_minutesBefore} Minute${_plural} Before @ $_alarmDisplayTime" : "Sunset Alarm" : "Enter Valid Time", ), ), value: _validTime ? _alarmEnabled : false, onChanged: _validTime ? _toggleAlarm : null, ), ], ), ), ), ); } String get _alarmDisplayTime { final sunsetText = _sunsetController.text.trim(); if (sunsetText.isEmpty) return '—'; final parts = sunsetText.split(':'); if (parts.length < 2) return '—'; final h = int.tryParse(parts[0]); final m = int.tryParse(parts[1]); if (h == null || m == null) return '—'; final alarmMinutes = h * 60 + m - _minutesBefore; final displayMin = alarmMinutes % (24 * 60); final dh = (displayMin ~/ 60) % 24; final dm = displayMin % 60; return '${dh.toString().padLeft(2, '0')}:${dm.toString().padLeft(2, '0')}'; } } final _plugin = FlutterLocalNotificationsPlugin(); Future requestPermissions() async { if (Platform.isAndroid) { final android = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >(); // Android 13+ — standard notification permission (shows a dialog) await android?.requestNotificationsPermission(); // Android 12+ — exact alarm special access (opens system settings page) await android?.requestExactAlarmsPermission(); } } final android = _plugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >();