diff --git a/.gitignore b/.gitignore index 3820a95..addfb96 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.pyc *.swp .DS_Store +.sync_*.db .atom/ .build/ .buildlog/ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a5b6216..b7591f7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,22 @@ + + + + + + + + + + + + + + diff --git a/assets/data/.gitkeep b/assets/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/data/emergency_numbers.json b/assets/data/emergency_numbers.json new file mode 100644 index 0000000..d9d8983 --- /dev/null +++ b/assets/data/emergency_numbers.json @@ -0,0 +1,4089 @@ +{ + "AD": { + "flag": "🇦🇩", + "name_de": "Andorra", + "name_en": "Andorra", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "118", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "116", + "type": "ambulance" + } + ] + }, + "AE": { + "flag": "🇦🇪", + "name_de": "Vereinigte Arabische Emirate", + "name_en": "United Arab Emirates", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "998", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "997", + "type": "fire" + } + ] + }, + "AF": { + "flag": "🇦🇫", + "name_de": "Afghanistan", + "name_en": "Afghanistan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "119", + "type": "police" + } + ] + }, + "AG": { + "flag": "🇦🇬", + "name_de": "Antigua und Barbuda", + "name_en": "Antigua and Barbuda", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "AL": { + "flag": "🇦🇱", + "name_de": "Albanien", + "name_en": "Albania", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "129", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "128", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "127", + "type": "ambulance" + } + ] + }, + "AM": { + "flag": "🇦🇲", + "name_de": "Armenien", + "name_en": "Armenia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "AO": { + "flag": "🇦🇴", + "name_de": "Angola", + "name_en": "Angola", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "113", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "115", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "116", + "type": "ambulance" + } + ] + }, + "AR": { + "flag": "🇦🇷", + "name_de": "Argentinien", + "name_en": "Argentina", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "AT": { + "flag": "🇦🇹", + "name_de": "Österreich", + "name_en": "Austria", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "133", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "122", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "144", + "type": "ambulance" + } + ] + }, + "AU": { + "flag": "🇦🇺", + "name_de": "Australien", + "name_en": "Australia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "000", + "type": "general" + }, + { + "label_de": "Notruf (Mobilfunk)", + "label_en": "Emergency (mobile)", + "number": "112", + "type": "general" + } + ] + }, + "AZ": { + "flag": "🇦🇿", + "name_de": "Aserbaidschan", + "name_en": "Azerbaijan", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "BA": { + "flag": "🇧🇦", + "name_de": "Bosnien und Herzegowina", + "name_en": "Bosnia and Herzegovina", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "122", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "123", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "124", + "type": "ambulance" + } + ] + }, + "BB": { + "flag": "🇧🇧", + "name_de": "Barbados", + "name_en": "Barbados", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "211", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "311", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "511", + "type": "ambulance" + } + ] + }, + "BD": { + "flag": "🇧🇩", + "name_de": "Bangladesch", + "name_en": "Bangladesh", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "BE": { + "flag": "🇧🇪", + "name_de": "Belgien", + "name_en": "Belgium", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "101", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "100", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "100", + "type": "ambulance" + } + ] + }, + "BF": { + "flag": "🇧🇫", + "name_de": "Burkina Faso", + "name_en": "Burkina Faso", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "18", + "type": "fire" + } + ] + }, + "BG": { + "flag": "🇧🇬", + "name_de": "Bulgarien", + "name_en": "Bulgaria", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "BH": { + "flag": "🇧🇭", + "name_de": "Bahrain", + "name_en": "Bahrain", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "BI": { + "flag": "🇧🇮", + "name_de": "Burundi", + "name_en": "Burundi", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + } + ] + }, + "BJ": { + "flag": "🇧🇯", + "name_de": "Benin", + "name_en": "Benin", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "118", + "type": "fire" + } + ] + }, + "BN": { + "flag": "🇧🇳", + "name_de": "Brunei", + "name_en": "Brunei", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "993", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "995", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "991", + "type": "ambulance" + } + ] + }, + "BO": { + "flag": "🇧🇴", + "name_de": "Bolivien", + "name_en": "Bolivia", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "119", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "118", + "type": "ambulance" + } + ] + }, + "BR": { + "flag": "🇧🇷", + "name_de": "Brasilien", + "name_en": "Brazil", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "190", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "193", + "type": "fire" + }, + { + "label_de": "SAMU", + "label_en": "SAMU", + "number": "192", + "type": "ambulance" + } + ] + }, + "BS": { + "flag": "🇧🇸", + "name_de": "Bahamas", + "name_en": "Bahamas", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "919", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "BT": { + "flag": "🇧🇹", + "name_de": "Bhutan", + "name_en": "Bhutan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "113", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "112", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "110", + "type": "fire" + } + ] + }, + "BW": { + "flag": "🇧🇼", + "name_de": "Botsuana", + "name_en": "Botswana", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "998", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "997", + "type": "ambulance" + } + ] + }, + "BY": { + "flag": "🇧🇾", + "name_de": "Weißrussland", + "name_en": "Belarus", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "BZ": { + "flag": "🇧🇿", + "name_de": "Belize", + "name_en": "Belize", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "CA": { + "flag": "🇨🇦", + "name_de": "Kanada", + "name_en": "Canada", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "CD": { + "flag": "🇨🇩", + "name_de": "Demokratische Republik Kongo", + "name_en": "DR Congo", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "CF": { + "flag": "🇨🇫", + "name_de": "Zentralafrikanische Republik", + "name_en": "Central African Republic", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + } + ] + }, + "CG": { + "flag": "🇨🇬", + "name_de": "Republik Kongo", + "name_en": "Republic of the Congo", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + } + ] + }, + "CH": { + "flag": "🇨🇭", + "name_de": "Schweiz", + "name_en": "Switzerland", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "118", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "144", + "type": "ambulance" + }, + { + "label_de": "Bergrettung", + "label_en": "Mountain Rescue", + "number": "1414", + "type": "mountain_rescue" + } + ] + }, + "CI": { + "flag": "🇨🇮", + "name_de": "Elfenbeinküste", + "name_en": "Côte d'Ivoire", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Gendarmerie", + "label_en": "Gendarmerie", + "number": "111", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "180", + "type": "fire" + } + ] + }, + "CL": { + "flag": "🇨🇱", + "name_de": "Chile", + "name_en": "Chile", + "numbers": [ + { + "label_de": "Carabineros", + "label_en": "Carabineros", + "number": "133", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "132", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "131", + "type": "ambulance" + } + ] + }, + "CM": { + "flag": "🇨🇲", + "name_de": "Kamerun", + "name_en": "Cameroon", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "118", + "type": "fire" + } + ] + }, + "CN": { + "flag": "🇨🇳", + "name_de": "China", + "name_en": "China", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "119", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "120", + "type": "ambulance" + } + ] + }, + "CO": { + "flag": "🇨🇴", + "name_de": "Kolumbien", + "name_en": "Colombia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "123", + "type": "general" + } + ] + }, + "CR": { + "flag": "🇨🇷", + "name_de": "Costa Rica", + "name_en": "Costa Rica", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "CU": { + "flag": "🇨🇺", + "name_de": "Kuba", + "name_en": "Cuba", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "106", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "105", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "104", + "type": "ambulance" + } + ] + }, + "CV": { + "flag": "🇨🇻", + "name_de": "Kap Verde", + "name_en": "Cabo Verde", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "132", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "131", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "130", + "type": "ambulance" + } + ] + }, + "CY": { + "flag": "🇨🇾", + "name_de": "Zypern", + "name_en": "Cyprus", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "199", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "199", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "199", + "type": "ambulance" + } + ] + }, + "CZ": { + "flag": "🇨🇿", + "name_de": "Tschechien", + "name_en": "Czechia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "158", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "150", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "155", + "type": "ambulance" + } + ] + }, + "DE": { + "flag": "🇩🇪", + "name_de": "Deutschland", + "name_en": "Germany", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + } + ] + }, + "DJ": { + "flag": "🇩🇯", + "name_de": "Dschibuti", + "name_en": "Djibouti", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + } + ] + }, + "DK": { + "flag": "🇩🇰", + "name_de": "Dänemark", + "name_en": "Denmark", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "DM": { + "flag": "🇩🇲", + "name_de": "Dominica", + "name_en": "Dominica", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "DO": { + "flag": "🇩🇴", + "name_de": "Dominikanische Republik", + "name_en": "Dominican Republic", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "DZ": { + "flag": "🇩🇿", + "name_de": "Algerien", + "name_en": "Algeria", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + }, + { + "label_de": "Zivilschutz", + "label_en": "Civil Protection", + "number": "14", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "14", + "type": "ambulance" + } + ] + }, + "EC": { + "flag": "🇪🇨", + "name_de": "Ecuador", + "name_en": "Ecuador", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "EE": { + "flag": "🇪🇪", + "name_de": "Estland", + "name_en": "Estonia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "EG": { + "flag": "🇪🇬", + "name_de": "Ägypten", + "name_en": "Egypt", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "122", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "180", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "123", + "type": "ambulance" + } + ] + }, + "ER": { + "flag": "🇪🇷", + "name_de": "Eritrea", + "name_en": "Eritrea", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "113", + "type": "police" + } + ] + }, + "ES": { + "flag": "🇪🇸", + "name_de": "Spanien", + "name_en": "Spain", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Nationalpolizei", + "label_en": "National Police", + "number": "091", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "080", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "061", + "type": "ambulance" + } + ] + }, + "ET": { + "flag": "🇪🇹", + "name_de": "Äthiopien", + "name_en": "Ethiopia", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "991", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "907", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "939", + "type": "fire" + } + ] + }, + "FI": { + "flag": "🇫🇮", + "name_de": "Finnland", + "name_en": "Finland", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "FJ": { + "flag": "🇫🇯", + "name_de": "Fidschi", + "name_en": "Fiji", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "917", + "type": "general" + } + ] + }, + "FM": { + "flag": "🇫🇲", + "name_de": "Mikronesien", + "name_en": "Micronesia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "FR": { + "flag": "🇫🇷", + "name_de": "Frankreich", + "name_en": "France", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "18", + "type": "fire" + }, + { + "label_de": "SAMU", + "label_en": "SAMU", + "number": "15", + "type": "ambulance" + } + ] + }, + "GA": { + "flag": "🇬🇦", + "name_de": "Gabun", + "name_en": "Gabon", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "1730", + "type": "police" + } + ] + }, + "GB": { + "flag": "🇬🇧", + "name_de": "Vereinigtes Königreich", + "name_en": "United Kingdom", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + }, + { + "label_de": "Notruf (Mobilfunk)", + "label_en": "Emergency (mobile)", + "number": "112", + "type": "general" + } + ] + }, + "GD": { + "flag": "🇬🇩", + "name_de": "Grenada", + "name_en": "Grenada", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "GE": { + "flag": "🇬🇪", + "name_de": "Georgien", + "name_en": "Georgia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "GH": { + "flag": "🇬🇭", + "name_de": "Ghana", + "name_en": "Ghana", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "191", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "192", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "193", + "type": "ambulance" + } + ] + }, + "GM": { + "flag": "🇬🇲", + "name_de": "Gambia", + "name_en": "Gambia", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + } + ] + }, + "GN": { + "flag": "🇬🇳", + "name_de": "Guinea", + "name_en": "Guinea", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + } + ] + }, + "GQ": { + "flag": "🇬🇶", + "name_de": "Äquatorialguinea", + "name_en": "Equatorial Guinea", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "114", + "type": "police" + } + ] + }, + "GR": { + "flag": "🇬🇷", + "name_de": "Griechenland", + "name_en": "Greece", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "100", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "199", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "166", + "type": "ambulance" + } + ] + }, + "GT": { + "flag": "🇬🇹", + "name_de": "Guatemala", + "name_en": "Guatemala", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "122", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "128", + "type": "ambulance" + } + ] + }, + "GW": { + "flag": "🇬🇼", + "name_de": "Guinea-Bissau", + "name_en": "Guinea-Bissau", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + } + ] + }, + "GY": { + "flag": "🇬🇾", + "name_de": "Guyana", + "name_en": "Guyana", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "911", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "912", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "913", + "type": "ambulance" + } + ] + }, + "HK": { + "flag": "🇭🇰", + "name_de": "Hongkong", + "name_en": "Hong Kong", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "HN": { + "flag": "🇭🇳", + "name_de": "Honduras", + "name_en": "Honduras", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "HR": { + "flag": "🇭🇷", + "name_de": "Kroatien", + "name_en": "Croatia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "192", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "193", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "194", + "type": "ambulance" + } + ] + }, + "HT": { + "flag": "🇭🇹", + "name_de": "Haiti", + "name_en": "Haiti", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "114", + "type": "police" + } + ] + }, + "HU": { + "flag": "🇭🇺", + "name_de": "Ungarn", + "name_en": "Hungary", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "107", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "105", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "104", + "type": "ambulance" + } + ] + }, + "ID": { + "flag": "🇮🇩", + "name_de": "Indonesien", + "name_en": "Indonesia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "113", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "118", + "type": "ambulance" + } + ] + }, + "IE": { + "flag": "🇮🇪", + "name_de": "Irland", + "name_en": "Ireland", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "IL": { + "flag": "🇮🇱", + "name_de": "Israel", + "name_en": "Israel", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "100", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "102", + "type": "fire" + }, + { + "label_de": "Magen David Adom", + "label_en": "Magen David Adom", + "number": "101", + "type": "ambulance" + } + ] + }, + "IN": { + "flag": "🇮🇳", + "name_de": "Indien", + "name_en": "India", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "IQ": { + "flag": "🇮🇶", + "name_de": "Irak", + "name_en": "Iraq", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "104", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "115", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "122", + "type": "ambulance" + } + ] + }, + "IR": { + "flag": "🇮🇷", + "name_de": "Iran", + "name_en": "Iran", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "125", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "115", + "type": "ambulance" + } + ] + }, + "IS": { + "flag": "🇮🇸", + "name_de": "Island", + "name_en": "Iceland", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "IT": { + "flag": "🇮🇹", + "name_de": "Italien", + "name_en": "Italy", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Staatspolizei", + "label_en": "State Police", + "number": "113", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "115", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "118", + "type": "ambulance" + }, + { + "label_de": "Seenotrettung", + "label_en": "Sea Rescue", + "number": "1530", + "type": "sea_rescue" + } + ] + }, + "JM": { + "flag": "🇯🇲", + "name_de": "Jamaika", + "name_en": "Jamaica", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "119", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "110", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "110", + "type": "ambulance" + } + ] + }, + "JO": { + "flag": "🇯🇴", + "name_de": "Jordanien", + "name_en": "Jordan", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "199", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "199", + "type": "ambulance" + } + ] + }, + "JP": { + "flag": "🇯🇵", + "name_de": "Japan", + "name_en": "Japan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "119", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "119", + "type": "ambulance" + } + ] + }, + "KE": { + "flag": "🇰🇪", + "name_de": "Kenia", + "name_en": "Kenya", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "KG": { + "flag": "🇰🇬", + "name_de": "Kirgisistan", + "name_en": "Kyrgyzstan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "KH": { + "flag": "🇰🇭", + "name_de": "Kambodscha", + "name_en": "Cambodia", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "118", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "119", + "type": "ambulance" + } + ] + }, + "KI": { + "flag": "🇰🇮", + "name_de": "Kiribati", + "name_en": "Kiribati", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + } + ] + }, + "KM": { + "flag": "🇰🇲", + "name_de": "Komoren", + "name_en": "Comoros", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + } + ] + }, + "KN": { + "flag": "🇰🇳", + "name_de": "St. Kitts und Nevis", + "name_en": "Saint Kitts and Nevis", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "KP": { + "flag": "🇰🇵", + "name_de": "Nordkorea", + "name_en": "North Korea", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "119", + "type": "police" + } + ] + }, + "KR": { + "flag": "🇰🇷", + "name_de": "Südkorea", + "name_en": "South Korea", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "112", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "119", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "119", + "type": "ambulance" + } + ] + }, + "KW": { + "flag": "🇰🇼", + "name_de": "Kuwait", + "name_en": "Kuwait", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "KZ": { + "flag": "🇰🇿", + "name_de": "Kasachstan", + "name_en": "Kazakhstan", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "LA": { + "flag": "🇱🇦", + "name_de": "Laos", + "name_en": "Laos", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "191", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "190", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "195", + "type": "ambulance" + } + ] + }, + "LB": { + "flag": "🇱🇧", + "name_de": "Libanon", + "name_en": "Lebanon", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "112", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "175", + "type": "fire" + }, + { + "label_de": "Rotes Kreuz", + "label_en": "Red Cross", + "number": "140", + "type": "ambulance" + } + ] + }, + "LC": { + "flag": "🇱🇨", + "name_de": "St. Lucia", + "name_en": "Saint Lucia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "LI": { + "flag": "🇱🇮", + "name_de": "Liechtenstein", + "name_en": "Liechtenstein", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "118", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "144", + "type": "ambulance" + } + ] + }, + "LK": { + "flag": "🇱🇰", + "name_de": "Sri Lanka", + "name_en": "Sri Lanka", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "119", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "1990", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "110", + "type": "fire" + } + ] + }, + "LR": { + "flag": "🇱🇷", + "name_de": "Liberia", + "name_en": "Liberia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "LS": { + "flag": "🇱🇸", + "name_de": "Lesotho", + "name_en": "Lesotho", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "123", + "type": "police" + } + ] + }, + "LT": { + "flag": "🇱🇹", + "name_de": "Litauen", + "name_en": "Lithuania", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "LU": { + "flag": "🇱🇺", + "name_de": "Luxemburg", + "name_en": "Luxembourg", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "113", + "type": "police" + } + ] + }, + "LV": { + "flag": "🇱🇻", + "name_de": "Lettland", + "name_en": "Latvia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "LY": { + "flag": "🇱🇾", + "name_de": "Libyen", + "name_en": "Libya", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "1515", + "type": "police" + } + ] + }, + "MA": { + "flag": "🇲🇦", + "name_de": "Marokko", + "name_en": "Morocco", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "19", + "type": "police" + }, + { + "label_de": "Gendarmerie", + "label_en": "Gendarmerie", + "number": "177", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "15", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "15", + "type": "ambulance" + } + ] + }, + "MC": { + "flag": "🇲🇨", + "name_de": "Monaco", + "name_en": "Monaco", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "18", + "type": "fire" + } + ] + }, + "MD": { + "flag": "🇲🇩", + "name_de": "Moldau", + "name_en": "Moldova", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "ME": { + "flag": "🇲🇪", + "name_de": "Montenegro", + "name_en": "Montenegro", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "122", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "123", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "124", + "type": "ambulance" + } + ] + }, + "MG": { + "flag": "🇲🇬", + "name_de": "Madagaskar", + "name_en": "Madagascar", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "118", + "type": "fire" + } + ] + }, + "MH": { + "flag": "🇲🇭", + "name_de": "Marshallinseln", + "name_en": "Marshall Islands", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "MK": { + "flag": "🇲🇰", + "name_de": "Nordmazedonien", + "name_en": "North Macedonia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "192", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "193", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "194", + "type": "ambulance" + } + ] + }, + "ML": { + "flag": "🇲🇱", + "name_de": "Mali", + "name_en": "Mali", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + } + ] + }, + "MM": { + "flag": "🇲🇲", + "name_de": "Myanmar", + "name_en": "Myanmar", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "199", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "191", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "192", + "type": "ambulance" + } + ] + }, + "MN": { + "flag": "🇲🇳", + "name_de": "Mongolei", + "name_en": "Mongolia", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "MO": { + "flag": "🇲🇴", + "name_de": "Macau", + "name_en": "Macau", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "MR": { + "flag": "🇲🇷", + "name_de": "Mauretanien", + "name_en": "Mauritania", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + } + ] + }, + "MT": { + "flag": "🇲🇹", + "name_de": "Malta", + "name_en": "Malta", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "MU": { + "flag": "🇲🇺", + "name_de": "Mauritius", + "name_en": "Mauritius", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "995", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "114", + "type": "ambulance" + } + ] + }, + "MV": { + "flag": "🇲🇻", + "name_de": "Malediven", + "name_en": "Maldives", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "119", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "118", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "102", + "type": "ambulance" + } + ] + }, + "MW": { + "flag": "🇲🇼", + "name_de": "Malawi", + "name_en": "Malawi", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "997", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "999", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "998", + "type": "ambulance" + } + ] + }, + "MX": { + "flag": "🇲🇽", + "name_de": "Mexiko", + "name_en": "Mexico", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "MY": { + "flag": "🇲🇾", + "name_de": "Malaysia", + "name_en": "Malaysia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + }, + { + "label_de": "Notruf (Mobilfunk)", + "label_en": "Emergency (mobile)", + "number": "112", + "type": "general" + } + ] + }, + "MZ": { + "flag": "🇲🇿", + "name_de": "Mosambik", + "name_en": "Mozambique", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "119", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "117", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "198", + "type": "fire" + } + ] + }, + "NA": { + "flag": "🇳🇦", + "name_de": "Namibia", + "name_en": "Namibia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "10111", + "type": "police" + } + ] + }, + "NE": { + "flag": "🇳🇪", + "name_de": "Niger", + "name_en": "Niger", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + } + ] + }, + "NG": { + "flag": "🇳🇬", + "name_de": "Nigeria", + "name_en": "Nigeria", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "NI": { + "flag": "🇳🇮", + "name_de": "Nicaragua", + "name_en": "Nicaragua", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "NL": { + "flag": "🇳🇱", + "name_de": "Niederlande", + "name_en": "Netherlands", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "NO": { + "flag": "🇳🇴", + "name_de": "Norwegen", + "name_en": "Norway", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "112", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "110", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "113", + "type": "ambulance" + } + ] + }, + "NP": { + "flag": "🇳🇵", + "name_de": "Nepal", + "name_en": "Nepal", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "100", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "102", + "type": "ambulance" + } + ] + }, + "NR": { + "flag": "🇳🇷", + "name_de": "Nauru", + "name_en": "Nauru", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + } + ] + }, + "NZ": { + "flag": "🇳🇿", + "name_de": "Neuseeland", + "name_en": "New Zealand", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "111", + "type": "general" + } + ] + }, + "OM": { + "flag": "🇴🇲", + "name_de": "Oman", + "name_en": "Oman", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "9999", + "type": "general" + } + ] + }, + "PA": { + "flag": "🇵🇦", + "name_de": "Panama", + "name_en": "Panama", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "104", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "103", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "911", + "type": "ambulance" + } + ] + }, + "PE": { + "flag": "🇵🇪", + "name_de": "Peru", + "name_en": "Peru", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "105", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "116", + "type": "fire" + }, + { + "label_de": "SAMU", + "label_en": "SAMU", + "number": "106", + "type": "ambulance" + } + ] + }, + "PG": { + "flag": "🇵🇬", + "name_de": "Papua-Neuguinea", + "name_en": "Papua New Guinea", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "000", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "PH": { + "flag": "🇵🇭", + "name_de": "Philippinen", + "name_en": "Philippines", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "PK": { + "flag": "🇵🇰", + "name_de": "Pakistan", + "name_en": "Pakistan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "15", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "16", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "1122", + "type": "ambulance" + } + ] + }, + "PL": { + "flag": "🇵🇱", + "name_de": "Polen", + "name_en": "Poland", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "997", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "998", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "999", + "type": "ambulance" + } + ] + }, + "PS": { + "flag": "🇵🇸", + "name_de": "Palästina", + "name_en": "Palestine", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "100", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "101", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "102", + "type": "fire" + } + ] + }, + "PT": { + "flag": "🇵🇹", + "name_de": "Portugal", + "name_en": "Portugal", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "PW": { + "flag": "🇵🇼", + "name_de": "Palau", + "name_en": "Palau", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "PY": { + "flag": "🇵🇾", + "name_de": "Paraguay", + "name_en": "Paraguay", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "132", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "141", + "type": "ambulance" + } + ] + }, + "QA": { + "flag": "🇶🇦", + "name_de": "Katar", + "name_en": "Qatar", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "RO": { + "flag": "🇷🇴", + "name_de": "Rumänien", + "name_en": "Romania", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "RS": { + "flag": "🇷🇸", + "name_de": "Serbien", + "name_en": "Serbia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "192", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "193", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "194", + "type": "ambulance" + } + ] + }, + "RU": { + "flag": "🇷🇺", + "name_de": "Russland", + "name_en": "Russia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "RW": { + "flag": "🇷🇼", + "name_de": "Ruanda", + "name_en": "Rwanda", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "SA": { + "flag": "🇸🇦", + "name_de": "Saudi-Arabien", + "name_en": "Saudi Arabia", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "997", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "998", + "type": "fire" + } + ] + }, + "SB": { + "flag": "🇸🇧", + "name_de": "Salomonen", + "name_en": "Solomon Islands", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + } + ] + }, + "SC": { + "flag": "🇸🇨", + "name_de": "Seychellen", + "name_en": "Seychelles", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "151", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "133", + "type": "fire" + } + ] + }, + "SD": { + "flag": "🇸🇩", + "name_de": "Sudan", + "name_en": "Sudan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "999", + "type": "ambulance" + } + ] + }, + "SE": { + "flag": "🇸🇪", + "name_de": "Schweden", + "name_en": "Sweden", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "SG": { + "flag": "🇸🇬", + "name_de": "Singapur", + "name_en": "Singapore", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "995", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "995", + "type": "ambulance" + } + ] + }, + "SI": { + "flag": "🇸🇮", + "name_de": "Slowenien", + "name_en": "Slovenia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "113", + "type": "police" + } + ] + }, + "SK": { + "flag": "🇸🇰", + "name_de": "Slowakei", + "name_en": "Slovakia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "158", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "150", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "155", + "type": "ambulance" + } + ] + }, + "SL": { + "flag": "🇸🇱", + "name_de": "Sierra Leone", + "name_en": "Sierra Leone", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "SM": { + "flag": "🇸🇲", + "name_de": "San Marino", + "name_en": "San Marino", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "113", + "type": "general" + } + ] + }, + "SN": { + "flag": "🇸🇳", + "name_de": "Senegal", + "name_en": "Senegal", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "18", + "type": "fire" + } + ] + }, + "SO": { + "flag": "🇸🇴", + "name_de": "Somalia", + "name_en": "Somalia", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "888", + "type": "general" + } + ] + }, + "SR": { + "flag": "🇸🇷", + "name_de": "Suriname", + "name_en": "Suriname", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "115", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "110", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "113", + "type": "ambulance" + } + ] + }, + "SS": { + "flag": "🇸🇸", + "name_de": "Südsudan", + "name_en": "South Sudan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "777", + "type": "police" + } + ] + }, + "ST": { + "flag": "🇸🇹", + "name_de": "São Tomé und Príncipe", + "name_en": "São Tomé and Príncipe", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "SV": { + "flag": "🇸🇻", + "name_de": "El Salvador", + "name_en": "El Salvador", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "SY": { + "flag": "🇸🇾", + "name_de": "Syrien", + "name_en": "Syria", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "112", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "113", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "110", + "type": "ambulance" + } + ] + }, + "SZ": { + "flag": "🇸🇿", + "name_de": "Eswatini", + "name_en": "Eswatini", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "977", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "933", + "type": "fire" + } + ] + }, + "TD": { + "flag": "🇹🇩", + "name_de": "Tschad", + "name_en": "Chad", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "17", + "type": "police" + } + ] + }, + "TG": { + "flag": "🇹🇬", + "name_de": "Togo", + "name_en": "Togo", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "117", + "type": "police" + } + ] + }, + "TH": { + "flag": "🇹🇭", + "name_de": "Thailand", + "name_en": "Thailand", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "191", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "1669", + "type": "ambulance" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "199", + "type": "fire" + } + ] + }, + "TJ": { + "flag": "🇹🇯", + "name_de": "Tadschikistan", + "name_en": "Tajikistan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "TL": { + "flag": "🇹🇱", + "name_de": "Osttimor", + "name_en": "Timor-Leste", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "TM": { + "flag": "🇹🇲", + "name_de": "Turkmenistan", + "name_en": "Turkmenistan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "02", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "01", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "03", + "type": "ambulance" + } + ] + }, + "TN": { + "flag": "🇹🇳", + "name_de": "Tunesien", + "name_en": "Tunisia", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "197", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "198", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "190", + "type": "ambulance" + } + ] + }, + "TO": { + "flag": "🇹🇴", + "name_de": "Tonga", + "name_en": "Tonga", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "922", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "911", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "933", + "type": "ambulance" + } + ] + }, + "TR": { + "flag": "🇹🇷", + "name_de": "Türkei", + "name_en": "Turkey", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "155", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "110", + "type": "fire" + }, + { + "label_de": "Jandarma", + "label_en": "Gendarmerie", + "number": "156", + "type": "police" + } + ] + }, + "TT": { + "flag": "🇹🇹", + "name_de": "Trinidad und Tobago", + "name_en": "Trinidad and Tobago", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "990", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "990", + "type": "ambulance" + } + ] + }, + "TV": { + "flag": "🇹🇻", + "name_de": "Tuvalu", + "name_en": "Tuvalu", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "TW": { + "flag": "🇹🇼", + "name_de": "Taiwan", + "name_en": "Taiwan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "110", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "119", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "119", + "type": "ambulance" + } + ] + }, + "TZ": { + "flag": "🇹🇿", + "name_de": "Tansania", + "name_en": "Tanzania", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + } + ] + }, + "UA": { + "flag": "🇺🇦", + "name_de": "Ukraine", + "name_en": "Ukraine", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "UG": { + "flag": "🇺🇬", + "name_de": "Uganda", + "name_en": "Uganda", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "999", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "US": { + "flag": "🇺🇸", + "name_de": "Vereinigte Staaten", + "name_en": "United States", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "UY": { + "flag": "🇺🇾", + "name_de": "Uruguay", + "name_en": "Uruguay", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "104", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "105", + "type": "ambulance" + } + ] + }, + "UZ": { + "flag": "🇺🇿", + "name_de": "Usbekistan", + "name_en": "Uzbekistan", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "102", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "101", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "103", + "type": "ambulance" + } + ] + }, + "VA": { + "flag": "🇻🇦", + "name_de": "Vatikanstadt", + "name_en": "Vatican City", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + } + ] + }, + "VC": { + "flag": "🇻🇨", + "name_de": "St. Vincent und die Grenadinen", + "name_en": "Saint Vincent and the Grenadines", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "VE": { + "flag": "🇻🇪", + "name_de": "Venezuela", + "name_en": "Venezuela", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "VN": { + "flag": "🇻🇳", + "name_de": "Vietnam", + "name_en": "Vietnam", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "113", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "114", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "115", + "type": "ambulance" + } + ] + }, + "VU": { + "flag": "🇻🇺", + "name_de": "Vanuatu", + "name_en": "Vanuatu", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "911", + "type": "general" + } + ] + }, + "WS": { + "flag": "🇼🇸", + "name_de": "Samoa", + "name_en": "Samoa", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "911", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "994", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "996", + "type": "ambulance" + } + ] + }, + "XK": { + "flag": "🇽🇰", + "name_de": "Kosovo", + "name_en": "Kosovo", + "numbers": [ + { + "label_de": "Notruf", + "label_en": "Emergency", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "192", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "193", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "194", + "type": "ambulance" + } + ] + }, + "YE": { + "flag": "🇾🇪", + "name_de": "Jemen", + "name_en": "Yemen", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "194", + "type": "police" + } + ] + }, + "ZA": { + "flag": "🇿🇦", + "name_de": "Südafrika", + "name_en": "South Africa", + "numbers": [ + { + "label_de": "Notruf (Mobilfunk)", + "label_en": "Emergency (mobile)", + "number": "112", + "type": "general" + }, + { + "label_de": "Polizei", + "label_en": "Police", + "number": "10111", + "type": "police" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "10177", + "type": "ambulance" + } + ] + }, + "ZM": { + "flag": "🇿🇲", + "name_de": "Sambia", + "name_en": "Zambia", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "999", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "993", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "992", + "type": "ambulance" + } + ] + }, + "ZW": { + "flag": "🇿🇼", + "name_de": "Simbabwe", + "name_en": "Zimbabwe", + "numbers": [ + { + "label_de": "Polizei", + "label_en": "Police", + "number": "995", + "type": "police" + }, + { + "label_de": "Feuerwehr", + "label_en": "Fire Brigade", + "number": "993", + "type": "fire" + }, + { + "label_de": "Rettungsdienst", + "label_en": "Ambulance", + "number": "994", + "type": "ambulance" + } + ] + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3933fc3..9974849 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -24,6 +24,12 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + LSApplicationQueriesSchemes + + tel + + NSLocationWhenInUseUsageDescription + Wird verwendet, um die passende Notrufnummer für dein aktuelles Land anzuzeigen. LSRequiresIPhoneOS UIApplicationSceneManifest diff --git a/lib/app.dart b/lib/app.dart new file mode 100644 index 0000000..1d431f4 --- /dev/null +++ b/lib/app.dart @@ -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( + 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( + 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(), + ); + }, + ), + ); + } +} diff --git a/lib/core/country_locale_map.dart b/lib/core/country_locale_map.dart new file mode 100644 index 0000000..a6a7d9a --- /dev/null +++ b/lib/core/country_locale_map.dart @@ -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 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 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); +} diff --git a/lib/features/home/country_picker_screen.dart b/lib/features/home/country_picker_screen.dart new file mode 100644 index 0000000..dbeb28b --- /dev/null +++ b/lib/features/home/country_picker_screen.dart @@ -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 createState() => _CountryPickerScreenState(); +} + +class _CountryPickerScreenState extends State { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filtered(List 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 _select(BuildContext context, Country country) async { + await context.read().selectCountryManually(country.isoCode); + if (context.mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final repository = context.read().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), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart new file mode 100644 index 0000000..550fa18 --- /dev/null +++ b/lib/features/home/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @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().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().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().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(); + 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, + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/features/home/widgets/country_header.dart b/lib/features/home/widgets/country_header.dart new file mode 100644 index 0000000..abd723c --- /dev/null +++ b/lib/features/home/widgets/country_header.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/lib/features/home/widgets/emergency_button.dart b/lib/features/home/widgets/emergency_button.dart new file mode 100644 index 0000000..7c930cd --- /dev/null +++ b/lib/features/home/widgets/emergency_button.dart @@ -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 _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, + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb new file mode 100644 index 0000000..aa2540c --- /dev/null +++ b/lib/l10n/app_de.arb @@ -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" +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7dc6d05..e86a47c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb new file mode 100644 index 0000000..f41a04b --- /dev/null +++ b/lib/l10n/app_es.arb @@ -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}" +} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb new file mode 100644 index 0000000..17595c2 --- /dev/null +++ b/lib/l10n/app_fr.arb @@ -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}" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0f3a0fa..88b7197 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 supportedLocales = [Locale('en')]; + static const List supportedLocales = [ + 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) => - ['en'].contains(locale.languageCode); + ['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( diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart new file mode 100644 index 0000000..0052343 --- /dev/null +++ b/lib/l10n/app_localizations_de.dart @@ -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'; + } +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5d9907e..a17c0d5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart new file mode 100644 index 0000000..5e46194 --- /dev/null +++ b/lib/l10n/app_localizations_es.dart @@ -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'; + } +} diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart new file mode 100644 index 0000000..5089e99 --- /dev/null +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; + } +} diff --git a/lib/main.dart b/lib/main.dart index 244a702..d9fd68c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - 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()); } diff --git a/lib/models/.gitkeep b/lib/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/models/country.dart b/lib/models/country.dart new file mode 100644 index 0000000..9dfe16b --- /dev/null +++ b/lib/models/country.dart @@ -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 numbers; + + factory Country.fromJson(String isoCode, Map json) { + final numbersJson = json['numbers'] as List; + 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)) + .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 a, List 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; + } +} diff --git a/lib/models/emergency_number.dart b/lib/models/emergency_number.dart new file mode 100644 index 0000000..1d316df --- /dev/null +++ b/lib/models/emergency_number.dart @@ -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 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); +} diff --git a/lib/models/emergency_type.dart b/lib/models/emergency_type.dart new file mode 100644 index 0000000..a626b46 --- /dev/null +++ b/lib/models/emergency_type.dart @@ -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'; + } + } +} diff --git a/lib/providers/emergency_provider.dart b/lib/providers/emergency_provider.dart new file mode 100644 index 0000000..f39e56f --- /dev/null +++ b/lib/providers/emergency_provider.dart @@ -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 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 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 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 clearManualSelection() async { + await _locationService.setManualCountryCode(null); + await init(); + } +} diff --git a/lib/providers/emergency_status.dart b/lib/providers/emergency_status.dart new file mode 100644 index 0000000..43312e1 --- /dev/null +++ b/lib/providers/emergency_status.dart @@ -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, +} diff --git a/lib/repositories/.gitkeep b/lib/repositories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/repositories/emergency_number_repository.dart b/lib/repositories/emergency_number_repository.dart new file mode 100644 index 0000000..2dc8acd --- /dev/null +++ b/lib/repositories/emergency_number_repository.dart @@ -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? _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 load() async { + if (isLoaded) return; + + final raw = await rootBundle.loadString(assetPath); + final decoded = jsonDecode(raw); + + if (decoded is! Map) { + throw const FormatException( + 'emergency_numbers.json: erwartetes Top-Level-Objekt fehlt', + ); + } + + final parsed = {}; + for (final entry in decoded.entries) { + final isoCode = entry.key.toUpperCase(); + parsed[isoCode] = Country.fromJson(isoCode, entry.value as Map); + } + _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 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; + } +} diff --git a/lib/services/country_code_sources/.gitkeep b/lib/services/country_code_sources/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/lib/services/country_code_sources/cached_country_code_source.dart b/lib/services/country_code_sources/cached_country_code_source.dart new file mode 100644 index 0000000..abf19fb --- /dev/null +++ b/lib/services/country_code_sources/cached_country_code_source.dart @@ -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 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 save(String isoCode) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, isoCode.toUpperCase()); + } catch (_) { + // Best effort, siehe Doc-Kommentar oben. + } + } +} diff --git a/lib/services/country_code_sources/country_code_source.dart b/lib/services/country_code_sources/country_code_source.dart new file mode 100644 index 0000000..0bf026a --- /dev/null +++ b/lib/services/country_code_sources/country_code_source.dart @@ -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 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 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 save(String? isoCode); +} diff --git a/lib/services/country_code_sources/gps_country_code_source.dart b/lib/services/country_code_sources/gps_country_code_source.dart new file mode 100644 index 0000000..8172d09 --- /dev/null +++ b/lib/services/country_code_sources/gps_country_code_source.dart @@ -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 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 _hasPermission() async { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + return permission == LocationPermission.whileInUse || permission == LocationPermission.always; + } +} diff --git a/lib/services/country_code_sources/manual_country_code_source.dart b/lib/services/country_code_sources/manual_country_code_source.dart new file mode 100644 index 0000000..912f070 --- /dev/null +++ b/lib/services/country_code_sources/manual_country_code_source.dart @@ -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 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 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. + } + } +} diff --git a/lib/services/country_code_sources/sim_country_code_source.dart b/lib/services/country_code_sources/sim_country_code_source.dart new file mode 100644 index 0000000..4b2904a --- /dev/null +++ b/lib/services/country_code_sources/sim_country_code_source.dart @@ -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 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 _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 _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(); + } +} diff --git a/lib/services/dialer_service.dart b/lib/services/dialer_service.dart new file mode 100644 index 0000000..41b23fa --- /dev/null +++ b/lib/services/dialer_service.dart @@ -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 call(String number) async { + final uri = Uri(scheme: 'tel', path: number); + + if (!await canLaunchUrl(uri)) { + return false; + } + + return launchUrl(uri); + } +} diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart new file mode 100644 index 0000000..fb3af83 --- /dev/null +++ b/lib/services/location_service.dart @@ -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 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 setManualCountryCode(String? isoCode) => _manualSource.save(isoCode); +} diff --git a/pubspec.lock b/pubspec.lock index 369cc8a..516e581 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.6" + carrier_info: + dependency: "direct main" + description: + name: carrier_info + sha256: "4bddb1e2b6c9b57a16964890999430dc0c2756aaf4d5a7117308c0852b17dcf4" + url: "https://pub.dev" + source: hosted + version: "3.0.3" characters: dependency: transitive description: @@ -230,7 +238,7 @@ packages: source: hosted version: "1.0.2" geocoding_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: geocoding_platform_interface sha256: "5164b8871705af7573698b3aa0746337faf818d72b488988741bcc7aeedd2f6e" @@ -270,7 +278,7 @@ packages: source: hosted version: "0.2.6" geolocator_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: geolocator_platform_interface sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 @@ -715,7 +723,7 @@ packages: source: hosted version: "3.2.5" url_launcher_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" diff --git a/pubspec.yaml b/pubspec.yaml index b05c602..3bf81dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: geolocator: ^14.0.3 geocoding: ^5.0.0 permission_handler: ^12.0.3 + carrier_info: ^3.0.3 provider: ^6.1.5+1 shared_preferences: ^2.5.5 flutter_localizations: @@ -48,6 +49,16 @@ dev_dependencies: flutter_test: sdk: flutter + # Für Unit-Tests von DialerService: erlaubt das Ersetzen von + # UrlLauncherPlatform.instance durch eine Fake-Implementierung. + url_launcher_platform_interface: ^2.3.2 + + # Für Unit-Tests von GpsCountryCodeSource (AP4): erlaubt analog das + # Ersetzen von GeolocatorPlatform.instance / GeocodingPlatformFactory.instance + # durch Fake-Implementierungen, ohne echte Plattform-Kanäle. + geolocator_platform_interface: ^4.2.8 + geocoding_platform_interface: ^5.0.0 + # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is # activated in the `analysis_options.yaml` file located at the root of your @@ -55,6 +66,16 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 +# Abweichung vom Plan nötig (AP4): `carrier_info: ^3.0.3` deklariert +# `flutter_lints: ^4.0.0` fälschlich als normale (nicht Dev-)Dependency, obwohl +# das Paket flutter_lints im eigenen lib/-Code gar nicht importiert (nur für +# die eigene Analyse verwendet). Ohne Override kollidiert das mit unserem +# `flutter_lints: ^6.0.0`. Der Override ist unkritisch, da flutter_lints rein +# ein Analyzer-Regelwerk ist und zur Laufzeit von carrier_info nicht genutzt +# wird. +dependency_overrides: + flutter_lints: ^6.0.0 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -70,6 +91,8 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: + assets: + - assets/data/emergency_numbers.json # assets: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg diff --git a/test/app_locale_test.dart b/test/app_locale_test.dart new file mode 100644 index 0000000..8eb9d9e --- /dev/null +++ b/test/app_locale_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/app.dart'; +import 'package:emergency/repositories/emergency_number_repository.dart'; +import 'package:emergency/services/country_code_sources/country_code_source.dart'; +import 'package:emergency/services/location_service.dart'; + +/// Siehe `test/widget_test.dart` für die Begründung dieser Fakes +/// (deterministische Ländererkennung ohne echte Plattform-Kanäle). +class _FakeSource implements CountryCodeSource { + _FakeSource(this._result); + + final String? _result; + + @override + Future resolve() async => _result; +} + +class _FakeCache implements CountryCodeCache { + @override + Future resolve() async => null; + + @override + Future save(String isoCode) async {} +} + +class _FakeManualSource implements ManualCountryCodeStore { + @override + Future resolve() async => null; + + @override + Future save(String? isoCode) async {} +} + +/// AP7: Verifiziert, dass die sichtbare App-Sprache tatsächlich an das +/// erkannte Land gekoppelt ist (`docs/plan.md`, Abschnitt 2 + 8), nicht nur, +/// dass `resolveAppLocale` als reine Funktion korrekt rechnet +/// (`test/core/country_locale_map_test.dart`). +Future _pumpAppWithCountry(WidgetTester tester, String? isoCode) async { + final repository = EmergencyNumberRepository(); + await tester.runAsync(() => repository.load()); + final locationService = LocationService( + simSource: _FakeSource(isoCode), + gpsSource: _FakeSource(null), + cacheSource: _FakeCache(), + manualSource: _FakeManualSource(), + ); + + await tester.pumpWidget(App(repository: repository, locationService: locationService)); + await tester.pumpAndSettle(); +} + +/// Findet Text konkret innerhalb der `AppBar`, nicht irgendwo im Baum: +/// Der Notrufnummern-Datensatz mancher Länder (z. B. DE: "Notruf", US: +/// "Emergency") verwendet zufällig denselben Text wie der übersetzte +/// AppBar-Titel, ein ungebundener `find.text(...)` wäre daher mehrdeutig. +Finder _appBarText(String text) => + find.descendant(of: find.byType(AppBar), matching: find.text(text)); + +void main() { + testWidgets('DE wird erkannt -> AppBar-Titel erscheint auf Deutsch ("Notruf")', (tester) async { + await _pumpAppWithCountry(tester, 'DE'); + + expect(_appBarText('Notruf'), findsOneWidget); + expect(_appBarText('Emergency'), findsNothing); + }); + + testWidgets('US wird erkannt -> AppBar-Titel erscheint auf Englisch ("Emergency")', ( + tester, + ) async { + await _pumpAppWithCountry(tester, 'US'); + + expect(_appBarText('Emergency'), findsOneWidget); + expect(_appBarText('Notruf'), findsNothing); + }); + + testWidgets('FR wird erkannt -> AppBar-Titel erscheint auf Französisch ("Urgence")', ( + tester, + ) async { + await _pumpAppWithCountry(tester, 'FR'); + + expect(_appBarText('Urgence'), findsOneWidget); + }); + + testWidgets('Land nicht ermittelbar -> Fallback auf Englisch ("Emergency")', (tester) async { + await _pumpAppWithCountry(tester, null); + + expect(_appBarText('Emergency'), findsOneWidget); + }); +} diff --git a/test/core/country_locale_map_test.dart b/test/core/country_locale_map_test.dart new file mode 100644 index 0000000..6aff07c --- /dev/null +++ b/test/core/country_locale_map_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/core/country_locale_map.dart'; +import 'package:emergency/l10n/app_localizations.dart'; + +void main() { + group('resolveAppLocale', () { + // Realistische Basis: die tatsächlich per ARB generierten Locales + // (docs/plan.md, Abschnitt 8, AP7), statt einer synthetischen Liste. + final supportedLocales = AppLocalizations.supportedLocales; + + test('bekanntes Land mit unterstützter Sprache liefert diese Sprache (DE -> de)', () { + expect(resolveAppLocale('DE', supportedLocales), const Locale('de')); + }); + + test('Ländercode ist case-insensitiv (de -> de)', () { + expect(resolveAppLocale('de', supportedLocales), const Locale('de')); + }); + + test( + 'bekanntes Land mit einer NICHT unterstützten Sprache fällt auf Englisch zurück (JP -> ja, nicht unterstützt)', + () { + expect(countryLocaleMap['JP'], 'ja'); + expect(supportedLocales.any((l) => l.languageCode == 'ja'), isFalse); + expect(resolveAppLocale('JP', supportedLocales), const Locale('en')); + }, + ); + + test('unbekannter/nicht gemappter Ländercode fällt auf Englisch zurück', () { + expect(countryLocaleMap.containsKey('ZZ'), isFalse); + expect(resolveAppLocale('ZZ', supportedLocales), const Locale('en')); + }); + + test('country == null (noch nicht ermittelt) fällt auf Englisch zurück', () { + expect(resolveAppLocale(null, supportedLocales), const Locale('en')); + }); + + test('weitere unterstützte Sprachen werden ebenfalls korrekt aufgelöst (FR -> fr, ES -> es)', () { + expect(resolveAppLocale('FR', supportedLocales), const Locale('fr')); + expect(resolveAppLocale('ES', supportedLocales), const Locale('es')); + }); + }); +} diff --git a/test/features/home/country_picker_screen_test.dart b/test/features/home/country_picker_screen_test.dart new file mode 100644 index 0000000..ba928f7 --- /dev/null +++ b/test/features/home/country_picker_screen_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:emergency/features/home/country_picker_screen.dart'; +import 'package:emergency/l10n/app_localizations.dart'; +import 'package:emergency/models/country.dart'; +import 'package:emergency/models/emergency_number.dart'; +import 'package:emergency/models/emergency_type.dart'; +import 'package:emergency/providers/emergency_provider.dart'; +import 'package:emergency/repositories/emergency_number_repository.dart'; +import 'package:emergency/services/country_code_sources/country_code_source.dart'; +import 'package:emergency/services/location_service.dart'; + +/// Liefert eine feste Länderliste, ohne das echte JSON-Asset laden zu +/// müssen (analog zum Fake-Repository-Muster in `emergency_provider_test.dart`). +class _FakeRepository extends EmergencyNumberRepository { + @override + List get allCountries => const [ + Country( + isoCode: 'DE', + nameDe: 'Deutschland', + nameEn: 'Germany', + flag: '🇩🇪', + numbers: [ + EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'), + ], + ), + Country( + isoCode: 'FR', + nameDe: 'Frankreich', + nameEn: 'France', + flag: '🇫🇷', + numbers: [ + EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'), + ], + ), + ]; +} + +class _NoopSource implements CountryCodeSource { + const _NoopSource(); + + @override + Future resolve() async => null; +} + +/// Fake-Provider: überschreibt `selectCountryManually`, damit der Test ohne +/// echte `LocationService`-Ermittlungskette prüfen kann, mit welchem +/// ISO-Code der Screen die Auswahl auslöst. +class _FakeProvider extends EmergencyProvider { + _FakeProvider({required super.repository}) + : super( + locationService: LocationService( + simSource: const _NoopSource(), + gpsSource: const _NoopSource(), + ), + ); + + String? selectedIsoCode; + int selectCallCount = 0; + + @override + Future selectCountryManually(String isoCode) async { + selectCallCount++; + selectedIsoCode = isoCode; + } +} + +void main() { + Future pumpPickerBehindAButton(WidgetTester tester, EmergencyProvider provider) async { + // CountryPickerScreen ruft `Navigator.pop()` beim Auswählen auf – dafür + // muss eine vorherige Route existieren, sonst wäre der Pop ein No-op. + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + // Feste Locale, damit die (unveränderten) deutschen Text-Assertions + // dieser Tests unabhängig von der Test-Umgebungs-/Systemsprache + // funktionieren; das eigentliche Umschalten der App-Sprache wird + // gezielt in `test/core/country_locale_map_test.dart` bzw. + // `test/app_locale_test.dart` (AP7) geprüft. + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CountryPickerScreen()), + ), + child: const Text('Öffnen'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Öffnen')); + await tester.pumpAndSettle(); + } + + testWidgets('zeigt die Länderliste an', (tester) async { + final provider = _FakeProvider(repository: _FakeRepository()); + await pumpPickerBehindAButton(tester, provider); + + expect(find.text('Deutschland'), findsOneWidget); + expect(find.text('Frankreich'), findsOneWidget); + }); + + testWidgets('Suchfeld filtert die Länderliste', (tester) async { + final provider = _FakeProvider(repository: _FakeRepository()); + await pumpPickerBehindAButton(tester, provider); + + await tester.enterText(find.byType(TextField), 'Deutsch'); + await tester.pump(); + + expect(find.text('Deutschland'), findsOneWidget); + expect(find.text('Frankreich'), findsNothing); + }); + + testWidgets('zeigt einen Hinweis, wenn kein Land zum Filter passt', (tester) async { + final provider = _FakeProvider(repository: _FakeRepository()); + await pumpPickerBehindAButton(tester, provider); + + await tester.enterText(find.byType(TextField), 'Nichtland'); + await tester.pump(); + + expect(find.text('Kein Land gefunden.'), findsOneWidget); + }); + + testWidgets('Tap auf ein Land löst selectCountryManually aus und schließt den Screen', ( + tester, + ) async { + final provider = _FakeProvider(repository: _FakeRepository()); + await pumpPickerBehindAButton(tester, provider); + + await tester.tap(find.text('Deutschland')); + await tester.pumpAndSettle(); + + expect(provider.selectCallCount, 1); + expect(provider.selectedIsoCode, 'DE'); + // Screen wurde geschlossen (zurück zum "Öffnen"-Button). + expect(find.text('Öffnen'), findsOneWidget); + expect(find.text('Deutschland'), findsNothing); + }); +} diff --git a/test/models/country_test.dart b/test/models/country_test.dart new file mode 100644 index 0000000..bc068d2 --- /dev/null +++ b/test/models/country_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/models/country.dart'; +import 'package:emergency/models/emergency_type.dart'; + +void main() { + group('Country.fromJson', () { + test('parst ein Land mit mehreren Notrufnummern korrekt', () { + final country = Country.fromJson('DE', { + 'name_en': 'Germany', + 'name_de': 'Deutschland', + 'flag': '🇩🇪', + 'numbers': [ + { + 'type': 'general', + 'number': '112', + 'label_de': 'Notruf', + 'label_en': 'Emergency', + }, + { + 'type': 'police', + 'number': '110', + 'label_de': 'Polizei', + 'label_en': 'Police', + }, + ], + }); + + expect(country.isoCode, 'DE'); + expect(country.nameDe, 'Deutschland'); + expect(country.nameEn, 'Germany'); + expect(country.flag, '🇩🇪'); + expect(country.numbers, hasLength(2)); + expect(country.numbers[0].type, EmergencyType.general); + expect(country.numbers[1].type, EmergencyType.police); + }); + + test('parst ein Land mit nur einer Notrufnummer korrekt', () { + final country = Country.fromJson('US', { + 'name_en': 'United States', + 'name_de': 'Vereinigte Staaten', + 'flag': '🇺🇸', + 'numbers': [ + { + 'type': 'general', + 'number': '911', + 'label_de': 'Notruf', + 'label_en': 'Emergency', + }, + ], + }); + + expect(country.numbers, hasLength(1)); + expect(country.numbers.single.number, '911'); + }); + }); +} diff --git a/test/models/emergency_number_test.dart b/test/models/emergency_number_test.dart new file mode 100644 index 0000000..46d594c --- /dev/null +++ b/test/models/emergency_number_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/models/emergency_number.dart'; +import 'package:emergency/models/emergency_type.dart'; + +void main() { + group('EmergencyNumber.fromJson', () { + test('parst gültiges JSON korrekt', () { + final number = EmergencyNumber.fromJson({ + 'type': 'police', + 'number': '110', + 'label_de': 'Polizei', + 'label_en': 'Police', + }); + + expect(number.type, EmergencyType.police); + expect(number.number, '110'); + expect(number.labelDe, 'Polizei'); + expect(number.labelEn, 'Police'); + }); + + test('zwei inhaltsgleiche Instanzen sind gleich', () { + final a = EmergencyNumber.fromJson({ + 'type': 'general', + 'number': '112', + 'label_de': 'Notruf', + 'label_en': 'Emergency', + }); + final b = EmergencyNumber.fromJson({ + 'type': 'general', + 'number': '112', + 'label_de': 'Notruf', + 'label_en': 'Emergency', + }); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); +} diff --git a/test/models/emergency_type_test.dart b/test/models/emergency_type_test.dart new file mode 100644 index 0000000..c560714 --- /dev/null +++ b/test/models/emergency_type_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/models/emergency_type.dart'; + +void main() { + group('EmergencyType', () { + test('fromJson mappt alle bekannten Werte korrekt', () { + expect(EmergencyType.fromJson('general'), EmergencyType.general); + expect(EmergencyType.fromJson('police'), EmergencyType.police); + expect(EmergencyType.fromJson('fire'), EmergencyType.fire); + expect(EmergencyType.fromJson('ambulance'), EmergencyType.ambulance); + expect(EmergencyType.fromJson('sea_rescue'), EmergencyType.seaRescue); + expect(EmergencyType.fromJson('mountain_rescue'), EmergencyType.mountainRescue); + }); + + test('fromJson wirft FormatException bei unbekanntem Wert', () { + expect(() => EmergencyType.fromJson('unknown'), throwsFormatException); + }); + + test('toJson ist invers zu fromJson', () { + for (final type in EmergencyType.values) { + expect(EmergencyType.fromJson(type.toJson()), type); + } + }); + }); +} diff --git a/test/providers/emergency_provider_test.dart b/test/providers/emergency_provider_test.dart new file mode 100644 index 0000000..1dbec44 --- /dev/null +++ b/test/providers/emergency_provider_test.dart @@ -0,0 +1,247 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/models/country.dart'; +import 'package:emergency/models/emergency_number.dart'; +import 'package:emergency/models/emergency_type.dart'; +import 'package:emergency/providers/emergency_provider.dart'; +import 'package:emergency/providers/emergency_status.dart'; +import 'package:emergency/repositories/emergency_number_repository.dart'; +import 'package:emergency/services/country_code_sources/country_code_source.dart'; +import 'package:emergency/services/location_service.dart'; + +/// Einfache manuelle Fakes statt Mocking-Package (siehe AP4-Vorgabe: kein +/// neues Mocking-Package einführen). Beide Basisklassen sind nicht `final`/ +/// `sealed`, daher genügt es, `load`/`lookup` bzw. `resolveCountryCode` zu +/// überschreiben, statt ein eigenes Interface einzuführen. +class _FakeRepository extends EmergencyNumberRepository { + _FakeRepository({this.loadError, this.countryToReturn}); + + Object? loadError; + Country? countryToReturn; + int loadCallCount = 0; + + @override + Future load() async { + loadCallCount++; + final error = loadError; + if (error != null) throw error; + } + + @override + Country? lookup(String isoCode) => countryToReturn; +} + +/// Nur zur Erfüllung des `super()`-Konstruktors von [LocationService] nötig +/// (wird nie aufgerufen, da [_FakeLocationService.resolveCountryCode] +/// überschrieben ist). Ohne einen expliziten `gpsSource` würde +/// `LocationService()` intern ein echtes `GpsCountryCodeSource` (und damit +/// `Geocoding()`) konstruieren, was außerhalb eines registrierten +/// Platform-Interface (siehe `gps_country_code_source_test.dart`) sofort wirft. +class _NoopSource implements CountryCodeSource { + const _NoopSource(); + + @override + Future resolve() async => null; +} + +class _FakeLocationService extends LocationService { + _FakeLocationService(this.isoCode) + : super(simSource: const _NoopSource(), gpsSource: const _NoopSource()); + + /// Simuliert das Ergebnis der automatischen Kette (SIM → GPS → Cache). + String? isoCode; + + /// Simuliert die manuelle Auswahl (AP6): hat, analog zur echten + /// Priorisierung in `LocationService`, Vorrang vor [isoCode]. + String? manualIsoCode; + + int resolveCallCount = 0; + int setManualCallCount = 0; + String? lastManualCode; + + @override + Future resolveCountryCode() async { + resolveCallCount++; + return manualIsoCode ?? isoCode; + } + + @override + Future setManualCountryCode(String? code) async { + setManualCallCount++; + lastManualCode = code; + manualIsoCode = code; + } +} + +const _germany = Country( + isoCode: 'DE', + nameDe: 'Deutschland', + nameEn: 'Germany', + flag: '🇩🇪', + numbers: [ + EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'), + ], +); + +const _france = Country( + isoCode: 'FR', + nameDe: 'Frankreich', + nameEn: 'France', + flag: '🇫🇷', + numbers: [ + EmergencyNumber(type: EmergencyType.general, number: '112', labelDe: 'Notruf', labelEn: 'Emergency'), + ], +); + +void main() { + group('EmergencyProvider.init()', () { + test('erfolgreicher Durchlauf -> status ready mit Land', () async { + final provider = EmergencyProvider( + repository: _FakeRepository(countryToReturn: _germany), + locationService: _FakeLocationService('DE'), + ); + + await provider.init(); + + expect(provider.status, EmergencyStatus.ready); + expect(provider.country, _germany); + expect(provider.errorMessage, isNull); + }); + + test('Ländercode nicht ermittelbar -> status countryUnknown', () async { + final provider = EmergencyProvider( + repository: _FakeRepository(countryToReturn: _germany), + locationService: _FakeLocationService(null), + ); + + await provider.init(); + + expect(provider.status, EmergencyStatus.countryUnknown); + expect(provider.country, isNull); + }); + + test('Land nicht in Datenbasis -> status ready mit country == null', () async { + final provider = EmergencyProvider( + repository: _FakeRepository(countryToReturn: null), + locationService: _FakeLocationService('XX'), + ); + + await provider.init(); + + expect(provider.status, EmergencyStatus.ready); + expect(provider.country, isNull); + }); + + test('repository.load() wirft -> status error mit errorMessage', () async { + final repository = _FakeRepository(loadError: const FormatException('kaputtes Asset')); + final locationService = _FakeLocationService('DE'); + final provider = EmergencyProvider(repository: repository, locationService: locationService); + + await provider.init(); + + expect(provider.status, EmergencyStatus.error); + expect(provider.errorMessage, isNotNull); + expect(repository.loadCallCount, 1); + // Ländererkennung darf nach einem Ladefehler nicht mehr angestoßen werden. + expect(locationService.resolveCallCount, 0); + }); + + test('durchläuft die Status initializing -> locating -> ready', () async { + final provider = EmergencyProvider( + repository: _FakeRepository(countryToReturn: _germany), + locationService: _FakeLocationService('DE'), + ); + + final observedStatuses = []; + provider.addListener(() => observedStatuses.add(provider.status)); + + await provider.init(); + + expect(observedStatuses, [ + EmergencyStatus.initializing, + EmergencyStatus.locating, + EmergencyStatus.ready, + ]); + }); + }); + + group('EmergencyProvider.retry()', () { + test('wiederholt init() und kann nach einem Fehler erfolgreich sein', () async { + final repository = _FakeRepository( + loadError: const FormatException('kaputtes Asset'), + countryToReturn: _germany, + ); + final locationService = _FakeLocationService('DE'); + final provider = EmergencyProvider(repository: repository, locationService: locationService); + + await provider.init(); + expect(provider.status, EmergencyStatus.error); + expect(repository.loadCallCount, 1); + + repository.loadError = null; + await provider.retry(); + + expect(provider.status, EmergencyStatus.ready); + expect(provider.country, _germany); + expect(provider.errorMessage, isNull); + expect(repository.loadCallCount, 2); + }); + }); + + group('EmergencyProvider.selectCountryManually()', () { + test('persistiert die Auswahl und stößt init() erneut an', () async { + final repository = _FakeRepository(countryToReturn: _germany); + final locationService = _FakeLocationService('DE'); + final provider = EmergencyProvider(repository: repository, locationService: locationService); + + await provider.init(); + expect(provider.country, _germany); + + repository.countryToReturn = _france; + await provider.selectCountryManually('FR'); + + expect(locationService.setManualCallCount, 1); + expect(locationService.lastManualCode, 'FR'); + // init() wurde ein zweites Mal komplett durchlaufen. + expect(locationService.resolveCallCount, 2); + expect(provider.status, EmergencyStatus.ready); + expect(provider.country, _france); + }); + }); + + group('EmergencyProvider.clearManualSelection()', () { + test('löscht die manuelle Auswahl und ermittelt danach erneut automatisch', () async { + // isoCode simuliert das Ergebnis der automatischen Kette (z. B. SIM), + // manualIsoCode eine bereits zuvor gesetzte manuelle Auswahl. + final locationService = _FakeLocationService('DE')..manualIsoCode = 'FR'; + final repository = _FakeRepository(countryToReturn: _france); + final provider = EmergencyProvider(repository: repository, locationService: locationService); + + await provider.init(); + expect(provider.country, _france); // manuelle Auswahl hat Vorrang + + repository.countryToReturn = _germany; + await provider.clearManualSelection(); + + expect(locationService.setManualCallCount, 1); + expect(locationService.lastManualCode, isNull); + expect(locationService.manualIsoCode, isNull); + expect(locationService.resolveCallCount, 2); + expect(provider.status, EmergencyStatus.ready); + // Nach dem Löschen greift wieder das automatische Ergebnis (DE). + expect(provider.country, _germany); + }); + }); + + group('EmergencyProvider.repository', () { + test('exponiert das intern verwendete Repository', () { + final repository = _FakeRepository(countryToReturn: _germany); + final provider = EmergencyProvider( + repository: repository, + locationService: _FakeLocationService('DE'), + ); + + expect(provider.repository, same(repository)); + }); + }); +} diff --git a/test/repositories/emergency_number_repository_test.dart b/test/repositories/emergency_number_repository_test.dart new file mode 100644 index 0000000..346d8f7 --- /dev/null +++ b/test/repositories/emergency_number_repository_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/repositories/emergency_number_repository.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('EmergencyNumberRepository', () { + test('lookup() vor load() liefert null statt Exception', () { + final repository = EmergencyNumberRepository(); + expect(repository.isLoaded, isFalse); + expect(repository.lookup('DE'), isNull); + }); + + test('load() liest das Asset ein und lookup() liefert bekannte Länder', () async { + final repository = EmergencyNumberRepository(); + await repository.load(); + + expect(repository.isLoaded, isTrue); + + final germany = repository.lookup('DE'); + expect(germany, isNotNull); + expect(germany!.nameEn, 'Germany'); + expect(germany.nameDe, 'Deutschland'); + expect(germany.numbers.map((e) => e.number), containsAll(['112', '110'])); + + final us = repository.lookup('US'); + expect(us, isNotNull); + expect(us!.nameEn, 'United States'); + expect(us.numbers.single.number, '911'); + + final uk = repository.lookup('GB'); + expect(uk, isNotNull); + expect(uk!.nameEn, 'United Kingdom'); + expect(uk.numbers.map((e) => e.number), contains('999')); + }); + + test('lookup() ist case-insensitive', () async { + final repository = EmergencyNumberRepository(); + await repository.load(); + + final lower = repository.lookup('de'); + final mixed = repository.lookup('De'); + final upper = repository.lookup('DE'); + + expect(lower, isNotNull); + expect(lower, equals(upper)); + expect(mixed, equals(upper)); + }); + + test('lookup() mit unbekanntem Ländercode liefert null', () async { + final repository = EmergencyNumberRepository(); + await repository.load(); + + expect(repository.lookup('ZZ'), isNull); + expect(repository.lookup(''), isNull); + }); + + test('load() ist idempotent (zweiter Aufruf lädt nicht erneut)', () async { + final repository = EmergencyNumberRepository(); + await repository.load(); + final firstLookup = repository.lookup('DE'); + + await repository.load(); + final secondLookup = repository.lookup('DE'); + + expect(firstLookup, equals(secondLookup)); + }); + }); + + group('EmergencyNumberRepository.allCountries', () { + test('vor load() liefert eine leere Liste statt Exception', () { + final repository = EmergencyNumberRepository(); + expect(repository.allCountries, isEmpty); + }); + + test('nach load() nicht leer und enthält bekannte Länder (DE/US)', () async { + final repository = EmergencyNumberRepository(); + await repository.load(); + + final countries = repository.allCountries; + + expect(countries, isNotEmpty); + expect(countries.map((c) => c.isoCode), containsAll(['DE', 'US'])); + }); + + test('ist alphabetisch nach nameDe sortiert', () async { + final repository = EmergencyNumberRepository(); + await repository.load(); + + final names = repository.allCountries.map((c) => c.nameDe).toList(); + final sortedNames = [...names]..sort(); + + expect(names, equals(sortedNames)); + }); + }); +} diff --git a/test/services/country_code_sources/cached_country_code_source_test.dart b/test/services/country_code_sources/cached_country_code_source_test.dart new file mode 100644 index 0000000..15fc9bb --- /dev/null +++ b/test/services/country_code_sources/cached_country_code_source_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:emergency/services/country_code_sources/cached_country_code_source.dart'; + +/// Im Gegensatz zu den SIM-/GPS-Quellen ist `shared_preferences` über +/// `SharedPreferences.setMockInitialValues` vollständig ohne echten +/// Plattform-Kanal testbar – daher hier ein echter Verhaltenstest statt nur +/// eines Struktur-Tests. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('CachedCountryCodeSource', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('resolve() liefert null, wenn noch kein Code gespeichert ist', () async { + const source = CachedCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + + test('save() gefolgt von resolve() liefert den gespeicherten, normalisierten Code', () async { + const source = CachedCountryCodeSource(); + + await source.save('de'); + + expect(await source.resolve(), 'DE'); + }); + + test('resolve() liefert null, wenn der gespeicherte Wert leer ist', () async { + SharedPreferences.setMockInitialValues({'last_known_country_code': ' '}); + const source = CachedCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + + test('save() überschreibt einen zuvor gespeicherten Code', () async { + const source = CachedCountryCodeSource(); + + await source.save('DE'); + await source.save('fr'); + + expect(await source.resolve(), 'FR'); + }); + }); +} diff --git a/test/services/country_code_sources/gps_country_code_source_test.dart b/test/services/country_code_sources/gps_country_code_source_test.dart new file mode 100644 index 0000000..5cf6c68 --- /dev/null +++ b/test/services/country_code_sources/gps_country_code_source_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/widgets.dart' show Locale; +import 'package:flutter_test/flutter_test.dart'; +import 'package:geocoding_platform_interface/geocoding_platform_interface.dart' as pi; +import 'package:geolocator_platform_interface/geolocator_platform_interface.dart'; + +import 'package:emergency/services/country_code_sources/gps_country_code_source.dart'; + +/// Analog zum `url_launcher`-Test von `DialerService`: `geolocator` und +/// `geocoding` erlauben es, ihre `*Platform.instance` durch eine eigene +/// Implementierung zu ersetzen – der offiziell vorgesehene Weg, diese +/// Packages ohne echten Plattform-Kanal zu testen. +class _FakeGeolocatorPlatform extends GeolocatorPlatform { + _FakeGeolocatorPlatform({ + required this.permission, + this.position, + this.currentPositionError, + }); + + LocationPermission permission; + Position? position; + Object? currentPositionError; + + @override + Future checkPermission() async => permission; + + @override + Future requestPermission() async => permission; + + @override + Future getCurrentPosition({LocationSettings? locationSettings}) async { + if (currentPositionError != null) throw currentPositionError!; + return position!; + } +} + +class _FakeGeocodingPlatform extends pi.Geocoding { + _FakeGeocodingPlatform({this.placemarks = const [], this.error}) + : super.implementation(const pi.GeocodingCreationParams()); + + final List placemarks; + Object? error; + + @override + Future> placemarkFromCoordinates( + double latitude, + double longitude, { + Locale? locale, + }) async { + if (error != null) throw error!; + return placemarks; + } +} + +class _FakeGeocodingPlatformFactory extends pi.GeocodingPlatformFactory { + _FakeGeocodingPlatformFactory(this.geocoding); + + final pi.Geocoding geocoding; + + @override + pi.Geocoding createGeocoding(pi.GeocodingCreationParams params) => geocoding; +} + +Position _somePosition() => Position( + latitude: 52.5, + longitude: 13.4, + timestamp: DateTime.utc(2026, 1, 1), + accuracy: 0, + altitude: 0, + altitudeAccuracy: 0, + heading: 0, + headingAccuracy: 0, + speed: 0, + speedAccuracy: 0, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + void setUpPlatforms({ + required LocationPermission permission, + Position? position, + Object? currentPositionError, + List placemarks = const [], + Object? geocodingError, + }) { + GeolocatorPlatform.instance = _FakeGeolocatorPlatform( + permission: permission, + position: position, + currentPositionError: currentPositionError, + ); + pi.GeocodingPlatformFactory.instance = _FakeGeocodingPlatformFactory( + _FakeGeocodingPlatform(placemarks: placemarks, error: geocodingError), + ); + } + + group('GpsCountryCodeSource', () { + test('liefert den ISO-Code aus dem ersten Placemark bei erteilter Berechtigung', () async { + setUpPlatforms( + permission: LocationPermission.whileInUse, + position: _somePosition(), + placemarks: const [pi.Placemark(isoCountryCode: 'de')], + ); + + final source = GpsCountryCodeSource(); + + expect(await source.resolve(), 'DE'); + }); + + test('fordert die Berechtigung an, wenn sie noch nicht erteilt ist', () async { + setUpPlatforms( + permission: LocationPermission.denied, + position: _somePosition(), + placemarks: const [pi.Placemark(isoCountryCode: 'fr')], + ); + + final source = GpsCountryCodeSource(); + + // Der Fake liefert bei checkPermission() und requestPermission() denselben + // Wert; hier reicht es zu prüfen, dass bei "denied" nachgefragt wird und + // trotzdem kein Ergebnis zustande kommt (Fake bleibt bei "denied"). + expect(await source.resolve(), isNull); + }); + + test('liefert null, wenn die Berechtigung dauerhaft verweigert ist', () async { + setUpPlatforms( + permission: LocationPermission.deniedForever, + position: _somePosition(), + placemarks: const [pi.Placemark(isoCountryCode: 'de')], + ); + + final source = GpsCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + + test('liefert null, wenn getCurrentPosition wirft (z. B. GPS aus)', () async { + setUpPlatforms( + permission: LocationPermission.whileInUse, + currentPositionError: const LocationServiceDisabledException(), + ); + + final source = GpsCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + + test('liefert null, wenn das Reverse-Geocoding wirft (z. B. kein Netz)', () async { + setUpPlatforms( + permission: LocationPermission.whileInUse, + position: _somePosition(), + geocodingError: Exception('network error'), + ); + + final source = GpsCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + + test('liefert null, wenn keine Placemarks gefunden werden', () async { + setUpPlatforms( + permission: LocationPermission.whileInUse, + position: _somePosition(), + placemarks: const [], + ); + + final source = GpsCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + }); +} diff --git a/test/services/country_code_sources/manual_country_code_source_test.dart b/test/services/country_code_sources/manual_country_code_source_test.dart new file mode 100644 index 0000000..dfc7be9 --- /dev/null +++ b/test/services/country_code_sources/manual_country_code_source_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:emergency/services/country_code_sources/manual_country_code_source.dart'; + +/// Analog zu `cached_country_code_source_test.dart`: `shared_preferences` +/// ist über `SharedPreferences.setMockInitialValues` vollständig ohne echten +/// Plattform-Kanal testbar. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ManualCountryCodeSource', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('resolve() liefert null, wenn noch keine manuelle Auswahl gespeichert ist', () async { + const source = ManualCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + + test('save() gefolgt von resolve() liefert den gespeicherten, normalisierten Code', () async { + const source = ManualCountryCodeSource(); + + await source.save('fr'); + + expect(await source.resolve(), 'FR'); + }); + + test('save(null) löscht eine zuvor gesetzte manuelle Auswahl wieder', () async { + const source = ManualCountryCodeSource(); + + await source.save('FR'); + expect(await source.resolve(), 'FR'); + + await source.save(null); + expect(await source.resolve(), isNull); + }); + + test('verwendet einen eigenen Key, unabhängig vom automatischen Cache', () async { + SharedPreferences.setMockInitialValues({'last_known_country_code': 'US'}); + const source = ManualCountryCodeSource(); + + // Der automatische Cache-Wert darf die manuelle Auswahl nicht + // beeinflussen, solange keine manuelle Auswahl gesetzt wurde. + expect(await source.resolve(), isNull); + }); + }); +} diff --git a/test/services/country_code_sources/sim_country_code_source_test.dart b/test/services/country_code_sources/sim_country_code_source_test.dart new file mode 100644 index 0000000..bd247d4 --- /dev/null +++ b/test/services/country_code_sources/sim_country_code_source_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/services/country_code_sources/sim_country_code_source.dart'; + +/// `carrier_info` spricht direkt (ohne Platform-Interface-Package) den +/// MethodChannel `plugins.chizi.tech/carrier_info` an. Flutter erlaubt es, +/// diesen Kanal in Tests mit einem Mock-Handler zu belegen – dadurch lässt +/// sich die eigentliche Priorisierungs-/Normalisierungslogik von +/// [SimCountryCodeSource] (anders als bei GPS, siehe dortigen Struktur-Test) +/// echt testen, ganz ohne reales Gerät/Emulator. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('plugins.chizi.tech/carrier_info'); + + void mockHandler(Future Function(MethodCall call) handler) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, handler); + } + + Map androidInfo({ + List> telephonyInfo = const [], + List> subscriptionsInfo = const [], + }) { + return { + 'isVoiceCapable': true, + 'isDataEnabled': true, + 'subscriptionsInfo': subscriptionsInfo, + 'isDataCapable': true, + 'isMultiSimSupported': 'MULTISIM_NOT_SUPPORTED_BY_HARDWARE', + 'isSmsCapable': true, + 'telephonyInfo': telephonyInfo, + }; + } + + Map iosInfo({ + List> carrierData = const [], + }) { + return { + 'carrierData': carrierData, + 'supportsEmbeddedSIM': false, + 'carrierRadioAccessTechnologyTypeList': ['LTE'], + 'isSIMInserted': true, + }; + } + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('SimCountryCodeSource auf Android', () { + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + }); + + test('bevorzugt networkCountryIso (aktuelles Netz, roaming-fest) vor allem anderen', () async { + mockHandler((call) async { + expect(call.method, 'getAndroidInfo'); + return androidInfo( + telephonyInfo: [ + {'networkCountryIso': 'fr', 'isoCountryCode': 'de'}, + ], + subscriptionsInfo: [ + {'countryIso': 'de'}, + ], + ); + }); + + const source = SimCountryCodeSource(); + + expect(await source.resolve(), 'FR'); + }); + + test('fällt auf isoCountryCode zurück, wenn networkCountryIso leer ist', () async { + mockHandler((call) async { + return androidInfo( + telephonyInfo: [ + {'networkCountryIso': '', 'isoCountryCode': 'de'}, + ], + ); + }); + + const source = SimCountryCodeSource(); + + expect(await source.resolve(), 'DE'); + }); + + test('fällt auf subscriptionsInfo.countryIso zurück, wenn telephonyInfo leer ist', () async { + mockHandler((call) async { + return androidInfo( + subscriptionsInfo: [ + {'countryIso': 'us'}, + ], + ); + }); + + const source = SimCountryCodeSource(); + + expect(await source.resolve(), 'US'); + }); + + test('liefert null, wenn alle Quellen leer sind (z. B. kein SIM-Slot belegt)', () async { + mockHandler((call) async => androidInfo()); + + const source = SimCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + + test('liefert null statt zu werfen, wenn das Plugin eine PlatformException wirft', () async { + mockHandler((call) async { + throw PlatformException(code: 'UNAVAILABLE', message: 'no telephony service'); + }); + + const source = SimCountryCodeSource(); + + expect(await source.resolve(), isNull); + }); + }); + + group('SimCountryCodeSource auf iOS', () { + setUp(() { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + }); + + test('liefert isoCountryCode auf iOS < 16 (CTCarrier noch verfügbar)', () async { + mockHandler((call) async { + expect(call.method, 'getIosInfo'); + return iosInfo( + carrierData: [ + {'isoCountryCode': 'ng', 'carrierName': 'Airtel'}, + ], + ); + }); + + const source = SimCountryCodeSource(); + + expect(await source.resolve(), 'NG'); + }); + + test( + 'liefert null auf iOS 16+ (CTCarrier deprecated, isoCountryCode ist nil) – ' + 'bekanntes, akzeptiertes Risiko laut docs/plan.md Abschnitt 9', + () async { + mockHandler((call) async { + return iosInfo( + carrierData: [ + {'isoCountryCode': null, 'carrierName': null}, + ], + ); + }); + + const source = SimCountryCodeSource(); + + expect(await source.resolve(), isNull); + }, + ); + }); +} diff --git a/test/services/dialer_service_test.dart b/test/services/dialer_service_test.dart new file mode 100644 index 0000000..896dd61 --- /dev/null +++ b/test/services/dialer_service_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import 'package:emergency/services/dialer_service.dart'; + +/// Fake-Implementierung der `url_launcher`-Plattformschnittstelle. +/// +/// Vermeidet Mocking auf `MethodChannel`-Ebene: `url_launcher` erlaubt es, +/// [UrlLauncherPlatform.instance] durch eine eigene Implementierung zu +/// ersetzen – das ist der offiziell vorgesehene Weg, das Package in reinen +/// Unit-Tests ohne echte Plattform-Kanäle zu testen. +class _FakeUrlLauncherPlatform extends UrlLauncherPlatform { + _FakeUrlLauncherPlatform({required this.canLaunchResult}); + + final bool canLaunchResult; + + String? launchedUrl; + int launchCallCount = 0; + + @override + LinkDelegate? get linkDelegate => null; + + @override + Future canLaunch(String url) async => canLaunchResult; + + @override + Future launchUrl(String url, LaunchOptions options) async { + launchCallCount++; + launchedUrl = url; + return true; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('DialerService', () { + const dialerService = DialerService(); + + test('call() öffnet den Wähler mit tel:-URI und liefert true, wenn canLaunch true ist', () async { + final fakePlatform = _FakeUrlLauncherPlatform(canLaunchResult: true); + UrlLauncherPlatform.instance = fakePlatform; + + final result = await dialerService.call('112'); + + expect(result, isTrue); + expect(fakePlatform.launchCallCount, 1); + expect(fakePlatform.launchedUrl, 'tel:112'); + }); + + test('call() liefert false ohne launch(), wenn canLaunch false ist', () async { + final fakePlatform = _FakeUrlLauncherPlatform(canLaunchResult: false); + UrlLauncherPlatform.instance = fakePlatform; + + final result = await dialerService.call('911'); + + expect(result, isFalse); + expect(fakePlatform.launchCallCount, 0); + }); + }); +} diff --git a/test/services/location_service_test.dart b/test/services/location_service_test.dart new file mode 100644 index 0000000..9725d1f --- /dev/null +++ b/test/services/location_service_test.dart @@ -0,0 +1,214 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:emergency/services/country_code_sources/country_code_source.dart'; +import 'package:emergency/services/location_service.dart'; + +/// Einfache manuelle Fake-Quelle statt Mocking-Package (siehe AP4-Vorgabe: +/// kein neues Mocking-Package einführen). +class _FakeSource implements CountryCodeSource { + _FakeSource(this._result); + + final String? _result; + int callCount = 0; + + @override + Future resolve() async { + callCount++; + return _result; + } +} + +class _FakeCache implements CountryCodeCache { + _FakeCache({String? initial}) : _stored = initial; + + String? _stored; + int resolveCallCount = 0; + final List savedCodes = []; + + @override + Future resolve() async { + resolveCallCount++; + return _stored; + } + + @override + Future save(String isoCode) async { + savedCodes.add(isoCode); + _stored = isoCode; + } +} + +/// Fake für die manuelle Auswahl (AP6). Ohne explizite Injektion würde +/// `LocationService()` intern ein echtes `ManualCountryCodeSource` (und damit +/// `shared_preferences`) ansprechen, was in diesen reinen Unit-Tests (kein +/// `TestWidgetsFlutterBinding.ensureInitialized()`/Mock-Setup) nicht +/// notwendig sein soll – die bestehenden SIM/GPS/Cache-Tests sollen von der +/// manuellen Auswahl unbeeinflusst bleiben. +class _FakeManualSource implements ManualCountryCodeStore { + _FakeManualSource([this._result]); + + final String? _result; + int resolveCallCount = 0; + final List savedCodes = []; + + @override + Future resolve() async { + resolveCallCount++; + return _result; + } + + @override + Future save(String? isoCode) async { + savedCodes.add(isoCode); + } +} + +void main() { + group('LocationService.resolveCountryCode()', () { + test( + 'SIM liefert Ergebnis -> wird verwendet, GPS/Cache werden nicht abgefragt, ' + 'Ergebnis wird gecacht', + () async { + final sim = _FakeSource('DE'); + final gps = _FakeSource('FR'); + final cache = _FakeCache(initial: 'US'); + final service = LocationService( + simSource: sim, + gpsSource: gps, + cacheSource: cache, + manualSource: _FakeManualSource(), + ); + + final result = await service.resolveCountryCode(); + + expect(result, 'DE'); + expect(sim.callCount, 1); + expect(gps.callCount, 0); + expect(cache.resolveCallCount, 0); + expect(cache.savedCodes, ['DE']); + }, + ); + + test( + 'SIM liefert null, GPS liefert Ergebnis -> wird verwendet, Cache wird nicht ' + 'gelesen, aber beschrieben', + () async { + final sim = _FakeSource(null); + final gps = _FakeSource('FR'); + final cache = _FakeCache(initial: 'US'); + final service = LocationService( + simSource: sim, + gpsSource: gps, + cacheSource: cache, + manualSource: _FakeManualSource(), + ); + + final result = await service.resolveCountryCode(); + + expect(result, 'FR'); + expect(sim.callCount, 1); + expect(gps.callCount, 1); + expect(cache.resolveCallCount, 0); + expect(cache.savedCodes, ['FR']); + }, + ); + + test('SIM und GPS liefern null, Cache liefert Ergebnis -> wird verwendet', () async { + final sim = _FakeSource(null); + final gps = _FakeSource(null); + final cache = _FakeCache(initial: 'US'); + final service = LocationService( + simSource: sim, + gpsSource: gps, + cacheSource: cache, + manualSource: _FakeManualSource(), + ); + + final result = await service.resolveCountryCode(); + + expect(result, 'US'); + expect(cache.resolveCallCount, 1); + // Kein erneutes Schreiben, da das Ergebnis ja bereits aus dem Cache kam. + expect(cache.savedCodes, isEmpty); + }); + + test('Alle drei automatischen Quellen liefern null -> Ergebnis ist null', () async { + final sim = _FakeSource(null); + final gps = _FakeSource(null); + final cache = _FakeCache(); + final service = LocationService( + simSource: sim, + gpsSource: gps, + cacheSource: cache, + manualSource: _FakeManualSource(), + ); + + final result = await service.resolveCountryCode(); + + expect(result, isNull); + expect(cache.savedCodes, isEmpty); + }); + + test( + 'manuelle Auswahl gesetzt -> hat Vorrang vor SIM/GPS/Cache, die anderen ' + 'Quellen werden gar nicht erst abgefragt', + () async { + final sim = _FakeSource('DE'); + final gps = _FakeSource('FR'); + final cache = _FakeCache(initial: 'US'); + final manual = _FakeManualSource('IT'); + final service = LocationService( + simSource: sim, + gpsSource: gps, + cacheSource: cache, + manualSource: manual, + ); + + final result = await service.resolveCountryCode(); + + expect(result, 'IT'); + expect(sim.callCount, 0); + expect(gps.callCount, 0); + expect(cache.resolveCallCount, 0); + // Die manuelle Auswahl wird bewusst nicht in den automatischen Cache + // geschrieben (getrennte Mechanismen, siehe Doc-Kommentar). + expect(cache.savedCodes, isEmpty); + }, + ); + + test('keine manuelle Auswahl gesetzt -> Kette fällt normal auf SIM zurück', () async { + final sim = _FakeSource('DE'); + final gps = _FakeSource('FR'); + final cache = _FakeCache(initial: 'US'); + final manual = _FakeManualSource(null); + final service = LocationService( + simSource: sim, + gpsSource: gps, + cacheSource: cache, + manualSource: manual, + ); + + final result = await service.resolveCountryCode(); + + expect(result, 'DE'); + expect(manual.resolveCallCount, 1); + }); + }); + + group('LocationService.setManualCountryCode()', () { + test('delegiert an die manuelle Quelle', () async { + final manual = _FakeManualSource(); + final service = LocationService( + simSource: _FakeSource(null), + gpsSource: _FakeSource(null), + cacheSource: _FakeCache(), + manualSource: manual, + ); + + await service.setManualCountryCode('IT'); + await service.setManualCountryCode(null); + + expect(manual.savedCodes, ['IT', null]); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 6a4069b..2ff40fa 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,104 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:emergency/main.dart'; +import 'package:emergency/app.dart'; +import 'package:emergency/features/home/widgets/emergency_button.dart'; +import 'package:emergency/repositories/emergency_number_repository.dart'; +import 'package:emergency/services/country_code_sources/country_code_source.dart'; +import 'package:emergency/services/location_service.dart'; + +/// Einfache manuelle Fake-Quelle statt Mocking-Package (siehe AP4-Vorgabe: +/// kein neues Mocking-Package einführen), analog zu +/// `test/services/location_service_test.dart`. +class _FakeSource implements CountryCodeSource { + _FakeSource(this._result); + + final String? _result; + + @override + Future resolve() async => _result; +} + +/// Fake-Cache: Verhindert, dass `LocationService` beim Cachen eines +/// SIM/GPS-Treffers den echten `shared_preferences`-Plugin-Kanal anspricht +/// (der ohne Mock-Setup in reinen Widget-Tests nicht antwortet). +class _FakeCache implements CountryCodeCache { + @override + Future resolve() async => null; + + @override + Future save(String isoCode) async {} +} + +/// Analog zu `_FakeCache`, aber für die manuelle Auswahl (AP6): verhindert +/// den echten `shared_preferences`-Zugriff, den `LocationService` seit AP6 +/// als vorgeschaltete Prioritätsstufe abfragt. +class _FakeManualSource implements ManualCountryCodeStore { + @override + Future resolve() async => null; + + @override + Future save(String? isoCode) async {} +} void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('HomeScreen zeigt nach dem Laden mindestens einen EmergencyButton für DE', ( + WidgetTester tester, + ) async { + // Asset-Laden über rootBundle ist echte I/O und läuft nicht innerhalb des + // FakeAsync-Zeitrahmens von pump()/pumpAndSettle() durch. Deshalb wird + // die Datenbasis hier vorab über runAsync geladen (Repository.load() ist + // danach idempotent, s. Repository). Die Ländererkennung selbst wird + // über eine gefakte SIM-Quelle deterministisch auf "DE" gesetzt, damit + // der Test nicht von echten Plattform-Kanälen (SIM/GPS/Cache) abhängt. + final repository = EmergencyNumberRepository(); + await tester.runAsync(() => repository.load()); + // gpsSource muss explizit gesetzt werden: Ohne eigenen Wert würde + // LocationService() intern ein echtes GpsCountryCodeSource (und damit + // Geocoding()) konstruieren, was ohne registriertes Platform-Interface + // sofort wirft (siehe gps_country_code_source_test.dart). cacheSource + // wird ebenfalls gefaked, siehe _FakeCache. + final locationService = LocationService( + simSource: _FakeSource('DE'), + gpsSource: _FakeSource(null), + cacheSource: _FakeCache(), + manualSource: _FakeManualSource(), + ); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + await tester.pumpWidget(App(repository: repository, locationService: locationService)); + await tester.pumpAndSettle(); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.byType(EmergencyButton), findsWidgets); + // DE führt "112" bereits als general-Nummer, daher darf der globale + // Panik-Button hier nicht zusätzlich dieselbe Nummer anzeigen (Dedup). + expect(find.text('112'), findsOneWidget); + expect(find.byKey(const Key('panicButton')), findsNothing); }); + + testWidgets( + 'HomeScreen zeigt den globalen Panik-Button bereits während des Ladens der Ländererkennung', + (WidgetTester tester) async { + // Bewusst kein runAsync/Vorladen des Repositories: Der Ladezustand + // (EmergencyStatus.initializing) soll direkt nach pumpWidget + // beobachtbar sein, ohne auf das Ende des echten Asset-Ladens zu warten. + // Die Ländererkennung wird ohnehin nie erreicht (blockiert beim + // Repository-Laden), locationService wird trotzdem explizit gefaked, + // siehe Kommentar im Test oben. + await tester.pumpWidget( + App( + repository: EmergencyNumberRepository(), + locationService: LocationService( + simSource: _FakeSource(null), + gpsSource: _FakeSource(null), + cacheSource: _FakeCache(), + manualSource: _FakeManualSource(), + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byKey(const Key('panicButton')), findsOneWidget); + expect(find.text('112'), findsOneWidget); + }, + ); }