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
+1
View File
@@ -4,6 +4,7 @@
*.pyc
*.swp
.DS_Store
.sync_*.db
.atom/
.build/
.buildlog/
+24
View File
@@ -1,4 +1,22 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Ländererkennung (docs/plan.md Abschnitt 6+7, AP4): GPS-Fallback,
falls die SIM-/Netz-Quelle nichts liefert. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Ländererkennung, Primärquelle SIM/Netz (`carrier_info`-Plugin, laut
dessen README/Beispiel-Manifest im pub-cache benötigt):
ACCESS_NETWORK_STATE für den Netzwerkstatus,
READ_PHONE_STATE/READ_BASIC_PHONE_STATE/READ_PHONE_NUMBERS für
TelephonyManager- bzw. SubscriptionManager-Zugriff auf
networkCountryIso/countryIso. Bewusst NICHT übernommen:
READ_PRIVILEGED_PHONE_STATE ist eine signature|privileged-Permission,
die eine normale Store-App ohnehin nie gewährt bekommt sie zu
deklarieren hätte keinen Nutzen, nur unnötige Store-Review-Fragen. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_BASIC_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<application
android:label="emergency"
android:name="${applicationName}"
@@ -41,5 +59,11 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Ermöglicht canLaunchUrl()-Prüfung und Öffnen von tel:-Intents
ab Android 11 (Package Visibility), siehe docs/plan.md Abschnitt 7. -->
<intent>
<action android:name="android.intent.action.DIAL" />
<data android:scheme="tel" />
</intent>
</queries>
</manifest>
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -24,6 +24,12 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tel</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Wird verwendet, um die passende Notrufnummer für dein aktuelles Land anzuzeigen.</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/country_locale_map.dart';
import 'features/home/home_screen.dart';
import 'l10n/app_localizations.dart';
import 'providers/emergency_provider.dart';
import 'repositories/emergency_number_repository.dart';
import 'services/location_service.dart';
/// Wurzel-Widget der App.
///
/// [repository] und [locationService] sind injizierbar, damit Tests eigene
/// (ggf. gefakte) Instanzen für den intern erzeugten [EmergencyProvider]
/// verwenden können, statt echte Asset-I/O bzw. SIM/GPS/Cache-Zugriffe
/// anzustoßen. Wird stattdessen direkt ein fertiger [provider] übergeben,
/// werden [repository]/[locationService] ignoriert das erlaubt Tests, den
/// `EmergencyProvider` selbst zu faken bzw. vorab zu befüllen.
class App extends StatelessWidget {
const App({super.key, this.repository, this.locationService, this.provider});
final EmergencyNumberRepository? repository;
final LocationService? locationService;
final EmergencyProvider? provider;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<EmergencyProvider>(
create: (_) =>
provider ??
EmergencyProvider(
repository: repository ?? EmergencyNumberRepository(),
locationService: locationService ?? LocationService(),
),
// AP7 (Lokalisierung): `locale` wird reaktiv aus dem erkannten Land
// berechnet (docs/plan.md, Abschnitt 2 + 8), daher ein Consumer statt
// eines einmalig gebauten MaterialApp ändert sich `country` (z. B.
// nach manueller Länderauswahl aus AP6), rebuildet auch die App-Sprache.
child: Consumer<EmergencyProvider>(
builder: (context, emergencyProvider, _) {
return MaterialApp(
title: 'Emergency',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.red)),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: resolveAppLocale(
emergencyProvider.country?.isoCode,
AppLocalizations.supportedLocales,
),
home: const HomeScreen(),
);
},
),
);
}
}
+243
View File
@@ -0,0 +1,243 @@
import 'package:flutter/widgets.dart';
/// Ordnet ISO 3166-1 alpha-2 Ländercodes eine primäre Amts-/Mehrheitssprache
/// (ISO 639-1) zu (`docs/plan.md`, Abschnitt 2 + 8, AP7: "Lokalisierung:
/// Sprache an erkanntes Land gekoppelt").
///
/// **Bewusst pragmatisch, nicht für jedes Land 100% exakt:** Viele Länder
/// sind mehrsprachig (z. B. Belgien, Kamerun); hier wird jeweils die
/// praktisch sinnvollste/meistgesprochene Amtssprache gewählt. Deckt alle
/// Länder aus `assets/data/emergency_numbers.json` ab, ohne Anspruch auf
/// linguistische Vollständigkeit die App braucht daraus nur eine
/// plausible UI-Sprache, nicht eine sprachwissenschaftlich exakte Aussage.
///
/// Die meisten hier gelisteten Sprachen haben (Stand AP7) **keine** eigene
/// ARB-Übersetzung (nur `de`/`en`/`fr`/`es` existieren) das ist gewollt,
/// siehe [resolveAppLocale]: Nicht unterstützte Sprachen fallen automatisch
/// auf Englisch zurück.
const Map<String, String> countryLocaleMap = {
'AD': 'ca',
'AE': 'ar',
'AF': 'fa',
'AG': 'en',
'AL': 'sq',
'AM': 'hy',
'AO': 'pt',
'AR': 'es',
'AT': 'de',
'AU': 'en',
'AZ': 'az',
'BA': 'bs',
'BB': 'en',
'BD': 'bn',
'BE': 'nl',
'BF': 'fr',
'BG': 'bg',
'BH': 'ar',
'BI': 'rn',
'BJ': 'fr',
'BN': 'ms',
'BO': 'es',
'BR': 'pt',
'BS': 'en',
'BT': 'dz',
'BW': 'en',
'BY': 'be',
'BZ': 'en',
'CA': 'en',
'CD': 'fr',
'CF': 'fr',
'CG': 'fr',
'CH': 'de',
'CI': 'fr',
'CL': 'es',
'CM': 'fr',
'CN': 'zh',
'CO': 'es',
'CR': 'es',
'CU': 'es',
'CV': 'pt',
'CY': 'el',
'CZ': 'cs',
'DE': 'de',
'DJ': 'fr',
'DK': 'da',
'DM': 'en',
'DO': 'es',
'DZ': 'ar',
'EC': 'es',
'EE': 'et',
'EG': 'ar',
'ER': 'ti',
'ES': 'es',
'ET': 'am',
'FI': 'fi',
'FJ': 'en',
'FM': 'en',
'FR': 'fr',
'GA': 'fr',
'GB': 'en',
'GD': 'en',
'GE': 'ka',
'GH': 'en',
'GM': 'en',
'GN': 'fr',
'GQ': 'es',
'GR': 'el',
'GT': 'es',
'GW': 'pt',
'GY': 'en',
'HK': 'zh',
'HN': 'es',
'HR': 'hr',
'HT': 'fr',
'HU': 'hu',
'ID': 'id',
'IE': 'en',
'IL': 'he',
'IN': 'hi',
'IQ': 'ar',
'IR': 'fa',
'IS': 'is',
'IT': 'it',
'JM': 'en',
'JO': 'ar',
'JP': 'ja',
'KE': 'sw',
'KG': 'ky',
'KH': 'km',
'KI': 'en',
'KM': 'ar',
'KN': 'en',
'KP': 'ko',
'KR': 'ko',
'KW': 'ar',
'KZ': 'kk',
'LA': 'lo',
'LB': 'ar',
'LC': 'en',
'LI': 'de',
'LK': 'si',
'LR': 'en',
'LS': 'en',
'LT': 'lt',
'LU': 'fr',
'LV': 'lv',
'LY': 'ar',
'MA': 'ar',
'MC': 'fr',
'MD': 'ro',
'ME': 'sr',
'MG': 'fr',
'MH': 'en',
'MK': 'mk',
'ML': 'fr',
'MM': 'my',
'MN': 'mn',
'MO': 'zh',
'MR': 'ar',
'MT': 'mt',
'MU': 'en',
'MV': 'dv',
'MW': 'en',
'MX': 'es',
'MY': 'ms',
'MZ': 'pt',
'NA': 'en',
'NE': 'fr',
'NG': 'en',
'NI': 'es',
'NL': 'nl',
'NO': 'nb',
'NP': 'ne',
'NR': 'en',
'NZ': 'en',
'OM': 'ar',
'PA': 'es',
'PE': 'es',
'PG': 'en',
'PH': 'en',
'PK': 'ur',
'PL': 'pl',
'PS': 'ar',
'PT': 'pt',
'PW': 'en',
'PY': 'es',
'QA': 'ar',
'RO': 'ro',
'RS': 'sr',
'RU': 'ru',
'RW': 'rw',
'SA': 'ar',
'SB': 'en',
'SC': 'en',
'SD': 'ar',
'SE': 'sv',
'SG': 'en',
'SI': 'sl',
'SK': 'sk',
'SL': 'en',
'SM': 'it',
'SN': 'fr',
'SO': 'so',
'SR': 'nl',
'SS': 'en',
'ST': 'pt',
'SV': 'es',
'SY': 'ar',
'SZ': 'en',
'TD': 'fr',
'TG': 'fr',
'TH': 'th',
'TJ': 'tg',
'TL': 'pt',
'TM': 'tk',
'TN': 'ar',
'TO': 'en',
'TR': 'tr',
'TT': 'en',
'TV': 'en',
'TW': 'zh',
'TZ': 'sw',
'UA': 'uk',
'UG': 'en',
'US': 'en',
'UY': 'es',
'UZ': 'uz',
'VA': 'it',
'VC': 'en',
'VE': 'es',
'VN': 'vi',
'VU': 'en',
'WS': 'en',
'XK': 'sq',
'YE': 'ar',
'ZA': 'en',
'ZM': 'en',
'ZW': 'en',
};
/// Sprache, auf die zurückgefallen wird, wenn kein gemapptes/unterstütztes
/// Ergebnis ermittelbar ist. Muss immer als `AppLocalizations.supportedLocales`
/// vorhanden sein.
const _fallbackLanguageCode = 'en';
/// Berechnet die effektiv zu verwendende App-[Locale] aus einem (optionalen)
/// ISO 3166-1 alpha-2 Ländercode und der Liste tatsächlich unterstützter
/// Locales (`AppLocalizations.supportedLocales`).
///
/// Regeln (`docs/plan.md`, Abschnitt 2 + 8, AP7):
/// - [isoCode] ist `null` (Land noch nicht ermittelt/unbekannt) → Fallback
/// Englisch, kein Rätselraten über die Gerätesystemsprache.
/// - [isoCode] ist nicht in [countryLocaleMap] gelistet → Fallback Englisch.
/// - Die gemappte Sprache ist nicht in [supportedLocales] enthalten (also
/// keine ARB-Übersetzung vorhanden) → Fallback Englisch.
/// - Sonst: die gemappte Sprache.
Locale resolveAppLocale(String? isoCode, List<Locale> supportedLocales) {
final languageCode = isoCode == null ? null : countryLocaleMap[isoCode.toUpperCase()];
if (languageCode != null &&
supportedLocales.any((locale) => locale.languageCode == languageCode)) {
return Locale(languageCode);
}
return const Locale(_fallbackLanguageCode);
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/country.dart';
import '../../providers/emergency_provider.dart';
/// Durchsuchbare Länderliste zur manuellen Länderauswahl
/// (`docs/plan.md`, Abschnitt 7 + 8, AP6).
///
/// Tap auf ein Land ruft [EmergencyProvider.selectCountryManually] auf
/// das persistiert die Auswahl und stößt die Ermittlungskette danach sofort
/// erneut an und schließt den Screen.
class CountryPickerScreen extends StatefulWidget {
const CountryPickerScreen({super.key});
@override
State<CountryPickerScreen> createState() => _CountryPickerScreenState();
}
class _CountryPickerScreenState extends State<CountryPickerScreen> {
final _searchController = TextEditingController();
String _query = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Country> _filtered(List<Country> countries) {
final query = _query.trim().toLowerCase();
if (query.isEmpty) return countries;
return countries
.where(
(country) =>
country.nameDe.toLowerCase().contains(query) ||
country.nameEn.toLowerCase().contains(query) ||
country.isoCode.toLowerCase().contains(query),
)
.toList(growable: false);
}
Future<void> _select(BuildContext context, Country country) async {
await context.read<EmergencyProvider>().selectCountryManually(country.isoCode);
if (context.mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final repository = context.read<EmergencyProvider>().repository;
final countries = _filtered(repository.allCountries);
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.pickerTitle)),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: l10n.pickerSearchHint,
border: const OutlineInputBorder(),
),
onChanged: (value) => setState(() => _query = value),
),
),
Expanded(
child: countries.isEmpty
? Center(child: Text(l10n.pickerNoResults))
: ListView.builder(
itemCount: countries.length,
itemBuilder: (context, index) {
final country = countries[index];
return ListTile(
key: ValueKey(country.isoCode),
leading: Text(country.flag, style: const TextStyle(fontSize: 24)),
title: Text(country.nameDe),
subtitle: Text(country.isoCode),
onTap: () => _select(context, country),
);
},
),
),
],
),
);
}
}
+231
View File
@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/country.dart';
import '../../models/emergency_number.dart';
import '../../models/emergency_type.dart';
import '../../providers/emergency_provider.dart';
import '../../providers/emergency_status.dart';
import 'country_picker_screen.dart';
import 'widgets/country_header.dart';
import 'widgets/emergency_button.dart';
/// Startbildschirm mit Länder-Header und Notruf-Buttons.
///
/// AP5 (State-Integration): Land und Ladezustand kommen jetzt aus dem
/// [EmergencyProvider] (SIM/GPS/Cache-Ermittlungskette aus AP4), nicht mehr
/// aus einem hartkodierten ISO-Code (AP3).
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
// Einmaliger Start der Ermittlungskette nach dem ersten Frame: init()
// ruft synchron (vor dem ersten await) notifyListeners() auf, was
// während der laufenden Baumkonstruktion (initState) zu "setState()
// called during build" führen würde daher erst im Post-Frame-Callback.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<EmergencyProvider>().init();
});
}
/// Globaler Panikmodus-Button (Plan Abschnitt 2 + 6): 112 funktioniert als
/// GSM-weiter Fallback in den meisten Mobilfunknetzen und muss unabhängig
/// vom Lade-/Fehlerzustand der Ländererkennung immer erreichbar sein.
static const _panicNumber = EmergencyNumber(
type: EmergencyType.general,
number: '112',
labelDe: 'Internationaler Notruf',
labelEn: 'International Emergency',
);
/// True, wenn das erkannte Land bereits eine `general`-Nummer "112" führt
/// (z. B. DE) dann würde der Panik-Button dieselbe Nummer doppelt zeigen.
bool _countryAlreadyHasPanicNumber(Country country) {
return country.numbers.any(
(n) => n.type == EmergencyType.general && n.number == _panicNumber.number,
);
}
bool _showPanicButton(EmergencyProvider provider) {
final country = provider.country;
if (provider.status == EmergencyStatus.ready && country != null) {
return !_countryAlreadyHasPanicNumber(country);
}
// Ladezustand, Fehler, countryUnknown oder Land nicht in Datenbasis:
// Panik-Button ist die einzig sichere Konstante, also immer sichtbar.
return true;
}
/// Öffnet den `CountryPickerScreen` (Plan Abschnitt 7 + 8, AP6: manuelle
/// Länderauswahl als Fallback, wenn das Land nicht automatisch ermittelbar
/// ist bzw. jederzeit als bewusste Nutzerentscheidung).
void _openCountryPicker(BuildContext context) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CountryPickerScreen()));
}
Widget _buildContent(BuildContext context, EmergencyProvider provider) {
final l10n = AppLocalizations.of(context)!;
switch (provider.status) {
case EmergencyStatus.initializing:
case EmergencyStatus.locating:
return const Center(child: CircularProgressIndicator());
case EmergencyStatus.error:
return _StatusMessage(
message: provider.errorMessage ?? l10n.errorGeneric,
retryLabel: l10n.retryButton,
selectCountryLabel: l10n.selectCountryButton,
onRetry: () => context.read<EmergencyProvider>().retry(),
onSelectCountry: () => _openCountryPicker(context),
);
// permissionDenied wird bewusst wie countryUnknown behandelt, siehe
// Doc-Kommentar in EmergencyProvider.
case EmergencyStatus.permissionDenied:
case EmergencyStatus.countryUnknown:
return _StatusMessage(
message: l10n.countryUnknownMessage,
retryLabel: l10n.retryButton,
selectCountryLabel: l10n.selectCountryButton,
onRetry: () => context.read<EmergencyProvider>().retry(),
onSelectCountry: () => _openCountryPicker(context),
);
case EmergencyStatus.ready:
final country = provider.country;
if (country == null) {
return _StatusMessage(message: l10n.countryNotInDatabaseMessage);
}
return _CountryContent(country: country);
}
}
@override
Widget build(BuildContext context) {
final provider = context.watch<EmergencyProvider>();
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
IconButton(
key: const Key('countryPickerButton'),
icon: const Icon(Icons.public),
tooltip: l10n.selectCountryButton,
onPressed: () => _openCountryPicker(context),
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_showPanicButton(provider))
EmergencyButton(
key: const Key('panicButton'),
emergencyNumber: _panicNumber,
),
Expanded(child: _buildContent(context, provider)),
],
),
),
),
);
}
}
/// Kurzer Hinweistext für Fehler-/Unknown-/Fallback-Zustände, optional mit
/// "Erneut versuchen"-Button.
class _StatusMessage extends StatelessWidget {
const _StatusMessage({
required this.message,
this.retryLabel,
this.selectCountryLabel,
this.onRetry,
this.onSelectCountry,
});
final String message;
final String? retryLabel;
final String? selectCountryLabel;
final VoidCallback? onRetry;
/// Öffnet den `CountryPickerScreen`, damit der Nutzer sein Land manuell
/// auswählen kann, wenn es nicht automatisch ermittelbar war (Plan
/// Abschnitt 7, "Land nicht ermittelbar → Panik-Button + Länder-Suchfeld").
final VoidCallback? onSelectCountry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
if (onRetry != null) ...[
const SizedBox(height: 12),
ElevatedButton(onPressed: onRetry, child: Text(retryLabel!)),
],
if (onSelectCountry != null) ...[
const SizedBox(height: 12),
TextButton.icon(
onPressed: onSelectCountry,
icon: const Icon(Icons.public),
label: Text(selectCountryLabel!),
),
],
],
),
),
);
}
}
/// Länder-Header + sortierte Liste der landesspezifischen Notruf-Buttons.
class _CountryContent extends StatelessWidget {
const _CountryContent({required this.country});
final Country country;
@override
Widget build(BuildContext context) {
// general zuoberst, danach der Rest in JSON-Reihenfolge.
final numbers = [
...country.numbers.where((n) => n.type == EmergencyType.general),
...country.numbers.where((n) => n.type != EmergencyType.general),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CountryHeader(country: country),
Expanded(
child: ListView.builder(
itemCount: numbers.length,
itemBuilder: (context, index) {
final number = numbers[index];
return EmergencyButton(
key: ValueKey(number.type),
emergencyNumber: number,
);
},
),
),
],
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../../../models/country.dart';
/// Kompakter Header, der Flagge und Name eines [Country] anzeigt.
class CountryHeader extends StatelessWidget {
const CountryHeader({super.key, required this.country});
final Country country;
/// Wählt `nameDe`/`nameEn` (AP1-Datenfeld, keine ARB-Übersetzung die
/// Notrufnummern-Datenbasis kennt bewusst nur diese zwei Sprachen, siehe
/// `docs/plan.md` Abschnitt 4) passend zur aktuellen App-Locale aus.
String _localizedName(BuildContext context) {
final languageCode = Localizations.localeOf(context).languageCode;
return languageCode == 'de' ? country.nameDe : country.nameEn;
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Text(country.flag, style: const TextStyle(fontSize: 32)),
const SizedBox(width: 12),
Text(
_localizedName(context),
style: Theme.of(context).textTheme.titleLarge,
),
],
),
);
}
}
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/emergency_number.dart';
import '../../../models/emergency_type.dart';
import '../../../services/dialer_service.dart';
/// Großer, gut antippbarer Button für eine einzelne [EmergencyNumber].
///
/// Löst den Anruf direkt über [DialerService] aus dafür ist kein
/// Callback nach oben nötig, da der Button keine weitere UI-Logik
/// beeinflusst. Kann der Wähler nicht geöffnet werden, zeigt der Button
/// die Nummer als einfachen Fallback in einer [SnackBar] an.
class EmergencyButton extends StatelessWidget {
const EmergencyButton({
super.key,
required this.emergencyNumber,
this.dialerService = const DialerService(),
});
final EmergencyNumber emergencyNumber;
final DialerService dialerService;
IconData get _icon {
switch (emergencyNumber.type) {
case EmergencyType.general:
return Icons.emergency;
case EmergencyType.police:
return Icons.local_police;
case EmergencyType.fire:
return Icons.local_fire_department;
case EmergencyType.ambulance:
return Icons.local_hospital;
case EmergencyType.seaRescue:
return Icons.directions_boat;
case EmergencyType.mountainRescue:
return Icons.terrain;
}
}
Future<void> _onTap(BuildContext context) async {
final success = await dialerService.call(emergencyNumber.number);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(emergencyNumber.number)),
);
}
}
/// Wählt `labelDe`/`labelEn` (AP1-Datenfeld, keine ARB-Übersetzung die
/// Notrufnummern-Datenbasis kennt bewusst nur diese zwei Sprachen, siehe
/// `docs/plan.md` Abschnitt 4) passend zur aktuellen App-Locale aus.
String _localizedNumberLabel(BuildContext context) {
final languageCode = Localizations.localeOf(context).languageCode;
return languageCode == 'de' ? emergencyNumber.labelDe : emergencyNumber.labelEn;
}
@override
Widget build(BuildContext context) {
final label = _localizedNumberLabel(context);
final semanticLabel = AppLocalizations.of(
context,
)!.emergencyButtonSemanticLabel(label, emergencyNumber.number);
return Semantics(
button: true,
label: semanticLabel,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SizedBox(
width: double.infinity,
height: 96,
child: ElevatedButton(
onPressed: () => _onTap(context),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Tooltip(
message: semanticLabel,
child: Row(
children: [
Icon(_icon, size: 40),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
emergencyNumber.number,
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
],
),
),
),
),
),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"@@locale": "de",
"appTitle": "Notruf",
"selectCountryButton": "Land manuell wählen",
"retryButton": "Erneut versuchen",
"errorGeneric": "Unbekannter Fehler beim Laden der Notrufnummern.",
"countryUnknownMessage": "Land konnte nicht ermittelt werden.",
"countryNotInDatabaseMessage": "Land nicht in der Notrufnummern-Datenbasis hinterlegt. Es steht nur der globale Notruf zur Verfügung.",
"pickerTitle": "Land auswählen",
"pickerSearchHint": "Land suchen",
"pickerNoResults": "Kein Land gefunden.",
"emergencyButtonSemanticLabel": "{label}, {number} anrufen"
}
+49 -1
View File
@@ -1,3 +1,51 @@
{
"@@locale": "en"
"@@locale": "en",
"appTitle": "Emergency",
"@appTitle": {
"description": "AppBar title of the home screen"
},
"selectCountryButton": "Select country manually",
"@selectCountryButton": {
"description": "Label of the button/tooltip used to open the manual country picker"
},
"retryButton": "Try again",
"@retryButton": {
"description": "Label of the button that retries country/number detection after an error"
},
"errorGeneric": "Unknown error while loading the emergency numbers.",
"@errorGeneric": {
"description": "Fallback error message shown when the provider does not report a specific error"
},
"countryUnknownMessage": "Country could not be determined.",
"@countryUnknownMessage": {
"description": "Shown when the country could not be detected automatically (and no location permission info is shown separately)"
},
"countryNotInDatabaseMessage": "Country not listed in the emergency number database. Only the global emergency number is available.",
"@countryNotInDatabaseMessage": {
"description": "Shown when the detected country has no entry in the emergency number database"
},
"pickerTitle": "Select country",
"@pickerTitle": {
"description": "AppBar title of the manual country picker screen"
},
"pickerSearchHint": "Search country",
"@pickerSearchHint": {
"description": "Hint text of the search field in the manual country picker screen"
},
"pickerNoResults": "No country found.",
"@pickerNoResults": {
"description": "Empty state shown when the search filter matches no country"
},
"emergencyButtonSemanticLabel": "{label}, call {number}",
"@emergencyButtonSemanticLabel": {
"description": "Semantics/tooltip label of an emergency button, combining the number's label and the number itself",
"placeholders": {
"label": {
"type": "String"
},
"number": {
"type": "String"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"@@locale": "es",
"appTitle": "Emergencia",
"selectCountryButton": "Elegir país manualmente",
"retryButton": "Reintentar",
"errorGeneric": "Error desconocido al cargar los números de emergencia.",
"countryUnknownMessage": "No se pudo determinar el país.",
"countryNotInDatabaseMessage": "País no disponible en la base de datos de números de emergencia. Solo está disponible el número de emergencia global.",
"pickerTitle": "Seleccionar país",
"pickerSearchHint": "Buscar país",
"pickerNoResults": "No se encontró ningún país.",
"emergencyButtonSemanticLabel": "{label}, llamar al {number}"
}
+13
View File
@@ -0,0 +1,13 @@
{
"@@locale": "fr",
"appTitle": "Urgence",
"selectCountryButton": "Choisir le pays manuellement",
"retryButton": "Réessayer",
"errorGeneric": "Erreur inconnue lors du chargement des numéros d'urgence.",
"countryUnknownMessage": "Impossible de déterminer le pays.",
"countryNotInDatabaseMessage": "Pays non répertorié dans la base de données des numéros d'urgence. Seul le numéro d'urgence global est disponible.",
"pickerTitle": "Sélectionner un pays",
"pickerSearchHint": "Rechercher un pays",
"pickerNoResults": "Aucun pays trouvé.",
"emergencyButtonSemanticLabel": "{label}, appeler le {number}"
}
+76 -2
View File
@@ -5,7 +5,10 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_de.dart';
import 'app_localizations_en.dart';
import 'app_localizations_es.dart';
import 'app_localizations_fr.dart';
// ignore_for_file: type=lint
@@ -92,7 +95,72 @@ abstract class AppLocalizations {
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('en')];
static const List<Locale> supportedLocales = <Locale>[
Locale('de'),
Locale('en'),
Locale('es'),
Locale('fr'),
];
/// AppBar title of the home screen
///
/// In en, this message translates to:
/// **'Emergency'**
String get appTitle;
/// Label of the button/tooltip used to open the manual country picker
///
/// In en, this message translates to:
/// **'Select country manually'**
String get selectCountryButton;
/// Label of the button that retries country/number detection after an error
///
/// In en, this message translates to:
/// **'Try again'**
String get retryButton;
/// Fallback error message shown when the provider does not report a specific error
///
/// In en, this message translates to:
/// **'Unknown error while loading the emergency numbers.'**
String get errorGeneric;
/// Shown when the country could not be detected automatically (and no location permission info is shown separately)
///
/// In en, this message translates to:
/// **'Country could not be determined.'**
String get countryUnknownMessage;
/// Shown when the detected country has no entry in the emergency number database
///
/// In en, this message translates to:
/// **'Country not listed in the emergency number database. Only the global emergency number is available.'**
String get countryNotInDatabaseMessage;
/// AppBar title of the manual country picker screen
///
/// In en, this message translates to:
/// **'Select country'**
String get pickerTitle;
/// Hint text of the search field in the manual country picker screen
///
/// In en, this message translates to:
/// **'Search country'**
String get pickerSearchHint;
/// Empty state shown when the search filter matches no country
///
/// In en, this message translates to:
/// **'No country found.'**
String get pickerNoResults;
/// Semantics/tooltip label of an emergency button, combining the number's label and the number itself
///
/// In en, this message translates to:
/// **'{label}, call {number}'**
String emergencyButtonSemanticLabel(String label, String number);
}
class _AppLocalizationsDelegate
@@ -106,7 +174,7 @@ class _AppLocalizationsDelegate
@override
bool isSupported(Locale locale) =>
<String>['en'].contains(locale.languageCode);
<String>['de', 'en', 'es', 'fr'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
@@ -115,8 +183,14 @@ class _AppLocalizationsDelegate
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'de':
return AppLocalizationsDe();
case 'en':
return AppLocalizationsEn();
case 'es':
return AppLocalizationsEs();
case 'fr':
return AppLocalizationsFr();
}
throw FlutterError(
+43
View File
@@ -0,0 +1,43 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for German (`de`).
class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get appTitle => 'Notruf';
@override
String get selectCountryButton => 'Land manuell wählen';
@override
String get retryButton => 'Erneut versuchen';
@override
String get errorGeneric => 'Unbekannter Fehler beim Laden der Notrufnummern.';
@override
String get countryUnknownMessage => 'Land konnte nicht ermittelt werden.';
@override
String get countryNotInDatabaseMessage =>
'Land nicht in der Notrufnummern-Datenbasis hinterlegt. Es steht nur der globale Notruf zur Verfügung.';
@override
String get pickerTitle => 'Land auswählen';
@override
String get pickerSearchHint => 'Land suchen';
@override
String get pickerNoResults => 'Kein Land gefunden.';
@override
String emergencyButtonSemanticLabel(String label, String number) {
return '$label, $number anrufen';
}
}
+34
View File
@@ -7,4 +7,38 @@ import 'app_localizations.dart';
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'Emergency';
@override
String get selectCountryButton => 'Select country manually';
@override
String get retryButton => 'Try again';
@override
String get errorGeneric =>
'Unknown error while loading the emergency numbers.';
@override
String get countryUnknownMessage => 'Country could not be determined.';
@override
String get countryNotInDatabaseMessage =>
'Country not listed in the emergency number database. Only the global emergency number is available.';
@override
String get pickerTitle => 'Select country';
@override
String get pickerSearchHint => 'Search country';
@override
String get pickerNoResults => 'No country found.';
@override
String emergencyButtonSemanticLabel(String label, String number) {
return '$label, call $number';
}
}
+44
View File
@@ -0,0 +1,44 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Spanish Castilian (`es`).
class AppLocalizationsEs extends AppLocalizations {
AppLocalizationsEs([String locale = 'es']) : super(locale);
@override
String get appTitle => 'Emergencia';
@override
String get selectCountryButton => 'Elegir país manualmente';
@override
String get retryButton => 'Reintentar';
@override
String get errorGeneric =>
'Error desconocido al cargar los números de emergencia.';
@override
String get countryUnknownMessage => 'No se pudo determinar el país.';
@override
String get countryNotInDatabaseMessage =>
'País no disponible en la base de datos de números de emergencia. Solo está disponible el número de emergencia global.';
@override
String get pickerTitle => 'Seleccionar país';
@override
String get pickerSearchHint => 'Buscar país';
@override
String get pickerNoResults => 'No se encontró ningún país.';
@override
String emergencyButtonSemanticLabel(String label, String number) {
return '$label, llamar al $number';
}
}
+44
View File
@@ -0,0 +1,44 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for French (`fr`).
class AppLocalizationsFr extends AppLocalizations {
AppLocalizationsFr([String locale = 'fr']) : super(locale);
@override
String get appTitle => 'Urgence';
@override
String get selectCountryButton => 'Choisir le pays manuellement';
@override
String get retryButton => 'Réessayer';
@override
String get errorGeneric =>
'Erreur inconnue lors du chargement des numéros d\'urgence.';
@override
String get countryUnknownMessage => 'Impossible de déterminer le pays.';
@override
String get countryNotInDatabaseMessage =>
'Pays non répertorié dans la base de données des numéros d\'urgence. Seul le numéro d\'urgence global est disponible.';
@override
String get pickerTitle => 'Sélectionner un pays';
@override
String get pickerSearchHint => 'Rechercher un pays';
@override
String get pickerNoResults => 'Aucun pays trouvé.';
@override
String emergencyButtonSemanticLabel(String label, String number) {
return '$label, appeler le $number';
}
}
+3 -118
View File
@@ -1,122 +1,7 @@
import 'package:flutter/material.dart';
import 'app.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: .center,
children: [
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
runApp(const App());
}
View File
+56
View File
@@ -0,0 +1,56 @@
import 'emergency_number.dart';
/// Ein Land mit seinen landesspezifischen Notrufnummern.
class Country {
const Country({
required this.isoCode,
required this.nameDe,
required this.nameEn,
required this.flag,
required this.numbers,
});
/// ISO 3166-1 alpha-2 Code, z. B. "DE".
final String isoCode;
final String nameDe;
final String nameEn;
final String flag;
final List<EmergencyNumber> numbers;
factory Country.fromJson(String isoCode, Map<String, dynamic> json) {
final numbersJson = json['numbers'] as List<dynamic>;
return Country(
isoCode: isoCode,
nameDe: json['name_de'] as String,
nameEn: json['name_en'] as String,
flag: json['flag'] as String,
numbers: numbersJson
.map((e) => EmergencyNumber.fromJson(e as Map<String, dynamic>))
.toList(growable: false),
);
}
@override
String toString() => 'Country(isoCode: $isoCode, nameEn: $nameEn)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Country &&
other.isoCode == isoCode &&
other.nameDe == nameDe &&
other.nameEn == nameEn &&
other.flag == flag &&
_listEquals(other.numbers, numbers));
@override
int get hashCode => Object.hash(isoCode, nameDe, nameEn, flag, Object.hashAll(numbers));
static bool _listEquals(List<EmergencyNumber> a, List<EmergencyNumber> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'emergency_type.dart';
/// Eine einzelne Notrufnummer eines Landes (z. B. Polizei, Feuerwehr).
class EmergencyNumber {
const EmergencyNumber({
required this.type,
required this.number,
required this.labelDe,
required this.labelEn,
});
final EmergencyType type;
final String number;
final String labelDe;
final String labelEn;
factory EmergencyNumber.fromJson(Map<String, dynamic> json) {
return EmergencyNumber(
type: EmergencyType.fromJson(json['type'] as String),
number: json['number'] as String,
labelDe: json['label_de'] as String,
labelEn: json['label_en'] as String,
);
}
@override
String toString() => 'EmergencyNumber(type: $type, number: $number)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is EmergencyNumber &&
other.type == type &&
other.number == number &&
other.labelDe == labelDe &&
other.labelEn == labelEn);
@override
int get hashCode => Object.hash(type, number, labelDe, labelEn);
}
+50
View File
@@ -0,0 +1,50 @@
/// Kategorie eines Notrufs, bestimmt Icon/Farbe/Priorität im UI.
enum EmergencyType {
general,
police,
fire,
ambulance,
seaRescue,
mountainRescue;
/// Wandelt den `type`-String aus `emergency_numbers.json` in den Enum-Wert um.
///
/// Wirft eine [FormatException], wenn der Wert unbekannt ist, damit
/// fehlerhafte Datensätze beim Laden auffallen statt still ignoriert zu werden.
static EmergencyType fromJson(String value) {
switch (value) {
case 'general':
return EmergencyType.general;
case 'police':
return EmergencyType.police;
case 'fire':
return EmergencyType.fire;
case 'ambulance':
return EmergencyType.ambulance;
case 'sea_rescue':
return EmergencyType.seaRescue;
case 'mountain_rescue':
return EmergencyType.mountainRescue;
default:
throw FormatException('Unbekannter EmergencyType: $value');
}
}
/// Wandelt den Enum-Wert zurück in den JSON-String (z. B. für Tests/Debugging).
String toJson() {
switch (this) {
case EmergencyType.general:
return 'general';
case EmergencyType.police:
return 'police';
case EmergencyType.fire:
return 'fire';
case EmergencyType.ambulance:
return 'ambulance';
case EmergencyType.seaRescue:
return 'sea_rescue';
case EmergencyType.mountainRescue:
return 'mountain_rescue';
}
}
}
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter/foundation.dart';
import '../models/country.dart';
import '../repositories/emergency_number_repository.dart';
import '../services/location_service.dart';
import 'emergency_status.dart';
/// Orchestriert Repository + Ländererkennung für die UI
/// (`docs/plan.md`, Abschnitt 5 + 6 + 8, AP5).
///
/// Kennt keinen `BuildContext`; die UI (`HomeScreen`) beobachtet [status]/
/// [country]/[errorMessage] über `ChangeNotifier` und ruft [init]/[retry] auf.
///
/// **Entscheidung zu `EmergencyStatus.permissionDenied`:** `LocationService`
/// liefert bewusst nur `String? isoCode` ohne Fehlergrund (AP4-Entscheidung,
/// um die Ermittlungskette einfach zu halten). Eine zusätzliche, direkte
/// Abfrage z. B. von `Geolocator.checkPermission()` hier im Provider würde
/// eine plattformnahe Geolocator-Abhängigkeit in die State-Schicht ziehen,
/// obwohl das reine Wissen um "GPS-Berechtigung verweigert" den eigentlichen
/// Ermittlungs-Flow gar nicht beeinflusst (SIM/Cache können trotzdem einen
/// Treffer liefern → dann ist der Status weiterhin `ready`, nicht
/// `permissionDenied`). Das würde Zuständigkeiten vermischen, ohne den
/// Nutzer:innen im Ladezustand einen Mehrwert zu bieten. Deshalb bleibt
/// `permissionDenied` für AP5 unbenutzt; der Fall "GPS verweigert und keine
/// andere Quelle liefert einen Code" wird korrekt (und ausreichend) über
/// `countryUnknown` abgebildet. Eine gezieltere Unterscheidung kann bei
/// Bedarf in einem späteren AP (Banner/UX-Feinschliff) ergänzt werden.
class EmergencyProvider extends ChangeNotifier {
// Bewusst keine initializing formals (`this._repository`): Die externen
// Konstruktorparameter sollen lesbar `repository`/`locationService`
// heißen, die Felder aber `_repository`/`_locationService` (privat).
EmergencyProvider({required EmergencyNumberRepository repository, required LocationService locationService})
: _repository = repository, // ignore: prefer_initializing_formals
_locationService = locationService; // ignore: prefer_initializing_formals
final EmergencyNumberRepository _repository;
final LocationService _locationService;
EmergencyStatus _status = EmergencyStatus.initializing;
Country? _country;
String? _errorMessage;
bool _disposed = false;
EmergencyStatus get status => _status;
Country? get country => _country;
String? get errorMessage => _errorMessage;
/// Exponiert das Repository für die UI (`docs/plan.md`, Abschnitt 8, AP6):
/// Der `CountryPickerScreen` braucht Zugriff auf die bereits geladene
/// Länderliste (`EmergencyNumberRepository.allCountries`), ohne eine
/// zweite, unabhängige Repository-Instanz zu erzeugen oder das Asset
/// erneut zu laden (`load()` ist idempotent, aber unnötig).
EmergencyNumberRepository get repository => _repository;
@override
void dispose() {
_disposed = true;
super.dispose();
}
/// Wie [notifyListeners], aber ein no-op, wenn der Provider inzwischen
/// disposed wurde kann passieren, wenn [init]/[retry] noch auf eine
/// echte Asset-/Plugin-I/O wartet (z. B. asset load, GPS), während die
/// UI (z. B. `HomeScreen`) längst nicht mehr im Widgetbaum ist.
void _notifyIfActive() {
if (!_disposed) notifyListeners();
}
/// Startet die Ermittlungskette: Datenbasis laden → Ländercode ermitteln
/// → Land nachschlagen. Wirft nie; Fehler landen im `error`-Status.
Future<void> init() async {
_status = EmergencyStatus.initializing;
_country = null;
_errorMessage = null;
_notifyIfActive();
try {
await _repository.load();
} catch (e) {
_status = EmergencyStatus.error;
_errorMessage = e.toString();
_notifyIfActive();
return;
}
_status = EmergencyStatus.locating;
_notifyIfActive();
final isoCode = await _locationService.resolveCountryCode();
if (isoCode == null) {
_status = EmergencyStatus.countryUnknown;
_country = null;
_notifyIfActive();
return;
}
// country bleibt `null`, wenn der ermittelte isoCode nicht in der
// Datenbasis vorhanden ist (Plan Abschnitt 7) Status ist dennoch
// `ready`, die UI fällt dann auf den globalen Panik-Button zurück.
_country = _repository.lookup(isoCode);
_status = EmergencyStatus.ready;
_notifyIfActive();
}
/// Wiederholt die komplette Ermittlungskette (kein separater Zustand nötig).
Future<void> retry() => init();
/// Setzt die manuelle Länderauswahl des Nutzers (`docs/plan.md`,
/// Abschnitt 8, AP6: `CountryPickerScreen`) und stößt die
/// Ermittlungskette danach sofort erneut an, damit [isoCode] unmittelbar
/// wirksam wird (die manuelle Auswahl hat in [LocationService] höchste
/// Priorität vor SIM/GPS/Cache).
Future<void> selectCountryManually(String isoCode) async {
await _locationService.setManualCountryCode(isoCode);
await init();
}
/// Löscht die manuelle Länderauswahl wieder und ermittelt das Land danach
/// erneut automatisch (SIM → GPS → Cache) erlaubt dem Nutzer, zur
/// automatischen Erkennung zurückzukehren (`docs/plan.md`, Abschnitt 8, AP6).
Future<void> clearManualSelection() async {
await _locationService.setManualCountryCode(null);
await init();
}
}
+36
View File
@@ -0,0 +1,36 @@
/// Zustände der Ländererkennungs-/Notrufnummern-Ermittlung
/// (`docs/plan.md`, Abschnitt 5 + 8, AP5).
///
/// Siehe [EmergencyProvider] für die Zustandsübergänge.
enum EmergencyStatus {
/// [EmergencyProvider.init] wurde gerade gestartet, die Notrufnummern-
/// Datenbasis wird geladen.
initializing,
/// Die Datenbasis ist geladen, die Ländererkennungskette
/// (SIM → GPS → Cache) läuft gerade.
locating,
/// Ermittlung abgeschlossen. `country` ist gesetzt, wenn das ermittelte
/// Land in der Datenbasis vorhanden ist; ist es `null`, wurde zwar ein
/// Ländercode ermittelt, aber kein Eintrag dafür gefunden (Plan
/// Abschnitt 7, "Land nicht in Datenbasis") die UI zeigt dann nur den
/// globalen Panik-Button.
ready,
/// Aktuell unbenutzt (siehe Doc-Kommentar in [EmergencyProvider]): Die
/// Standortberechtigung wurde verweigert. Dieser Fall wird zur Laufzeit
/// bewusst nicht von [countryUnknown] unterschieden, ist als Status aber
/// für eine spätere, gezieltere UI-Behandlung (z. B. Banner mit
/// Berechtigungs-Hinweis) bereits vorbereitet.
permissionDenied,
/// Kein Ländercode ermittelbar (alle Quellen der Ermittlungskette lieferten
/// `null`, Plan Abschnitt 7, "Land nicht ermittelbar").
countryUnknown,
/// Laden der Notrufnummern-Datenbasis ist fehlgeschlagen (z. B. kaputtes
/// JSON-Asset). Sollte im Normalbetrieb nicht auftreten, siehe Doc-Kommentar
/// in `EmergencyNumberRepository.load()`.
error,
}
View File
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import '../models/country.dart';
/// Lädt und cacht die statische Notrufnummern-Datenbasis aus
/// `assets/data/emergency_numbers.json`.
///
/// Die Datei wird nur beim ersten [load]-Aufruf eingelesen und geparst;
/// danach liefert [lookup] aus dem In-Memory-Cache.
class EmergencyNumberRepository {
EmergencyNumberRepository({this.assetPath = 'assets/data/emergency_numbers.json'});
final String assetPath;
Map<String, Country>? _countriesByIsoCode;
/// True, sobald [load] erfolgreich durchgelaufen ist.
bool get isLoaded => _countriesByIsoCode != null;
/// Liest und parst die JSON-Datei einmalig ein.
///
/// Wird [load] mehrfach aufgerufen, geschieht nach dem ersten
/// erfolgreichen Durchlauf nichts mehr (Cache bleibt bestehen).
///
/// Wirft eine [FormatException], wenn das JSON fehlerhaft ist das ist
/// ein Programmierfehler (kaputtes Asset) und soll beim Start auffallen,
/// nicht still verschluckt werden.
Future<void> load() async {
if (isLoaded) return;
final raw = await rootBundle.loadString(assetPath);
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) {
throw const FormatException(
'emergency_numbers.json: erwartetes Top-Level-Objekt fehlt',
);
}
final parsed = <String, Country>{};
for (final entry in decoded.entries) {
final isoCode = entry.key.toUpperCase();
parsed[isoCode] = Country.fromJson(isoCode, entry.value as Map<String, dynamic>);
}
_countriesByIsoCode = parsed;
}
/// Sucht ein Land anhand seines ISO 3166-1 alpha-2 Codes (case-insensitive).
///
/// Gibt `null` zurück, wenn [load] noch nicht aufgerufen wurde oder das
/// Land nicht in der Datenbasis vorhanden ist bewusst keine Exception,
/// damit Aufrufer den Fallback-Pfad (z. B. globaler 112/911-Button) ohne
/// try/catch abbilden können.
Country? lookup(String isoCode) {
final countries = _countriesByIsoCode;
if (countries == null) return null;
return countries[isoCode.toUpperCase()];
}
/// Alle Länder der Datenbasis, alphabetisch nach [Country.nameDe] sortiert
/// (`docs/plan.md`, Abschnitt 8, AP6: Grundlage für den `CountryPickerScreen`).
///
/// Liefert eine leere Liste, wenn [load] noch nicht (erfolgreich)
/// durchgelaufen ist bewusst kein Fehler, analog zu [lookup].
List<Country> get allCountries {
final countries = _countriesByIsoCode;
if (countries == null) return const [];
final sorted = countries.values.toList()..sort((a, b) => a.nameDe.compareTo(b.nameDe));
return sorted;
}
}
@@ -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();
}
}
+25
View File
@@ -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);
}
}
+70
View File
@@ -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);
}
+11 -3
View File
@@ -57,6 +57,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.12.6"
carrier_info:
dependency: "direct main"
description:
name: carrier_info
sha256: "4bddb1e2b6c9b57a16964890999430dc0c2756aaf4d5a7117308c0852b17dcf4"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
characters:
dependency: transitive
description:
@@ -230,7 +238,7 @@ packages:
source: hosted
version: "1.0.2"
geocoding_platform_interface:
dependency: transitive
dependency: "direct dev"
description:
name: geocoding_platform_interface
sha256: "5164b8871705af7573698b3aa0746337faf818d72b488988741bcc7aeedd2f6e"
@@ -270,7 +278,7 @@ packages:
source: hosted
version: "0.2.6"
geolocator_platform_interface:
dependency: transitive
dependency: "direct dev"
description:
name: geolocator_platform_interface
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
@@ -715,7 +723,7 @@ packages:
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
dependency: "direct dev"
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
+23
View File
@@ -38,6 +38,7 @@ dependencies:
geolocator: ^14.0.3
geocoding: ^5.0.0
permission_handler: ^12.0.3
carrier_info: ^3.0.3
provider: ^6.1.5+1
shared_preferences: ^2.5.5
flutter_localizations:
@@ -48,6 +49,16 @@ dev_dependencies:
flutter_test:
sdk: flutter
# Für Unit-Tests von DialerService: erlaubt das Ersetzen von
# UrlLauncherPlatform.instance durch eine Fake-Implementierung.
url_launcher_platform_interface: ^2.3.2
# Für Unit-Tests von GpsCountryCodeSource (AP4): erlaubt analog das
# Ersetzen von GeolocatorPlatform.instance / GeocodingPlatformFactory.instance
# durch Fake-Implementierungen, ohne echte Plattform-Kanäle.
geolocator_platform_interface: ^4.2.8
geocoding_platform_interface: ^5.0.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
@@ -55,6 +66,16 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^6.0.0
# Abweichung vom Plan nötig (AP4): `carrier_info: ^3.0.3` deklariert
# `flutter_lints: ^4.0.0` fälschlich als normale (nicht Dev-)Dependency, obwohl
# das Paket flutter_lints im eigenen lib/-Code gar nicht importiert (nur für
# die eigene Analyse verwendet). Ohne Override kollidiert das mit unserem
# `flutter_lints: ^6.0.0`. Der Override ist unkritisch, da flutter_lints rein
# ein Analyzer-Regelwerk ist und zur Laufzeit von carrier_info nicht genutzt
# wird.
dependency_overrides:
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -70,6 +91,8 @@ flutter:
uses-material-design: true
# To add assets to your application, add an assets section, like this:
assets:
- assets/data/emergency_numbers.json
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
+91
View File
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/app.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Siehe `test/widget_test.dart` für die Begründung dieser Fakes
/// (deterministische Ländererkennung ohne echte Plattform-Kanäle).
class _FakeSource implements CountryCodeSource {
_FakeSource(this._result);
final String? _result;
@override
Future<String?> resolve() async => _result;
}
class _FakeCache implements CountryCodeCache {
@override
Future<String?> resolve() async => null;
@override
Future<void> save(String isoCode) async {}
}
class _FakeManualSource implements ManualCountryCodeStore {
@override
Future<String?> resolve() async => null;
@override
Future<void> save(String? isoCode) async {}
}
/// AP7: Verifiziert, dass die sichtbare App-Sprache tatsächlich an das
/// erkannte Land gekoppelt ist (`docs/plan.md`, Abschnitt 2 + 8), nicht nur,
/// dass `resolveAppLocale` als reine Funktion korrekt rechnet
/// (`test/core/country_locale_map_test.dart`).
Future<void> _pumpAppWithCountry(WidgetTester tester, String? isoCode) async {
final repository = EmergencyNumberRepository();
await tester.runAsync(() => repository.load());
final locationService = LocationService(
simSource: _FakeSource(isoCode),
gpsSource: _FakeSource(null),
cacheSource: _FakeCache(),
manualSource: _FakeManualSource(),
);
await tester.pumpWidget(App(repository: repository, locationService: locationService));
await tester.pumpAndSettle();
}
/// Findet Text konkret innerhalb der `AppBar`, nicht irgendwo im Baum:
/// Der Notrufnummern-Datensatz mancher Länder (z. B. DE: "Notruf", US:
/// "Emergency") verwendet zufällig denselben Text wie der übersetzte
/// AppBar-Titel, ein ungebundener `find.text(...)` wäre daher mehrdeutig.
Finder _appBarText(String text) =>
find.descendant(of: find.byType(AppBar), matching: find.text(text));
void main() {
testWidgets('DE wird erkannt -> AppBar-Titel erscheint auf Deutsch ("Notruf")', (tester) async {
await _pumpAppWithCountry(tester, 'DE');
expect(_appBarText('Notruf'), findsOneWidget);
expect(_appBarText('Emergency'), findsNothing);
});
testWidgets('US wird erkannt -> AppBar-Titel erscheint auf Englisch ("Emergency")', (
tester,
) async {
await _pumpAppWithCountry(tester, 'US');
expect(_appBarText('Emergency'), findsOneWidget);
expect(_appBarText('Notruf'), findsNothing);
});
testWidgets('FR wird erkannt -> AppBar-Titel erscheint auf Französisch ("Urgence")', (
tester,
) async {
await _pumpAppWithCountry(tester, 'FR');
expect(_appBarText('Urgence'), findsOneWidget);
});
testWidgets('Land nicht ermittelbar -> Fallback auf Englisch ("Emergency")', (tester) async {
await _pumpAppWithCountry(tester, null);
expect(_appBarText('Emergency'), findsOneWidget);
});
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/core/country_locale_map.dart';
import 'package:emergency/l10n/app_localizations.dart';
void main() {
group('resolveAppLocale', () {
// Realistische Basis: die tatsächlich per ARB generierten Locales
// (docs/plan.md, Abschnitt 8, AP7), statt einer synthetischen Liste.
final supportedLocales = AppLocalizations.supportedLocales;
test('bekanntes Land mit unterstützter Sprache liefert diese Sprache (DE -> de)', () {
expect(resolveAppLocale('DE', supportedLocales), const Locale('de'));
});
test('Ländercode ist case-insensitiv (de -> de)', () {
expect(resolveAppLocale('de', supportedLocales), const Locale('de'));
});
test(
'bekanntes Land mit einer NICHT unterstützten Sprache fällt auf Englisch zurück (JP -> ja, nicht unterstützt)',
() {
expect(countryLocaleMap['JP'], 'ja');
expect(supportedLocales.any((l) => l.languageCode == 'ja'), isFalse);
expect(resolveAppLocale('JP', supportedLocales), const Locale('en'));
},
);
test('unbekannter/nicht gemappter Ländercode fällt auf Englisch zurück', () {
expect(countryLocaleMap.containsKey('ZZ'), isFalse);
expect(resolveAppLocale('ZZ', supportedLocales), const Locale('en'));
});
test('country == null (noch nicht ermittelt) fällt auf Englisch zurück', () {
expect(resolveAppLocale(null, supportedLocales), const Locale('en'));
});
test('weitere unterstützte Sprachen werden ebenfalls korrekt aufgelöst (FR -> fr, ES -> es)', () {
expect(resolveAppLocale('FR', supportedLocales), const Locale('fr'));
expect(resolveAppLocale('ES', supportedLocales), const Locale('es'));
});
});
}
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:emergency/features/home/country_picker_screen.dart';
import 'package:emergency/l10n/app_localizations.dart';
import 'package:emergency/models/country.dart';
import 'package:emergency/models/emergency_number.dart';
import 'package:emergency/models/emergency_type.dart';
import 'package:emergency/providers/emergency_provider.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Liefert eine feste Länderliste, ohne das echte JSON-Asset laden zu
/// müssen (analog zum Fake-Repository-Muster in `emergency_provider_test.dart`).
class _FakeRepository extends EmergencyNumberRepository {
@override
List<Country> get allCountries => const [
Country(
isoCode: 'DE',
nameDe: 'Deutschland',
nameEn: 'Germany',
flag: '🇩🇪',
numbers: [
EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'),
],
),
Country(
isoCode: 'FR',
nameDe: 'Frankreich',
nameEn: 'France',
flag: '🇫🇷',
numbers: [
EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'),
],
),
];
}
class _NoopSource implements CountryCodeSource {
const _NoopSource();
@override
Future<String?> resolve() async => null;
}
/// Fake-Provider: überschreibt `selectCountryManually`, damit der Test ohne
/// echte `LocationService`-Ermittlungskette prüfen kann, mit welchem
/// ISO-Code der Screen die Auswahl auslöst.
class _FakeProvider extends EmergencyProvider {
_FakeProvider({required super.repository})
: super(
locationService: LocationService(
simSource: const _NoopSource(),
gpsSource: const _NoopSource(),
),
);
String? selectedIsoCode;
int selectCallCount = 0;
@override
Future<void> selectCountryManually(String isoCode) async {
selectCallCount++;
selectedIsoCode = isoCode;
}
}
void main() {
Future<void> pumpPickerBehindAButton(WidgetTester tester, EmergencyProvider provider) async {
// CountryPickerScreen ruft `Navigator.pop()` beim Auswählen auf dafür
// muss eine vorherige Route existieren, sonst wäre der Pop ein No-op.
await tester.pumpWidget(
ChangeNotifierProvider<EmergencyProvider>.value(
value: provider,
child: MaterialApp(
// Feste Locale, damit die (unveränderten) deutschen Text-Assertions
// dieser Tests unabhängig von der Test-Umgebungs-/Systemsprache
// funktionieren; das eigentliche Umschalten der App-Sprache wird
// gezielt in `test/core/country_locale_map_test.dart` bzw.
// `test/app_locale_test.dart` (AP7) geprüft.
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const CountryPickerScreen()),
),
child: const Text('Öffnen'),
),
),
),
),
);
await tester.tap(find.text('Öffnen'));
await tester.pumpAndSettle();
}
testWidgets('zeigt die Länderliste an', (tester) async {
final provider = _FakeProvider(repository: _FakeRepository());
await pumpPickerBehindAButton(tester, provider);
expect(find.text('Deutschland'), findsOneWidget);
expect(find.text('Frankreich'), findsOneWidget);
});
testWidgets('Suchfeld filtert die Länderliste', (tester) async {
final provider = _FakeProvider(repository: _FakeRepository());
await pumpPickerBehindAButton(tester, provider);
await tester.enterText(find.byType(TextField), 'Deutsch');
await tester.pump();
expect(find.text('Deutschland'), findsOneWidget);
expect(find.text('Frankreich'), findsNothing);
});
testWidgets('zeigt einen Hinweis, wenn kein Land zum Filter passt', (tester) async {
final provider = _FakeProvider(repository: _FakeRepository());
await pumpPickerBehindAButton(tester, provider);
await tester.enterText(find.byType(TextField), 'Nichtland');
await tester.pump();
expect(find.text('Kein Land gefunden.'), findsOneWidget);
});
testWidgets('Tap auf ein Land löst selectCountryManually aus und schließt den Screen', (
tester,
) async {
final provider = _FakeProvider(repository: _FakeRepository());
await pumpPickerBehindAButton(tester, provider);
await tester.tap(find.text('Deutschland'));
await tester.pumpAndSettle();
expect(provider.selectCallCount, 1);
expect(provider.selectedIsoCode, 'DE');
// Screen wurde geschlossen (zurück zum "Öffnen"-Button).
expect(find.text('Öffnen'), findsOneWidget);
expect(find.text('Deutschland'), findsNothing);
});
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/models/country.dart';
import 'package:emergency/models/emergency_type.dart';
void main() {
group('Country.fromJson', () {
test('parst ein Land mit mehreren Notrufnummern korrekt', () {
final country = Country.fromJson('DE', {
'name_en': 'Germany',
'name_de': 'Deutschland',
'flag': '🇩🇪',
'numbers': [
{
'type': 'general',
'number': '112',
'label_de': 'Notruf',
'label_en': 'Emergency',
},
{
'type': 'police',
'number': '110',
'label_de': 'Polizei',
'label_en': 'Police',
},
],
});
expect(country.isoCode, 'DE');
expect(country.nameDe, 'Deutschland');
expect(country.nameEn, 'Germany');
expect(country.flag, '🇩🇪');
expect(country.numbers, hasLength(2));
expect(country.numbers[0].type, EmergencyType.general);
expect(country.numbers[1].type, EmergencyType.police);
});
test('parst ein Land mit nur einer Notrufnummer korrekt', () {
final country = Country.fromJson('US', {
'name_en': 'United States',
'name_de': 'Vereinigte Staaten',
'flag': '🇺🇸',
'numbers': [
{
'type': 'general',
'number': '911',
'label_de': 'Notruf',
'label_en': 'Emergency',
},
],
});
expect(country.numbers, hasLength(1));
expect(country.numbers.single.number, '911');
});
});
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/models/emergency_number.dart';
import 'package:emergency/models/emergency_type.dart';
void main() {
group('EmergencyNumber.fromJson', () {
test('parst gültiges JSON korrekt', () {
final number = EmergencyNumber.fromJson({
'type': 'police',
'number': '110',
'label_de': 'Polizei',
'label_en': 'Police',
});
expect(number.type, EmergencyType.police);
expect(number.number, '110');
expect(number.labelDe, 'Polizei');
expect(number.labelEn, 'Police');
});
test('zwei inhaltsgleiche Instanzen sind gleich', () {
final a = EmergencyNumber.fromJson({
'type': 'general',
'number': '112',
'label_de': 'Notruf',
'label_en': 'Emergency',
});
final b = EmergencyNumber.fromJson({
'type': 'general',
'number': '112',
'label_de': 'Notruf',
'label_en': 'Emergency',
});
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
});
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/models/emergency_type.dart';
void main() {
group('EmergencyType', () {
test('fromJson mappt alle bekannten Werte korrekt', () {
expect(EmergencyType.fromJson('general'), EmergencyType.general);
expect(EmergencyType.fromJson('police'), EmergencyType.police);
expect(EmergencyType.fromJson('fire'), EmergencyType.fire);
expect(EmergencyType.fromJson('ambulance'), EmergencyType.ambulance);
expect(EmergencyType.fromJson('sea_rescue'), EmergencyType.seaRescue);
expect(EmergencyType.fromJson('mountain_rescue'), EmergencyType.mountainRescue);
});
test('fromJson wirft FormatException bei unbekanntem Wert', () {
expect(() => EmergencyType.fromJson('unknown'), throwsFormatException);
});
test('toJson ist invers zu fromJson', () {
for (final type in EmergencyType.values) {
expect(EmergencyType.fromJson(type.toJson()), type);
}
});
});
}
+247
View File
@@ -0,0 +1,247 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/models/country.dart';
import 'package:emergency/models/emergency_number.dart';
import 'package:emergency/models/emergency_type.dart';
import 'package:emergency/providers/emergency_provider.dart';
import 'package:emergency/providers/emergency_status.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Einfache manuelle Fakes statt Mocking-Package (siehe AP4-Vorgabe: kein
/// neues Mocking-Package einführen). Beide Basisklassen sind nicht `final`/
/// `sealed`, daher genügt es, `load`/`lookup` bzw. `resolveCountryCode` zu
/// überschreiben, statt ein eigenes Interface einzuführen.
class _FakeRepository extends EmergencyNumberRepository {
_FakeRepository({this.loadError, this.countryToReturn});
Object? loadError;
Country? countryToReturn;
int loadCallCount = 0;
@override
Future<void> load() async {
loadCallCount++;
final error = loadError;
if (error != null) throw error;
}
@override
Country? lookup(String isoCode) => countryToReturn;
}
/// Nur zur Erfüllung des `super()`-Konstruktors von [LocationService] nötig
/// (wird nie aufgerufen, da [_FakeLocationService.resolveCountryCode]
/// überschrieben ist). Ohne einen expliziten `gpsSource` würde
/// `LocationService()` intern ein echtes `GpsCountryCodeSource` (und damit
/// `Geocoding()`) konstruieren, was außerhalb eines registrierten
/// Platform-Interface (siehe `gps_country_code_source_test.dart`) sofort wirft.
class _NoopSource implements CountryCodeSource {
const _NoopSource();
@override
Future<String?> resolve() async => null;
}
class _FakeLocationService extends LocationService {
_FakeLocationService(this.isoCode)
: super(simSource: const _NoopSource(), gpsSource: const _NoopSource());
/// Simuliert das Ergebnis der automatischen Kette (SIM → GPS → Cache).
String? isoCode;
/// Simuliert die manuelle Auswahl (AP6): hat, analog zur echten
/// Priorisierung in `LocationService`, Vorrang vor [isoCode].
String? manualIsoCode;
int resolveCallCount = 0;
int setManualCallCount = 0;
String? lastManualCode;
@override
Future<String?> resolveCountryCode() async {
resolveCallCount++;
return manualIsoCode ?? isoCode;
}
@override
Future<void> setManualCountryCode(String? code) async {
setManualCallCount++;
lastManualCode = code;
manualIsoCode = code;
}
}
const _germany = Country(
isoCode: 'DE',
nameDe: 'Deutschland',
nameEn: 'Germany',
flag: '🇩🇪',
numbers: [
EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'),
],
);
const _france = Country(
isoCode: 'FR',
nameDe: 'Frankreich',
nameEn: 'France',
flag: '🇫🇷',
numbers: [
EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'),
],
);
void main() {
group('EmergencyProvider.init()', () {
test('erfolgreicher Durchlauf -> status ready mit Land', () async {
final provider = EmergencyProvider(
repository: _FakeRepository(countryToReturn: _germany),
locationService: _FakeLocationService('DE'),
);
await provider.init();
expect(provider.status, EmergencyStatus.ready);
expect(provider.country, _germany);
expect(provider.errorMessage, isNull);
});
test('Ländercode nicht ermittelbar -> status countryUnknown', () async {
final provider = EmergencyProvider(
repository: _FakeRepository(countryToReturn: _germany),
locationService: _FakeLocationService(null),
);
await provider.init();
expect(provider.status, EmergencyStatus.countryUnknown);
expect(provider.country, isNull);
});
test('Land nicht in Datenbasis -> status ready mit country == null', () async {
final provider = EmergencyProvider(
repository: _FakeRepository(countryToReturn: null),
locationService: _FakeLocationService('XX'),
);
await provider.init();
expect(provider.status, EmergencyStatus.ready);
expect(provider.country, isNull);
});
test('repository.load() wirft -> status error mit errorMessage', () async {
final repository = _FakeRepository(loadError: const FormatException('kaputtes Asset'));
final locationService = _FakeLocationService('DE');
final provider = EmergencyProvider(repository: repository, locationService: locationService);
await provider.init();
expect(provider.status, EmergencyStatus.error);
expect(provider.errorMessage, isNotNull);
expect(repository.loadCallCount, 1);
// Ländererkennung darf nach einem Ladefehler nicht mehr angestoßen werden.
expect(locationService.resolveCallCount, 0);
});
test('durchläuft die Status initializing -> locating -> ready', () async {
final provider = EmergencyProvider(
repository: _FakeRepository(countryToReturn: _germany),
locationService: _FakeLocationService('DE'),
);
final observedStatuses = <EmergencyStatus>[];
provider.addListener(() => observedStatuses.add(provider.status));
await provider.init();
expect(observedStatuses, [
EmergencyStatus.initializing,
EmergencyStatus.locating,
EmergencyStatus.ready,
]);
});
});
group('EmergencyProvider.retry()', () {
test('wiederholt init() und kann nach einem Fehler erfolgreich sein', () async {
final repository = _FakeRepository(
loadError: const FormatException('kaputtes Asset'),
countryToReturn: _germany,
);
final locationService = _FakeLocationService('DE');
final provider = EmergencyProvider(repository: repository, locationService: locationService);
await provider.init();
expect(provider.status, EmergencyStatus.error);
expect(repository.loadCallCount, 1);
repository.loadError = null;
await provider.retry();
expect(provider.status, EmergencyStatus.ready);
expect(provider.country, _germany);
expect(provider.errorMessage, isNull);
expect(repository.loadCallCount, 2);
});
});
group('EmergencyProvider.selectCountryManually()', () {
test('persistiert die Auswahl und stößt init() erneut an', () async {
final repository = _FakeRepository(countryToReturn: _germany);
final locationService = _FakeLocationService('DE');
final provider = EmergencyProvider(repository: repository, locationService: locationService);
await provider.init();
expect(provider.country, _germany);
repository.countryToReturn = _france;
await provider.selectCountryManually('FR');
expect(locationService.setManualCallCount, 1);
expect(locationService.lastManualCode, 'FR');
// init() wurde ein zweites Mal komplett durchlaufen.
expect(locationService.resolveCallCount, 2);
expect(provider.status, EmergencyStatus.ready);
expect(provider.country, _france);
});
});
group('EmergencyProvider.clearManualSelection()', () {
test('löscht die manuelle Auswahl und ermittelt danach erneut automatisch', () async {
// isoCode simuliert das Ergebnis der automatischen Kette (z. B. SIM),
// manualIsoCode eine bereits zuvor gesetzte manuelle Auswahl.
final locationService = _FakeLocationService('DE')..manualIsoCode = 'FR';
final repository = _FakeRepository(countryToReturn: _france);
final provider = EmergencyProvider(repository: repository, locationService: locationService);
await provider.init();
expect(provider.country, _france); // manuelle Auswahl hat Vorrang
repository.countryToReturn = _germany;
await provider.clearManualSelection();
expect(locationService.setManualCallCount, 1);
expect(locationService.lastManualCode, isNull);
expect(locationService.manualIsoCode, isNull);
expect(locationService.resolveCallCount, 2);
expect(provider.status, EmergencyStatus.ready);
// Nach dem Löschen greift wieder das automatische Ergebnis (DE).
expect(provider.country, _germany);
});
});
group('EmergencyProvider.repository', () {
test('exponiert das intern verwendete Repository', () {
final repository = _FakeRepository(countryToReturn: _germany);
final provider = EmergencyProvider(
repository: repository,
locationService: _FakeLocationService('DE'),
);
expect(provider.repository, same(repository));
});
});
}
@@ -0,0 +1,97 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('EmergencyNumberRepository', () {
test('lookup() vor load() liefert null statt Exception', () {
final repository = EmergencyNumberRepository();
expect(repository.isLoaded, isFalse);
expect(repository.lookup('DE'), isNull);
});
test('load() liest das Asset ein und lookup() liefert bekannte Länder', () async {
final repository = EmergencyNumberRepository();
await repository.load();
expect(repository.isLoaded, isTrue);
final germany = repository.lookup('DE');
expect(germany, isNotNull);
expect(germany!.nameEn, 'Germany');
expect(germany.nameDe, 'Deutschland');
expect(germany.numbers.map((e) => e.number), containsAll(['112', '110']));
final us = repository.lookup('US');
expect(us, isNotNull);
expect(us!.nameEn, 'United States');
expect(us.numbers.single.number, '911');
final uk = repository.lookup('GB');
expect(uk, isNotNull);
expect(uk!.nameEn, 'United Kingdom');
expect(uk.numbers.map((e) => e.number), contains('999'));
});
test('lookup() ist case-insensitive', () async {
final repository = EmergencyNumberRepository();
await repository.load();
final lower = repository.lookup('de');
final mixed = repository.lookup('De');
final upper = repository.lookup('DE');
expect(lower, isNotNull);
expect(lower, equals(upper));
expect(mixed, equals(upper));
});
test('lookup() mit unbekanntem Ländercode liefert null', () async {
final repository = EmergencyNumberRepository();
await repository.load();
expect(repository.lookup('ZZ'), isNull);
expect(repository.lookup(''), isNull);
});
test('load() ist idempotent (zweiter Aufruf lädt nicht erneut)', () async {
final repository = EmergencyNumberRepository();
await repository.load();
final firstLookup = repository.lookup('DE');
await repository.load();
final secondLookup = repository.lookup('DE');
expect(firstLookup, equals(secondLookup));
});
});
group('EmergencyNumberRepository.allCountries', () {
test('vor load() liefert eine leere Liste statt Exception', () {
final repository = EmergencyNumberRepository();
expect(repository.allCountries, isEmpty);
});
test('nach load() nicht leer und enthält bekannte Länder (DE/US)', () async {
final repository = EmergencyNumberRepository();
await repository.load();
final countries = repository.allCountries;
expect(countries, isNotEmpty);
expect(countries.map((c) => c.isoCode), containsAll(['DE', 'US']));
});
test('ist alphabetisch nach nameDe sortiert', () async {
final repository = EmergencyNumberRepository();
await repository.load();
final names = repository.allCountries.map((c) => c.nameDe).toList();
final sortedNames = [...names]..sort();
expect(names, equals(sortedNames));
});
});
}
@@ -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);
},
);
});
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import 'package:emergency/services/dialer_service.dart';
/// Fake-Implementierung der `url_launcher`-Plattformschnittstelle.
///
/// Vermeidet Mocking auf `MethodChannel`-Ebene: `url_launcher` erlaubt es,
/// [UrlLauncherPlatform.instance] durch eine eigene Implementierung zu
/// ersetzen das ist der offiziell vorgesehene Weg, das Package in reinen
/// Unit-Tests ohne echte Plattform-Kanäle zu testen.
class _FakeUrlLauncherPlatform extends UrlLauncherPlatform {
_FakeUrlLauncherPlatform({required this.canLaunchResult});
final bool canLaunchResult;
String? launchedUrl;
int launchCallCount = 0;
@override
LinkDelegate? get linkDelegate => null;
@override
Future<bool> canLaunch(String url) async => canLaunchResult;
@override
Future<bool> launchUrl(String url, LaunchOptions options) async {
launchCallCount++;
launchedUrl = url;
return true;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('DialerService', () {
const dialerService = DialerService();
test('call() öffnet den Wähler mit tel:-URI und liefert true, wenn canLaunch true ist', () async {
final fakePlatform = _FakeUrlLauncherPlatform(canLaunchResult: true);
UrlLauncherPlatform.instance = fakePlatform;
final result = await dialerService.call('112');
expect(result, isTrue);
expect(fakePlatform.launchCallCount, 1);
expect(fakePlatform.launchedUrl, 'tel:112');
});
test('call() liefert false ohne launch(), wenn canLaunch false ist', () async {
final fakePlatform = _FakeUrlLauncherPlatform(canLaunchResult: false);
UrlLauncherPlatform.instance = fakePlatform;
final result = await dialerService.call('911');
expect(result, isFalse);
expect(fakePlatform.launchCallCount, 0);
});
});
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Einfache manuelle Fake-Quelle statt Mocking-Package (siehe AP4-Vorgabe:
/// kein neues Mocking-Package einführen).
class _FakeSource implements CountryCodeSource {
_FakeSource(this._result);
final String? _result;
int callCount = 0;
@override
Future<String?> resolve() async {
callCount++;
return _result;
}
}
class _FakeCache implements CountryCodeCache {
_FakeCache({String? initial}) : _stored = initial;
String? _stored;
int resolveCallCount = 0;
final List<String> savedCodes = [];
@override
Future<String?> resolve() async {
resolveCallCount++;
return _stored;
}
@override
Future<void> save(String isoCode) async {
savedCodes.add(isoCode);
_stored = isoCode;
}
}
/// Fake für die manuelle Auswahl (AP6). Ohne explizite Injektion würde
/// `LocationService()` intern ein echtes `ManualCountryCodeSource` (und damit
/// `shared_preferences`) ansprechen, was in diesen reinen Unit-Tests (kein
/// `TestWidgetsFlutterBinding.ensureInitialized()`/Mock-Setup) nicht
/// notwendig sein soll die bestehenden SIM/GPS/Cache-Tests sollen von der
/// manuellen Auswahl unbeeinflusst bleiben.
class _FakeManualSource implements ManualCountryCodeStore {
_FakeManualSource([this._result]);
final String? _result;
int resolveCallCount = 0;
final List<String?> savedCodes = [];
@override
Future<String?> resolve() async {
resolveCallCount++;
return _result;
}
@override
Future<void> save(String? isoCode) async {
savedCodes.add(isoCode);
}
}
void main() {
group('LocationService.resolveCountryCode()', () {
test(
'SIM liefert Ergebnis -> wird verwendet, GPS/Cache werden nicht abgefragt, '
'Ergebnis wird gecacht',
() async {
final sim = _FakeSource('DE');
final gps = _FakeSource('FR');
final cache = _FakeCache(initial: 'US');
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: _FakeManualSource(),
);
final result = await service.resolveCountryCode();
expect(result, 'DE');
expect(sim.callCount, 1);
expect(gps.callCount, 0);
expect(cache.resolveCallCount, 0);
expect(cache.savedCodes, ['DE']);
},
);
test(
'SIM liefert null, GPS liefert Ergebnis -> wird verwendet, Cache wird nicht '
'gelesen, aber beschrieben',
() async {
final sim = _FakeSource(null);
final gps = _FakeSource('FR');
final cache = _FakeCache(initial: 'US');
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: _FakeManualSource(),
);
final result = await service.resolveCountryCode();
expect(result, 'FR');
expect(sim.callCount, 1);
expect(gps.callCount, 1);
expect(cache.resolveCallCount, 0);
expect(cache.savedCodes, ['FR']);
},
);
test('SIM und GPS liefern null, Cache liefert Ergebnis -> wird verwendet', () async {
final sim = _FakeSource(null);
final gps = _FakeSource(null);
final cache = _FakeCache(initial: 'US');
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: _FakeManualSource(),
);
final result = await service.resolveCountryCode();
expect(result, 'US');
expect(cache.resolveCallCount, 1);
// Kein erneutes Schreiben, da das Ergebnis ja bereits aus dem Cache kam.
expect(cache.savedCodes, isEmpty);
});
test('Alle drei automatischen Quellen liefern null -> Ergebnis ist null', () async {
final sim = _FakeSource(null);
final gps = _FakeSource(null);
final cache = _FakeCache();
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: _FakeManualSource(),
);
final result = await service.resolveCountryCode();
expect(result, isNull);
expect(cache.savedCodes, isEmpty);
});
test(
'manuelle Auswahl gesetzt -> hat Vorrang vor SIM/GPS/Cache, die anderen '
'Quellen werden gar nicht erst abgefragt',
() async {
final sim = _FakeSource('DE');
final gps = _FakeSource('FR');
final cache = _FakeCache(initial: 'US');
final manual = _FakeManualSource('IT');
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: manual,
);
final result = await service.resolveCountryCode();
expect(result, 'IT');
expect(sim.callCount, 0);
expect(gps.callCount, 0);
expect(cache.resolveCallCount, 0);
// Die manuelle Auswahl wird bewusst nicht in den automatischen Cache
// geschrieben (getrennte Mechanismen, siehe Doc-Kommentar).
expect(cache.savedCodes, isEmpty);
},
);
test('keine manuelle Auswahl gesetzt -> Kette fällt normal auf SIM zurück', () async {
final sim = _FakeSource('DE');
final gps = _FakeSource('FR');
final cache = _FakeCache(initial: 'US');
final manual = _FakeManualSource(null);
final service = LocationService(
simSource: sim,
gpsSource: gps,
cacheSource: cache,
manualSource: manual,
);
final result = await service.resolveCountryCode();
expect(result, 'DE');
expect(manual.resolveCallCount, 1);
});
});
group('LocationService.setManualCountryCode()', () {
test('delegiert an die manuelle Quelle', () async {
final manual = _FakeManualSource();
final service = LocationService(
simSource: _FakeSource(null),
gpsSource: _FakeSource(null),
cacheSource: _FakeCache(),
manualSource: manual,
);
await service.setManualCountryCode('IT');
await service.setManualCountryCode(null);
expect(manual.savedCodes, ['IT', null]);
});
});
}
+95 -21
View File
@@ -1,30 +1,104 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:emergency/main.dart';
import 'package:emergency/app.dart';
import 'package:emergency/features/home/widgets/emergency_button.dart';
import 'package:emergency/repositories/emergency_number_repository.dart';
import 'package:emergency/services/country_code_sources/country_code_source.dart';
import 'package:emergency/services/location_service.dart';
/// Einfache manuelle Fake-Quelle statt Mocking-Package (siehe AP4-Vorgabe:
/// kein neues Mocking-Package einführen), analog zu
/// `test/services/location_service_test.dart`.
class _FakeSource implements CountryCodeSource {
_FakeSource(this._result);
final String? _result;
@override
Future<String?> resolve() async => _result;
}
/// Fake-Cache: Verhindert, dass `LocationService` beim Cachen eines
/// SIM/GPS-Treffers den echten `shared_preferences`-Plugin-Kanal anspricht
/// (der ohne Mock-Setup in reinen Widget-Tests nicht antwortet).
class _FakeCache implements CountryCodeCache {
@override
Future<String?> resolve() async => null;
@override
Future<void> save(String isoCode) async {}
}
/// Analog zu `_FakeCache`, aber für die manuelle Auswahl (AP6): verhindert
/// den echten `shared_preferences`-Zugriff, den `LocationService` seit AP6
/// als vorgeschaltete Prioritätsstufe abfragt.
class _FakeManualSource implements ManualCountryCodeStore {
@override
Future<String?> resolve() async => null;
@override
Future<void> save(String? isoCode) async {}
}
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
testWidgets('HomeScreen zeigt nach dem Laden mindestens einen EmergencyButton für DE', (
WidgetTester tester,
) async {
// Asset-Laden über rootBundle ist echte I/O und läuft nicht innerhalb des
// FakeAsync-Zeitrahmens von pump()/pumpAndSettle() durch. Deshalb wird
// die Datenbasis hier vorab über runAsync geladen (Repository.load() ist
// danach idempotent, s. Repository). Die Ländererkennung selbst wird
// über eine gefakte SIM-Quelle deterministisch auf "DE" gesetzt, damit
// der Test nicht von echten Plattform-Kanälen (SIM/GPS/Cache) abhängt.
final repository = EmergencyNumberRepository();
await tester.runAsync(() => repository.load());
// gpsSource muss explizit gesetzt werden: Ohne eigenen Wert würde
// LocationService() intern ein echtes GpsCountryCodeSource (und damit
// Geocoding()) konstruieren, was ohne registriertes Platform-Interface
// sofort wirft (siehe gps_country_code_source_test.dart). cacheSource
// wird ebenfalls gefaked, siehe _FakeCache.
final locationService = LocationService(
simSource: _FakeSource('DE'),
gpsSource: _FakeSource(null),
cacheSource: _FakeCache(),
manualSource: _FakeManualSource(),
);
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
await tester.pumpWidget(App(repository: repository, locationService: locationService));
await tester.pumpAndSettle();
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.byType(EmergencyButton), findsWidgets);
// DE führt "112" bereits als general-Nummer, daher darf der globale
// Panik-Button hier nicht zusätzlich dieselbe Nummer anzeigen (Dedup).
expect(find.text('112'), findsOneWidget);
expect(find.byKey(const Key('panicButton')), findsNothing);
});
testWidgets(
'HomeScreen zeigt den globalen Panik-Button bereits während des Ladens der Ländererkennung',
(WidgetTester tester) async {
// Bewusst kein runAsync/Vorladen des Repositories: Der Ladezustand
// (EmergencyStatus.initializing) soll direkt nach pumpWidget
// beobachtbar sein, ohne auf das Ende des echten Asset-Ladens zu warten.
// Die Ländererkennung wird ohnehin nie erreicht (blockiert beim
// Repository-Laden), locationService wird trotzdem explizit gefaked,
// siehe Kommentar im Test oben.
await tester.pumpWidget(
App(
repository: EmergencyNumberRepository(),
locationService: LocationService(
simSource: _FakeSource(null),
gpsSource: _FakeSource(null),
cacheSource: _FakeCache(),
manualSource: _FakeManualSource(),
),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byKey(const Key('panicButton')), findsOneWidget);
expect(find.text('112'), findsOneWidget);
},
);
}