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:http/http.dart' as http; import 'package:alarm/alarm.dart'; import 'dart:convert'; /// TBD: /// v0.2.0 /// - Cancel the Android alarm when the toggle is unswitched. /// v0.2.1 /// - Fix time to get 0 prefixed if before 10:00 (5:30 becomes 05:30). /// - Fix time to validate properly rather than allowing garbage (error 530). /// v0.3.0 /// - Add sunrise section and change app name to Sunshine Alarm? /// v1.0.0 /// - Follow system theme rather than using a custom theme. /// v1.0.1 /// - Use a timepicker if the user clicks on the sunset/sunrise time. /// :TBD void main() async { WidgetsFlutterBinding.ensureInitialized(); tz.initializeTimeZones(); await Alarm.init(); runApp(const SunsetAlarmApp()); } class SunsetAlarmApp extends StatelessWidget { const SunsetAlarmApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Sunset Alarm', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange), useMaterial3: true, ), 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(); final _minutesController = TextEditingController(text: '30'); final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); bool _alarmEnabled = false; int _minutesBefore = 30; bool _loading = 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'); final lng = prefs.getDouble('lng'); final sunset = prefs.getString('sunset_time'); _alarmEnabled = prefs.getBool('alarm_enabled') ?? false; _minutesBefore = prefs.getInt('minutes_before') ?? 30; if (lat != null) _latController.text = lat.toString(); if (lng != null) _lngController.text = lng.toString(); if (sunset != null) _sunsetController.text = sunset; _minutesController.text = '$_minutesBefore'; }); 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); } Future _getGpsLocation() async { try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { _showSnack('Location services are 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 and longitude first.'); 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: $timePart'); } else { _showSnack('API error: ${response.statusCode}'); } } catch (e) { _showSnack('Network error: $e'); } finally { setState(() => _loading = false); } } Timer? _alarmTimer; Future _scheduleAlarm() async { // 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 not yet granted, requesting...'); await android?.requestExactAlarmsPermission(); } 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 is in the past. Will fire 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. Time to get ready!', notificationDetails: const NotificationDetails(linux: linuxDetails), ); }); _showSnack( 'Alarm set for ${alarmTime.hour.toString().padLeft(2, '0')}:${alarmTime.minute.toString().padLeft(2, '0')} (app must stay open)', ); } else if (Platform.isAndroid) { /*** Notifcation Method - Works *** / // 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 - WIP ***/ // Schedule: final settings = AlarmSettings( id: _alarmId, dateTime: alarmTime, // your computed sunset time //assetAudioPath: 'assets/alarm.mp3', // put an alarm sound in assets/ loopAudio: true, // keeps ringing until dismissed vibrate: true, androidFullScreenIntent: true, // wakes screen like clock app volumeSettings: VolumeSettings.fade( //volume: 0.8, fadeDuration: Duration(seconds: 60), ), notificationSettings: NotificationSettings( title: 'Sunset Alarm', body: 'Sun is setting in ${_minutesBefore} minute(s)!', stopButton: 'Dismiss', ), ); await Alarm.set(alarmSettings: settings); // End Alarm Method ***/ } else { _showSnack('Platform not detected, unsure how to show alarm.'); } } Future _toggleAlarm(bool value) async { setState(() => _alarmEnabled = value); await _savePrefs(); if (value) { await _scheduleAlarm(); } else { _alarmTimer?.cancel(); if (!Platform.isLinux) { // Notification Method //await _notifications.cancel(id: _alarmId); // Alarm Method await Alarm.stop(_alarmId); } _showSnack('Alarm cancelled.'); } } void _showSnack(String msg) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } @override void dispose() { _latController.dispose(); _lngController.dispose(); _sunsetController.dispose(); _minutesController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Sunset Alarm')), body: 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: const InputDecoration( labelText: 'Sunset time (HH:MM)', border: OutlineInputBorder(), ), onChanged: (_) => _savePrefs(), ), ), 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 sunset from Open-Meteo', onPressed: _loading ? null : _fetchSunset, ), ], ), const SizedBox(height: 16), // --- Minutes before --- const Text( 'Alert Before Sunset', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Row( children: [ const Text('Minutes before: '), SizedBox( width: 80, child: TextField( controller: _minutesController, keyboardType: TextInputType.number, decoration: const InputDecoration( border: OutlineInputBorder(), ), onChanged: (v) { final val = int.tryParse(v); if (val != null && val >= 0) { _minutesBefore = val; } else { _showSnack('Invalid Minute Value'); } }, ), ), ], ), const SizedBox(height: 24), // --- Toggle --- SwitchListTile( title: const Text('Enable Sunset Alarm'), subtitle: Text( _alarmEnabled ? 'Will alert ${_minutesBefore} min before sunset. ($_alarmDisplayTime)' : 'Alarm is off', ), value: _alarmEnabled, onChanged: _toggleAlarm, ), ], ), ), ); } 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 >();