From f689fceda8073374bd130a267abc9ccf863be2ef Mon Sep 17 00:00:00 2001 From: Hyperling Date: Sat, 12 Sep 2026 16:23:55 -0700 Subject: [PATCH] Restart project with code snippets by Ask Brave. --- android/app/src/main/AndroidManifest.xml | 5 + lib/main.dart | 386 +++++++++++++++++++++-- pubspec.yaml | 5 + 3 files changed, 369 insertions(+), 27 deletions(-) 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: