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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Öffnet die native Telefon-App mit vorgewählter Nummer.
|
||||
///
|
||||
/// Löst bewusst **kein** Auto-Dial aus (siehe `docs/plan.md`, Abschnitt 2):
|
||||
/// Der Nutzer bestätigt den Anruf selbst im Wähler. Wirft keine Exceptions –
|
||||
/// wenn das `tel:`-Schema nicht geöffnet werden kann, liefert [call] `false`
|
||||
/// zurück und überlässt der UI (AP3+), wie damit umgegangen wird.
|
||||
class DialerService {
|
||||
const DialerService();
|
||||
|
||||
/// Baut die `tel:`-URI für [number] und öffnet sie im Wähler.
|
||||
///
|
||||
/// Gibt `true` zurück, wenn der Wähler erfolgreich geöffnet wurde,
|
||||
/// sonst `false`.
|
||||
Future<bool> call(String number) async {
|
||||
final uri = Uri(scheme: 'tel', path: number);
|
||||
|
||||
if (!await canLaunchUrl(uri)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return launchUrl(uri);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'country_code_sources/cached_country_code_source.dart';
|
||||
import 'country_code_sources/country_code_source.dart';
|
||||
import 'country_code_sources/gps_country_code_source.dart';
|
||||
import 'country_code_sources/manual_country_code_source.dart';
|
||||
import 'country_code_sources/sim_country_code_source.dart';
|
||||
|
||||
/// Orchestriert die priorisierte Ermittlungskette für den aktuellen
|
||||
/// Ländercode (`docs/plan.md`, Abschnitt 6 + 8, AP4 + AP6):
|
||||
///
|
||||
/// 0. manuelle Auswahl des Nutzers (`shared_preferences`, übersteuert alles)
|
||||
/// 1. SIM/Netz-Ländercode (offline, primär)
|
||||
/// 2. GPS + Reverse-Geocoding (Fallback, ggf. Netz nötig)
|
||||
/// 3. zuletzt gespeicherter Code (`shared_preferences`)
|
||||
///
|
||||
/// Die erste Quelle, die einen nicht-`null`-Wert liefert, gewinnt; die
|
||||
/// übrigen Quellen werden dann nicht mehr angefragt. Nach einem Treffer über
|
||||
/// SIM oder GPS wird der Cache best-effort aktualisiert (Fehler dabei
|
||||
/// beeinflussen die Rückgabe nicht), damit ein späterer Aufruf ohne
|
||||
/// Netz/GPS/SIM trotzdem einen aktuellen Wert liefern kann. Die manuelle
|
||||
/// Auswahl (Stufe 0) wird bewusst **nicht** in diesen automatischen Cache
|
||||
/// (Stufe 3) geschrieben – beides sind getrennte Mechanismen mit eigenem
|
||||
/// `shared_preferences`-Key, siehe `ManualCountryCodeSource` und
|
||||
/// `CachedCountryCodeSource`.
|
||||
///
|
||||
/// `null` wird nur zurückgegeben, wenn wirklich alle Quellen fehlschlagen –
|
||||
/// die UI (AP5+) muss diesen Fall (`status = countryUnknown`) explizit
|
||||
/// behandeln.
|
||||
class LocationService {
|
||||
LocationService({
|
||||
CountryCodeSource? simSource,
|
||||
CountryCodeSource? gpsSource,
|
||||
CountryCodeCache? cacheSource,
|
||||
ManualCountryCodeStore? manualSource,
|
||||
}) : _simSource = simSource ?? const SimCountryCodeSource(),
|
||||
_gpsSource = gpsSource ?? GpsCountryCodeSource(),
|
||||
_cacheSource = cacheSource ?? const CachedCountryCodeSource(),
|
||||
_manualSource = manualSource ?? const ManualCountryCodeSource();
|
||||
|
||||
final CountryCodeSource _simSource;
|
||||
final CountryCodeSource _gpsSource;
|
||||
final CountryCodeCache _cacheSource;
|
||||
final ManualCountryCodeStore _manualSource;
|
||||
|
||||
Future<String?> resolveCountryCode() async {
|
||||
final manualResult = await _manualSource.resolve();
|
||||
if (manualResult != null) {
|
||||
return manualResult;
|
||||
}
|
||||
|
||||
final simResult = await _simSource.resolve();
|
||||
if (simResult != null) {
|
||||
await _cacheSource.save(simResult);
|
||||
return simResult;
|
||||
}
|
||||
|
||||
final gpsResult = await _gpsSource.resolve();
|
||||
if (gpsResult != null) {
|
||||
await _cacheSource.save(gpsResult);
|
||||
return gpsResult;
|
||||
}
|
||||
|
||||
return _cacheSource.resolve();
|
||||
}
|
||||
|
||||
/// Setzt oder löscht die manuelle Länderauswahl (`docs/plan.md`,
|
||||
/// Abschnitt 8, AP6). `isoCode == null` löscht eine zuvor gesetzte Auswahl
|
||||
/// wieder, sodass der nächste [resolveCountryCode]-Aufruf wieder auf die
|
||||
/// automatische Kette (SIM → GPS → Cache) zurückfällt.
|
||||
Future<void> setManualCountryCode(String? isoCode) => _manualSource.save(isoCode);
|
||||
}
|
||||
Reference in New Issue
Block a user