diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e0d0521..9b017ee 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -42,4 +42,9 @@
+
+
+
+
+
diff --git a/lib/main.dart b/lib/main.dart
index fb005f2..7044f24 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,45 +1,377 @@
+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 'dart:convert';
-void main() {
- runApp(const MainApp());
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ tz.initializeTimeZones();
+ runApp(const SunsetAlarmApp());
}
-class MainApp extends StatelessWidget {
- const MainApp({super.key});
+class SunsetAlarmApp extends StatelessWidget {
+ const SunsetAlarmApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
- home: Scaffold(
- body: Column(
+ 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 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 linuxInit = DarwinInitializationSettings();
+ await _notifications.initialize(
+ const InitializationSettings(
+ android: androidInit,
+ iOS: 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;
+ });
+ 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;
+ // "2026-09-12T19:42" -> "19:42"
+ 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);
+ }
+ }
+
+ 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 the alarm time has already passed today, schedule for tomorrow
+ if (alarmTime.isBefore(now)) {
+ alarmTime = alarmTime.add(const Duration(days: 1));
+ }
+
+ // Subtract the "minutes before" offset
+ 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));
+ }
+
+ 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,
+ );
+ const linuxDetails = DarwinNotificationDetails(
+ presentAlert: true,
+ presentBadge: true,
+ presentSound: true,
+ );
+
+ await _notifications.zonedSchedule(
+ _alarmId,
+ 'Sunset in ${_minutesBefore} min',
+ 'The sun sets at $sunsetText. Time to get ready!',
+ tzAlarm,
+ NotificationDetails(android: androidDetails, iOS: linuxDetails),
+ androidScheduleMode: AndroidScheduleMode.alarmClock,
+ uiLocalNotificationDateInterpretation:
+ UILocalNotificationDateInterpretation.absoluteTime,
+ );
+ }
+
+ Future _toggleAlarm(bool value) async {
+ setState(() => _alarmEnabled = value);
+ await _savePrefs();
+ if (value) {
+ await _scheduleAlarm();
+ _showSnack('Alarm scheduled.');
+ } else {
+ await _notifications.cancel(_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();
+ 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: [
- Text("Sunset Coordinates"), // TODO: Text Field
- Text(
- "Hours before Sunset",
- ), // TODO: Text Field. Add Spanish translation "Horas antes Noche"
- TextButton(
- child: Text("Set Alarm"),
- onPressed: () {
- null;
- },
- ),
- // If enabled, this section controls "Pythagorian theorum" (spelling)
- // type math to check if the user has exited the distance from the
- // sunset location.
- Text("Enable Proximity Detector / Geofencing"), // TODO: Checkbox
- Text(
- "Current Coordinates",
- ), // TODO: Read Only View with current location
+ // --- Location section ---
+ const Text('Location',
+ style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
+ const SizedBox(height: 8),
Row(
- mainAxisAlignment: MainAxisAlignment.center,
children: [
- Text("Distance"), // TODO: Text Field
- Text("* Miles, * Kilometers"), // TODO: Radio Button Group
+ 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: TextEditingController(text: '$_minutesBefore'),
+ keyboardType: TextInputType.number,
+ decoration:
+ const InputDecoration(border: OutlineInputBorder()),
+ onEditingComplete: () async {
+ final val = int.tryParse(
+ (findContextController() as TextEditingController?)?.text ?? '');
+ if (val != null && val > 0) {
+ setState(() => _minutesBefore = val);
+ await _savePrefs();
+ }
+ },
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 24),
+
+ // --- Toggle ---
+ SwitchListTile(
+ title: const Text('Enable Sunset Alarm'),
+ subtitle: Text(
+ _alarmEnabled
+ ? 'Will alert ${_minutesBefore} min before sunset'
+ : 'Alarm is off',
+ ),
+ value: _alarmEnabled,
+ onChanged: _toggleAlarm,
+ ),
],
),
),
);
}
-}
+
+ // Helper to avoid recreating TextEditingController on each build
+ TextEditingController? _minutesController;
+ TextEditingController? findContextController() {
+ _minutesController ??= TextEditingController(text: '$_minutesBefore');
+ return _minutesController;
+ }
+}
\ No newline at end of file
diff --git a/pubspec.yaml b/pubspec.yaml
index 3b99060..bd45dcb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -9,6 +9,11 @@ environment:
dependencies:
flutter:
sdk: flutter
+ geolocator: ^13.0.0
+ shared_preferences: ^2.3.0
+ flutter_local_notifications: ^18.0.0
+ timezone: ^0.9.0
+ http: ^1.2.0
dev_dependencies:
flutter_test: