Notrufnummern-Datenbasis, Ländererkennung, State-Management und Lokalisierung (AP1-AP7)

Implementiert den kompletten Kern-Flow der Emergency App gemäß docs/plan.md:
- Notrufnummern-Datenbasis für 199 Länder + Modelle/Repository (AP1)
- DialerService für tel:-Notrufe ohne Auto-Dial (AP2)
- Statisches UI mit Panik-Button-Fallback (AP3)
- Priorisierte Ländererkennung: SIM/Netz -> GPS/Geocoding -> Cache (AP4)
- EmergencyProvider verdrahtet Repository/LocationService mit der UI (AP5)
- Manuelle Länderauswahl als Fallback samt eigener Priorität (AP6)
- UI-Lokalisierung (de/en/fr/es), an erkanntes Land gekoppelt (AP7)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-24 11:29:38 +02:00
co-authored by Claude Sonnet 5
parent 40e95cc495
commit e3aa088bc2
54 changed files with 7550 additions and 145 deletions
@@ -0,0 +1,40 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'country_code_source.dart';
/// Liest/schreibt den zuletzt erfolgreich ermittelten ISO-Ländercode aus
/// `shared_preferences` (`docs/plan.md`, Abschnitt 6, letzter Fallback vor
/// `countryUnknown`).
class CachedCountryCodeSource implements CountryCodeCache {
const CachedCountryCodeSource();
static const _key = 'last_known_country_code';
@override
Future<String?> resolve() async {
try {
final prefs = await SharedPreferences.getInstance();
final code = prefs.getString(_key)?.trim();
if (code == null || code.isEmpty) return null;
return code.toUpperCase();
} catch (_) {
return null;
}
}
/// Speichert [isoCode] als zuletzt bekannten Ländercode.
///
/// Wird von [LocationService] nach jeder erfolgreichen Ermittlung über
/// SIM oder GPS aufgerufen. Best effort: Schreibfehler werden verschluckt,
/// damit ein kaputter Cache die eigentliche Ländererkennung nicht
/// beeinflusst.
@override
Future<void> save(String isoCode) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, isoCode.toUpperCase());
} catch (_) {
// Best effort, siehe Doc-Kommentar oben.
}
}
}
@@ -0,0 +1,35 @@
/// Gemeinsame Schnittstelle für Ländercode-Ermittlungsquellen
/// (`docs/plan.md`, Abschnitt 6 + 8, AP4).
///
/// Implementierungen liefern best-effort einen ISO 3166-1 alpha-2
/// Ländercode oder `null`, wenn sie ihn nicht ermitteln konnten. Sie dürfen
/// dabei **niemals** werfen Fehler (fehlende Berechtigung, kein Netz,
/// Plugin-Fehler auf Emulatoren o. Ä.) werden intern abgefangen und als
/// `null` codiert. Das erlaubt [LocationService], die Kette gleichförmig zu
/// orchestrieren, ohne pro Quelle try/catch zu benötigen.
abstract class CountryCodeSource {
Future<String?> resolve();
}
/// Erweiterung von [CountryCodeSource] um eine Schreibmöglichkeit.
///
/// Wird von der Cache-Quelle implementiert: Sie liefert nicht nur den
/// zuletzt bekannten Ländercode zurück ([resolve]), sondern erlaubt auch,
/// einen neu ermittelten Code zu speichern ([save]) siehe
/// `CachedCountryCodeSource`.
abstract class CountryCodeCache implements CountryCodeSource {
/// Speichert [isoCode] als zuletzt bekannten Ländercode (best effort).
Future<void> save(String isoCode);
}
/// Erweiterung von [CountryCodeSource] um die manuelle Länderauswahl
/// (`docs/plan.md`, Abschnitt 8, AP6).
///
/// Im Gegensatz zu [CountryCodeCache] erlaubt [save] hier explizit `null`,
/// damit der Nutzer die manuelle Auswahl wieder löschen und zur
/// automatischen Ermittlungskette (SIM → GPS → Cache) zurückkehren kann.
abstract class ManualCountryCodeStore implements CountryCodeSource {
/// Setzt die manuelle Länderauswahl. `null` löscht eine zuvor gesetzte
/// Auswahl wieder (best effort).
Future<void> save(String? isoCode);
}
@@ -0,0 +1,64 @@
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'country_code_source.dart';
/// Ermittelt den ISO-Ländercode per GPS-Position + Reverse-Geocoding
/// (`docs/plan.md`, Abschnitt 6, Fallback-Quelle Nr. 2 insbesondere auf
/// iOS 16+ wichtig, da die SIM-Quelle dort eingeschränkt ist, siehe
/// Abschnitt 9).
///
/// Nutzt bewusst nur die in `geolocator` eingebaute Berechtigungs-API
/// (`checkPermission()` / `requestPermission()`) ein zusätzlicher Einsatz
/// von `permission_handler` wäre redundant (AP4-Vorgabe).
///
/// Liefert `null`, sobald irgendein Schritt scheitert: verweigerte/dauerhaft
/// verweigerte Berechtigung, deaktivierte Standortdienste, kein GPS-Fix
/// innerhalb des Zeitlimits, oder ein Netzwerkfehler beim Reverse-Geocoding
/// (`placemarkFromCoordinates` braucht ggf. Netz). Wirft dabei nie eine
/// Exception das ist im Plan als Edge Case vorgesehen (Abschnitt 7): Die
/// Kette fällt dann weiter auf den Cache zurück.
class GpsCountryCodeSource implements CountryCodeSource {
GpsCountryCodeSource({Geocoding? geocoding}) : _geocoding = geocoding ?? Geocoding();
final Geocoding _geocoding;
static const _timeout = Duration(seconds: 10);
@override
Future<String?> resolve() async {
try {
if (!await _hasPermission()) return null;
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.low,
timeLimit: _timeout,
),
);
final placemarks = await _geocoding.placemarkFromCoordinates(
position.latitude,
position.longitude,
);
for (final placemark in placemarks) {
final code = placemark.isoCountryCode?.trim();
if (code != null && code.isNotEmpty) {
return code.toUpperCase();
}
}
return null;
} catch (_) {
return null;
}
}
Future<bool> _hasPermission() async {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
return permission == LocationPermission.whileInUse || permission == LocationPermission.always;
}
}
@@ -0,0 +1,46 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'country_code_source.dart';
/// Liest/schreibt den vom Nutzer manuell gewählten ISO-Ländercode aus
/// `shared_preferences` (`docs/plan.md`, Abschnitt 8, AP6).
///
/// Bewusst getrennt von [CachedCountryCodeSource] (eigener Key, eigene
/// Klasse): Die manuelle Auswahl ist ein Nutzer-Override und darf vom
/// automatisch zuletzt ermittelten Code (SIM/GPS-Treffer) nicht überschrieben
/// oder mit ihm verwechselt werden. `LocationService` fragt diese Quelle
/// deshalb mit höchster Priorität vor SIM/GPS/Cache ab.
class ManualCountryCodeSource implements ManualCountryCodeStore {
const ManualCountryCodeSource();
static const _key = 'manual_country_code';
@override
Future<String?> resolve() async {
try {
final prefs = await SharedPreferences.getInstance();
final code = prefs.getString(_key)?.trim();
if (code == null || code.isEmpty) return null;
return code.toUpperCase();
} catch (_) {
return null;
}
}
/// Setzt die manuelle Länderauswahl. `isoCode == null` löscht eine zuvor
/// gesetzte Auswahl wieder, damit der Nutzer zur automatischen Ermittlung
/// zurückkehren kann. Best effort: Schreibfehler werden verschluckt.
@override
Future<void> save(String? isoCode) async {
try {
final prefs = await SharedPreferences.getInstance();
if (isoCode == null) {
await prefs.remove(_key);
} else {
await prefs.setString(_key, isoCode.toUpperCase());
}
} catch (_) {
// Best effort, siehe Doc-Kommentar oben.
}
}
}
@@ -0,0 +1,85 @@
import 'package:carrier_info/carrier_info.dart';
import 'package:flutter/foundation.dart';
import 'country_code_source.dart';
/// Ermittelt den ISO-Ländercode aus SIM-/Netzwerk-Informationen via
/// `carrier_info` (`docs/plan.md`, Abschnitt 2 + 3: primäre, offline
/// verfügbare Quelle).
///
/// **Android:** Priorität liegt auf `networkCountryIso` des aktuell
/// eingebuchten Mobilfunknetzes (aus `telephonyInfo`) nicht auf dem
/// Heimatland der SIM. Genau dieses Netz wickelt einen Notruf ab, ändert
/// sich also korrekt bei Roaming (siehe Plan, Zeile "Bei Konflikt ...
/// gewinnt automatisch die SIM-Quelle"). Fällt zurück auf
/// `isoCountryCode` (SIM-Ausgabeland laut Telephony API) und zuletzt auf
/// `countryIso` aus `subscriptionsInfo`.
///
/// **iOS:** Apple hat `CTCarrier` ab iOS 16 deprecated (`docs/plan.md`,
/// Abschnitt 9 verbleibendes Risiko). `carrier_info` liefert auf iOS
/// 16+ für `isoCountryCode` verlässlich `null` zurück (siehe Paket-Doku
/// `iOS_16_DEPRECATION_NOTICE.md`). Das ist eine bekannte Plattform-
/// Einschränkung, kein Bug: Diese Quelle liefert dann einfach `null`,
/// wodurch `LocationService` automatisch auf GPS/Reverse-Geocoding
/// zurückfällt.
///
/// Alle Plugin-Aufrufe sind defensiv in try/catch gekapselt: Auf
/// Emulatoren, Geräten ohne SIM oder bei Plugin-Fehlern kann
/// `carrier_info` werfen oder leere/`null`-Werte liefern in jedem Fall
/// liefert [resolve] dann `null`, nie eine Exception.
class SimCountryCodeSource implements CountryCodeSource {
const SimCountryCodeSource();
@override
Future<String?> resolve() async {
try {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return await _resolveAndroid();
case TargetPlatform.iOS:
return await _resolveIos();
default:
// Kein Mobilfunk-Stack auf Desktop/Web-Zielen (auch wenn diese
// App laut Scope nur Android/iOS unterstützt, siehe Plan
// Abschnitt 7 defensiv trotzdem abgedeckt).
return null;
}
} catch (_) {
return null;
}
}
Future<String?> _resolveAndroid() async {
final info = await CarrierInfo.getAndroidInfo();
if (info == null) return null;
for (final telephony in info.telephonyInfo) {
final code = _normalize(telephony.networkCountryIso);
if (code != null) return code;
}
for (final telephony in info.telephonyInfo) {
final code = _normalize(telephony.isoCountryCode);
if (code != null) return code;
}
for (final subscription in info.subscriptionsInfo) {
final code = _normalize(subscription.countryIso);
if (code != null) return code;
}
return null;
}
Future<String?> _resolveIos() async {
final info = await CarrierInfo.getIosInfo();
for (final carrier in info.carrierData) {
final code = _normalize(carrier.isoCountryCode);
if (code != null) return code;
}
return null;
}
String? _normalize(String? code) {
final trimmed = code?.trim();
if (trimmed == null || trimmed.isEmpty) return null;
return trimmed.toUpperCase();
}
}