EggTimer/lib/main.dart

557 lines
17 KiB
Dart
Raw Permalink 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: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<EggTimerScreen> createState() => _EggTimerScreenState();
}
class _EggTimerScreenState extends State<EggTimerScreen> {
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<void> _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<int> _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<String, dynamic>;
final results = data['results'] as List<dynamic>;
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<void> _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<void> _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 80100 °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);
}