Files
flutter-sunset-alarm/lib/main.dart
T

595 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/// TBD:
/// - Add other languages (Spanish, Croatian)
/// v1.1.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();
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<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _latController = TextEditingController();
final _lngController = TextEditingController();
final _sunsetController = TextEditingController();
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;
static const _alarmId = 1001;
@override
void initState() {
super.initState();
_initNotifications();
_loadPrefs();
}
Future<void> _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<void> _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<void> _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<void> _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<void> _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);
}
if (_alarmEnabled) {
_scheduleAlarm();
}
}
Timer? _alarmTimer;
Future<void> _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<void> _toggleAlarm(bool value) async {
setState(() => _alarmEnabled = value);
await _savePrefs();
if (value) {
await _scheduleAlarm();
} else {
_alarmTimer?.cancel();
if (!Platform.isLinux) {
// 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) {
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';
return null; // valid
}
@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) {
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 ---
Row(
children: [
if (!_alarmEnabled)
Expanded(
child: 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';
}
_plural = (_minutesBefore == 1) ? '' : 's';
});
},
),
),
// --- Toggle ---
Expanded(
child: SwitchListTile(
//title: const Text('Enable Sunset Alarm'),
title: Text(_alarmEnabled ? 'Sunset Alarm Enabled' : ''),
subtitle: Text(
_alarmEnabled
? '${_minutesBefore} Minute${_plural} Before @ $_alarmDisplayTime'
: '',
),
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<void> 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
>();