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

552 lines
18 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:http/http.dart' as http;
import 'package:alarm/alarm.dart';
import 'dart:convert';
/// TBD:
/// 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.
/// :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<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 _alarmEnabled = false;
int _minutesBefore = 30;
bool _loading = false;
String? _sunsetErrorText = null;
String? _minuteErrorText = null;
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 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<void> _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<void> _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,
//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(s)!',
stopButton: 'Dismiss',
),
);
await Alarm.set(alarmSettings: settings);
// End Alarm Method ***/
} else {
_showSnack('Platform not detected, unsure how to show alarm.');
}
}
Future<void> _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();
}
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')),
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: 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,
),
);
}
},
),
),
onChanged: (v) {
final val = _validateTime(v);
if (val == null) {
_savePrefs();
}
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 Sunset Time from Open-Meteo',
onPressed: _loading ? null : _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';
}
});
},
),
),
// --- Toggle ---
Expanded(
child: SwitchListTile(
//title: const Text('Enable Sunset Alarm'),
title: Text(_alarmEnabled ? 'Sunset Alarm Enabled' : ''),
subtitle: Text(
_alarmEnabled
? 'Will alert ${_minutesBefore} min before sunset. ($_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
>();