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