diff --git a/Localizable.xcstrings b/Localizable.xcstrings index c3e242b8..9db264a8 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -13187,6 +13187,1520 @@ } } } + }, + "Total solar eclipse" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "كسوف كلي للشمس" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Totale Sonnenfinsternis" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclipse solar total" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Éclipse solaire totale" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclissi solare totale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "皆既日食" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Totale zonsverduistering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Całkowite zaćmienie Słońca" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "日全食" + } + } + } + }, + "Annular solar eclipse" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "كسوف حلقي للشمس" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ringförmige Sonnenfinsternis" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclipse solar anular" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Éclipse solaire annulaire" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclissi solare anulare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "金環日食" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ringvormige zonsverduistering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Obrączkowe zaćmienie Słońca" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "日环食" + } + } + } + }, + "Partial solar eclipse" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "كسوف جزئي للشمس" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Partielle Sonnenfinsternis" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclipse solar parcial" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Éclipse solaire partielle" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclissi solare parziale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "部分日食" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Gedeeltelijke zonsverduistering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Częściowe zaćmienie Słońca" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "日偏食" + } + } + } + }, + "%@ covered" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ مغطى" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ bedeckt" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ cubierto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ couvert" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ coperto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ が隠れます" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ bedekt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zakryte w %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "遮挡 %@" + } + } + } + }, + "%@ of the sun" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ من الشمس" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ der Sonne" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ del Sol" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ du Soleil" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ del Sole" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "太陽の %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ van de zon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%@ tarczy Słońca" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "太阳的 %@" + } + } + } + }, + "Maximum coverage" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "أقصى تغطية" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Maximale Bedeckung" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Cobertura máxima" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Couverture maximale" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Copertura massima" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "最大食分" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Maximale bedekking" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Maksymalne zakrycie" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "最大遮挡" + } + } + } + }, + "Totality lasts about" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "مدة الكسوف الكلي حوالي" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Totalität dauert etwa" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "La totalidad dura unos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "La totalité dure environ" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "La totalità dura circa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "皆既の継続時間は約" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Totaliteit duurt ongeveer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Faza całkowita trwa około" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "全食持续约" + } + } + } + }, + "Annularity lasts about" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "مدة الكسوف الحلقي حوالي" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ringphase dauert etwa" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "La anularidad dura unos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "L'annularité dure environ" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "L'anularità dura circa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "金環の継続時間は約" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ringfase duurt ongeveer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Faza obrączkowa trwa około" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "环食持续约" + } + } + } + }, + "Eclipse begins" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "يبدأ الكسوف" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Finsternis beginnt" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "El eclipse comienza" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Début de l'éclipse" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Inizio dell'eclissi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "食の開始" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Verduistering begint" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Początek zaćmienia" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "初亏" + } + } + } + }, + "Maximum eclipse" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "ذروة الكسوف" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Maximum der Finsternis" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Máximo del eclipse" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Maximum de l'éclipse" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Massimo dell'eclissi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "食の最大" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Maximum van de verduistering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Maksimum zaćmienia" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "食甚" + } + } + } + }, + "Eclipse ends" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "ينتهي الكسوف" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Finsternis endet" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "El eclipse termina" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Fin de l'éclipse" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Fine dell'eclissi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "食の終了" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Verduistering eindigt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Koniec zaćmienia" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "复圆" + } + } + } + }, + "%lld° above the horizon" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld° فوق الأفق" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld° über dem Horizont" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld° sobre el horizonte" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld° au-dessus de l'horizon" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld° sopra l'orizzonte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "地平線から %lld°" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld° boven de horizon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%lld° nad horyzontem" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "地平线以上 %lld°" + } + } + } + }, + "Low in the sky" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "منخفض في السماء" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Tief am Himmel" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Bajo en el cielo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Bas dans le ciel" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Basso nel cielo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "空の低い位置" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Laag aan de hemel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nisko nad horyzontem" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "位置很低" + } + } + } + }, + "Never look at the sun without certified eclipse glasses. Only during totality, when the sun is completely covered, is it safe to look with the naked eye." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "لا تنظر إلى الشمس أبدًا دون نظارات كسوف معتمدة. لا يكون النظر بالعين المجردة آمنًا إلا أثناء الكسوف الكلي، عندما تكون الشمس مغطاة تمامًا." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Blicken Sie niemals ohne zertifizierte Sonnenfinsternisbrille in die Sonne. Nur während der Totalität, wenn die Sonne vollständig bedeckt ist, ist der Blick mit bloßem Auge gefahrlos." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nunca mires al Sol sin gafas de eclipse homologadas. Solo durante la totalidad, cuando el Sol está completamente cubierto, es seguro mirar a simple vista." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ne regardez jamais le Soleil sans lunettes d'éclipse certifiées. Ce n'est que pendant la totalité, lorsque le Soleil est entièrement couvert, qu'il est sans danger de regarder à l'œil nu." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Non guardare mai il Sole senza occhiali da eclissi certificati. Solo durante la totalità, quando il Sole è completamente coperto, è sicuro guardare a occhio nudo." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "認証済みの日食グラスなしで太陽を見ないでください。肉眼で見ても安全なのは、太陽が完全に隠れる皆既中だけです。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Kijk nooit naar de zon zonder gecertificeerde eclipsbril. Alleen tijdens de totaliteit, wanneer de zon volledig bedekt is, is het veilig om met het blote oog te kijken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nigdy nie patrz na Słońce bez certyfikowanych okularów do obserwacji zaćmień. Tylko podczas fazy całkowitej, gdy Słońce jest całkowicie zakryte, można bezpiecznie patrzeć gołym okiem." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "切勿在没有认证日食眼镜的情况下直视太阳。只有在太阳被完全遮挡的全食阶段,才可以用肉眼观看。" + } + } + } + }, + "Never look at the sun without certified eclipse glasses, even when it is almost entirely covered." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "لا تنظر إلى الشمس أبدًا دون نظارات كسوف معتمدة، حتى عندما تكون مغطاة بالكامل تقريبًا." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Blicken Sie niemals ohne zertifizierte Sonnenfinsternisbrille in die Sonne, auch dann nicht, wenn sie fast vollständig bedeckt ist." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nunca mires al Sol sin gafas de eclipse homologadas, ni siquiera cuando esté casi completamente cubierto." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ne regardez jamais le Soleil sans lunettes d'éclipse certifiées, même lorsqu'il est presque entièrement couvert." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Non guardare mai il Sole senza occhiali da eclissi certificati, nemmeno quando è quasi completamente coperto." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "認証済みの日食グラスなしで太陽を見ないでください。ほとんど隠れているときでも同じです。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Kijk nooit naar de zon zonder gecertificeerde eclipsbril, ook niet wanneer deze bijna volledig bedekt is." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nigdy nie patrz na Słońce bez certyfikowanych okularów do obserwacji zaćmień, nawet gdy jest niemal całkowicie zakryte." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "切勿在没有认证日食眼镜的情况下直视太阳,即使它几乎被完全遮挡。" + } + } + } + }, + "Solar eclipse alerts" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "تنبيهات كسوف الشمس" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Sonnenfinsternis-Hinweise" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Avisos de eclipse solar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Alertes d'éclipse solaire" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Avvisi di eclissi solare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "日食の通知" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Meldingen voor zonsverduistering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Powiadomienia o zaćmieniu Słońca" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "日食提醒" + } + } + } + }, + "Get a reminder a week before, and again on the morning of, a solar eclipse that covers most of the sun where you are. Most locations see one only once or twice a decade." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "احصل على تذكير قبل أسبوع، ومرة أخرى في صباح يوم كسوف الشمس الذي يغطي معظم قرص الشمس في موقعك. معظم المواقع تشهد ذلك مرة أو مرتين فقط كل عقد." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Erhalte eine Erinnerung eine Woche vorher und erneut am Morgen einer Sonnenfinsternis, die an deinem Ort den größten Teil der Sonne bedeckt. An den meisten Orten kommt das nur ein- bis zweimal pro Jahrzehnt vor." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Recibe un recordatorio una semana antes, y otro la mañana de un eclipse solar que cubra la mayor parte del Sol donde estás. En la mayoría de los lugares esto ocurre solo una o dos veces por década." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Recevez un rappel une semaine avant, puis le matin même, pour une éclipse solaire qui couvre la majeure partie du Soleil là où vous êtes. La plupart des endroits n'en voient qu'une ou deux par décennie." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ricevi un promemoria una settimana prima e di nuovo la mattina di un'eclissi solare che copre gran parte del Sole dove ti trovi. Nella maggior parte dei luoghi accade solo una o due volte per decennio." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "お住まいの場所で太陽の大部分が隠れる日食について、1週間前と当日の朝にお知らせします。ほとんどの場所では10年に1〜2回しか起こりません。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ontvang een herinnering een week van tevoren en nogmaals op de ochtend van een zonsverduistering die het grootste deel van de zon bedekt waar jij bent. Op de meeste plekken gebeurt dat maar één of twee keer per decennium." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Otrzymaj przypomnienie tydzień wcześniej i ponownie rano w dniu zaćmienia Słońca, które zakryje większość tarczy w Twojej lokalizacji. W większości miejsc zdarza się to tylko raz lub dwa razy na dekadę." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "当你所在的位置将出现遮挡大部分太阳的日食时,会在一周前和当天早上收到提醒。大多数地点每十年只会遇到一两次。" + } + } + } + }, + "eclipse-notif-week-title" : { + "comment" : "Notification title one week before a solar eclipse", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "كسوف الشمس بعد أسبوع" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Sonnenfinsternis in einer Woche" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Solar eclipse in one week" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclipse solar en una semana" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Éclipse solaire dans une semaine" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclissi solare tra una settimana" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "1週間後に日食" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Over een week zonsverduistering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zaćmienie Słońca za tydzień" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "一周后有日食" + } + } + } + }, + "eclipse-notif-day-title" : { + "comment" : "Notification title on the day of a solar eclipse", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "كسوف الشمس اليوم" + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Heute Sonnenfinsternis" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Solar eclipse today" + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclipse solar hoy" + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Éclipse solaire aujourd'hui" + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eclissi solare oggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "今日は日食です" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Vandaag zonsverduistering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Dziś zaćmienie Słońca" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "今天有日食" + } + } + } + }, + "eclipse-notif-total" : { + "comment" : "Notification fragment describing a total solar eclipse", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "سيغطي كسوف كلي قرص الشمس بالكامل." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eine totale Sonnenfinsternis wird die Sonne vollständig bedecken." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A total solar eclipse will completely cover the sun." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Un eclipse solar total cubrirá el Sol por completo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Une éclipse solaire totale couvrira entièrement le Soleil." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Un'eclissi solare totale coprirà completamente il Sole." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "皆既日食で太陽が完全に隠れます。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Een totale zonsverduistering zal de zon volledig bedekken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Całkowite zaćmienie Słońca całkowicie zakryje tarczę Słońca." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "日全食将完全遮挡太阳。" + } + } + } + }, + "eclipse-notif-annular" : { + "comment" : "Notification fragment describing an annular solar eclipse", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "سيغطي كسوف حلقي %@ من قرص الشمس، تاركًا حلقة من الضوء." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eine ringförmige Sonnenfinsternis wird %@ der Sonne bedecken und einen Lichtring hinterlassen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "An annular solar eclipse will cover %@ of the sun, leaving a ring of light." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Un eclipse solar anular cubrirá %@ del Sol, dejando un anillo de luz." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Une éclipse solaire annulaire couvrira %@ du Soleil, laissant un anneau de lumière." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Un'eclissi solare anulare coprirà %@ del Sole, lasciando un anello di luce." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "金環日食で太陽の %@ が隠れ、光の輪が残ります。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Een ringvormige zonsverduistering bedekt %@ van de zon en laat een lichtring achter." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Obrączkowe zaćmienie Słońca zakryje %@ tarczy, pozostawiając pierścień światła." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "日环食将遮挡太阳的 %@,留下一圈光环。" + } + } + } + }, + "eclipse-notif-partial" : { + "comment" : "Notification fragment describing a partial solar eclipse", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "سيغطي كسوف جزئي %@ من قرص الشمس." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Eine partielle Sonnenfinsternis wird %@ der Sonne bedecken." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A partial solar eclipse will cover %@ of the sun." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Un eclipse solar parcial cubrirá %@ del Sol." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Une éclipse solaire partielle couvrira %@ du Soleil." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Un'eclissi solare parziale coprirà %@ del Sole." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "部分日食で太陽の %@ が隠れます。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Een gedeeltelijke zonsverduistering bedekt %@ van de zon." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Częściowe zaćmienie Słońca zakryje %@ tarczy Słońca." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "日偏食将遮挡太阳的 %@。" + } + } + } + }, + "eclipse-notif-times" : { + "comment" : "Notification fragment for when a solar eclipse starts and peaks", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "يبدأ في %1$@ ويبلغ ذروته في %2$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Sie beginnt um %1$@ und erreicht ihr Maximum um %2$@." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "It begins at %1$@ and peaks at %2$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Comienza a las %1$@ y alcanza su máximo a las %2$@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Elle commence à %1$@ et atteint son maximum à %2$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Inizia alle %1$@ e raggiunge il massimo alle %2$@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "%1$@ に始まり、%2$@ に最大となります。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Hij begint om %1$@ en bereikt het maximum om %2$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Rozpocznie się o %1$@ i osiągnie maksimum o %2$@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "将于 %1$@ 开始,%2$@ 达到最大。" + } + } + } + }, + "eclipse-notif-totality" : { + "comment" : "Notification fragment for how long totality lasts", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "يستمر الكسوف الكلي حوالي %@." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Die Totalität dauert etwa %@." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Totality lasts about %@." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "La totalidad dura unos %@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "La totalité dure environ %@." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "La totalità dura circa %@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "皆既は約 %@ 続きます。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "De totaliteit duurt ongeveer %@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Faza całkowita potrwa około %@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "全食持续约 %@。" + } + } + } + }, + "eclipse-notif-safety" : { + "comment" : "Eye safety warning appended to solar eclipse notifications", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "لا تنظر إلى الشمس أبدًا دون نظارات كسوف معتمدة." + } + }, + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Blicken Sie niemals ohne zertifizierte Sonnenfinsternisbrille in die Sonne." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Never look at the sun without certified eclipse glasses." + } + }, + "es" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nunca mires al Sol sin gafas de eclipse homologadas." + } + }, + "fr" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Ne regardez jamais le Soleil sans lunettes d'éclipse certifiées." + } + }, + "it" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Non guardare mai il Sole senza occhiali da eclissi certificati." + } + }, + "ja" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "認証済みの日食グラスなしで太陽を見ないでください。" + } + }, + "nl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Kijk nooit naar de zon zonder gecertificeerde eclipsbril." + } + }, + "pl" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Nigdy nie patrz na Słońce bez certyfikowanych okularów do obserwacji zaćmień." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "切勿在没有认证日食眼镜的情况下直视太阳。" + } + } + } } }, "version" : "1.3" diff --git a/Solstice.xcodeproj/project.pbxproj b/Solstice.xcodeproj/project.pbxproj index 9a5f1c98..73e0ed3c 100644 --- a/Solstice.xcodeproj/project.pbxproj +++ b/Solstice.xcodeproj/project.pbxproj @@ -94,6 +94,12 @@ 713F7FF929BE843700BEA156 /* AnnualOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F7FF829BE843700BEA156 /* AnnualOverview.swift */; }; 713F7FFA29BE843700BEA156 /* AnnualOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F7FF829BE843700BEA156 /* AnnualOverview.swift */; }; 713F7FFC29BE867E00BEA156 /* DailyOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F7FFB29BE867E00BEA156 /* DailyOverview.swift */; }; + 71EC11FE2F30000000000011 /* EclipseCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71EC11FE2F30000000000001 /* EclipseCalculator.swift */; }; + 71EC11FE2F30000000000012 /* EclipseCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71EC11FE2F30000000000001 /* EclipseCalculator.swift */; }; + 71EC11FE2F30000000000013 /* EclipseCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71EC11FE2F30000000000001 /* EclipseCalculator.swift */; }; + 71EC11FE2F30000000000014 /* EclipseCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71EC11FE2F30000000000001 /* EclipseCalculator.swift */; }; + 71EC11FE2F30000000000021 /* EclipseOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71EC11FE2F30000000000002 /* EclipseOverview.swift */; }; + 71EC11FE2F30000000000022 /* EclipseOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71EC11FE2F30000000000002 /* EclipseOverview.swift */; }; 713F7FFD29BE867E00BEA156 /* DailyOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F7FFB29BE867E00BEA156 /* DailyOverview.swift */; }; 713F7FFF29BE88E800BEA156 /* DaylightSummaryTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F7FFE29BE88E800BEA156 /* DaylightSummaryTitle.swift */; }; 713F800029BE88E800BEA156 /* DaylightSummaryTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713F7FFE29BE88E800BEA156 /* DaylightSummaryTitle.swift */; }; @@ -515,6 +521,8 @@ 71C635C72E9D9A8C003FA1AE /* Shared Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Shared Assets.xcassets"; sourceTree = ""; }; 71C718202E83361C006A6835 /* TimeTravelCompactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeTravelCompactView.swift; sourceTree = ""; }; 71C8F4CD29A38752009A86B4 /* SolsticeCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SolsticeCalculator.swift; sourceTree = ""; }; + 71EC11FE2F30000000000001 /* EclipseCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EclipseCalculator.swift; sourceTree = ""; }; + 71EC11FE2F30000000000002 /* EclipseOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EclipseOverview.swift; sourceTree = ""; }; 71CA1E0029E0000000000001 /* Calendar++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar++.swift"; sourceTree = ""; }; 71CCE7993022311F00E2782D /* TimeTravelOrnamentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeTravelOrnamentView.swift; sourceTree = ""; }; 71DB52AB29AB8BED00D62BB7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -649,6 +657,7 @@ 713F7FF829BE843700BEA156 /* AnnualOverview.swift */, 713F7FFB29BE867E00BEA156 /* DailyOverview.swift */, 713F7FFE29BE88E800BEA156 /* DaylightSummaryTitle.swift */, + 71EC11FE2F30000000000002 /* EclipseOverview.swift */, 7198469D28E58CCD00E866CE /* DetailView.swift */, 71C4D8092EA9856800EEB023 /* SolarExtremetiesOverview.swift */, ); @@ -823,6 +832,7 @@ 48E63C6609375907FE3E8C3C /* Secrets.swift */, BE27E44FFAA0D1493AA14281 /* Secrets.swift.template */, 71E9457F2F31FDA600B26D02 /* NTSolar.swift */, + 71EC11FE2F30000000000001 /* EclipseCalculator.swift */, 71C8F4CD29A38752009A86B4 /* SolsticeCalculator.swift */, 71908ACC2AC95A5500C7B610 /* StringBuilder.swift */, 7123E75E30052CE20091C591 /* ScreenshotSupport.swift */, @@ -1230,6 +1240,7 @@ 710720913009257E006AA79C /* MarketingWidgetRenderer.swift in Sources */, 7187147729DDE6B700EADF03 /* CLLocationCoordinate2D++.swift in Sources */, 7150CC5C29AB7C3200E6B90C /* SolsticeCalculator.swift in Sources */, + 71EC11FE2F30000000000011 /* EclipseCalculator.swift in Sources */, 7121DE0229C22E7D0031EEE7 /* View+Debugging.swift in Sources */, 7198C40E29DB06C1009A457E /* OverviewWidget.swift in Sources */, 718177692F2EA2EC007A2E9E /* LocationAppEntity.swift in Sources */, @@ -1306,6 +1317,7 @@ 719465232C2C1E09008408C0 /* AppStorage++.swift in Sources */, 719465242C2C1E09008408C0 /* CLLocationCoordinate2D++.swift in Sources */, 719465252C2C1E09008408C0 /* SolsticeCalculator.swift in Sources */, + 71EC11FE2F30000000000012 /* EclipseCalculator.swift in Sources */, 719465262C2C1E09008408C0 /* View+Debugging.swift in Sources */, 719465272C2C1E09008408C0 /* OverviewWidget.swift in Sources */, 719465282C2C1E09008408C0 /* Color+Mix.swift in Sources */, @@ -1375,6 +1387,7 @@ 7198468728E5895E00E866CE /* ContentView.swift in Sources */, 71A029B22C3052BC00E19819 /* EquinoxAndSolsticeDescriptions.swift in Sources */, 713F7FF929BE843700BEA156 /* AnnualOverview.swift in Sources */, + 71EC11FE2F30000000000021 /* EclipseOverview.swift in Sources */, 71F641B729994DD900FE5AB5 /* LocationListRow.swift in Sources */, 7194650E2C22B61E008408C0 /* AppChangeMigrator.swift in Sources */, 713F7FFF29BE88E800BEA156 /* DaylightSummaryTitle.swift in Sources */, @@ -1407,6 +1420,7 @@ 712433A92D78870600709C20 /* StackedButtonStyle.swift in Sources */, 71BC51CB2E91B56D0025892F /* CircularSolarChart.swift in Sources */, 71C8F4CE29A38752009A86B4 /* SolsticeCalculator.swift in Sources */, + 71EC11FE2F30000000000013 /* EclipseCalculator.swift in Sources */, 711782EA2E4E7CE20006CE5B /* AsyncButton.swift in Sources */, 7116B1EE2DDD12BA0018A294 /* TimeMachinePanelView.swift in Sources */, 718177622F2E9BCF007A2E9E /* SolsticeConfigurationIntent.swift in Sources */, @@ -1467,6 +1481,7 @@ 711782DD2E4E0E030006CE5B /* LandingView.swift in Sources */, 719F928829ACD29100C06921 /* ContentView.swift in Sources */, 713F7FFA29BE843700BEA156 /* AnnualOverview.swift in Sources */, + 71EC11FE2F30000000000022 /* EclipseOverview.swift in Sources */, 7132AC1A29E6962D00523215 /* ConditionalGlobals.swift in Sources */, 711782DA2E4DFDFE0006CE5B /* DeduplicateLocationRecordsModifier.swift in Sources */, 711782E72E4E77AF0006CE5B /* View+glassButtonStyle.swift in Sources */, @@ -1504,6 +1519,7 @@ 7188714429DC8C60001A4327 /* View+RectangularEdgeMask.swift in Sources */, 71E945C82F32506000B26D02 /* CircularSolarChart.swift in Sources */, 719F927C29ACD22100C06921 /* SolsticeCalculator.swift in Sources */, + 71EC11FE2F30000000000014 /* EclipseCalculator.swift in Sources */, 719465162C294B42008408C0 /* Color+Mix.swift in Sources */, 71E3C41B2EA12B030005C884 /* View+backportGlassEffect.swift in Sources */, 7194650F2C22B61E008408C0 /* AppChangeMigrator.swift in Sources */, @@ -1873,7 +1889,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Solstice uses your location to calculate local sunrise and sunset times"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.3.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1935,7 +1951,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Solstice uses your location to calculate local sunrise and sunset times"; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 3.3.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; diff --git a/Solstice/Detail View/DetailView.swift b/Solstice/Detail View/DetailView.swift index 8534eafb..2d8e1ece 100644 --- a/Solstice/Detail View/DetailView.swift +++ b/Solstice/Detail View/DetailView.swift @@ -6,6 +6,7 @@ // import CoreData +import CoreLocation import Suite import SwiftUI import TimeMachine @@ -26,6 +27,7 @@ struct DetailView: View { #endif @State private var showRemainingDaylight = false @State private var showShareSheet = false + @State private var eclipse: EclipseCalculator.LocalCircumstances? @AppStorage(Preferences.detailViewChartAppearance) private var chartAppearance @SceneStorage("selectedLocation") private var selectedLocation: String? @@ -56,10 +58,17 @@ struct DetailView: View { DailyOverview(solar: solar, location: location) } + if let eclipse { + EclipseOverview(circumstances: eclipse, location: location) + } + AnnualOverview(location: location) .id(Self.annualAnchor) } .formStyle(.grouped) + .task(id: eclipseSearchKey) { + await findEclipse() + } #if os(macOS) // The macOS toolbar has no Share button to carry the detail-screen identifier // (that's iOS-only below), so tag the detail root for screenshot navigation. @@ -123,6 +132,37 @@ struct DetailView: View { "annual-overview" } + /// Keyed on the place and the *day*, not the instant. Searching for eclipses is far + /// heavier than building an `NTSolar`, so it can't live in a computed property that + /// re-evaluates on every body pass — and keying on the day means dragging the + /// time-travel slider doesn't restart the search on every frame. + private var eclipseSearchKey: String { + "\(location.latitude),\(location.longitude),\(timeMachine.date.startOfDay.timeIntervalSince1970)" + } + + private func findEclipse() async { + let latitude = location.latitude + let longitude = location.longitude + let date = timeMachine.date + + let result = await Task.detached(priority: .utility) { + EclipseCalculator.nextEclipse( + at: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + after: date, + within: Constants.Eclipse.detailWindow, + minimumObscuration: Constants.Eclipse.detailThreshold + ) + }.value + + // Animating at the mutation site rather than with `.animation(_:value:)` on the + // form, which would also animate everything else in it. Covers all three + // transitions: the section arriving once the first search finishes, leaving when + // the eclipse is time-travelled past, and swapping one eclipse for another. + withAnimation { + eclipse = result + } + } + var toolbarItemPlacement: ToolbarItemPlacement { #if os(macOS) return .automatic diff --git a/Solstice/Detail View/EclipseOverview.swift b/Solstice/Detail View/EclipseOverview.swift new file mode 100644 index 00000000..9d85935f --- /dev/null +++ b/Solstice/Detail View/EclipseOverview.swift @@ -0,0 +1,257 @@ +// +// EclipseOverview.swift +// Solstice +// +// Created by Daniel Eden on 11/08/2026. +// + +import Suite +import SwiftUI +import TimeMachine + +private var eclipseFormatter: RelativeDateTimeFormatter { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + formatter.dateTimeStyle = .named + return formatter +} + +/// Surfaces an upcoming solar eclipse for a location. +/// +/// The eclipse itself is worked out by `DetailView`, which owns the `@State` and the +/// task that fills it. Doing it here would be circular: this view renders nothing when +/// there is no eclipse, so a `.task` attached to it would never get the chance to run +/// and find one. +struct EclipseOverview: View { + @Environment(\.timeMachine) var timeMachine: TimeMachine + + var circumstances: EclipseCalculator.LocalCircumstances + var location: Location + + /// A near-total eclipse is a reason to take the day off work; a third of the sun + /// quietly disappearing is a curiosity. Chooses between the full row set and a single + /// compact row — and nothing else, so the section's chrome stays the same either way. + private var isMajor: Bool { + circumstances.obscuration >= Constants.Eclipse.majorThreshold || circumstances.isCentral + } + + private var coverage: String { + circumstances.obscuration.formatted(.percent.precision(.fractionLength(0))) + } + + private var title: Text { + switch circumstances.kind { + case .total: Text("Total solar eclipse") + case .annular: Text("Annular solar eclipse") + case .partial: Text("Partial solar eclipse") + } + } + + private var icon: String { + switch circumstances.kind { + case .total: "circle.fill" + case .annular: "circle.circle" + case .partial: "sun.righthalf.filled" + } + } + + var body: some View { + Section { + Group { + if isMajor { + majorRows + } else { + compactRow + } + } + .environment(\.timeZone, location.timeZone) + } footer: { + // An app that tells someone to go and look at the sun owes them this, and a + // smaller eclipse is no safer to stare at than a near-total one — so it + // appears whenever the section does, not just for the headline events. + if circumstances.kind == .total { + Text("Never look at the sun without certified eclipse glasses. Only during totality, when the sun is completely covered, is it safe to look with the naked eye.") + } else { + Text("Never look at the sun without certified eclipse glasses, even when it is almost entirely covered.") + } + } + .materialListRowBackground() + } + + // MARK: - Compact treatment + + /// One line, in the same shape as the "Longest day" row: the kind of eclipse as the + /// label, with the value tapping between when it is and how much it takes. + private var compactRow: some View { + Label { + AdaptiveStack { + ContentToggle { showContent in + if showContent { + Text("\(coverage) covered") + } else { + Text(circumstances.maximum, style: .date) + } + } + } label: { + title + } + } icon: { + Image(systemName: icon) + } + .jumpToEclipse(circumstances.maximum, timeMachine: timeMachine) + } + + // MARK: - Full treatment + + @ViewBuilder + private var majorRows: some View { + Label { + AdaptiveStack { + ContentToggle { showContent in + if showContent { + Text(circumstances.maximum, style: .date) + } else { + Text(eclipseFormatter.localizedString( + for: circumstances.maximum.startOfDay, + relativeTo: timeMachine.date.startOfDay + )) + } + } + } label: { + title + } + } icon: { + Image(systemName: icon) + } + .jumpToEclipse(circumstances.maximum, timeMachine: timeMachine) + + Label { + AdaptiveStack { + Text("\(coverage) of the sun") + } label: { + Text("Maximum coverage") + } + } icon: { + Image(systemName: "slider.horizontal.below.sun.max") + } + + if let centralDuration = circumstances.centralDuration { + Label { + AdaptiveStack { + // Deliberately coarse. This figure comes from the difference between + // two apparent radii within a few percent of each other, so it is the + // least certain number here — quoting it to the second would claim a + // precision the underlying theory can't support. + Text(Duration.seconds(centralDuration).formatted( + .units(allowed: [.minutes, .seconds], maximumUnitCount: 2) + )) + } label: { + if circumstances.kind == .total { + Text("Totality lasts about") + } else { + Text("Annularity lasts about") + } + } + } icon: { + Image(systemName: "hourglass") + } + } + + Label { + AdaptiveStack { + Text(circumstances.firstContact, style: .time) + } label: { + Text("Eclipse begins") + } + } icon: { + Image(systemName: "moonphase.waxing.gibbous") + } + + Label { + AdaptiveStack { + Text(circumstances.maximum, style: .time) + } label: { + Text("Maximum eclipse") + } + } icon: { + Image(systemName: "moonphase.full.moon") + } + + Label { + AdaptiveStack { + Text(circumstances.lastContact, style: .time) + } label: { + Text("Eclipse ends") + } + } icon: { + Image(systemName: "moonphase.waning.gibbous") + } + + // Below roughly ten degrees the sun is behind most rooftops and hills, which + // changes the advice from "look up" to "find somewhere with a clear horizon". + if circumstances.sunAltitudeAtMaximum < 10 { + Label { + AdaptiveStack { + Text("\(Int(circumstances.sunAltitudeAtMaximum.rounded()))° above the horizon") + } label: { + Text("Low in the sky") + } + } icon: { + Image(systemName: "mountain.2") + } + } + } +} + +private extension View { + /// Matches the swipe-to-time-travel affordance the solstice and equinox rows carry. + func jumpToEclipse(_ date: Date, timeMachine: TimeMachine) -> some View { + swipeActions(edge: .leading) { + Button { + withAnimation { + timeMachine.date = date + } + } label: { + Label("Jump to date", systemImage: "clock.arrow.2.circlepath") + } + } + } +} + +#Preview("Total") { + Form { + EclipseOverview( + circumstances: .init( + kind: .total, + obscuration: 1, + magnitude: 1.038, + firstContact: .now.addingTimeInterval(3600), + maximum: .now.addingTimeInterval(7200), + lastContact: .now.addingTimeInterval(10800), + centralDuration: 63, + sunAltitudeAtMaximum: 24.5 + ), + location: TemporaryLocation.placeholderLondon + ) + } + .withTimeMachine(.solsticeTimeMachine) +} + +#Preview("Partial") { + Form { + EclipseOverview( + circumstances: .init( + kind: .partial, + obscuration: 0.34, + magnitude: 0.45, + firstContact: .now.addingTimeInterval(3600), + maximum: .now.addingTimeInterval(7200), + lastContact: .now.addingTimeInterval(10800), + centralDuration: nil, + sunAltitudeAtMaximum: 32 + ), + location: TemporaryLocation.placeholderLondon + ) + } + .withTimeMachine(.solsticeTimeMachine) +} diff --git a/Solstice/Extensions/AppStorage++.swift b/Solstice/Extensions/AppStorage++.swift index 58fdaca8..208c1daa 100644 --- a/Solstice/Extensions/AppStorage++.swift +++ b/Solstice/Extensions/AppStorage++.swift @@ -71,6 +71,14 @@ enum Preferences { /// The user preference for whether notifications include the time until the next solstice static let notificationsIncludeSolsticeCountdown: Value = ("notifsIncludeSolsticeCountdown", false) + /// The user preference for whether a major solar eclipse produces its own notifications. + /// + /// Unlike the fragment toggles above this doesn't change the daily notification; it + /// schedules two extra ones around the eclipse itself. On by default, so the handful + /// of people whose location is ever in the path don't miss it through not knowing the + /// setting existed. + static let notificationsIncludeEclipses: Value = ("notifsIncludeEclipses", true) + /// The user preference for how notifications are altered during periods of lessening daylight static let sadPreference: Value = ("sadPreverence", .none) diff --git a/Solstice/Helpers/EclipseCalculator.swift b/Solstice/Helpers/EclipseCalculator.swift new file mode 100644 index 00000000..e228f990 --- /dev/null +++ b/Solstice/Helpers/EclipseCalculator.swift @@ -0,0 +1,886 @@ +// +// EclipseCalculator.swift +// Solstice +// +// Created by Daniel Eden on 11/08/2026. +// +// Local circumstances for solar eclipses. +// +// Source: Jean Meeus, *Astronomical Algorithms* (2nd edition, Willmann-Bell 1998) — +// chapter 22 (nutation and obliquity), chapter 25 (solar position), chapter 47 (lunar +// position, the truncated ELP-2000/82 series) and chapter 49 (phases of the moon). +// The ΔT model is the Espenak & Meeus polynomial set published with NASA's Five +// Millennium Canon of Solar Eclipses. +// +// `NTSolar` deliberately isn't reused here. Its solar model is low order — it uses a +// linear obliquity and explicitly neglects aberration — which costs about an arcminute. +// Against the sun's ~16 arcminute semidiameter that is roughly 6% of eclipse magnitude, +// far too coarse to say "87% of the sun will be covered". Its internals are `private` +// to that file in any case, and it is vendored source that shouldn't be edited. +// +// Accuracy, checked against NASA's published circumstances: +// +// - Contact times land within about half a minute. Meeus's truncated lunar series is +// good to roughly 10 arcseconds, and the moon closes on the sun at about half an +// arcsecond per second of time, so half a minute is the floor for this class of +// method rather than something to tune away. +// - Obscuration is good to well under a percent, and is the figure the UI leans on. +// - Duration of totality is the weakest number, at a few percent. It depends on the +// *difference* between the two apparent radii — about 53 arcseconds out of roughly +// 960 — so the same 10 arcsecond uncertainty that barely moves obscuration moves +// this by several seconds. Present it as an approximation, not to the second. +// + +import CoreLocation +import Foundation + +enum EclipseCalculator { + // MARK: - Public types + + enum Kind: Hashable, Sendable { + /// The moon covers part of the sun's disc. + case partial + /// The moon is centred on the sun but too distant to cover it, leaving a ring. + case annular + /// The moon completely covers the sun's disc. + case total + } + + /// What an observer at one particular place will actually see of one eclipse. + /// + /// Every time here is clamped to the period during which the sun is above the + /// observer's horizon, so an eclipse that begins before sunrise reports the moment it + /// becomes visible rather than the moment it began somewhere below the horizon. + struct LocalCircumstances: Hashable, Sendable, Identifiable { + /// What the eclipse looks like *from this location* at its maximum. An eclipse + /// that is total along its central path is still `.partial` for someone standing + /// outside that path. + let kind: Kind + + /// Fraction of the sun's *area* hidden at maximum, 0...1. + /// + /// This is the number the UI shows and the notification threshold tests. It is + /// deliberately not magnitude: 90% magnitude is only about 83% obscuration, and + /// obscuration is the one that matches both how much light is lost and what + /// people mean by "how much of the sun is covered". + let obscuration: Double + + /// Fraction of the sun's *diameter* covered at maximum. Quoted by most almanacs, + /// and greater than 1 inside a total eclipse's path. + let magnitude: Double + + /// When the moon's disc first touches the sun's, or when the eclipse first + /// becomes visible above the horizon — whichever is later. + let firstContact: Date + + /// The instant of greatest obscuration as seen from this location. + let maximum: Date + + /// When the discs last touch, or when the sun sets — whichever is earlier. + let lastContact: Date + + /// How long totality or annularity lasts here. `nil` for a partial eclipse. + let centralDuration: TimeInterval? + + /// The sun's altitude in degrees at maximum. A single-digit altitude means the + /// user needs a clear horizon in the right direction to see anything. + let sunAltitudeAtMaximum: Double + + var id: Date { maximum } + + /// Whether the observer is inside the path of totality or annularity. + var isCentral: Bool { centralDuration != nil } + } + + // MARK: - Public API + + /// The next solar eclipse visible from `coordinate`, or `nil` if none qualifies. + /// + /// - Parameters: + /// - coordinate: Where the observer is standing. + /// - date: Search forward from this instant. + /// - within: How far ahead to look. + /// - minimumObscuration: Ignore eclipses covering less than this fraction of the + /// sun's area. The detail view passes 0.1; notifications pass 0.9. + static func nextEclipse( + at coordinate: CLLocationCoordinate2D, + after date: Date, + within: TimeInterval, + minimumObscuration: Double + ) -> LocalCircumstances? { + eclipses( + at: coordinate, + from: date, + through: date.addingTimeInterval(within), + minimumObscuration: minimumObscuration + ).first + } + + /// Every solar eclipse visible from `coordinate` between two dates, in time order. + static func eclipses( + at coordinate: CLLocationCoordinate2D, + from startDate: Date, + through endDate: Date, + minimumObscuration: Double + ) -> [LocalCircumstances] { + guard endDate > startDate else { return [] } + + let startJD = julianDay(from: startDate) + let endJD = julianDay(from: endDate) + + // Solar eclipses only happen at new moon, so step lunation by lunation rather + // than scanning the whole window. `k` is Meeus's new moon index, zero at the new + // moon of 2000 January 6. + let firstK = Int(floor((decimalYear(julianDay: startJD) - 2000) * 12.3685)) - 1 + let lastK = Int(ceil((decimalYear(julianDay: endJD) - 2000) * 12.3685)) + 1 + + var results: [LocalCircumstances] = [] + + for k in firstK ... lastK { + guard let circumstances = eclipse(lunation: k, at: coordinate) else { continue } + guard circumstances.maximum >= startDate, circumstances.maximum <= endDate else { continue } + guard circumstances.obscuration >= minimumObscuration else { continue } + results.append(circumstances) + } + + return results.sorted { $0.maximum < $1.maximum } + } + + // MARK: - Per-lunation search + + /// Half-width of the coarse search around mean new moon, in days. True new moon can + /// fall a little over half a day either side of the mean. + private static let searchHalfWidth = 0.75 + + /// Ecliptic latitude beyond which no eclipse is possible anywhere on Earth, padded + /// for how far the moon's latitude can move between mean and true new moon. Screening + /// on this costs one lunar position and rejects most lunations outright. + private static let latitudeScreen = 2.5 + + /// The sun's upper limb is still above the horizon down to about this altitude of the + /// disc centre, once refraction is allowed for. + private static let horizonAltitude = -0.9 + + private static func eclipse(lunation k: Int, at coordinate: CLLocationCoordinate2D) -> LocalCircumstances? { + let meanNewMoonJDE = meanNewMoon(k: k) + let meanNewMoonJD = meanNewMoonJDE - deltaT(julianDay: meanNewMoonJDE) / 86400 + + // Cheap rejection: if the moon passes far from the ecliptic at this new moon, + // its shadow misses the Earth entirely and there is nothing to compute. + let (_, moonLatitude, _) = lunarPosition(jde: meanNewMoonJDE) + guard abs(moonLatitude) < latitudeScreen else { return nil } + + // Coarse pass at 20 minute steps to find roughly when the eclipse peaks here. + guard var best = bestSample( + around: meanNewMoonJD, + halfWidth: searchHalfWidth, + step: 20.0 / 1440.0, + at: coordinate + ) else { return nil } + + // Two refinement passes: to the minute, then to five seconds. + if let finer = bestSample(around: best.jd, halfWidth: 25.0 / 1440.0, step: 1.0 / 1440.0, at: coordinate) { + best = finer + } + if let finest = bestSample(around: best.jd, halfWidth: 90.0 / 86400.0, step: 5.0 / 86400.0, at: coordinate) { + best = finest + } + + let peak = best.sample + guard peak.obscuration > 0 else { return nil } + + let kind: Kind + if peak.separation <= peak.moonRadius - peak.sunRadius { + kind = .total + } else if peak.separation <= peak.sunRadius - peak.moonRadius { + kind = .annular + } else { + kind = .partial + } + + let outerRadius = { (sample: Sample) in sample.sunRadius + sample.moonRadius } + let firstContact = contact(from: best.jd, direction: -1, at: coordinate, threshold: outerRadius) + let lastContact = contact(from: best.jd, direction: 1, at: coordinate, threshold: outerRadius) + + var centralDuration: TimeInterval? + if kind != .partial { + let innerRadius = { (sample: Sample) in abs(sample.sunRadius - sample.moonRadius) } + let secondContact = contact(from: best.jd, direction: -1, at: coordinate, threshold: innerRadius) + let thirdContact = contact(from: best.jd, direction: 1, at: coordinate, threshold: innerRadius) + centralDuration = (thirdContact - secondContact) * 86400 + } + + return LocalCircumstances( + kind: kind, + obscuration: peak.obscuration, + magnitude: peak.magnitude, + firstContact: date(fromJulianDay: firstContact), + maximum: date(fromJulianDay: best.jd), + lastContact: date(fromJulianDay: lastContact), + centralDuration: centralDuration, + sunAltitudeAtMaximum: peak.sunAltitude + ) + } + + /// Scans a window and returns the moment the two discs are closest together, + /// considering only instants at which the sun is above the observer's horizon. + /// + /// Ranking on separation rather than obscuration matters: obscuration saturates at + /// 1.0 for the whole of totality, so picking the largest value would land on + /// whichever instant happened to reach it first — the beginning of totality rather + /// than the middle of the eclipse. Separation has a single well-defined minimum + /// either way, and constraining the search to daylight means an eclipse interrupted + /// by sunset reports the best the observer actually gets to see. + private static func bestSample( + around centreJD: Double, + halfWidth: Double, + step: Double, + at coordinate: CLLocationCoordinate2D + ) -> (jd: Double, sample: Sample)? { + var best: (jd: Double, sample: Sample)? + let steps = Int((halfWidth * 2 / step).rounded()) + + for index in 0 ... steps { + let jd = centreJD - halfWidth + Double(index) * step + let sample = self.sample(at: jd, coordinate: coordinate) + guard sample.sunAltitude > horizonAltitude, sample.obscuration > 0 else { continue } + if best == nil || sample.separation < best!.sample.separation { + best = (jd, sample) + } + } + + return best + } + + /// Walks outwards from maximum to find where the discs stop overlapping by the given + /// measure, or where the sun reaches the horizon — whichever comes first. + /// + /// `threshold` returns the separation at which contact occurs, so passing the sum of + /// the radii finds first and last contact, and the absolute difference finds the + /// start and end of totality or annularity. + private static func contact( + from maximumJD: Double, + direction: Double, + at coordinate: CLLocationCoordinate2D, + threshold: (Sample) -> Double + ) -> Double { + let coarseStep = 10.0 / 1440.0 + var inside = maximumJD + var outside = maximumJD + + // Bracket the crossing. Four hours is longer than any local partial phase. + let limit = 4.0 / 24.0 + var offset = coarseStep + while offset <= limit { + let jd = maximumJD + direction * offset + let sample = self.sample(at: jd, coordinate: coordinate) + if sample.separation >= threshold(sample) || sample.sunAltitude <= horizonAltitude { + outside = jd + break + } + inside = jd + offset += coarseStep + } + + guard outside != maximumJD else { return inside } + + // Bisect down to well under a second. + for _ in 0 ..< 20 { + let midpoint = (inside + outside) / 2 + let sample = self.sample(at: midpoint, coordinate: coordinate) + if sample.separation >= threshold(sample) || sample.sunAltitude <= horizonAltitude { + outside = midpoint + } else { + inside = midpoint + } + } + + return (inside + outside) / 2 + } + + // MARK: - A single instant + + /// The geometry of the two discs as seen from the observer at one instant. + private struct Sample { + /// Angular distance between the centres of the two discs, in degrees. + let separation: Double + /// The sun's apparent semidiameter in degrees. + let sunRadius: Double + /// The moon's apparent semidiameter in degrees. + let moonRadius: Double + /// The sun's altitude above the horizon in degrees. + let sunAltitude: Double + + /// Fraction of the sun's diameter covered. + /// + /// Once one disc sits entirely inside the other the "fraction covered" stops + /// being meaningful, and the convention almanacs use — including NASA's + /// published magnitudes — is the ratio of the two apparent diameters. Following + /// that convention is what makes these numbers comparable with other sources. + var magnitude: Double { + guard separation < sunRadius + moonRadius else { return 0 } + + if separation <= abs(moonRadius - sunRadius) { + return moonRadius / sunRadius + } + + return (sunRadius + moonRadius - separation) / (2 * sunRadius) + } + + /// Fraction of the sun's area covered — the overlap of two circles. + var obscuration: Double { + guard separation < sunRadius + moonRadius else { return 0 } + + if separation <= abs(moonRadius - sunRadius) { + // One disc sits entirely inside the other: either the sun is completely + // hidden, or the moon is small enough to leave a ring all the way round. + return moonRadius >= sunRadius ? 1 : pow(moonRadius / sunRadius, 2) + } + + let s = separation + let a = sunRadius + let b = moonRadius + + let alpha = acos(clamped((s * s + a * a - b * b) / (2 * s * a))) + let beta = acos(clamped((s * s + b * b - a * a) / (2 * s * b))) + let triangle = 0.5 * sqrt(max(0, (-s + a + b) * (s + a - b) * (s - a + b) * (s + a + b))) + let overlap = a * a * alpha + b * b * beta - triangle + + return min(1, overlap / (.pi * a * a)) + } + } + + /// Radius of the sun in kilometres. Chosen so the apparent semidiameter matches the + /// conventional 959.63 arcseconds at one astronomical unit. + private static let sunRadiusKm = 696_000.0 + + /// Radius of the moon in kilometres: the value `k = 0.272281` that NASA adopts for + /// umbral contacts, times the Earth's equatorial radius. + /// + /// This is deliberately the umbral `k` rather than the slightly larger mean lunar + /// radius. The moon's limb is mountainous, and eclipse prediction has long adopted + /// the smaller figure so that computed durations of totality match what observers + /// actually time. Using it reproduces NASA's published eclipse magnitudes; the mean + /// radius overstates them by about 0.2%, which sounds negligible but lands squarely + /// on the difference of two nearly equal radii that sets how long totality lasts. + private static let moonRadiusKm = 1736.65 + + private static let earthRadiusKm = 6378.14 + private static let astronomicalUnitKm = 149_597_870.7 + + private static func sample(at jd: Double, coordinate: CLLocationCoordinate2D) -> Sample { + let jde = jd + deltaT(julianDay: jd) / 86400 + let t = (jde - 2451545.0) / 36525.0 + + let (nutationLongitude, nutationObliquity) = nutation(t: t) + let obliquity = meanObliquity(t: t) + nutationObliquity + + let (sunLongitude, sunDistanceAU) = solarPosition(jde: jde) + let apparentSunLongitude = sunLongitude + nutationLongitude - 20.4898 / 3600 / sunDistanceAU + + let (moonLongitude, moonLatitude, moonDistanceKm) = lunarPosition(jde: jde) + let apparentMoonLongitude = moonLongitude + nutationLongitude + + let sun = equatorial(longitude: apparentSunLongitude, latitude: 0, obliquity: obliquity) + let moon = equatorial(longitude: apparentMoonLongitude, latitude: moonLatitude, obliquity: obliquity) + + // Apparent sidereal time is computed from UT, not TT — mixing the two here would + // put the observer in the wrong place by roughly a quarter of a degree. + let siderealTime = apparentSiderealTime( + jd: jd, + nutationLongitude: nutationLongitude, + obliquity: obliquity + ) + let localSiderealTime = siderealTime + coordinate.longitude + + // The moon's horizontal parallax is nearly a degree — comparable to the whole + // geometry of an eclipse — so the observer's offset from the centre of the Earth + // is what makes an eclipse a local event at all. + let observer = observerVector(latitude: coordinate.latitude, localSiderealTime: localSiderealTime) + + let sunTopocentric = topocentric( + rightAscension: sun.rightAscension, + declination: sun.declination, + distance: sunDistanceAU * astronomicalUnitKm, + observer: observer + ) + let moonTopocentric = topocentric( + rightAscension: moon.rightAscension, + declination: moon.declination, + distance: moonDistanceKm, + observer: observer + ) + + let separation = angularSeparation( + rightAscension1: sunTopocentric.rightAscension, + declination1: sunTopocentric.declination, + rightAscension2: moonTopocentric.rightAscension, + declination2: moonTopocentric.declination + ) + + let hourAngle = localSiderealTime - sunTopocentric.rightAscension + let sunAltitude = degrees(asin(clamped( + sin(radians(coordinate.latitude)) * sin(radians(sunTopocentric.declination)) + + cos(radians(coordinate.latitude)) * cos(radians(sunTopocentric.declination)) + * cos(radians(hourAngle)) + ))) + + return Sample( + separation: separation, + sunRadius: degrees(asin(clamped(sunRadiusKm / sunTopocentric.distance))), + moonRadius: degrees(asin(clamped(moonRadiusKm / moonTopocentric.distance))), + sunAltitude: sunAltitude + ) + } + + // MARK: - Coordinate machinery + + private struct EquatorialPosition { + let rightAscension: Double + let declination: Double + let distance: Double + } + + private static func equatorial( + longitude: Double, + latitude: Double, + obliquity: Double + ) -> (rightAscension: Double, declination: Double) { + let l = radians(longitude) + let b = radians(latitude) + let e = radians(obliquity) + + let rightAscension = atan2(sin(l) * cos(e) - tan(b) * sin(e), cos(l)) + let declination = asin(clamped(sin(b) * cos(e) + cos(b) * sin(e) * sin(l))) + + return (normalise(degrees(rightAscension)), degrees(declination)) + } + + /// The observer's position relative to the centre of the Earth, in kilometres, in the + /// same equatorial frame the bodies are expressed in. + private static func observerVector(latitude: Double, localSiderealTime: Double) -> (x: Double, y: Double, z: Double) { + // The Earth is flattened, so geodetic latitude has to be converted before it can + // be used as a direction from the centre. + let clampedLatitude = min(max(latitude, -89.9999), 89.9999) + let phi = radians(clampedLatitude) + let u = atan(0.99664719 * tan(phi)) + let rhoSinPhi = 0.99664719 * sin(u) + let rhoCosPhi = cos(u) + let theta = radians(localSiderealTime) + + return ( + x: earthRadiusKm * rhoCosPhi * cos(theta), + y: earthRadiusKm * rhoCosPhi * sin(theta), + z: earthRadiusKm * rhoSinPhi + ) + } + + private static func topocentric( + rightAscension: Double, + declination: Double, + distance: Double, + observer: (x: Double, y: Double, z: Double) + ) -> EquatorialPosition { + let ra = radians(rightAscension) + let dec = radians(declination) + + let x = distance * cos(dec) * cos(ra) - observer.x + let y = distance * cos(dec) * sin(ra) - observer.y + let z = distance * sin(dec) - observer.z + + let range = sqrt(x * x + y * y + z * z) + + return EquatorialPosition( + rightAscension: normalise(degrees(atan2(y, x))), + declination: degrees(asin(clamped(z / range))), + distance: range + ) + } + + /// Great-circle distance in degrees, using the form that stays accurate for the very + /// small separations an eclipse involves. + private static func angularSeparation( + rightAscension1: Double, + declination1: Double, + rightAscension2: Double, + declination2: Double + ) -> Double { + let d1 = radians(declination1) + let d2 = radians(declination2) + let deltaRA = radians(rightAscension2 - rightAscension1) + + let numerator = sqrt( + pow(cos(d2) * sin(deltaRA), 2) + + pow(cos(d1) * sin(d2) - sin(d1) * cos(d2) * cos(deltaRA), 2) + ) + let denominator = sin(d1) * sin(d2) + cos(d1) * cos(d2) * cos(deltaRA) + + return degrees(atan2(numerator, denominator)) + } + + private static func apparentSiderealTime( + jd: Double, + nutationLongitude: Double, + obliquity: Double + ) -> Double { + let t = (jd - 2451545.0) / 36525.0 + let mean = 280.46061837 + + 360.98564736629 * (jd - 2451545.0) + + 0.000387933 * t * t + - t * t * t / 38_710_000.0 + + return normalise(mean + nutationLongitude * cos(radians(obliquity))) + } + + // MARK: - Solar position (Meeus chapter 25) + + /// Geometric longitude in degrees and radius vector in astronomical units. + private static func solarPosition(jde: Double) -> (longitude: Double, distance: Double) { + let t = (jde - 2451545.0) / 36525.0 + + let meanLongitude = 280.46646 + 36000.76983 * t + 0.0003032 * t * t + let meanAnomaly = 357.52911 + 35999.05029 * t - 0.0001537 * t * t + let eccentricity = 0.016708634 - 0.000042037 * t - 0.0000001267 * t * t + + let m = radians(meanAnomaly) + let centre = (1.914602 - 0.004817 * t - 0.000014 * t * t) * sin(m) + + (0.019993 - 0.000101 * t) * sin(2 * m) + + 0.000289 * sin(3 * m) + + let trueLongitude = meanLongitude + centre + let trueAnomaly = radians(meanAnomaly + centre) + let distance = 1.000001018 * (1 - eccentricity * eccentricity) / (1 + eccentricity * cos(trueAnomaly)) + + return (normalise(trueLongitude), distance) + } + + // MARK: - Lunar position (Meeus chapter 47) + + /// Apparent geocentric ecliptic longitude and latitude in degrees, and distance in + /// kilometres. Accurate to roughly 10 arcseconds in longitude and 4 in latitude, + /// which is a small fraction of a percent of eclipse obscuration. + private static func lunarPosition(jde: Double) -> (longitude: Double, latitude: Double, distance: Double) { + let t = (jde - 2451545.0) / 36525.0 + let t2 = t * t + let t3 = t2 * t + let t4 = t3 * t + + let meanLongitude = 218.3164477 + 481_267.88123421 * t - 0.0015786 * t2 + t3 / 538_841 - t4 / 65_194_000 + let elongation = 297.8501921 + 445_267.1114034 * t - 0.0018819 * t2 + t3 / 545_868 - t4 / 113_065_000 + let solarAnomaly = 357.5291092 + 35999.0502909 * t - 0.0001536 * t2 + t3 / 24_490_000 + let lunarAnomaly = 134.9633964 + 477_198.8675055 * t + 0.0087414 * t2 + t3 / 69699 - t4 / 14_712_000 + let argumentOfLatitude = 93.2720950 + 483_202.0175233 * t - 0.0036539 * t2 - t3 / 3_526_000 + t4 / 863_310_000 + + let a1 = 119.75 + 131.849 * t + let a2 = 53.09 + 479_264.290 * t + let a3 = 313.45 + 481_266.484 * t + + // The sun's varying eccentricity damps terms involving its anomaly. + let e = 1 - 0.002516 * t - 0.0000074 * t2 + + var sumLongitude = 0.0 + var sumDistance = 0.0 + + for term in lunarTermsA { + let argument = radians( + term[0] * elongation + term[1] * solarAnomaly + + term[2] * lunarAnomaly + term[3] * argumentOfLatitude + ) + let damping = pow(e, abs(term[1])) + sumLongitude += term[4] * damping * sin(argument) + sumDistance += term[5] * damping * cos(argument) + } + + var sumLatitude = 0.0 + + for term in lunarTermsB { + let argument = radians( + term[0] * elongation + term[1] * solarAnomaly + + term[2] * lunarAnomaly + term[3] * argumentOfLatitude + ) + sumLatitude += term[4] * pow(e, abs(term[1])) * sin(argument) + } + + // Additive terms for Venus, Jupiter and the flattening of the Earth. + sumLongitude += 3958 * sin(radians(a1)) + + 1962 * sin(radians(meanLongitude - argumentOfLatitude)) + + 318 * sin(radians(a2)) + + sumLatitude += -2235 * sin(radians(meanLongitude)) + + 382 * sin(radians(a3)) + + 175 * sin(radians(a1 - argumentOfLatitude)) + + 175 * sin(radians(a1 + argumentOfLatitude)) + + 127 * sin(radians(meanLongitude - lunarAnomaly)) + - 115 * sin(radians(meanLongitude + lunarAnomaly)) + + return ( + longitude: normalise(meanLongitude + sumLongitude / 1_000_000), + latitude: sumLatitude / 1_000_000, + distance: 385_000.56 + sumDistance / 1000 + ) + } + + // MARK: - Nutation and obliquity (Meeus chapter 22) + + private static func nutation(t: Double) -> (longitude: Double, obliquity: Double) { + let omega = radians(125.04452 - 1934.136261 * t) + let solarLongitude = radians(280.4665 + 36000.7698 * t) + let lunarLongitude = radians(218.3165 + 481_267.8813 * t) + + let longitude = (-17.20 * sin(omega) + - 1.32 * sin(2 * solarLongitude) + - 0.23 * sin(2 * lunarLongitude) + + 0.21 * sin(2 * omega)) / 3600 + + let obliquity = (9.20 * cos(omega) + + 0.57 * cos(2 * solarLongitude) + + 0.10 * cos(2 * lunarLongitude) + - 0.09 * cos(2 * omega)) / 3600 + + return (longitude, obliquity) + } + + private static func meanObliquity(t: Double) -> Double { + 23.0 + 26.0 / 60.0 + 21.448 / 3600.0 + - (46.8150 * t + 0.00059 * t * t - 0.001813 * t * t * t) / 3600.0 + } + + // MARK: - New moon (Meeus chapter 49) + + /// Mean new moon for lunation `k`, as a Julian Ephemeris Day. Only used as a starting + /// point — the true new moon can be over half a day either side, which the search + /// window around it absorbs. + private static func meanNewMoon(k: Int) -> Double { + let k = Double(k) + let t = k / 1236.85 + + return 2_451_550.09766 + + 29.530588861 * k + + 0.00015437 * t * t + - 0.000000150 * t * t * t + + 0.00000000073 * t * t * t * t + } + + // MARK: - ΔT + + /// The difference between Terrestrial Time and Universal Time in seconds. + /// + /// Positions are computed in TT while the observer's rotation is tracked in UT; + /// getting this wrong simply shifts every predicted contact time by the same amount. + /// Polynomials from Espenak & Meeus. + private static func deltaT(julianDay: Double) -> Double { + let year = decimalYear(julianDay: julianDay) + + switch year { + case ..<1920: + let t = year - 1900 + return -2.79 + 1.494119 * t - 0.0598939 * t * t + 0.0061966 * t * t * t - 0.000197 * pow(t, 4) + case ..<1941: + let t = year - 1920 + return 21.20 + 0.84493 * t - 0.076100 * t * t + 0.0020936 * t * t * t + case ..<1961: + let t = year - 1950 + return 29.07 + 0.407 * t - t * t / 233 + t * t * t / 2547 + case ..<1986: + let t = year - 1975 + return 45.45 + 1.067 * t - t * t / 260 - t * t * t / 718 + case ..<2005: + let t = year - 2000 + return 63.86 + 0.3345 * t - 0.060374 * t * t + 0.0017275 * t * t * t + + 0.000651814 * pow(t, 4) + 0.00002373599 * pow(t, 5) + case ..<2050: + let t = year - 2000 + return 62.92 + 0.32217 * t + 0.005589 * t * t + case ..<2150: + return -20 + 32 * pow((year - 1820) / 100, 2) - 0.5628 * (2150 - year) + default: + let u = (year - 1820) / 100 + return -20 + 32 * u * u + } + } + + // MARK: - Julian day helpers + + private static let julianDayAtUnixEpoch = 2_440_587.5 + + private static func julianDay(from date: Date) -> Double { + date.timeIntervalSince1970 / 86400 + julianDayAtUnixEpoch + } + + private static func date(fromJulianDay julianDay: Double) -> Date { + Date(timeIntervalSince1970: (julianDay - julianDayAtUnixEpoch) * 86400) + } + + /// Year with a fractional part, derived arithmetically rather than through `Calendar`. + /// + /// The ΔT polynomials are defined over proleptic Gregorian years, and reading a year + /// back through the user's calendar would give the wrong answer in a non-Gregorian + /// region — the same hazard `SolsticeCalculator` documents. + private static func decimalYear(julianDay: Double) -> Double { + let z = (julianDay + 0.5).rounded(.down) + + var a = z + if z >= 2_299_161 { + let alpha = ((z - 1_867_216.25) / 36524.25).rounded(.down) + a = z + 1 + alpha - (alpha / 4).rounded(.down) + } + + let b = a + 1524 + let c = ((b - 122.1) / 365.25).rounded(.down) + let d = (365.25 * c).rounded(.down) + let e = ((b - d) / 30.6001).rounded(.down) + + let month = e < 14 ? e - 1 : e - 13 + let year = month > 2 ? c - 4716 : c - 4715 + + return year + (month - 0.5) / 12 + } + + // MARK: - Small helpers + + private static func radians(_ degrees: Double) -> Double { degrees * .pi / 180 } + private static func degrees(_ radians: Double) -> Double { radians * 180 / .pi } + + private static func normalise(_ degrees: Double) -> Double { + let wrapped = degrees.truncatingRemainder(dividingBy: 360) + return wrapped < 0 ? wrapped + 360 : wrapped + } + + /// Guards the inverse trigonometric functions against arguments that drift a hair + /// outside their domain through rounding. + private static func clamped(_ value: Double) -> Double { + min(max(value, -1), 1) + } + + // MARK: - Periodic terms + + /// Meeus table 47.A — columns are the multiples of D, M, M′ and F, then the + /// coefficients for longitude (units of 1e-6 degrees) and distance (units of metres). + private static let lunarTermsA: [[Double]] = [ + [0, 0, 1, 0, 6_288_774, -20_905_355], + [2, 0, -1, 0, 1_274_027, -3_699_111], + [2, 0, 0, 0, 658_314, -2_955_968], + [0, 0, 2, 0, 213_618, -569_925], + [0, 1, 0, 0, -185_116, 48888], + [0, 0, 0, 2, -114_332, -3149], + [2, 0, -2, 0, 58793, 246_158], + [2, -1, -1, 0, 57066, -152_138], + [2, 0, 1, 0, 53322, -170_733], + [2, -1, 0, 0, 45758, -204_586], + [0, 1, -1, 0, -40923, -129_620], + [1, 0, 0, 0, -34720, 108_743], + [0, 1, 1, 0, -30383, 104_755], + [2, 0, 0, -2, 15327, 10321], + [0, 0, 1, 2, -12528, 0], + [0, 0, 1, -2, 10980, 79661], + [4, 0, -1, 0, 10675, -34782], + [0, 0, 3, 0, 10034, -23210], + [4, 0, -2, 0, 8548, -21636], + [2, 1, -1, 0, -7888, 24208], + [2, 1, 0, 0, -6766, 30824], + [1, 0, -1, 0, -5163, -8379], + [1, 1, 0, 0, 4987, -16675], + [2, -1, 1, 0, 4036, -12831], + [2, 0, 2, 0, 3994, -10445], + [4, 0, 0, 0, 3861, -11650], + [2, 0, -3, 0, 3665, 14403], + [0, 1, -2, 0, -2689, -7003], + [2, 0, -1, 2, -2602, 0], + [2, -1, -2, 0, 2390, 10056], + [1, 0, 1, 0, -2348, 6322], + [2, -2, 0, 0, 2236, -9884], + [0, 1, 2, 0, -2120, 5751], + [0, 2, 0, 0, -2069, 0], + [2, -2, -1, 0, 2048, -4950], + [2, 0, 1, -2, -1773, 4130], + [2, 0, 0, 2, -1595, 0], + [4, -1, -1, 0, 1215, -3958], + [0, 0, 2, 2, -1110, 0], + [3, 0, -1, 0, -892, 3258], + [2, 1, 1, 0, -810, 2616], + [4, -1, -2, 0, 759, -1897], + [0, 2, -1, 0, -713, -2117], + [2, 2, -1, 0, -700, 2354], + [2, 1, -2, 0, 691, 0], + [2, -1, 0, -2, 596, 0], + [4, 0, 1, 0, 549, -1423], + [0, 0, 4, 0, 537, -1117], + [4, -1, 0, 0, 520, -1571], + [1, 0, -2, 0, -487, -1739], + [2, 1, 0, -2, -399, 0], + [0, 0, 2, -2, -381, -4421], + [1, 1, 1, 0, 351, 0], + [3, 0, -2, 0, -340, 0], + [4, 0, -3, 0, 330, 0], + [2, -1, 2, 0, 327, 0], + [0, 2, 1, 0, -323, 1165], + [1, 1, -1, 0, 299, 0], + [2, 0, 3, 0, 294, 0], + [2, 0, -1, -2, 0, 8752], + ] + + /// Meeus table 47.B — columns are the multiples of D, M, M′ and F, then the + /// coefficient for latitude (units of 1e-6 degrees). + private static let lunarTermsB: [[Double]] = [ + [0, 0, 0, 1, 5_128_122], + [0, 0, 1, 1, 280_602], + [0, 0, 1, -1, 277_693], + [2, 0, 0, -1, 173_237], + [2, 0, -1, 1, 55413], + [2, 0, -1, -1, 46271], + [2, 0, 0, 1, 32573], + [0, 0, 2, 1, 17198], + [2, 0, 1, -1, 9266], + [0, 0, 2, -1, 8822], + [2, -1, 0, -1, 8216], + [2, 0, -2, -1, 4324], + [2, 0, 1, 1, 4200], + [2, 1, 0, -1, -3359], + [2, -1, -1, 1, 2463], + [2, -1, 0, 1, 2211], + [2, -1, -1, -1, 2065], + [0, 1, -1, -1, -1870], + [4, 0, -1, -1, 1828], + [0, 1, 0, 1, -1794], + [0, 0, 0, 3, -1749], + [0, 1, -1, 1, -1565], + [1, 0, 0, 1, -1491], + [0, 1, 1, 1, -1475], + [0, 1, 1, -1, -1410], + [0, 1, 0, -1, -1344], + [1, 0, 0, -1, -1335], + [0, 0, 3, 1, 1107], + [4, 0, 0, -1, 1021], + [4, 0, -1, 1, 833], + [0, 0, 1, -3, 777], + [4, 0, -2, 1, 671], + [2, 0, 0, -3, 607], + [2, 0, 2, -1, 596], + [2, -1, 1, -1, 491], + [2, 0, -2, 1, -451], + [0, 0, 3, -1, 439], + [2, 0, 2, 1, 422], + [2, 0, -3, -1, 421], + [2, 1, -1, 1, -366], + [2, 1, 0, 1, -351], + [4, 0, 0, 1, 331], + [2, -1, 1, 1, 315], + [2, -2, 0, -1, 302], + [0, 0, 1, 3, -283], + [2, 1, 1, -1, -229], + [1, 1, 0, -1, 223], + [1, 1, 0, 1, 223], + [0, 1, -2, -1, -220], + [2, 1, -1, -1, -220], + [1, 0, 1, 1, -185], + [2, -1, -2, -1, 181], + [0, 1, 2, 1, -177], + [4, 0, -2, -1, 176], + [4, -1, -1, -1, 166], + [1, 0, 1, -1, -164], + [4, 0, 1, -1, 132], + [1, 0, -1, -1, -119], + [4, -1, 0, -1, 115], + [2, -2, 0, 1, 107], + ] +} diff --git a/Solstice/Helpers/Globals.swift b/Solstice/Helpers/Globals.swift index 8ee84947..eba339f3 100644 --- a/Solstice/Helpers/Globals.swift +++ b/Solstice/Helpers/Globals.swift @@ -322,6 +322,42 @@ enum Constants { /// Prefix for notification request identifiers static let notificationIdentifierPrefix = "me.daneden.Solstice.notification-" + /// Prefix for eclipse notification request identifiers, kept distinct from the daily + /// ones so the two can be told apart when inspecting what's pending. + static let eclipseNotificationIdentifierPrefix = "me.daneden.Solstice.eclipse-" + + enum Eclipse { + /// How far ahead the detail view looks for an eclipse. + static let detailWindow: TimeInterval = 182 * 24 * 60 * 60 + + /// The smallest slice of the sun worth a row in the detail view. Below about a + /// tenth of the disc there is nothing to notice without a filter and a reason to + /// look, and a location typically gets an eclipse this size every year or two — + /// often enough for the section to earn its place. + static let detailThreshold = 0.1 + + /// Eclipses at or above this get a fuller treatment: contact times, how long + /// totality lasts, and the safety note. + static let majorThreshold = 0.9 + + /// Only an eclipse this deep is worth interrupting someone for. At this size a + /// given location sees one perhaps once or twice a decade, which is what keeps + /// the notification feeling like news rather than noise. + static let notificationThreshold = 0.9 + + /// How far ahead notification scheduling looks. Comfortably past the 64-day + /// horizon of the daily notifications so an eclipse is never missed between + /// background refreshes. + static let notificationWindow: TimeInterval = 70 * 24 * 60 * 60 + + /// How far in advance the first eclipse notification fires. + static let notificationLeadTime: TimeInterval = 7 * 24 * 60 * 60 + + /// The latest the morning-of notification may fire before first contact, so a + /// preferred notification time that falls mid-eclipse still gives some warning. + static let sameDayMinimumWarning: TimeInterval = 60 * 60 + } + /// URL scheme for deep links static let urlScheme = "solstice" diff --git a/Solstice/Helpers/NotificationManager.swift b/Solstice/Helpers/NotificationManager.swift index 30afea84..261167c2 100644 --- a/Solstice/Helpers/NotificationManager.swift +++ b/Solstice/Helpers/NotificationManager.swift @@ -20,6 +20,7 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { @AppStorage(Preferences.notificationsIncludeDaylightChange) static var includeDaylightChange @AppStorage(Preferences.notificationsIncludeDaylightDuration) static var includeDaylightDuration @AppStorage(Preferences.notificationsIncludeSolsticeCountdown) static var includeSolsticeCountdown + @AppStorage(Preferences.notificationsIncludeEclipses) static var includeEclipses @AppStorage(Preferences.NotificationSettings.scheduleType) static var scheduleType @AppStorage(Preferences.NotificationSettings.notificationDateComponents) static var notificationDateComponents @AppStorage(Preferences.NotificationSettings.relativeOffset) static var userPreferenceNotificationOffset @@ -83,7 +84,14 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { return } - for i in 0 ... 63 { + // Eclipse alerts go in first, for two independent reasons. iOS keeps only 64 + // pending requests per app, and the daily loop below would otherwise claim every + // one of them — so the eclipse alerts have to be budgeted for rather than + // appended. And the daily loop returns outright on the first suppressed day + // (see the SAD handling), which would silently skip anything scheduled after it. + let eclipseRequestCount = await scheduleEclipseNotifications(location: location, timeZone: timeZone) + + for i in 0 ... (63 - eclipseRequestCount) { let date = calendar.date(byAdding: .day, value: i, to: Date()) ?? .now guard let solar = NTSolar(for: date, coordinate: location.coordinate, timeZone: timeZone) else { @@ -93,7 +101,11 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { let notificationDate = getNextNotificationDate(after: date, with: solar) guard let notificationContent = buildNotificationContent(for: notificationDate, location: location, timeZone: timeZone) else { - return + // `continue`, not `return`: content comes back nil for a single day — + // either the solar maths failed or the SAD preference suppressed that one + // day — and every remaining day still deserves its notification. Returning + // here cancelled the rest of the schedule. + continue } let content = UNMutableNotificationContent() @@ -122,6 +134,171 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { #endif } + // MARK: - Eclipse Notifications + + /// Schedules alerts for an upcoming major solar eclipse at the notification location. + /// + /// Two go out: one a week ahead, which is enough notice to get hold of eclipse + /// glasses or arrange to be somewhere with a clear view, and one on the morning + /// itself carrying the local times. + /// + /// Must only ever be called from `scheduleNotifications`, which clears every pending + /// request before it runs. Scheduling eclipse alerts anywhere else would work right + /// up until the next reschedule quietly deleted them. + /// + /// - Returns: How many requests were added, so the daily schedule can leave room. + private static func scheduleEclipseNotifications(location: CLLocation, timeZone: TimeZone) async -> Int { + guard includeEclipses else { return 0 } + + guard let eclipse = EclipseCalculator.nextEclipse( + at: location.coordinate, + after: Date(), + within: Constants.Eclipse.notificationWindow, + minimumObscuration: Constants.Eclipse.notificationThreshold + ) else { + return 0 + } + + var scheduled = 0 + + let weekAhead = eclipse.maximum.addingTimeInterval(-Constants.Eclipse.notificationLeadTime) + let weekAheadTime = eclipseAlertTime(on: weekAhead, location: location, timeZone: timeZone) + + if weekAheadTime > Date(), + await addEclipseRequest(for: eclipse, at: weekAheadTime, timeZone: timeZone, isSameDay: false) + { + scheduled += 1 + } + + // A preferred notification time of 08:00 is no use for an eclipse that starts at + // 07:30, so pull the same-day alert forward if it would otherwise land too late. + var sameDayTime = eclipseAlertTime(on: eclipse.maximum, location: location, timeZone: timeZone) + let latestUseful = eclipse.firstContact.addingTimeInterval(-Constants.Eclipse.sameDayMinimumWarning) + if sameDayTime > latestUseful { + sameDayTime = latestUseful + } + + if sameDayTime > Date(), + await addEclipseRequest(for: eclipse, at: sameDayTime, timeZone: timeZone, isSameDay: true) + { + scheduled += 1 + } + + return scheduled + } + + /// Places an eclipse alert at whatever time of day the user has chosen for their + /// daily notification, so these arrive when they already expect to hear from the app. + private static func eclipseAlertTime(on day: Date, location: CLLocation, timeZone: TimeZone) -> Date { + let solar = NTSolar(for: day, coordinate: location.coordinate, timeZone: timeZone) + return getNextNotificationDate(after: day, with: solar) + } + + private static func addEclipseRequest( + for eclipse: EclipseCalculator.LocalCircumstances, + at date: Date, + timeZone: TimeZone, + isSameDay: Bool + ) async -> Bool { + // Note there is deliberately no SAD suppression here. That exists to soften the + // message that the days are drawing in; an eclipse isn't that message, and it is + // far too rare to be worth withholding. + let content = UNMutableNotificationContent() + content.title = eclipseTitle(isSameDay: isSameDay) + content.body = eclipseBody(for: eclipse, timeZone: timeZone) + + var components = calendar.dateComponents([.hour, .minute, .day, .month], from: date) + components.calendar = calendar + components.timeZone = .autoupdatingCurrent + + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) + let request = UNNotificationRequest( + identifier: "\(Constants.eclipseNotificationIdentifierPrefix)\(date.ISO8601Format())", + content: content, + trigger: trigger + ) + + do { + try await UNUserNotificationCenter.current().add(request) + return true + } catch { + print(error) + return false + } + } + + private static func eclipseTitle(isSameDay: Bool) -> String { + isSameDay + ? NSLocalizedString("eclipse-notif-day-title", value: "Solar eclipse today", comment: "Notification title on the day of a solar eclipse") + : NSLocalizedString("eclipse-notif-week-title", value: "Solar eclipse in one week", comment: "Notification title one week before a solar eclipse") + } + + private static func eclipseBody(for eclipse: EclipseCalculator.LocalCircumstances, timeZone: TimeZone) -> String { + let formatStyle = Date.FormatStyle(timeZone: timeZone).hour().minute() + let coverage = eclipse.obscuration.formatted(.percent.precision(.fractionLength(0))) + + let description: String + switch eclipse.kind { + case .total: + description = NSLocalizedString( + "eclipse-notif-total", + value: "A total solar eclipse will completely cover the sun.", + comment: "Notification fragment describing a total solar eclipse" + ) + case .annular: + description = String.localizedStringWithFormat( + NSLocalizedString( + "eclipse-notif-annular", + value: "An annular solar eclipse will cover %@ of the sun, leaving a ring of light.", + comment: "Notification fragment describing an annular solar eclipse" + ), coverage + ) + case .partial: + description = String.localizedStringWithFormat( + NSLocalizedString( + "eclipse-notif-partial", + value: "A partial solar eclipse will cover %@ of the sun.", + comment: "Notification fragment describing a partial solar eclipse" + ), coverage + ) + } + + let times = String.localizedStringWithFormat( + NSLocalizedString( + "eclipse-notif-times", + value: "It begins at %1$@ and peaks at %2$@.", + comment: "Notification fragment for when a solar eclipse starts and peaks" + ), + eclipse.firstContact.formatted(formatStyle), + eclipse.maximum.formatted(formatStyle) + ) + + @StringBuilder var body: String { + description + + times + + if let centralDuration = eclipse.centralDuration, eclipse.kind == .total { + String.localizedStringWithFormat( + NSLocalizedString( + "eclipse-notif-totality", + value: "Totality lasts about %@.", + comment: "Notification fragment for how long totality lasts" + ), + Duration.seconds(centralDuration).formatted(.units(allowed: [.minutes, .seconds], maximumUnitCount: 2)) + ) + } + + NSLocalizedString( + "eclipse-notif-safety", + value: "Never look at the sun without certified eclipse glasses.", + comment: "Eye safety warning appended to solar eclipse notifications" + ) + } + + return body + } + // MARK: Background Task Management #if os(iOS) && !WIDGET_EXTENSION diff --git a/Solstice/Settings/NotificationSettings.swift b/Solstice/Settings/NotificationSettings.swift index 5bf17c7f..5a3f8358 100644 --- a/Solstice/Settings/NotificationSettings.swift +++ b/Solstice/Settings/NotificationSettings.swift @@ -26,6 +26,8 @@ struct NotificationSettings: View { @AppStorage(Preferences.notificationsIncludeSolsticeCountdown) var notifsIncludeSolsticeCountdown @AppStorage(Preferences.notificationsIncludeDaylightChange) var notifsIncludeDaylightChange + @AppStorage(Preferences.notificationsIncludeEclipses) var notifsIncludeEclipses + @AppStorage(Preferences.sadPreference) var sadPreference /// Expanded by default only for screenshot capture, so the marketing shot shows @@ -85,6 +87,7 @@ struct NotificationSettings: View { Group { locationSection scheduleSection + eclipseSection contentCustomizationGroup sadPreferenceSection } @@ -167,6 +170,17 @@ struct NotificationSettings: View { return text } + /// Kept out of the content-customisation group on purpose: those toggles change what + /// the daily notification says, whereas this one schedules two extra notifications of + /// its own and has no effect on the preview below it. + private var eclipseSection: some View { + Section { + Toggle("Solar eclipse alerts", isOn: $notifsIncludeEclipses) + } footer: { + Text("Get a reminder a week before, and again on the morning of, a solar eclipse that covers most of the sun where you are. Most locations see one only once or twice a decade.") + } + } + private var contentCustomizationGroup: some View { DisclosureGroup(isExpanded: $contentCustomizationExpanded) { ForEach(notificationFragments, id: \.label) { fragment in diff --git a/SolsticeTests/EclipseCalculatorTests.swift b/SolsticeTests/EclipseCalculatorTests.swift new file mode 100644 index 00000000..395250a8 --- /dev/null +++ b/SolsticeTests/EclipseCalculatorTests.swift @@ -0,0 +1,260 @@ +// +// EclipseCalculatorTests.swift +// SolsticeTests +// +// Reference circumstances come from NASA's eclipse catalogue and timeanddate.com. +// +// Tolerances are set by what the underlying theory can deliver, not by what the +// implementation happens to produce today: +// +// - Contact times are allowed two minutes. Meeus's truncated lunar series is good to +// about 10 arcseconds, and the moon closes on the sun at roughly half an arcsecond +// per second, so half a minute of scatter is inherent. +// - Obscuration is held to two percentage points. +// - Duration of totality gets a much wider band. It depends on the difference between +// two apparent radii that are within ~5% of each other, so the same small angular +// uncertainty that leaves obscuration untouched moves the duration by several +// percent. Anything tighter would be testing luck. +// + +import CoreLocation +import Foundation +@testable import Solstice +import Testing + +struct EclipseCalculatorTests { + /// Reference values are quoted in UT, and the search window has to be built in a + /// calendar that agrees. Reading these back through `Calendar.current` would fail on + /// a runner whose region defaults to a non-Gregorian calendar even though the + /// calculator itself is correct — the same hazard `SolsticeCalculatorTests` guards. + private static let utcGregorian: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .gmt + return calendar + }() + + private static func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 0, _ minute: Int = 0) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + return utcGregorian.date(from: components) ?? .distantPast + } + + /// Looks for an eclipse in the fortnight surrounding a known date, so a test failure + /// means the circumstances are wrong rather than that the window was mistimed. + private func eclipse( + latitude: Double, + longitude: Double, + around year: Int, + _ month: Int, + _ day: Int, + minimumObscuration: Double = 0 + ) -> EclipseCalculator.LocalCircumstances? { + let centre = Self.date(year, month, day) + + return EclipseCalculator.eclipses( + at: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + from: centre.addingTimeInterval(-7 * 86400), + through: centre.addingTimeInterval(7 * 86400), + minimumObscuration: minimumObscuration + ).first + } + + private func minutes(between first: Date, and second: Date) -> Double { + abs(first.timeIntervalSince(second)) / 60 + } + + // MARK: - Past eclipses, where the outcome is a matter of record + + /// The 2024 April 8 eclipse over North America. Dallas saw totality begin at about + /// 13:40 CDT and last a little under four minutes, with the partial phase opening + /// around 17:23 UT. + @Test("Dallas sees totality at the 2024 April 8 eclipse") + func dallas2024() throws { + let circumstances = try #require(eclipse(latitude: 32.7767, longitude: -96.7970, around: 2024, 4, 8)) + + #expect(circumstances.kind == .total) + #expect(circumstances.obscuration == 1.0) + #expect(circumstances.isCentral) + #expect(minutes(between: circumstances.firstContact, and: Self.date(2024, 4, 8, 17, 23)) < 2) + #expect(minutes(between: circumstances.maximum, and: Self.date(2024, 4, 8, 18, 42)) < 2) + #expect(minutes(between: circumstances.lastContact, and: Self.date(2024, 4, 8, 20, 2)) < 2) + + // Published duration is 3m51s; the band reflects how sensitive this quantity is. + let duration = try #require(circumstances.centralDuration) + #expect(duration > 200 && duration < 270) + } + + // MARK: - The eclipses this feature exists to announce + + @Test("Reykjavík is inside the path of the 2026 August 12 eclipse") + func reykjavik2026() throws { + let circumstances = try #require(eclipse(latitude: 64.1466, longitude: -21.9426, around: 2026, 8, 12)) + + #expect(circumstances.kind == .total) + #expect(circumstances.obscuration == 1.0) + #expect(circumstances.magnitude > 1) + #expect(try #require(circumstances.centralDuration) > 30) + // Iceland catches this one low in the evening sky. + #expect(circumstances.sunAltitudeAtMaximum > 0 && circumstances.sunAltitudeAtMaximum < 40) + } + + /// The discriminating case: Madrid missed the 2026 path of totality by a hair. A + /// calculator that is merely approximately right will call this one total. + @Test("Madrid misses totality in 2026 despite near-complete coverage") + func madrid2026() throws { + let circumstances = try #require(eclipse(latitude: 40.4168, longitude: -3.7038, around: 2026, 8, 12)) + + #expect(circumstances.kind == .partial) + #expect(circumstances.centralDuration == nil) + #expect(circumstances.obscuration > 0.97) + #expect(circumstances.obscuration < 1.0) + } + + @Test("London sees a deep partial eclipse in 2026") + func london2026() throws { + let circumstances = try #require(eclipse(latitude: 51.5072, longitude: -0.1276, around: 2026, 8, 12)) + + #expect(circumstances.kind == .partial) + #expect(abs(circumstances.obscuration - 0.91) < 0.02) + } + + /// The longest totality over land this century — a little over six minutes at Luxor. + @Test("Luxor sees more than six minutes of totality in 2027") + func luxor2027() throws { + let circumstances = try #require(eclipse(latitude: 25.6872, longitude: 32.6396, around: 2027, 8, 2)) + + #expect(circumstances.kind == .total) + let duration = try #require(circumstances.centralDuration) + #expect(duration > 340 && duration < 420) + // Near local noon in high summer, the sun is almost overhead. + #expect(circumstances.sunAltitudeAtMaximum > 70) + } + + @Test("Southern Spain sees an annular eclipse in 2028") + func spain2028() throws { + let circumstances = try #require(eclipse(latitude: 36.5271, longitude: -6.2886, around: 2028, 1, 26)) + + #expect(circumstances.kind == .annular) + #expect(circumstances.isCentral) + // A ring means the moon never covers the whole disc, however well centred it is. + #expect(circumstances.obscuration < 1.0) + #expect(circumstances.magnitude < 1.0) + } + + @Test("Sydney is inside the path of the 2028 July 22 eclipse") + func sydney2028() throws { + let circumstances = try #require(eclipse(latitude: -33.8688, longitude: 151.2093, around: 2028, 7, 22)) + + #expect(circumstances.kind == .total) + #expect(circumstances.obscuration == 1.0) + } + + // MARK: - Places and times that should see nothing + + @Test( + "Locations far from the 2026 path see no eclipse", + arguments: [ + (-34.6037, -58.3816), // Buenos Aires + (35.6762, 139.6503), // Tokyo + (-33.8688, 151.2093), // Sydney + ] + ) + func outsideThePath2026(latitude: Double, longitude: Double) { + #expect(eclipse(latitude: latitude, longitude: longitude, around: 2026, 8, 12) == nil) + } + + /// An eclipse happening while the sun is below the horizon isn't an eclipse to the + /// person standing there. Tahiti is on the far side of the world from the 2027 event. + @Test("An eclipse below the horizon is not reported") + func belowTheHorizon() { + #expect(eclipse(latitude: -17.6509, longitude: -149.4260, around: 2027, 8, 2) == nil) + } + + @Test("A window with no eclipse returns nothing") + func quietWindow() { + let results = EclipseCalculator.eclipses( + at: CLLocationCoordinate2D(latitude: 51.5072, longitude: -0.1276), + from: Self.date(2026, 9, 1), + through: Self.date(2027, 2, 1), + minimumObscuration: 0.1 + ) + + #expect(results.isEmpty) + } + + // MARK: - Invariants + + @Test("The obscuration threshold filters results") + func thresholdFilters() { + let coordinate = CLLocationCoordinate2D(latitude: 51.5072, longitude: -0.1276) + let from = Self.date(2026, 8, 5) + let through = Self.date(2026, 8, 19) + + // London sees about 91% in 2026, so it passes a 90% bar but not a 95% one. + #expect(!EclipseCalculator.eclipses(at: coordinate, from: from, through: through, minimumObscuration: 0.90).isEmpty) + #expect(EclipseCalculator.eclipses(at: coordinate, from: from, through: through, minimumObscuration: 0.95).isEmpty) + } + + @Test("Contacts bracket maximum and the sun is up throughout") + func contactsAreOrdered() throws { + let circumstances = try #require(eclipse(latitude: 51.5072, longitude: -0.1276, around: 2026, 8, 12)) + + #expect(circumstances.firstContact < circumstances.maximum) + #expect(circumstances.maximum < circumstances.lastContact) + #expect(circumstances.sunAltitudeAtMaximum > -1) + } + + /// Obscuration is an area and magnitude is a diameter, so for a partial eclipse the + /// area hidden always trails the fraction of the diameter covered. + @Test("Obscuration is below magnitude for a partial eclipse") + func obscurationTrailsMagnitude() throws { + let circumstances = try #require(eclipse(latitude: 51.5072, longitude: -0.1276, around: 2026, 8, 12)) + + #expect(circumstances.kind == .partial) + #expect(circumstances.obscuration < circumstances.magnitude) + } + + @Test("Results are ordered and confined to the requested window") + func resultsAreOrderedAndBounded() { + let from = Self.date(2026, 1, 1) + let through = Self.date(2029, 1, 1) + + let results = EclipseCalculator.eclipses( + at: CLLocationCoordinate2D(latitude: 51.5072, longitude: -0.1276), + from: from, + through: through, + minimumObscuration: 0.1 + ) + + #expect(results.count >= 2) + #expect(results == results.sorted { $0.maximum < $1.maximum }) + #expect(results.allSatisfy { $0.maximum >= from && $0.maximum <= through }) + #expect(results.allSatisfy { $0.obscuration >= 0.1 }) + } + + /// When one disc is centred inside the other, both figures reduce to the ratio of the + /// apparent radii — obscuration as its square, magnitude as itself. An annular + /// eclipse is the only case where that relationship is visible in the output, since + /// a total eclipse saturates obscuration at 1. + @Test("Annular obscuration is the square of magnitude") + func annularObscurationIsSquaredMagnitude() throws { + let circumstances = try #require(eclipse(latitude: 36.5271, longitude: -6.2886, around: 2028, 1, 26)) + + #expect(circumstances.kind == .annular) + #expect(abs(circumstances.obscuration - pow(circumstances.magnitude, 2)) < 0.001) + } + + /// Nothing in the calculator is seeded or cached, so the same query has to give the + /// same answer — the detail view re-runs it on every location and date change. + @Test("Repeated queries agree") + func repeatedQueriesAgree() throws { + let first = try #require(eclipse(latitude: 25.6872, longitude: 32.6396, around: 2027, 8, 2)) + let second = try #require(eclipse(latitude: 25.6872, longitude: 32.6396, around: 2027, 8, 2)) + + #expect(first == second) + } +}