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,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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user