576 lines
18 KiB
Dart
576 lines
18 KiB
Dart
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();
|
||
_configureAudioContext();
|
||
_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);
|
||
}
|
||
}
|
||
|
||
Future<void> _configureAudioContext() async {
|
||
await AudioPlayer.global.setAudioContext(
|
||
AudioContext(
|
||
android: const AudioContextAndroid(
|
||
isSpeakerphoneOn: true,
|
||
stayAwake: false,
|
||
contentType: AndroidContentType.sonification,
|
||
usageType: AndroidUsageType.alarm,
|
||
audioFocus: AndroidAudioFocus.gainTransient,
|
||
),
|
||
iOS: AudioContextIOS(
|
||
category: AVAudioSessionCategory.playback,
|
||
options: {AVAudioSessionOptions.defaultToSpeaker},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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 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);
|
||
}
|