import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; // SystemSound import 'package:geolocator/geolocator.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; enum EggSize { small, normal, large } enum EggDoneness { soft, medium, hard } enum EggOrigin { fridge, room } void main() { runApp(const BoiledEggApp()); } class BoiledEggApp extends StatelessWidget { const BoiledEggApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Eierkoch-Assistent', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange), useMaterial3: true, ), home: const EggTimerScreen(), ); } } class EggTimerScreen extends StatefulWidget { const EggTimerScreen({super.key}); @override State createState() => _EggTimerScreenState(); } class _EggTimerScreenState extends State { EggSize _size = EggSize.normal; EggDoneness _doneness = EggDoneness.medium; EggOrigin _eggOrigin = EggOrigin.fridge; int? _altitude; // in Metern Duration _cookTime = const Duration(minutes: 7); Duration _remainingTime = Duration.zero; Timer? _timer; late final AudioPlayer _audioPlayer; bool _loadingLocation = false; String? _error; String? _appVersion; @override void initState() { super.initState(); _audioPlayer = AudioPlayer(); _initLocationAndAltitude(); _loadAppVersion(); } Future _initLocationAndAltitude() async { setState(() { _loadingLocation = true; _error = null; }); try { // Berechtigungen prüfen/anfordern LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) { setState(() { _error = 'Standortberechtigung verweigert'; _loadingLocation = false; }); return; } // Standort ermitteln final position = await Geolocator.getCurrentPosition( desiredAccuracy: LocationAccuracy.high, ); // Höhe über Webservice ermitteln final altitude = await _fetchAltitudeFromWeb(position.latitude, position.longitude); setState(() { _altitude = altitude; _loadingLocation = false; }); _recalculateCookTime(); } catch (e) { setState(() { _error = 'Fehler: $e'; _loadingLocation = false; }); } } Future _fetchAltitudeFromWeb(double lat, double lon) async { final uri = Uri.parse( 'https://api.open-elevation.com/api/v1/lookup?locations=$lat,$lon', ); final response = await http.get(uri); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; final results = data['results'] as List; if (results.isNotEmpty) { final elevation = results[0]['elevation'] as num; return elevation.round(); } else { throw Exception('Keine Höhen-Daten erhalten'); } } else { throw Exception('HTTP-Fehler: ${response.statusCode}'); } } void _recalculateCookTime() { if (_altitude == null) return; final newTime = calculateCookTime( altitudeMeters: _altitude!, size: _size, doneness: _doneness, origin: _eggOrigin, ); setState(() { _cookTime = newTime; if (_timer == null || !_timer!.isActive) { _remainingTime = newTime; } }); } void _startTimer() { _timer?.cancel(); setState(() { _remainingTime = _cookTime; }); _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (_remainingTime.inSeconds <= 1) { timer.cancel(); setState(() { _remainingTime = Duration.zero; }); _playAlarm(); return; } setState(() { _remainingTime = _remainingTime - const Duration(seconds: 1); }); }); } void _stopTimer() { _timer?.cancel(); setState(() { _remainingTime = _cookTime; }); } Future _playAlarm() async { try { await _audioPlayer.stop(); await _audioPlayer.play(AssetSource('alarm.wav')); } catch (_) { await SystemSound.play(SystemSoundType.alert); } } String _formatDuration(Duration d) { final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$m:$s'; } double _progressValue() { if (_cookTime.inSeconds == 0) return 0.0; final total = _cookTime.inSeconds; final remaining = _remainingTime.inSeconds; final elapsed = (total - remaining).clamp(0, total); return elapsed / total; // 0.0 = Start, 1.0 = fertig } @override void dispose() { _timer?.cancel(); _audioPlayer.dispose(); super.dispose(); } Future _loadAppVersion() async { final info = await PackageInfo.fromPlatform(); if (!mounted) return; setState(() { _appVersion = info.version; }); } @override Widget build(BuildContext context) { final altitudeText = _altitude != null ? '$_altitude m über NN' : 'unbekannt'; final versionLabel = _appVersion != null ? 'EggTimer Version $_appVersion' : 'EggTimer'; return Scaffold( appBar: AppBar( title: const Text( 'Eierkoch-Assistent', style: TextStyle( fontWeight: FontWeight.bold, ), ), centerTitle: true, ), body: Stack( children: [ // Hintergrundgrafik (durchscheinendes Ei) Positioned.fill( child: Opacity( opacity: 0.15, child: Image.asset( 'assets/egg_background.png', fit: BoxFit.cover, ), ), ), Padding( padding: const EdgeInsets.all(16.0), child: _loadingLocation ? const Center(child: CircularProgressIndicator()) : SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (_error != null) Text( _error!, style: const TextStyle(color: Colors.red), textAlign: TextAlign.center, ), const SizedBox(height: 12), Text( 'Meereshöhe: $altitudeText', textAlign: TextAlign.center, ), const SizedBox(height: 16), // Ausgangstemperatur const Text( 'Ausgangstemperatur des Eis', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 8), Wrap( alignment: WrapAlignment.center, spacing: 8, children: [ ChoiceChip( label: const Text('Kühlschrank'), selected: _eggOrigin == EggOrigin.fridge, onSelected: (_) { setState(() => _eggOrigin = EggOrigin.fridge); _recalculateCookTime(); }, ), ChoiceChip( label: const Text('Raumtemperatur'), selected: _eggOrigin == EggOrigin.room, onSelected: (_) { setState(() => _eggOrigin = EggOrigin.room); _recalculateCookTime(); }, ), ], ), const SizedBox(height: 16), // Eiergröße const Text( 'Eiergröße', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 8), Wrap( alignment: WrapAlignment.center, spacing: 8, children: [ ChoiceChip( label: const Text('klein'), selected: _size == EggSize.small, onSelected: (_) { setState(() => _size = EggSize.small); _recalculateCookTime(); }, ), ChoiceChip( label: const Text('normal'), selected: _size == EggSize.normal, onSelected: (_) { setState(() => _size = EggSize.normal); _recalculateCookTime(); }, ), ChoiceChip( label: const Text('groß'), selected: _size == EggSize.large, onSelected: (_) { setState(() => _size = EggSize.large); _recalculateCookTime(); }, ), ], ), const SizedBox(height: 16), // Kochgrad const Text( 'Kochgrad', style: TextStyle(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 8), Wrap( alignment: WrapAlignment.center, spacing: 8, children: [ ChoiceChip( label: const Text('weich'), selected: _doneness == EggDoneness.soft, onSelected: (_) { setState(() => _doneness = EggDoneness.soft); _recalculateCookTime(); }, ), ChoiceChip( label: const Text('mittel'), selected: _doneness == EggDoneness.medium, onSelected: (_) { setState(() => _doneness = EggDoneness.medium); _recalculateCookTime(); }, ), ChoiceChip( label: const Text('hart'), selected: _doneness == EggDoneness.hard, onSelected: (_) { setState(() => _doneness = EggDoneness.hard); _recalculateCookTime(); }, ), ], ), const SizedBox(height: 24), Text( 'Berechnete Kochzeit: ${_formatDuration(_cookTime)}', textAlign: TextAlign.center, ), const SizedBox(height: 16), // Ring-Anzeige der Restzeit SizedBox( width: 180, height: 180, child: Stack( alignment: Alignment.center, children: [ SizedBox( width: 160, height: 160, child: CircularProgressIndicator( value: _cookTime.inSeconds == 0 ? 0.0 : _progressValue(), strokeWidth: 10, ), ), Column( mainAxisSize: MainAxisSize.min, children: [ const Text( 'Restzeit', style: TextStyle(fontSize: 14), ), Text( _formatDuration(_remainingTime), style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, ), ), ], ), ], ), ), const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // Start-Button: nur aktiv, wenn Höhe bekannt // und Timer nicht läuft ElevatedButton.icon( onPressed: (_altitude == null) ? null : (_timer == null || !_timer!.isActive) ? () => _startTimer() : null, icon: const Icon(Icons.timer), label: const Text('Start'), ), const SizedBox(width: 16), // Stop-Button: nur aktiv, wenn Timer läuft ElevatedButton.icon( onPressed: (_timer != null && _timer!.isActive) ? () => _stopTimer() : null, icon: const Icon(Icons.stop), label: const Text('Stop'), ), ], ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon( Icons.smart_toy, size: 18, color: Colors.black54, ), const SizedBox(width: 6), Text( versionLabel, style: const TextStyle(color: Colors.black54), ), ], ), const SizedBox(height: 8), ], ), ), ), ], ), ); } } // Siedetemperatur des Wassers aus Höhe (vereinfachtes Modell) double boilingTemperatureFromAltitude(int altitudeMeters) { // ca. -1 °C pro 285 m über NN final drop = altitudeMeters / 285.0; final t = 100.0 - drop; // Sicherheitsbereich 80–100 °C final clamped = t.clamp(80.0, 100.0); return clamped.toDouble(); } // Physikalisch basierte Kochzeitformel Duration calculateCookTime({ required int altitudeMeters, required EggSize size, required EggDoneness doneness, required EggOrigin origin, }) { // 1. Masse des Eis in g double massGrams; switch (size) { case EggSize.small: massGrams = 47.0; break; case EggSize.normal: massGrams = 57.0; // mittleres Ei break; case EggSize.large: massGrams = 67.0; break; } // 2. Zieltemperatur des Eigelbs Ty (°C) double Ty; switch (doneness) { case EggDoneness.soft: Ty = 65.0; break; case EggDoneness.medium: Ty = 72.0; break; case EggDoneness.hard: Ty = 82.0; break; } // 3. Anfangstemperatur To (°C) final double To = switch (origin) { EggOrigin.fridge => 7.0, EggOrigin.room => 20.0, }; // 4. Wassertemperatur Tw (Siedetemperatur) aus Höhe final double Tw = boilingTemperatureFromAltitude(altitudeMeters); // 5. Materialkonstanten const double rho = 1.038; // g/cm^3 const double c = 3.7; // J/(g*K) const double K = 5.4e-3; // W/(cm*K) // 6. Vorfaktor final double factorNumerator = math.pow(massGrams, 2.0 / 3.0) * c * math.pow(rho, 1.0 / 3.0); final double factorDenominator = K * math.pi * math.pi * math.pow((4.0 * math.pi / 3.0), 2.0 / 3.0); final double factor = factorNumerator / factorDenominator; // 7. Logarithmus-Term final double insideLog = 0.76 * ((To - Tw) / (Ty - Tw)); if (insideLog <= 0) { // Fallback, falls Parameter zu extrem sind return const Duration(minutes: 6); } final double tSeconds = factor * math.log(insideLog.abs()); int seconds = tSeconds.round(); if (!seconds.isFinite || seconds <= 0) { seconds = 300; // 5 Minuten Fallback } if (seconds > 1800) { seconds = 1800; // max. 30 Minuten } return Duration(seconds: seconds); }