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,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:emergency/services/country_code_sources/cached_country_code_source.dart';
/// Im Gegensatz zu den SIM-/GPS-Quellen ist `shared_preferences` über
/// `SharedPreferences.setMockInitialValues` vollständig ohne echten
/// Plattform-Kanal testbar daher hier ein echter Verhaltenstest statt nur
/// eines Struktur-Tests.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('CachedCountryCodeSource', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('resolve() liefert null, wenn noch kein Code gespeichert ist', () async {
const source = CachedCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('save() gefolgt von resolve() liefert den gespeicherten, normalisierten Code', () async {
const source = CachedCountryCodeSource();
await source.save('de');
expect(await source.resolve(), 'DE');
});
test('resolve() liefert null, wenn der gespeicherte Wert leer ist', () async {
SharedPreferences.setMockInitialValues({'last_known_country_code': ' '});
const source = CachedCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('save() überschreibt einen zuvor gespeicherten Code', () async {
const source = CachedCountryCodeSource();
await source.save('DE');
await source.save('fr');
expect(await source.resolve(), 'FR');
});
});
}
@@ -0,0 +1,171 @@
import 'package:flutter/widgets.dart' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:geocoding_platform_interface/geocoding_platform_interface.dart' as pi;
import 'package:geolocator_platform_interface/geolocator_platform_interface.dart';
import 'package:emergency/services/country_code_sources/gps_country_code_source.dart';
/// Analog zum `url_launcher`-Test von `DialerService`: `geolocator` und
/// `geocoding` erlauben es, ihre `*Platform.instance` durch eine eigene
/// Implementierung zu ersetzen der offiziell vorgesehene Weg, diese
/// Packages ohne echten Plattform-Kanal zu testen.
class _FakeGeolocatorPlatform extends GeolocatorPlatform {
_FakeGeolocatorPlatform({
required this.permission,
this.position,
this.currentPositionError,
});
LocationPermission permission;
Position? position;
Object? currentPositionError;
@override
Future<LocationPermission> checkPermission() async => permission;
@override
Future<LocationPermission> requestPermission() async => permission;
@override
Future<Position> getCurrentPosition({LocationSettings? locationSettings}) async {
if (currentPositionError != null) throw currentPositionError!;
return position!;
}
}
class _FakeGeocodingPlatform extends pi.Geocoding {
_FakeGeocodingPlatform({this.placemarks = const [], this.error})
: super.implementation(const pi.GeocodingCreationParams());
final List<pi.Placemark> placemarks;
Object? error;
@override
Future<List<pi.Placemark>> placemarkFromCoordinates(
double latitude,
double longitude, {
Locale? locale,
}) async {
if (error != null) throw error!;
return placemarks;
}
}
class _FakeGeocodingPlatformFactory extends pi.GeocodingPlatformFactory {
_FakeGeocodingPlatformFactory(this.geocoding);
final pi.Geocoding geocoding;
@override
pi.Geocoding createGeocoding(pi.GeocodingCreationParams params) => geocoding;
}
Position _somePosition() => Position(
latitude: 52.5,
longitude: 13.4,
timestamp: DateTime.utc(2026, 1, 1),
accuracy: 0,
altitude: 0,
altitudeAccuracy: 0,
heading: 0,
headingAccuracy: 0,
speed: 0,
speedAccuracy: 0,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
void setUpPlatforms({
required LocationPermission permission,
Position? position,
Object? currentPositionError,
List<pi.Placemark> placemarks = const [],
Object? geocodingError,
}) {
GeolocatorPlatform.instance = _FakeGeolocatorPlatform(
permission: permission,
position: position,
currentPositionError: currentPositionError,
);
pi.GeocodingPlatformFactory.instance = _FakeGeocodingPlatformFactory(
_FakeGeocodingPlatform(placemarks: placemarks, error: geocodingError),
);
}
group('GpsCountryCodeSource', () {
test('liefert den ISO-Code aus dem ersten Placemark bei erteilter Berechtigung', () async {
setUpPlatforms(
permission: LocationPermission.whileInUse,
position: _somePosition(),
placemarks: const [pi.Placemark(isoCountryCode: 'de')],
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), 'DE');
});
test('fordert die Berechtigung an, wenn sie noch nicht erteilt ist', () async {
setUpPlatforms(
permission: LocationPermission.denied,
position: _somePosition(),
placemarks: const [pi.Placemark(isoCountryCode: 'fr')],
);
final source = GpsCountryCodeSource();
// Der Fake liefert bei checkPermission() und requestPermission() denselben
// Wert; hier reicht es zu prüfen, dass bei "denied" nachgefragt wird und
// trotzdem kein Ergebnis zustande kommt (Fake bleibt bei "denied").
expect(await source.resolve(), isNull);
});
test('liefert null, wenn die Berechtigung dauerhaft verweigert ist', () async {
setUpPlatforms(
permission: LocationPermission.deniedForever,
position: _somePosition(),
placemarks: const [pi.Placemark(isoCountryCode: 'de')],
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('liefert null, wenn getCurrentPosition wirft (z. B. GPS aus)', () async {
setUpPlatforms(
permission: LocationPermission.whileInUse,
currentPositionError: const LocationServiceDisabledException(),
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('liefert null, wenn das Reverse-Geocoding wirft (z. B. kein Netz)', () async {
setUpPlatforms(
permission: LocationPermission.whileInUse,
position: _somePosition(),
geocodingError: Exception('network error'),
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('liefert null, wenn keine Placemarks gefunden werden', () async {
setUpPlatforms(
permission: LocationPermission.whileInUse,
position: _somePosition(),
placemarks: const [],
);
final source = GpsCountryCodeSource();
expect(await source.resolve(), isNull);
});
});
}
@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:emergency/services/country_code_sources/manual_country_code_source.dart';
/// Analog zu `cached_country_code_source_test.dart`: `shared_preferences`
/// ist über `SharedPreferences.setMockInitialValues` vollständig ohne echten
/// Plattform-Kanal testbar.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('ManualCountryCodeSource', () {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('resolve() liefert null, wenn noch keine manuelle Auswahl gespeichert ist', () async {
const source = ManualCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('save() gefolgt von resolve() liefert den gespeicherten, normalisierten Code', () async {
const source = ManualCountryCodeSource();
await source.save('fr');
expect(await source.resolve(), 'FR');
});
test('save(null) löscht eine zuvor gesetzte manuelle Auswahl wieder', () async {
const source = ManualCountryCodeSource();
await source.save('FR');
expect(await source.resolve(), 'FR');
await source.save(null);
expect(await source.resolve(), isNull);
});
test('verwendet einen eigenen Key, unabhängig vom automatischen Cache', () async {
SharedPreferences.setMockInitialValues({'last_known_country_code': 'US'});
const source = ManualCountryCodeSource();
// Der automatische Cache-Wert darf die manuelle Auswahl nicht
// beeinflussen, solange keine manuelle Auswahl gesetzt wurde.
expect(await source.resolve(), isNull);
});
});
}
@@ -0,0 +1,163 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/services/country_code_sources/sim_country_code_source.dart';
/// `carrier_info` spricht direkt (ohne Platform-Interface-Package) den
/// MethodChannel `plugins.chizi.tech/carrier_info` an. Flutter erlaubt es,
/// diesen Kanal in Tests mit einem Mock-Handler zu belegen dadurch lässt
/// sich die eigentliche Priorisierungs-/Normalisierungslogik von
/// [SimCountryCodeSource] (anders als bei GPS, siehe dortigen Struktur-Test)
/// echt testen, ganz ohne reales Gerät/Emulator.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('plugins.chizi.tech/carrier_info');
void mockHandler(Future<Object?> Function(MethodCall call) handler) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, handler);
}
Map<String, dynamic> androidInfo({
List<Map<String, dynamic>> telephonyInfo = const [],
List<Map<String, dynamic>> subscriptionsInfo = const [],
}) {
return {
'isVoiceCapable': true,
'isDataEnabled': true,
'subscriptionsInfo': subscriptionsInfo,
'isDataCapable': true,
'isMultiSimSupported': 'MULTISIM_NOT_SUPPORTED_BY_HARDWARE',
'isSmsCapable': true,
'telephonyInfo': telephonyInfo,
};
}
Map<String, dynamic> iosInfo({
List<Map<String, dynamic>> carrierData = const [],
}) {
return {
'carrierData': carrierData,
'supportsEmbeddedSIM': false,
'carrierRadioAccessTechnologyTypeList': <String>['LTE'],
'isSIMInserted': true,
};
}
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
group('SimCountryCodeSource auf Android', () {
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
});
test('bevorzugt networkCountryIso (aktuelles Netz, roaming-fest) vor allem anderen', () async {
mockHandler((call) async {
expect(call.method, 'getAndroidInfo');
return androidInfo(
telephonyInfo: [
{'networkCountryIso': 'fr', 'isoCountryCode': 'de'},
],
subscriptionsInfo: [
{'countryIso': 'de'},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), 'FR');
});
test('fällt auf isoCountryCode zurück, wenn networkCountryIso leer ist', () async {
mockHandler((call) async {
return androidInfo(
telephonyInfo: [
{'networkCountryIso': '', 'isoCountryCode': 'de'},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), 'DE');
});
test('fällt auf subscriptionsInfo.countryIso zurück, wenn telephonyInfo leer ist', () async {
mockHandler((call) async {
return androidInfo(
subscriptionsInfo: [
{'countryIso': 'us'},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), 'US');
});
test('liefert null, wenn alle Quellen leer sind (z. B. kein SIM-Slot belegt)', () async {
mockHandler((call) async => androidInfo());
const source = SimCountryCodeSource();
expect(await source.resolve(), isNull);
});
test('liefert null statt zu werfen, wenn das Plugin eine PlatformException wirft', () async {
mockHandler((call) async {
throw PlatformException(code: 'UNAVAILABLE', message: 'no telephony service');
});
const source = SimCountryCodeSource();
expect(await source.resolve(), isNull);
});
});
group('SimCountryCodeSource auf iOS', () {
setUp(() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
});
test('liefert isoCountryCode auf iOS < 16 (CTCarrier noch verfügbar)', () async {
mockHandler((call) async {
expect(call.method, 'getIosInfo');
return iosInfo(
carrierData: [
{'isoCountryCode': 'ng', 'carrierName': 'Airtel'},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), 'NG');
});
test(
'liefert null auf iOS 16+ (CTCarrier deprecated, isoCountryCode ist nil) '
'bekanntes, akzeptiertes Risiko laut docs/plan.md Abschnitt 9',
() async {
mockHandler((call) async {
return iosInfo(
carrierData: [
{'isoCountryCode': null, 'carrierName': null},
],
);
});
const source = SimCountryCodeSource();
expect(await source.resolve(), isNull);
},
);
});
}