From d90a1dc8dfe9b9385e11ce9cc1a695fce2ef4c4c Mon Sep 17 00:00:00 2001 From: Fr4nzD13trich Date: Fri, 21 Nov 2025 15:11:39 +0100 Subject: [PATCH] Repo created --- .editorconfig | 31 + .idea/icon.png | Bin 0 -> 5636 bytes CHANGELOG.md | 297 ++++ CODE_OF_CONDUCT.md | 128 ++ CONTRIBUTE.md | 171 ++ HELP.md | 173 ++ INSTALL.md | 82 + LICENSE | 165 ++ LICENSE_ADDITIONAL | 3 + PRIVACY.md | 13 + README.md | 209 ++- app/.gitignore | 5 + app/build.gradle.kts | 408 +++++ app/proguard-rules.pro | 73 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../debug/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1492 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2858 bytes .../debug/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 952 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1860 bytes .../debug/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1990 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3982 bytes .../debug/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 3010 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 6396 bytes .../debug/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3832 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 9242 bytes app/src/debug/res/values-night-v31/colors.xml | 7 + app/src/debug/res/values/colors.xml | 7 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/AndroidManifest.xml | 576 +++++++ app/src/main/ic_launcher-playstore.png | Bin 0 -> 15451 bytes .../java/org/breezyweather/BreezyWeather.kt | 155 ++ .../main/java/org/breezyweather/Migrations.kt | 307 ++++ .../forecast/ForecastNotificationNotifier.kt | 179 ++ .../forecast/TodayForecastNotificationJob.kt | 148 ++ .../TomorrowForecastNotificationJob.kt | 148 ++ .../background/interfaces/TileService.kt | 123 ++ .../provider/WeatherContentProvider.kt | 1014 ++++++++++++ .../background/receiver/BootReceiver.kt | 66 + .../receiver/NotificationReceiver.kt | 150 ++ .../widget/WidgetClockDayDetailsProvider.kt | 70 + .../WidgetClockDayHorizontalProvider.kt | 70 + .../widget/WidgetClockDayVerticalProvider.kt | 79 + .../widget/WidgetClockDayWeekProvider.kt | 70 + .../receiver/widget/WidgetDayProvider.kt | 79 + .../receiver/widget/WidgetDayWeekProvider.kt | 79 + .../WidgetMaterialYouCurrentProvider.kt | 77 + .../WidgetMaterialYouForecastProvider.kt | 67 + .../widget/WidgetMultiCityProvider.kt | 70 + .../receiver/widget/WidgetTextProvider.kt | 79 + .../widget/WidgetTrendDailyProvider.kt | 70 + .../widget/WidgetTrendHourlyProvider.kt | 70 + .../receiver/widget/WidgetWeekProvider.kt | 70 + .../background/updater/AppUpdateChecker.kt | 69 + .../background/updater/AppUpdateNotifier.kt | 100 ++ .../background/updater/data/GithubApi.kt | 31 + .../background/updater/data/GithubRelease.kt | 56 + .../background/updater/data/ReleaseService.kt | 42 + .../interactor/GetApplicationRelease.kt | 102 ++ .../background/updater/model/Release.kt | 59 + .../background/weather/WeatherUpdateJob.kt | 530 ++++++ .../weather/WeatherUpdateNotifier.kt | 112 ++ .../BreezyFloatingTextActionModeCallback.kt | 51 + .../BreezyPrimaryTextActionModeCallback.kt | 41 + .../BreezySelectionContainer.kt | 45 + .../BreezyTextActionModeCallback.kt | 109 ++ .../actionmodecallback/BreezyTextToolbar.kt | 173 ++ .../common/activities/BreezyActivity.kt | 93 ++ .../common/activities/BreezyFragment.kt | 43 + .../common/activities/BreezyViewModel.kt | 32 + .../common/activities/livedata/BusLiveData.kt | 108 ++ .../activities/livedata/EqualtableLiveData.kt | 39 + .../org/breezyweather/common/bus/EventBus.kt | 49 + .../common/bus/MyObserverWrapper.kt | 40 + .../org/breezyweather/common/di/DbModule.kt | 275 ++++ .../org/breezyweather/common/di/HttpModule.kt | 195 +++ .../org/breezyweather/common/di/RxModule.kt | 32 + .../exceptions/ApiKeyMissingException.kt | 19 + .../exceptions/ApiLimitReachedException.kt | 19 + .../exceptions/ApiUnauthorizedException.kt | 19 + .../exceptions/InvalidLocationException.kt | 19 + .../InvalidOrIncompleteDataException.kt | 19 + .../exceptions/LocationAccessOffException.kt | 19 + .../common/exceptions/LocationException.kt | 19 + .../exceptions/LocationSearchException.kt | 19 + ...ngPermissionLocationBackgroundException.kt | 19 + .../MissingPermissionLocationException.kt | 19 + .../common/exceptions/NoNetworkException.kt | 19 + .../exceptions/NonFreeNetSourceException.kt | 19 + .../exceptions/OutdatedServerDataException.kt | 19 + .../common/exceptions/ParsingException.kt | 19 + .../exceptions/ReverseGeocodingException.kt | 19 + .../exceptions/SourceNotInstalledException.kt | 19 + .../exceptions/UnsupportedFeatureException.kt | 19 + .../common/exceptions/WeatherException.kt | 19 + .../common/extensions/ContextExtensions.kt | 116 ++ .../common/extensions/CoroutinesExtensions.kt | 37 + .../extensions/DataSharingExtensions.kt | 43 + .../common/extensions/DateExtensions.kt | 286 ++++ .../common/extensions/DateOldExtensions.kt | 99 ++ .../common/extensions/DisplayExtensions.kt | 281 ++++ .../common/extensions/FileExtensions.kt | 43 + .../common/extensions/LanguageExtensions.kt | 135 ++ .../common/extensions/ModifierExtensions.kt | 33 + .../common/extensions/NetworkExtensions.kt | 49 + .../extensions/NotificationExtensions.kt | 118 ++ .../common/extensions/NumberExtensions.kt | 97 ++ .../common/extensions/StringExtensions.kt | 32 + .../common/extensions/UnitExtensions.kt | 434 +++++ .../extensions/WindowInsetsExtensions.kt | 61 + .../extensions/WorkManagerExtensions.kt | 56 + .../breezyweather/common/options/BaseEnum.kt | 47 + .../breezyweather/common/options/DarkMode.kt | 47 + .../common/options/DarkModeLocation.kt | 51 + .../common/options/NotificationStyle.kt | 46 + .../common/options/NotificationTextColor.kt | 45 + .../common/options/UpdateInterval.kt | 58 + .../common/options/WidgetWeekIconMode.kt | 45 + .../appearance/BackgroundAnimationMode.kt | 46 + .../options/appearance/CalendarHelper.kt | 171 ++ .../common/options/appearance/CardDisplay.kt | 89 + .../options/appearance/DailyTrendDisplay.kt | 71 + .../common/options/appearance/DetailScreen.kt | 121 ++ .../options/appearance/HourlyTrendDisplay.kt | 74 + .../common/options/appearance/LocaleHelper.kt | 94 ++ .../common/preference/EditTextPreference.kt | 39 + .../common/preference/ListPreference.kt | 28 + .../common/preference/Preference.kt | 23 + .../common/rxjava/ObserverContainer.kt | 45 + .../common/rxjava/SchedulerTransformer.kt | 38 + .../common/serializer/DateSerializer.kt | 46 + .../common/serializer/DateUtcSerializer.kt | 66 + .../common/serializer/LatLngSerializer.kt | 43 + .../StringOrStringListSerializer.kt | 54 + .../breezyweather/common/snackbar/Snackbar.kt | 577 +++++++ .../common/snackbar/SnackbarAnimationUtils.kt | 43 + .../common/snackbar/SnackbarContainer.kt | 26 + .../common/snackbar/SnackbarManager.kt | 205 +++ .../common/source/AddressSource.kt | 137 ++ .../common/source/BroadcastSource.kt | 39 + .../common/source/ConfigurableSource.kt | 32 + .../common/source/FeatureSource.kt | 78 + .../breezyweather/common/source/HttpSource.kt | 46 + .../common/source/LocationParametersSource.kt | 53 + .../common/source/LocationPositionWrapper.kt | 23 + .../common/source/LocationResult.kt | 24 + .../common/source/LocationSearchSource.kt | 40 + .../common/source/LocationSource.kt | 45 + .../common/source/NonFreeNetSource.kt | 22 + .../common/source/PollenIndexSource.kt | 36 + .../source/PreferencesParametersSource.kt | 50 + .../common/source/RefreshError.kt | 53 + .../common/source/ReverseGeocodingSource.kt | 36 + .../org/breezyweather/common/source/Source.kt | 55 + .../common/source/SourceExtensions.kt | 39 + .../common/source/TimeZoneSource.kt | 34 + .../common/source/WeatherResult.kt | 24 + .../common/source/WeatherSource.kt | 77 + .../breezyweather/common/utils/ColorUtils.kt | 81 + .../common/utils/CrashLogUtils.kt | 83 + .../common/utils/ISO8601Utils.kt | 390 +++++ .../breezyweather/common/utils/UnitUtils.kt | 212 +++ .../common/utils/helpers/AsyncHelper.kt | 96 ++ .../common/utils/helpers/IntentHelper.kt | 241 +++ .../common/utils/helpers/LogHelper.kt | 27 + .../common/utils/helpers/PermissionHelper.kt | 56 + .../common/utils/helpers/ShortcutsHelper.kt | 64 + .../common/utils/helpers/SnackbarHelper.kt | 41 + .../org/breezyweather/data/Contributors.kt | 439 +++++ .../domain/location/model/Location.kt | 81 + .../domain/settings/ConfigStore.kt | 113 ++ .../domain/settings/CurrentLocationStore.kt | 81 + .../domain/settings/SettingsManager.kt | 477 ++++++ .../domain/settings/SourceConfigStore.kt | 24 + .../domain/source/SourceContinent.kt | 31 + .../domain/source/SourceFeature.kt | 32 + .../domain/weather/index/PollenIndex.kt | 157 ++ .../domain/weather/index/PollutantIndex.kt | 253 +++ .../domain/weather/model/AirQuality.kt | 82 + .../domain/weather/model/Alert.kt | 57 + .../domain/weather/model/Astro.kt | 68 + .../domain/weather/model/Daily.kt | 117 ++ .../domain/weather/model/DailyCloudCover.kt | 57 + .../domain/weather/model/DailyDewPoint.kt | 53 + .../weather/model/DailyRelativeHumidity.kt | 37 + .../domain/weather/model/DailyVisibility.kt | 72 + .../domain/weather/model/Minutely.kt | 100 ++ .../domain/weather/model/MoonPhase.kt | 38 + .../domain/weather/model/Pollen.kt | 149 ++ .../domain/weather/model/Precipitation.kt | 96 ++ .../breezyweather/domain/weather/model/UV.kt | 90 + .../domain/weather/model/Weather.kt | 160 ++ .../domain/weather/model/Wind.kt | 156 ++ .../remoteviews/Notifications.kt | 355 ++++ .../org/breezyweather/remoteviews/Widgets.kt | 172 ++ .../common/MaterialYouWidgetShape.kt | 60 + .../remoteviews/common/WidgetSize.kt | 28 + .../remoteviews/common/WidgetSizeUtils.kt | 71 + .../config/AbstractWidgetConfigActivity.kt | 809 +++++++++ .../ClockDayDetailsWidgetConfigActivity.kt | 98 ++ .../ClockDayHorizontalWidgetConfigActivity.kt | 98 ++ .../ClockDayVerticalWidgetConfigActivity.kt | 133 ++ .../ClockDayWeekWidgetConfigActivity.kt | 98 ++ .../config/DailyTrendWidgetConfigActivity.kt | 91 + .../config/DayWeekWidgetConfigActivity.kt | 125 ++ .../config/DayWidgetConfigActivity.kt | 107 ++ .../config/HourlyTrendWidgetConfigActivity.kt | 91 + .../config/MultiCityWidgetConfigActivity.kt | 87 + .../config/TextWidgetConfigActivity.kt | 106 ++ .../config/WeekWidgetConfigActivity.kt | 96 ++ .../AbstractRemoteViewsPresenter.kt | 567 +++++++ .../presenters/ClockDayDetailsWidgetIMP.kt | 331 ++++ .../presenters/ClockDayHorizontalWidgetIMP.kt | 265 +++ .../presenters/ClockDayVerticalWidgetIMP.kt | 701 ++++++++ .../presenters/ClockDayWeekWidgetIMP.kt | 421 +++++ .../presenters/DailyTrendWidgetIMP.kt | 370 +++++ .../presenters/DayWeekWidgetIMP.kt | 536 ++++++ .../remoteviews/presenters/DayWidgetIMP.kt | 554 +++++++ .../presenters/HourlyTrendWidgetIMP.kt | 332 ++++ .../presenters/MaterialYouCurrentWidgetIMP.kt | 203 +++ .../MaterialYouForecastWidgetIMP.kt | 382 +++++ .../presenters/MultiCityWidgetIMP.kt | 218 +++ .../remoteviews/presenters/TextWidgetIMP.kt | 235 +++ .../remoteviews/presenters/WeekWidgetIMP.kt | 284 ++++ .../MultiCityWidgetNotificationIMP.kt | 288 ++++ .../NativeWidgetNotificationIMP.kt | 143 ++ .../notification/WidgetNotificationIMP.kt | 345 ++++ .../remoteviews/trend/TrendLinearLayout.kt | 208 +++ .../remoteviews/trend/WidgetItemView.kt | 239 +++ .../breezyweather/sources/CommonConverter.kt | 1432 ++++++++++++++++ .../breezyweather/sources/RefreshHelper.kt | 1462 +++++++++++++++++ .../breezyweather/sources/SourceManager.kt | 516 ++++++ .../sources/accu/AccuServiceStub.kt | 102 ++ .../accu/preferences/AccuPortalPreference.kt | 45 + .../sources/aemet/AemetServiceStub.kt | 70 + .../android/AndroidGeocoderServiceStub.kt | 45 + .../sources/android/AndroidLocationService.kt | 135 ++ .../sources/atmo/AtmoFranceServiceStub.kt | 70 + .../sources/atmo/AtmoServiceStub.kt | 66 + .../baiduip/BaiduIPLocationServiceStub.kt | 53 + .../sources/bmd/BmdServiceStub.kt | 84 + .../sources/bmkg/BmkgServiceStub.kt | 76 + .../sources/breezytz/BreezyTimeZoneService.kt | 424 +++++ .../BreezyUpdateNotifierService.kt | 68 + .../sources/brightsky/BrightSkyApi.kt | 46 + .../sources/brightsky/BrightSkyService.kt | 372 +++++ .../sources/brightsky/json/BrightSkyAlert.kt | 36 + .../brightsky/json/BrightSkyAlertsResult.kt | 24 + .../brightsky/json/BrightSkyCurrentWeather.kt | 38 + .../json/BrightSkyCurrentWeatherResult.kt | 24 + .../brightsky/json/BrightSkyWeather.kt | 41 + .../brightsky/json/BrightSkyWeatherResult.kt | 24 + .../sources/china/ChinaServiceStub.kt | 81 + .../sources/climweb/AnamBfService.kt | 52 + .../sources/climweb/AnametService.kt | 52 + .../sources/climweb/ClimWebApi.kt | 44 + .../sources/climweb/ClimWebService.kt | 392 +++++ .../sources/climweb/DccmsService.kt | 52 + .../sources/climweb/DmnNeService.kt | 52 + .../sources/climweb/DwrGmService.kt | 52 + .../sources/climweb/EthioMetService.kt | 52 + .../sources/climweb/GMetService.kt | 52 + .../sources/climweb/IgebuService.kt | 52 + .../sources/climweb/InmgbService.kt | 52 + .../sources/climweb/MaliMeteoService.kt | 59 + .../sources/climweb/MeteoBeninService.kt | 59 + .../sources/climweb/MeteoTchadService.kt | 59 + .../sources/climweb/MettelsatService.kt | 52 + .../sources/climweb/MsdZwService.kt | 52 + .../sources/climweb/SmaScService.kt | 52 + .../sources/climweb/SmaSuService.kt | 52 + .../sources/climweb/SsmsService.kt | 52 + .../climweb/json/ClimWebAlertsResult.kt | 30 + .../sources/climweb/json/ClimWebLocation.kt | 27 + .../sources/climweb/json/ClimWebNormals.kt | 33 + .../serializers/ClimWebAnySerializer.kt | 34 + .../sources/common/xml/CapAlert.kt | 259 +++ .../sources/cwa/CwaServiceStub.kt | 127 ++ .../sources/debug/DebugService.kt | 257 +++ .../sources/dmi/DmiServiceStub.kt | 78 + .../sources/eccc/EcccServiceStub.kt | 84 + .../sources/ekuk/EkukServiceStub.kt | 78 + .../sources/epdhk/EpdHkServiceStub.kt | 107 ++ .../breezyweather/sources/fpas/FpasJsonApi.kt | 31 + .../breezyweather/sources/fpas/FpasService.kt | 211 +++ .../breezyweather/sources/fpas/FpasXmlApi.kt | 29 + .../gadgetbridge/GadgetbridgeService.kt | 211 +++ .../json/GadgetbridgeAirQuality.kt | 36 + .../json/GadgetbridgeDailyForecast.kt | 37 + .../gadgetbridge/json/GadgetbridgeData.kt | 52 + .../json/GadgetbridgeHourlyForecast.kt | 31 + .../sources/geonames/GeoNamesServiceStub.kt | 43 + .../geosphereat/GeoSphereAtServiceStub.kt | 99 ++ .../sources/hko/HkoServiceStub.kt | 88 + .../ilmateenistus/IlmateenistusServiceStub.kt | 79 + .../sources/imd/ImdServiceStub.kt | 64 + .../sources/ims/ImsServiceStub.kt | 92 ++ .../sources/ipma/IpmaServiceStub.kt | 73 + .../sources/ipsb/IpSbLocationServiceStub.kt | 40 + .../sources/jma/JmaServiceStub.kt | 88 + .../sources/lhmt/LhmtServiceStub.kt | 74 + .../sources/lvgmc/LvgmcServiceStub.kt | 73 + .../sources/meteoam/MeteoAmServiceStub.kt | 78 + .../sources/meteolux/MeteoLuxServiceStub.kt | 84 + .../sources/metie/MetIeServiceStub.kt | 87 + .../sources/metno/MetNoServiceStub.kt | 104 ++ .../sources/metoffice/MetOfficeServiceStub.kt | 61 + .../breezyweather/sources/mf/MfServiceStub.kt | 114 ++ .../sources/mgm/MgmServiceStub.kt | 75 + .../sources/namem/NamemServiceStub.kt | 89 + .../naturalearth/NaturalEarthService.kt | 125 ++ .../sources/ncdr/NcdrServiceStub.kt | 91 + .../sources/ncei/NceiServiceStub.kt | 58 + .../sources/nlsc/NlscServiceStub.kt | 80 + .../sources/nominatim/NominatimApi.kt | 51 + .../sources/nominatim/NominatimService.kt | 290 ++++ .../nominatim/json/NominatimAddress.kt | 40 + .../nominatim/json/NominatimLocationResult.kt | 29 + .../sources/nws/NwsServiceStub.kt | 87 + .../openmeteo/OpenMeteoAirQualityApi.kt | 36 + .../sources/openmeteo/OpenMeteoForecastApi.kt | 41 + .../openmeteo/OpenMeteoGeocodingApi.kt | 34 + .../sources/openmeteo/OpenMeteoService.kt | 966 +++++++++++ .../openmeteo/OpenMeteoWeatherModel.kt | 123 ++ .../json/OpenMeteoAirQualityHourly.kt | 37 + .../json/OpenMeteoAirQualityResult.kt | 27 + .../openmeteo/json/OpenMeteoLocationResult.kt | 38 + .../json/OpenMeteoLocationResults.kt | 29 + .../openmeteo/json/OpenMeteoWeatherCurrent.kt | 37 + .../openmeteo/json/OpenMeteoWeatherDaily.kt | 46 + .../openmeteo/json/OpenMeteoWeatherHourly.kt | 44 + .../json/OpenMeteoWeatherMinutely.kt | 26 + .../openmeteo/json/OpenMeteoWeatherResult.kt | 33 + .../openweather/OpenWeatherServiceStub.kt | 44 + .../sources/pagasa/PagasaServiceStub.kt | 68 + .../sources/pirateweather/PirateWeatherApi.kt | 39 + .../pirateweather/PirateWeatherService.kt | 409 +++++ .../pirateweather/json/PirateWeatherAlert.kt | 31 + .../json/PirateWeatherCurrently.kt | 44 + .../pirateweather/json/PirateWeatherDaily.kt | 63 + .../json/PirateWeatherForecast.kt | 26 + .../json/PirateWeatherForecastResult.kt | 33 + .../pirateweather/json/PirateWeatherHourly.kt | 47 + .../json/PirateWeatherMinutely.kt | 28 + .../polleninfo/PollenInfoServiceStub.kt | 103 ++ .../breezyweather/sources/recosante/GeoApi.kt | 34 + .../sources/recosante/RecosanteApi.kt | 33 + .../sources/recosante/RecosanteService.kt | 344 ++++ .../sources/recosante/json/GeoCommune.kt | 25 + .../sources/recosante/json/RecosanteRaep.kt | 25 + .../recosante/json/RecosanteRaepIndice.kt | 24 + .../json/RecosanteRaepIndiceDetail.kt | 25 + .../json/RecosanteRaepIndiceDetailIndice.kt | 24 + .../recosante/json/RecosanteRaepValidity.kt | 25 + .../sources/recosante/json/RecosanteResult.kt | 24 + .../sources/smg/SmgServiceStub.kt | 88 + .../sources/smhi/SmhiServiceStub.kt | 63 + .../sources/veduris/VedurIsServiceStub.kt | 78 + .../WmoSevereWeatherServiceStub.kt | 61 + .../breezyweather/ui/about/AboutActivity.kt | 41 + .../org/breezyweather/ui/about/AboutScreen.kt | 467 ++++++ .../breezyweather/ui/about/AboutViewModel.kt | 56 + .../breezyweather/ui/alert/AlertActivity.kt | 43 + .../org/breezyweather/ui/alert/AlertScreen.kt | 224 +++ .../breezyweather/ui/alert/AlertUiState.kt | 24 + .../breezyweather/ui/alert/AlertViewModel.kt | 79 + .../ui/common/adapters/ButtonAdapter.kt | 83 + .../ui/common/adapters/SyncListAdapter.kt | 93 ++ .../ui/common/adapters/TagAdapter.kt | 94 ++ .../FloatingAboveSnackbarBehavior.kt | 38 + .../ui/common/charts/AxisItemPlacer.kt | 166 ++ .../ui/common/charts/BreezyBarChart.kt | 220 +++ .../ui/common/charts/BreezyLineChart.kt | 330 ++++ .../ui/common/charts/EphemerisChart.kt | 162 ++ .../common/composables/AllergenComposables.kt | 131 ++ .../common/composables/AnimatedVisibility.kt | 47 + .../ui/common/composables/Dialogs.kt | 399 +++++ .../common/composables/LocationPreferences.kt | 993 +++++++++++ .../ui/common/composables/NotificationCard.kt | 39 + .../decorations/GridMarginsDecoration.kt | 62 + .../ui/common/decorations/ListDecoration.kt | 74 + .../Material3ListItemDecoration.kt | 42 + .../ui/common/images/MoonDrawable.kt | 110 ++ .../ui/common/images/RotateDrawable.kt | 67 + .../ui/common/images/SunDrawable.kt | 113 ++ .../ui/common/widgets/AnimatableIconView.kt | 106 ++ .../ui/common/widgets/ArcProgress.kt | 143 ++ .../common/widgets/DayNightShaderWrapper.kt | 77 + .../ui/common/widgets/DrawerLayout.kt | 186 +++ .../ui/common/widgets/InkPageIndicator.kt | 937 +++++++++++ .../ui/common/widgets/Material3Widgets.kt | 231 +++ .../ui/common/widgets/NumberAnimTextView.kt | 192 +++ .../ui/common/widgets/RoundProgress.kt | 116 ++ .../ui/common/widgets/SquareFrameLayout.kt | 35 + .../ui/common/widgets/SwipeSwitchLayout.kt | 372 +++++ .../ui/common/widgets/astro/MoonPhaseView.kt | 205 +++ .../ui/common/widgets/astro/SunMoonView.kt | 339 ++++ .../insets/FitSystemBarAppBarLayout.kt | 52 + .../insets/FitSystemBarComposeWrappers.kt | 148 ++ .../slidingItem/SlidingItemContainerLayout.kt | 163 ++ .../slidingItem/SlidingItemTouchCallback.kt | 102 ++ .../widgets/trend/TrendLayoutManager.kt | 25 + .../common/widgets/trend/TrendRecyclerView.kt | 197 +++ .../widgets/trend/TrendRecyclerViewAdapter.kt | 31 + .../widgets/trend/chart/AbsChartItemView.kt | 30 + .../trend/chart/DoubleHistogramView.kt | 251 +++ .../trend/chart/PolylineAndHistogramView.kt | 517 ++++++ .../widgets/trend/item/AbsTrendItemView.kt | 33 + .../widgets/trend/item/DailyTrendItemView.kt | 258 +++ .../widgets/trend/item/HourlyTrendItemView.kt | 194 +++ .../ui/details/DetailsActivity.kt | 44 + .../breezyweather/ui/details/DetailsScreen.kt | 518 ++++++ .../ui/details/DetailsUiState.kt | 28 + .../ui/details/DetailsViewModel.kt | 118 ++ .../details/components/DetailsAirQuality.kt | 814 +++++++++ .../details/components/DetailsCloudCover.kt | 398 +++++ .../ui/details/components/DetailsCommon.kt | 458 ++++++ .../details/components/DetailsConditions.kt | 906 ++++++++++ .../ui/details/components/DetailsHumidity.kt | 524 ++++++ .../ui/details/components/DetailsPollen.kt | 89 + .../components/DetailsPrecipitation.kt | 844 ++++++++++ .../ui/details/components/DetailsPressure.kt | 338 ++++ .../ui/details/components/DetailsSunMoon.kt | 559 +++++++ .../ui/details/components/DetailsUV.kt | 377 +++++ .../details/components/DetailsVisibility.kt | 427 +++++ .../ui/details/components/DetailsWind.kt | 591 +++++++ .../org/breezyweather/ui/main/MainActivity.kt | 916 +++++++++++ .../ui/main/MainActivityModels.kt | 81 + .../ui/main/MainActivityViewModel.kt | 717 ++++++++ .../main/adapters/location/LocationAdapter.kt | 105 ++ .../main/adapters/location/LocationHolder.kt | 139 ++ .../main/adapters/location/LocationModel.kt | 87 + .../ui/main/adapters/main/MainAdapter.kt | 379 +++++ .../ui/main/adapters/main/ViewType.kt | 47 + .../main/holder/AbstractMainCardViewHolder.kt | 85 + .../main/holder/AbstractMainViewHolder.kt | 118 ++ .../main/holder/AirQualityViewHolder.kt | 157 ++ .../adapters/main/holder/AlertViewHolder.kt | 195 +++ .../adapters/main/holder/AstroViewHolder.kt | 208 +++ .../adapters/main/holder/ClockViewHolder.kt | 78 + .../adapters/main/holder/DailyViewHolder.kt | 161 ++ .../adapters/main/holder/FooterViewHolder.kt | 312 ++++ .../adapters/main/holder/HeaderViewHolder.kt | 160 ++ .../adapters/main/holder/HourlyViewHolder.kt | 153 ++ .../main/holder/HumidityViewHolder.kt | 98 ++ .../adapters/main/holder/MoonViewHolder.kt | 138 ++ .../adapters/main/holder/PollenViewHolder.kt | 138 ++ .../holder/PrecipitationNowcastViewHolder.kt | 298 ++++ .../main/holder/PrecipitationViewHolder.kt | 120 ++ .../main/holder/PressureViewHolder.kt | 85 + .../adapters/main/holder/SunViewHolder.kt | 102 ++ .../main/adapters/main/holder/UvViewHolder.kt | 79 + .../main/holder/VisibilityViewHolder.kt | 72 + .../adapters/main/holder/WindViewHolder.kt | 107 ++ .../main/adapters/trend/DailyTrendAdapter.kt | 90 + .../main/adapters/trend/HourlyTrendAdapter.kt | 97 ++ .../trend/daily/AbsDailyTrendAdapter.kt | 99 ++ .../trend/daily/DailyAirQualityAdapter.kt | 164 ++ .../trend/daily/DailyFeelsLikeAdapter.kt | 246 +++ .../trend/daily/DailyPrecipitationAdapter.kt | 195 +++ .../trend/daily/DailySunshineAdapter.kt | 156 ++ .../trend/daily/DailyTemperatureAdapter.kt | 307 ++++ .../adapters/trend/daily/DailyUVAdapter.kt | 143 ++ .../adapters/trend/daily/DailyWindAdapter.kt | 182 ++ .../trend/hourly/AbsHourlyTrendAdapter.kt | 82 + .../trend/hourly/HourlyAirQualityAdapter.kt | 168 ++ .../trend/hourly/HourlyCloudCoverAdapter.kt | 145 ++ .../trend/hourly/HourlyFeelsLikeAdapter.kt | 201 +++ .../trend/hourly/HourlyHumidityAdapter.kt | 205 +++ .../hourly/HourlyPrecipitationAdapter.kt | 175 ++ .../trend/hourly/HourlyPressureAdapter.kt | 205 +++ .../trend/hourly/HourlyTemperatureAdapter.kt | 247 +++ .../adapters/trend/hourly/HourlyUVAdapter.kt | 146 ++ .../trend/hourly/HourlyVisibilityAdapter.kt | 187 +++ .../trend/hourly/HourlyWindAdapter.kt | 154 ++ .../ui/main/dialogs/ErrorHelpDialog.kt | 105 ++ .../ui/main/dialogs/LocationHelpDialog.kt | 149 ++ .../SourceNoLongerAvailableHelpDialog.kt | 108 ++ .../ui/main/fragments/HomeFragment.kt | 522 ++++++ .../ui/main/fragments/MainModuleFragment.kt | 24 + .../ui/main/fragments/ManagementFragment.kt | 719 ++++++++ .../ui/main/layouts/MainLayoutManager.kt | 178 ++ .../ui/main/utils/MainModuleUtils.kt | 49 + .../ui/main/utils/RefreshErrorType.kt | 274 +++ .../ui/main/utils/StatementManager.kt | 86 + .../ui/main/widgets/FitTabletRecyclerView.kt | 36 + .../main/widgets/LocationItemTouchCallback.kt | 124 ++ .../widgets/NestedHorizontalRecyclerView.kt | 159 ++ .../ui/main/widgets/TextRelativeClock.kt | 112 ++ .../widgets/TrendRecyclerViewScrollBar.kt | 104 ++ .../ui/search/LoadableLocationStatus.kt | 23 + .../breezyweather/ui/search/SearchActivity.kt | 374 +++++ .../ui/search/SearchActivityRepository.kt | 205 +++ .../ui/search/SearchViewModel.kt | 140 ++ .../activities/CardDisplayManageActivity.kt | 231 +++ .../DailyTrendDisplayManageActivity.kt | 221 +++ .../activities/DependenciesActivity.kt | 69 + .../HourlyTrendDisplayManageActivity.kt | 221 +++ .../activities/PreviewIconActivity.kt | 236 +++ .../activities/PrivacyPolicyActivity.kt | 117 ++ .../settings/activities/SettingsActivity.kt | 361 ++++ .../settings/activities/WorkerInfoActivity.kt | 200 +++ .../settings/adapters/CardDisplayAdapter.kt | 109 ++ .../adapters/DailyTrendDisplayAdapter.kt | 95 ++ .../adapters/HourlyTrendDisplayAdapter.kt | 96 ++ .../settings/adapters/WeatherIconAdapter.kt | 137 ++ .../ui/settings/compose/AppBar.kt | 44 + .../compose/AppearanceSettingsScreen.kt | 352 ++++ .../BackgroundUpdatesSettingsScreen.kt | 302 ++++ .../settings/compose/DebugSettingsScreen.kt | 200 +++ .../compose/LocationSettingsScreen.kt | 327 ++++ .../compose/MainScreenSettingsScreen.kt | 245 +++ .../settings/compose/ModulesSettingsScreen.kt | 419 +++++ .../compose/NotificationsSettingsScreen.kt | 239 +++ .../ui/settings/compose/RootSettingsScreen.kt | 153 ++ .../settings/compose/SettingsScreenRouter.kt | 30 + .../ui/settings/compose/UnitSettingsScreen.kt | 264 +++ .../compose/WeatherSourcesSettingsScreen.kt | 231 +++ .../ui/settings/dialogs/AdaptiveIconDialog.kt | 66 + .../settings/dialogs/AnimatableIconDialog.kt | 63 + .../ui/settings/dialogs/MinimalIconDialog.kt | 67 + .../ui/settings/preference/PreferenceItems.kt | 164 ++ .../ui/settings/preference/PreferenceToken.kt | 88 + .../composables/EditTextPreference.kt | 227 +++ .../preference/composables/ListPreference.kt | 462 ++++++ .../composables/MultiListPreference.kt | 379 +++++ .../preference/composables/Preference.kt | 227 +++ .../composables/PreferenceScreen.kt | 38 + .../preference/composables/Section.kt | 51 + .../composables/SwitchPreference.kt | 224 +++ .../composables/TimePickerPreference.kt | 304 ++++ .../breezyweather/ui/theme/ThemeManager.kt | 117 ++ .../breezyweather/ui/theme/compose/Color.kt | 27 + .../breezyweather/ui/theme/compose/Theme.kt | 71 + .../breezyweather/ui/theme/compose/Type.kt | 49 + .../ui/theme/resource/ResourceHelper.kt | 199 +++ .../resource/ResourcesProviderFactory.kt | 72 + .../providers/ChronusResourceProvider.kt | 212 +++ .../providers/DefaultResourceProvider.kt | 288 ++++ .../providers/IconPackResourcesProvider.kt | 542 ++++++ .../resource/providers/ResourceProvider.kt | 78 + .../ui/theme/resource/utils/Config.kt | 25 + .../ui/theme/resource/utils/Constants.kt | 117 ++ .../ui/theme/resource/utils/ResourceUtils.kt | 42 + .../ui/theme/resource/utils/XmlHelper.kt | 75 + .../weatherView/WeatherViewController.kt | 87 + .../wallpaper/LiveWallpaperConfigActivity.kt | 351 ++++ .../wallpaper/LiveWallpaperConfigManager.kt | 49 + .../wallpaper/MaterialLiveWallpaperService.kt | 443 +++++ app/src/main/res/anim/slide_in_left.xml | 5 + app/src/main/res/anim/slide_in_right.xml | 5 + app/src/main/res/anim/slide_out_left.xml | 5 + app/src/main/res/anim/slide_out_right.xml | 5 + app/src/main/res/animator/start_shine_1.xml | 26 + app/src/main/res/animator/start_shine_2.xml | 26 + app/src/main/res/animator/touch_raise.xml | 42 + .../main/res/animator/weather_clear_day_1.xml | 21 + .../main/res/animator/weather_clear_day_2.xml | 28 + .../res/animator/weather_clear_night_1.xml | 53 + .../main/res/animator/weather_cloudy_1.xml | 17 + .../main/res/animator/weather_cloudy_2.xml | 17 + app/src/main/res/animator/weather_fog_1.xml | 39 + app/src/main/res/animator/weather_fog_2.xml | 39 + app/src/main/res/animator/weather_fog_3.xml | 18 + app/src/main/res/animator/weather_hail_1.xml | 65 + app/src/main/res/animator/weather_hail_2.xml | 32 + app/src/main/res/animator/weather_hail_3.xml | 32 + app/src/main/res/animator/weather_haze_1.xml | 29 + app/src/main/res/animator/weather_haze_2.xml | 29 + app/src/main/res/animator/weather_haze_3.xml | 29 + .../animator/weather_partly_cloudy_day_1.xml | 17 + .../animator/weather_partly_cloudy_day_2.xml | 21 + .../animator/weather_partly_cloudy_day_3.xml | 28 + .../weather_partly_cloudy_night_1.xml | 17 + .../weather_partly_cloudy_night_2.xml | 53 + app/src/main/res/animator/weather_rain_1.xml | 53 + app/src/main/res/animator/weather_rain_2.xml | 49 + app/src/main/res/animator/weather_rain_3.xml | 49 + app/src/main/res/animator/weather_sleet_1.xml | 53 + app/src/main/res/animator/weather_sleet_2.xml | 49 + app/src/main/res/animator/weather_sleet_3.xml | 37 + app/src/main/res/animator/weather_snow_1.xml | 53 + app/src/main/res/animator/weather_snow_2.xml | 37 + app/src/main/res/animator/weather_snow_3.xml | 37 + .../main/res/animator/weather_thunder_1.xml | 65 + .../main/res/animator/weather_thunder_2.xml | 55 + .../res/animator/weather_thunderstorm_1.xml | 65 + .../res/animator/weather_thunderstorm_2.xml | 49 + .../res/animator/weather_thunderstorm_3.xml | 49 + app/src/main/res/animator/weather_wind_1.xml | 71 + app/src/main/res/drawable/arrow_east.xml | 11 + app/src/main/res/drawable/arrow_north.xml | 11 + .../main/res/drawable/arrow_north_east.xml | 11 + .../main/res/drawable/arrow_north_west.xml | 11 + app/src/main/res/drawable/arrow_south.xml | 11 + .../main/res/drawable/arrow_south_east.xml | 11 + .../main/res/drawable/arrow_south_west.xml | 11 + app/src/main/res/drawable/arrow_west.xml | 11 + app/src/main/res/drawable/clock_dial_dark.png | Bin 0 -> 5154 bytes .../main/res/drawable/clock_dial_light.png | Bin 0 -> 5154 bytes app/src/main/res/drawable/clock_hour_dark.png | Bin 0 -> 275 bytes .../main/res/drawable/clock_hour_light.png | Bin 0 -> 275 bytes .../main/res/drawable/clock_minute_dark.png | Bin 0 -> 315 bytes .../main/res/drawable/clock_minute_light.png | Bin 0 -> 315 bytes app/src/main/res/drawable/clock_shape.xml | 10 + .../main/res/drawable/humidity_percent_30.xml | 13 + .../main/res/drawable/humidity_percent_50.xml | 13 + .../main/res/drawable/humidity_percent_7.xml | 13 + .../main/res/drawable/humidity_percent_75.xml | 13 + .../main/res/drawable/humidity_percent_90.xml | 13 + app/src/main/res/drawable/ic_about.xml | 11 + app/src/main/res/drawable/ic_alert.xml | 11 + app/src/main/res/drawable/ic_allergy.xml | 10 + .../res/drawable/ic_arrow_downward_alt.xml | 10 + .../main/res/drawable/ic_arrow_upward_alt.xml | 10 + app/src/main/res/drawable/ic_bug_report.xml | 10 + app/src/main/res/drawable/ic_calendar.xml | 10 + app/src/main/res/drawable/ic_circle.xml | 10 + app/src/main/res/drawable/ic_close.xml | 11 + app/src/main/res/drawable/ic_cloud.xml | 11 + app/src/main/res/drawable/ic_code.xml | 10 + app/src/main/res/drawable/ic_contract.xml | 11 + app/src/main/res/drawable/ic_delete.xml | 11 + .../res/drawable/ic_device_thermostat.xml | 10 + app/src/main/res/drawable/ic_dew_point.xml | 10 + app/src/main/res/drawable/ic_drag.xml | 11 + app/src/main/res/drawable/ic_edit.xml | 10 + app/src/main/res/drawable/ic_equal.xml | 10 + app/src/main/res/drawable/ic_error.xml | 10 + app/src/main/res/drawable/ic_eye.xml | 11 + app/src/main/res/drawable/ic_factory.xml | 10 + app/src/main/res/drawable/ic_forum.xml | 10 + app/src/main/res/drawable/ic_gauge.xml | 11 + app/src/main/res/drawable/ic_help.xml | 11 + app/src/main/res/drawable/ic_home.xml | 10 + .../res/drawable/ic_humidity_percentage.xml | 10 + app/src/main/res/drawable/ic_launcher.webp | Bin 0 -> 4516 bytes .../res/drawable/ic_launcher_foreground.xml | 48 + .../res/drawable/ic_launcher_monochrome.xml | 139 ++ .../main/res/drawable/ic_launcher_round.webp | Bin 0 -> 10664 bytes app/src/main/res/drawable/ic_list.xml | 10 + app/src/main/res/drawable/ic_location.xml | 11 + app/src/main/res/drawable/ic_mode_cool.xml | 10 + app/src/main/res/drawable/ic_mode_heat.xml | 10 + app/src/main/res/drawable/ic_more_vert.xml | 10 + .../main/res/drawable/ic_notifications.xml | 10 + app/src/main/res/drawable/ic_open_in_new.xml | 11 + app/src/main/res/drawable/ic_palette.xml | 10 + .../main/res/drawable/ic_precipitation.xml | 11 + app/src/main/res/drawable/ic_replay.xml | 11 + .../res/drawable/ic_running_in_background.xml | 11 + app/src/main/res/drawable/ic_schedule.xml | 10 + app/src/main/res/drawable/ic_search.xml | 11 + app/src/main/res/drawable/ic_settings.xml | 11 + app/src/main/res/drawable/ic_shield_lock.xml | 10 + .../res/drawable/ic_sunshine_duration.xml | 13 + app/src/main/res/drawable/ic_sync.xml | 10 + app/src/main/res/drawable/ic_time.xml | 11 + app/src/main/res/drawable/ic_toolbar_back.xml | 12 + app/src/main/res/drawable/ic_top.xml | 11 + app/src/main/res/drawable/ic_twilight.xml | 10 + app/src/main/res/drawable/ic_umbrella.xml | 10 + app/src/main/res/drawable/ic_uv.xml | 11 + app/src/main/res/drawable/ic_warning.xml | 10 + app/src/main/res/drawable/ic_water.xml | 11 + .../main/res/drawable/ic_water_percent.xml | 11 + app/src/main/res/drawable/ic_widgets.xml | 10 + app/src/main/res/drawable/ic_wind.xml | 11 + .../res/drawable/live_wallpaper_thumbnail.png | Bin 0 -> 9577 bytes .../drawable/selectable_item_background.xml | 7 + .../selectable_item_background_borderless.xml | 3 + .../main/res/drawable/shortcuts_clear_day.png | Bin 0 -> 7339 bytes .../shortcuts_clear_day_foreground.png | Bin 0 -> 12596 bytes .../res/drawable/shortcuts_clear_night.png | Bin 0 -> 4846 bytes .../shortcuts_clear_night_foreground.png | Bin 0 -> 8787 bytes .../main/res/drawable/shortcuts_cloudy.png | Bin 0 -> 6765 bytes .../drawable/shortcuts_cloudy_foreground.png | Bin 0 -> 19342 bytes app/src/main/res/drawable/shortcuts_fog.png | Bin 0 -> 2403 bytes .../res/drawable/shortcuts_fog_foreground.png | Bin 0 -> 2583 bytes app/src/main/res/drawable/shortcuts_hail.png | Bin 0 -> 6194 bytes .../drawable/shortcuts_hail_foreground.png | Bin 0 -> 18166 bytes app/src/main/res/drawable/shortcuts_haze.png | Bin 0 -> 4567 bytes .../drawable/shortcuts_haze_foreground.png | Bin 0 -> 7300 bytes .../drawable/shortcuts_partly_cloudy_day.png | Bin 0 -> 9287 bytes ...shortcuts_partly_cloudy_day_foreground.png | Bin 0 -> 19145 bytes .../shortcuts_partly_cloudy_night.png | Bin 0 -> 6478 bytes ...ortcuts_partly_cloudy_night_foreground.png | Bin 0 -> 14018 bytes app/src/main/res/drawable/shortcuts_rain.png | Bin 0 -> 6315 bytes .../drawable/shortcuts_rain_foreground.png | Bin 0 -> 18765 bytes .../main/res/drawable/shortcuts_refresh.png | Bin 0 -> 2583 bytes .../drawable/shortcuts_refresh_foreground.png | Bin 0 -> 4570 bytes app/src/main/res/drawable/shortcuts_sleet.png | Bin 0 -> 6164 bytes .../drawable/shortcuts_sleet_foreground.png | Bin 0 -> 18102 bytes app/src/main/res/drawable/shortcuts_snow.png | Bin 0 -> 5983 bytes .../drawable/shortcuts_snow_foreground.png | Bin 0 -> 17524 bytes .../main/res/drawable/shortcuts_thunder.png | Bin 0 -> 6764 bytes .../drawable/shortcuts_thunder_foreground.png | Bin 0 -> 18601 bytes .../res/drawable/shortcuts_thunderstorm.png | Bin 0 -> 7247 bytes .../shortcuts_thunderstorm_foreground.png | Bin 0 -> 20597 bytes app/src/main/res/drawable/shortcuts_wind.png | Bin 0 -> 4531 bytes .../drawable/shortcuts_wind_foreground.png | Bin 0 -> 7612 bytes app/src/main/res/drawable/star_1.png | Bin 0 -> 801 bytes app/src/main/res/drawable/star_2.png | Bin 0 -> 896 bytes app/src/main/res/drawable/uv_extreme.xml | 34 + app/src/main/res/drawable/uv_high.xml | 34 + app/src/main/res/drawable/uv_low.xml | 34 + app/src/main/res/drawable/uv_moderate.xml | 34 + app/src/main/res/drawable/uv_unknown.xml | 36 + app/src/main/res/drawable/uv_very_high.xml | 34 + .../main/res/drawable/visibility_shape.xml | 20 + .../main/res/drawable/weather_clear_day.png | Bin 0 -> 10612 bytes .../main/res/drawable/weather_clear_day_1.png | Bin 0 -> 6145 bytes .../main/res/drawable/weather_clear_day_2.png | Bin 0 -> 9331 bytes .../drawable/weather_clear_day_mini_dark.png | Bin 0 -> 2511 bytes .../drawable/weather_clear_day_mini_grey.png | Bin 0 -> 2511 bytes .../drawable/weather_clear_day_mini_light.png | Bin 0 -> 2511 bytes .../drawable/weather_clear_day_mini_xml.xml | 11 + .../main/res/drawable/weather_clear_night.png | Bin 0 -> 7085 bytes .../weather_clear_night_mini_dark.png | Bin 0 -> 3875 bytes .../weather_clear_night_mini_grey.png | Bin 0 -> 3875 bytes .../weather_clear_night_mini_light.png | Bin 0 -> 3875 bytes .../drawable/weather_clear_night_mini_xml.xml | 11 + app/src/main/res/drawable/weather_cloudy.png | Bin 0 -> 21567 bytes .../main/res/drawable/weather_cloudy_1.png | Bin 0 -> 13532 bytes .../main/res/drawable/weather_cloudy_2.png | Bin 0 -> 17162 bytes .../res/drawable/weather_cloudy_mini_dark.png | Bin 0 -> 2448 bytes .../res/drawable/weather_cloudy_mini_grey.png | Bin 0 -> 2448 bytes .../drawable/weather_cloudy_mini_light.png | Bin 0 -> 2448 bytes .../res/drawable/weather_cloudy_mini_xml.xml | 11 + app/src/main/res/drawable/weather_fog.png | Bin 0 -> 3682 bytes .../res/drawable/weather_fog_mini_dark.png | Bin 0 -> 2314 bytes .../res/drawable/weather_fog_mini_grey.png | Bin 0 -> 2314 bytes .../res/drawable/weather_fog_mini_light.png | Bin 0 -> 2314 bytes .../res/drawable/weather_fog_mini_xml.xml | 11 + app/src/main/res/drawable/weather_hail.png | Bin 0 -> 21530 bytes app/src/main/res/drawable/weather_hail_1.png | Bin 0 -> 17162 bytes app/src/main/res/drawable/weather_hail_2.png | Bin 0 -> 2151 bytes app/src/main/res/drawable/weather_hail_3.png | Bin 0 -> 2874 bytes .../res/drawable/weather_hail_mini_dark.png | Bin 0 -> 3086 bytes .../res/drawable/weather_hail_mini_grey.png | Bin 0 -> 3086 bytes .../res/drawable/weather_hail_mini_light.png | Bin 0 -> 3086 bytes .../res/drawable/weather_hail_mini_xml.xml | 11 + app/src/main/res/drawable/weather_haze.png | Bin 0 -> 13133 bytes app/src/main/res/drawable/weather_haze_1.png | Bin 0 -> 6787 bytes app/src/main/res/drawable/weather_haze_2.png | Bin 0 -> 11204 bytes app/src/main/res/drawable/weather_haze_3.png | Bin 0 -> 7523 bytes .../res/drawable/weather_haze_mini_dark.png | Bin 0 -> 960 bytes .../res/drawable/weather_haze_mini_grey.png | Bin 0 -> 960 bytes .../res/drawable/weather_haze_mini_light.png | Bin 0 -> 960 bytes .../res/drawable/weather_haze_mini_xml.xml | 11 + .../drawable/weather_partly_cloudy_day.png | Bin 0 -> 18310 bytes .../drawable/weather_partly_cloudy_day_1.png | Bin 0 -> 8614 bytes .../drawable/weather_partly_cloudy_day_2.png | Bin 0 -> 6145 bytes .../drawable/weather_partly_cloudy_day_3.png | Bin 0 -> 9331 bytes .../weather_partly_cloudy_day_mini_dark.png | Bin 0 -> 3526 bytes .../weather_partly_cloudy_day_mini_grey.png | Bin 0 -> 3526 bytes .../weather_partly_cloudy_day_mini_light.png | Bin 0 -> 3526 bytes .../weather_partly_cloudy_day_mini_xml.xml | 11 + .../drawable/weather_partly_cloudy_night.png | Bin 0 -> 13321 bytes .../weather_partly_cloudy_night_1.png | Bin 0 -> 7114 bytes .../weather_partly_cloudy_night_2.png | Bin 0 -> 7085 bytes .../weather_partly_cloudy_night_mini_dark.png | Bin 0 -> 3203 bytes .../weather_partly_cloudy_night_mini_grey.png | Bin 0 -> 3203 bytes ...weather_partly_cloudy_night_mini_light.png | Bin 0 -> 3203 bytes .../weather_partly_cloudy_night_mini_xml.xml | 11 + app/src/main/res/drawable/weather_rain.png | Bin 0 -> 21516 bytes app/src/main/res/drawable/weather_rain_1.png | Bin 0 -> 17162 bytes app/src/main/res/drawable/weather_rain_2.png | Bin 0 -> 2542 bytes app/src/main/res/drawable/weather_rain_3.png | Bin 0 -> 2463 bytes .../res/drawable/weather_rain_mini_dark.png | Bin 0 -> 3315 bytes .../res/drawable/weather_rain_mini_grey.png | Bin 0 -> 3315 bytes .../res/drawable/weather_rain_mini_light.png | Bin 0 -> 3315 bytes .../res/drawable/weather_rain_mini_xml.xml | 11 + app/src/main/res/drawable/weather_sleet.png | Bin 0 -> 20949 bytes app/src/main/res/drawable/weather_sleet_1.png | Bin 0 -> 17162 bytes app/src/main/res/drawable/weather_sleet_2.png | Bin 0 -> 1784 bytes app/src/main/res/drawable/weather_sleet_3.png | Bin 0 -> 2463 bytes .../res/drawable/weather_sleet_mini_dark.png | Bin 0 -> 3747 bytes .../res/drawable/weather_sleet_mini_grey.png | Bin 0 -> 3747 bytes .../res/drawable/weather_sleet_mini_light.png | Bin 0 -> 3747 bytes .../res/drawable/weather_sleet_mini_xml.xml | 11 + app/src/main/res/drawable/weather_snow.png | Bin 0 -> 20002 bytes app/src/main/res/drawable/weather_snow_1.png | Bin 0 -> 17162 bytes app/src/main/res/drawable/weather_snow_2.png | Bin 0 -> 1784 bytes app/src/main/res/drawable/weather_snow_3.png | Bin 0 -> 1712 bytes .../res/drawable/weather_snow_mini_dark.png | Bin 0 -> 3441 bytes .../res/drawable/weather_snow_mini_grey.png | Bin 0 -> 3441 bytes .../res/drawable/weather_snow_mini_light.png | Bin 0 -> 3441 bytes .../res/drawable/weather_snow_mini_xml.xml | 11 + app/src/main/res/drawable/weather_thunder.png | Bin 0 -> 20139 bytes .../main/res/drawable/weather_thunder_1.png | Bin 0 -> 17162 bytes .../main/res/drawable/weather_thunder_2.png | Bin 0 -> 3552 bytes .../drawable/weather_thunder_mini_dark.png | Bin 0 -> 3033 bytes .../drawable/weather_thunder_mini_grey.png | Bin 0 -> 3033 bytes .../drawable/weather_thunder_mini_light.png | Bin 0 -> 3033 bytes .../res/drawable/weather_thunder_mini_xml.xml | 11 + .../res/drawable/weather_thunderstorm.png | Bin 0 -> 22357 bytes .../res/drawable/weather_thunderstorm_1.png | Bin 0 -> 17162 bytes .../res/drawable/weather_thunderstorm_2.png | Bin 0 -> 3552 bytes .../res/drawable/weather_thunderstorm_3.png | Bin 0 -> 2463 bytes .../weather_thunderstorm_mini_dark.png | Bin 0 -> 3341 bytes .../weather_thunderstorm_mini_grey.png | Bin 0 -> 3341 bytes .../weather_thunderstorm_mini_light.png | Bin 0 -> 3341 bytes .../weather_thunderstorm_mini_xml.xml | 11 + app/src/main/res/drawable/weather_wind.png | Bin 0 -> 11871 bytes .../res/drawable/weather_wind_mini_dark.png | Bin 0 -> 2080 bytes .../res/drawable/weather_wind_mini_grey.png | Bin 0 -> 2080 bytes .../res/drawable/weather_wind_mini_light.png | Bin 0 -> 2080 bytes .../res/drawable/weather_wind_mini_xml.xml | 11 + .../main/res/drawable/widget_card_dark.xml | 10 + .../drawable/widget_card_follow_system.xml | 10 + .../main/res/drawable/widget_card_light.xml | 10 + .../res/drawable/widget_clock_day_details.png | Bin 0 -> 12281 bytes .../drawable/widget_clock_day_horizontal.png | Bin 0 -> 10516 bytes .../drawable/widget_clock_day_vertical.png | Bin 0 -> 12872 bytes .../res/drawable/widget_clock_day_week.png | Bin 0 -> 16753 bytes app/src/main/res/drawable/widget_day.png | Bin 0 -> 11012 bytes app/src/main/res/drawable/widget_day_week.png | Bin 0 -> 15010 bytes .../res/drawable/widget_m3_background.xml | 9 + .../drawable/widget_m3_current_background.xml | 10 + .../main/res/drawable/widget_multi_city.png | Bin 0 -> 9470 bytes app/src/main/res/drawable/widget_text.png | Bin 0 -> 2205 bytes .../main/res/drawable/widget_trend_daily.png | Bin 0 -> 7983 bytes .../main/res/drawable/widget_trend_hourly.png | Bin 0 -> 6511 bytes app/src/main/res/drawable/widget_week.png | Bin 0 -> 9834 bytes app/src/main/res/drawable/wind_arrow.xml | 13 + app/src/main/res/drawable/wind_variable.xml | 15 + .../main/res/font-v26/robotoflex_family.xml | 7 + app/src/main/res/font/asap_condensed_bold.ttf | Bin 0 -> 111544 bytes app/src/main/res/font/robotoflex.ttf | Bin 0 -> 1684624 bytes app/src/main/res/font/robotoflex_family.xml | 7 + .../layout-v26/container_main_pressure.xml | 150 ++ .../main/res/layout-w640dp/activity_main.xml | 49 + .../main/res/layout-w640dp/fragment_home.xml | 92 ++ .../layout/activity_card_display_manage.xml | 57 + .../activity_daily_trend_display_manage.xml | 57 + .../activity_hourly_trend_display_manage.xml | 57 + app/src/main/res/layout/activity_main.xml | 31 + .../main/res/layout/activity_preview_icon.xml | 39 + .../res/layout/activity_widget_config.xml | 380 +++++ .../res/layout/container_main_air_quality.xml | 134 ++ .../main/res/layout/container_main_alert.xml | 17 + .../main/res/layout/container_main_astro.xml | 151 ++ .../main/res/layout/container_main_clock.xml | 107 ++ .../container_main_daily_trend_card.xml | 71 + .../main/res/layout/container_main_header.xml | 139 ++ .../container_main_hourly_trend_card.xml | 71 + .../res/layout/container_main_humidity.xml | 155 ++ .../main/res/layout/container_main_pollen.xml | 193 +++ .../layout/container_main_precipitation.xml | 125 ++ ...tainer_main_precipitation_nowcast_card.xml | 62 + .../res/layout/container_main_pressure.xml | 132 ++ app/src/main/res/layout/container_main_uv.xml | 110 ++ .../res/layout/container_main_visibility.xml | 125 ++ .../main/res/layout/container_main_wind.xml | 124 ++ .../res/layout/container_snackbar_layout.xml | 9 + .../layout/container_snackbar_layout_card.xml | 9 + .../container_snackbar_layout_inner.xml | 54 + .../container_snackbar_layout_inner_card.xml | 55 + .../main/res/layout/dialog_adaptive_icon.xml | 19 + .../res/layout/dialog_animatable_icon.xml | 20 + app/src/main/res/layout/dialog_error_help.xml | 5 + .../main/res/layout/dialog_location_help.xml | 5 + .../main/res/layout/dialog_minimal_icon.xml | 55 + ...dialog_source_no_longer_available_help.xml | 5 + app/src/main/res/layout/fragment_home.xml | 95 ++ app/src/main/res/layout/item_button.xml | 11 + app/src/main/res/layout/item_card_display.xml | 66 + app/src/main/res/layout/item_line.xml | 5 + .../main/res/layout/item_location_card.xml | 97 ++ app/src/main/res/layout/item_pollen_daily.xml | 13 + app/src/main/res/layout/item_tag.xml | 11 + app/src/main/res/layout/item_trend_daily.xml | 8 + app/src/main/res/layout/item_trend_hourly.xml | 8 + app/src/main/res/layout/item_weather_icon.xml | 19 + .../res/layout/item_weather_icon_title.xml | 12 + app/src/main/res/layout/notification_base.xml | 78 + app/src/main/res/layout/notification_big.xml | 205 +++ .../res/layout/notification_multi_city.xml | 110 ++ .../res/layout/widget_clock_day_details.xml | 278 ++++ .../layout/widget_clock_day_details_card.xml | 231 +++ .../layout/widget_clock_day_horizontal.xml | 212 +++ .../widget_clock_day_horizontal_card.xml | 181 ++ .../main/res/layout/widget_clock_day_mini.xml | 224 +++ .../res/layout/widget_clock_day_mini_card.xml | 197 +++ .../res/layout/widget_clock_day_rectangle.xml | 251 +++ .../widget_clock_day_rectangle_card.xml | 219 +++ .../res/layout/widget_clock_day_symmetry.xml | 250 +++ .../layout/widget_clock_day_symmetry_card.xml | 223 +++ .../main/res/layout/widget_clock_day_temp.xml | 228 +++ .../res/layout/widget_clock_day_temp_card.xml | 197 +++ .../main/res/layout/widget_clock_day_tile.xml | 241 +++ .../res/layout/widget_clock_day_tile_card.xml | 210 +++ .../res/layout/widget_clock_day_vertical.xml | 237 +++ .../layout/widget_clock_day_vertical_card.xml | 210 +++ .../main/res/layout/widget_clock_day_week.xml | 457 ++++++ .../res/layout/widget_clock_day_week_card.xml | 386 +++++ app/src/main/res/layout/widget_day_mini.xml | 56 + .../main/res/layout/widget_day_mini_card.xml | 53 + app/src/main/res/layout/widget_day_nano.xml | 40 + .../main/res/layout/widget_day_nano_card.xml | 40 + app/src/main/res/layout/widget_day_oreo.xml | 50 + .../main/res/layout/widget_day_oreo_card.xml | 50 + app/src/main/res/layout/widget_day_pixel.xml | 66 + .../main/res/layout/widget_day_pixel_card.xml | 66 + .../main/res/layout/widget_day_rectangle.xml | 100 ++ .../res/layout/widget_day_rectangle_card.xml | 93 ++ .../main/res/layout/widget_day_symmetry.xml | 100 ++ .../res/layout/widget_day_symmetry_card.xml | 93 ++ app/src/main/res/layout/widget_day_temp.xml | 23 + .../main/res/layout/widget_day_temp_card.xml | 23 + app/src/main/res/layout/widget_day_tile.xml | 76 + .../main/res/layout/widget_day_tile_card.xml | 69 + .../main/res/layout/widget_day_vertical.xml | 103 ++ .../res/layout/widget_day_vertical_card.xml | 88 + .../res/layout/widget_day_week_rectangle.xml | 348 ++++ .../layout/widget_day_week_rectangle_card.xml | 301 ++++ .../res/layout/widget_day_week_symmetry.xml | 348 ++++ .../layout/widget_day_week_symmetry_card.xml | 301 ++++ .../main/res/layout/widget_day_week_tile.xml | 338 ++++ .../res/layout/widget_day_week_tile_card.xml | 290 ++++ app/src/main/res/layout/widget_init.xml | 21 + .../layout/widget_material_you_current.xml | 39 + .../widget_material_you_current_preview.xml | 51 + .../widget_material_you_forecast_1x1.xml | 21 + .../widget_material_you_forecast_2x1.xml | 39 + .../widget_material_you_forecast_2x2.xml | 108 ++ .../widget_material_you_forecast_3x1.xml | 100 ++ .../widget_material_you_forecast_3x2.xml | 146 ++ .../widget_material_you_forecast_4x1.xml | 143 ++ .../widget_material_you_forecast_4x2.xml | 379 +++++ .../widget_material_you_forecast_4x3.xml | 719 ++++++++ .../widget_material_you_forecast_5x2.xml | 422 +++++ .../widget_material_you_forecast_5x3.xml | 826 ++++++++++ .../layout/widget_multi_city_horizontal.xml | 145 ++ .../widget_multi_city_horizontal_card.xml | 126 ++ app/src/main/res/layout/widget_remote.xml | 26 + app/src/main/res/layout/widget_text.xml | 80 + app/src/main/res/layout/widget_text_end.xml | 81 + .../main/res/layout/widget_trend_daily.xml | 39 + .../main/res/layout/widget_trend_hourly.xml | 39 + app/src/main/res/layout/widget_week.xml | 260 +++ app/src/main/res/layout/widget_week_3.xml | 204 +++ .../main/res/layout/widget_week_3_card.xml | 181 ++ app/src/main/res/layout/widget_week_card.xml | 225 +++ .../main/res/menu-w640dp/activity_main.xml | 18 + app/src/main/res/menu/activity_main.xml | 18 + .../main/res/menu/activity_preview_icon.xml | 11 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1684 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 3362 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 1060 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 2168 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 2268 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 4932 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 3360 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 7642 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 4516 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 10664 bytes app/src/main/res/raw/breezytz_ar.json | 1 + app/src/main/res/raw/breezytz_au.json | 1 + app/src/main/res/raw/breezytz_br.json | 1 + app/src/main/res/raw/breezytz_ca.json | 1 + app/src/main/res/raw/breezytz_cd.json | 1 + app/src/main/res/raw/breezytz_cl.json | 1 + app/src/main/res/raw/breezytz_cn.json | 1 + app/src/main/res/raw/breezytz_cy.json | 1 + app/src/main/res/raw/breezytz_ec.json | 1 + app/src/main/res/raw/breezytz_es.json | 1 + app/src/main/res/raw/breezytz_fm.json | 1 + app/src/main/res/raw/breezytz_id.json | 1 + app/src/main/res/raw/breezytz_ki.json | 1 + app/src/main/res/raw/breezytz_kz.json | 1 + app/src/main/res/raw/breezytz_mn.json | 1 + app/src/main/res/raw/breezytz_mx.json | 1 + app/src/main/res/raw/breezytz_my.json | 1 + app/src/main/res/raw/breezytz_nz.json | 1 + app/src/main/res/raw/breezytz_pf.json | 1 + app/src/main/res/raw/breezytz_pg.json | 1 + app/src/main/res/raw/breezytz_ps.json | 1 + app/src/main/res/raw/breezytz_pt.json | 1 + app/src/main/res/raw/breezytz_ru.json | 1 + app/src/main/res/raw/breezytz_ua.json | 1 + app/src/main/res/raw/breezytz_us.json | 1 + app/src/main/res/raw/isrg_root_x1.pem | 31 + app/src/main/res/raw/isrg_root_x2.pem | 14 + .../res/raw/ne_50m_admin_0_countries.json | 1 + app/src/main/res/values-ar/strings.xml | 639 +++++++ app/src/main/res/values-be/strings.xml | 778 +++++++++ app/src/main/res/values-bg/strings.xml | 780 +++++++++ app/src/main/res/values-bn/strings.xml | 375 +++++ app/src/main/res/values-bs/strings.xml | 562 +++++++ app/src/main/res/values-ca/strings.xml | 579 +++++++ app/src/main/res/values-ckb/strings.xml | 135 ++ app/src/main/res/values-cs/strings.xml | 796 +++++++++ app/src/main/res/values-da/strings.xml | 616 +++++++ app/src/main/res/values-de/strings.xml | 791 +++++++++ app/src/main/res/values-el/strings.xml | 784 +++++++++ app/src/main/res/values-en-rAU/strings.xml | 13 + app/src/main/res/values-en-rCA/strings.xml | 11 + app/src/main/res/values-en-rGB/strings.xml | 13 + app/src/main/res/values-en-rUS/strings.xml | 13 + app/src/main/res/values-eo/strings.xml | 825 ++++++++++ app/src/main/res/values-es/strings.xml | 816 +++++++++ app/src/main/res/values-et/strings.xml | 780 +++++++++ app/src/main/res/values-eu/strings.xml | 605 +++++++ app/src/main/res/values-fa/strings.xml | 778 +++++++++ app/src/main/res/values-fi/strings.xml | 776 +++++++++ app/src/main/res/values-fr/strings.xml | 833 ++++++++++ app/src/main/res/values-ga/strings.xml | 778 +++++++++ app/src/main/res/values-gl/strings.xml | 289 ++++ app/src/main/res/values-he/strings.xml | 780 +++++++++ app/src/main/res/values-hi/strings.xml | 617 +++++++ app/src/main/res/values-hr/strings.xml | 780 +++++++++ app/src/main/res/values-hu/strings.xml | 784 +++++++++ app/src/main/res/values-ia/strings.xml | 24 + app/src/main/res/values-id/strings.xml | 768 +++++++++ app/src/main/res/values-is/strings.xml | 118 ++ app/src/main/res/values-it/strings.xml | 788 +++++++++ app/src/main/res/values-ja/strings.xml | 816 +++++++++ app/src/main/res/values-kab/strings.xml | 150 ++ app/src/main/res/values-ko/strings.xml | 530 ++++++ app/src/main/res/values-lt/strings.xml | 686 ++++++++ app/src/main/res/values-lv/strings.xml | 780 +++++++++ app/src/main/res/values-mk/strings.xml | 498 ++++++ app/src/main/res/values-mr/strings.xml | 130 ++ app/src/main/res/values-nb-rNO/strings.xml | 564 +++++++ app/src/main/res/values-night-v31/colors.xml | 43 + app/src/main/res/values-night-v34/colors.xml | 4 + app/src/main/res/values-night-v35/colors.xml | 5 + app/src/main/res/values-night/colors.xml | 56 + app/src/main/res/values-night/styles.xml | 47 + app/src/main/res/values-nl/strings.xml | 784 +++++++++ app/src/main/res/values-oc/strings.xml | 11 + app/src/main/res/values-pl/strings.xml | 791 +++++++++ app/src/main/res/values-pt-rBR/strings.xml | 843 ++++++++++ app/src/main/res/values-pt/strings.xml | 821 +++++++++ app/src/main/res/values-ro/strings.xml | 790 +++++++++ app/src/main/res/values-ru/strings.xml | 787 +++++++++ app/src/main/res/values-sk/strings.xml | 647 ++++++++ app/src/main/res/values-sl-rSI/strings.xml | 442 +++++ app/src/main/res/values-sr/strings.xml | 786 +++++++++ app/src/main/res/values-sv/strings.xml | 780 +++++++++ app/src/main/res/values-ta/strings.xml | 624 +++++++ app/src/main/res/values-th/strings.xml | 830 ++++++++++ app/src/main/res/values-tr/strings.xml | 786 +++++++++ app/src/main/res/values-uk/strings.xml | 778 +++++++++ app/src/main/res/values-v26/styles.xml | 8 + app/src/main/res/values-v28/styles.xml | 32 + app/src/main/res/values-v29/styles.xml | 32 + app/src/main/res/values-v31/colors.xml | 40 + app/src/main/res/values-v31/dimens.xml | 6 + app/src/main/res/values-v31/themes.xml | 13 + app/src/main/res/values-v34/colors.xml | 4 + app/src/main/res/values-v35/colors.xml | 5 + app/src/main/res/values-vi/strings.xml | 680 ++++++++ app/src/main/res/values-xhdpi/dimens.xml | 17 + app/src/main/res/values-xxhdpi/dimens.xml | 17 + app/src/main/res/values-xxxhdpi/dimens.xml | 17 + app/src/main/res/values-zh-rCN/strings.xml | 866 ++++++++++ app/src/main/res/values-zh-rHK/strings.xml | 854 ++++++++++ app/src/main/res/values-zh-rTW/strings.xml | 863 ++++++++++ app/src/main/res/values/arrays.xml | 489 ++++++ app/src/main/res/values/attrs.xml | 63 + app/src/main/res/values/colors.xml | 138 ++ app/src/main/res/values/dimens.xml | 88 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/ids.xml | 10 + app/src/main/res/values/keys.xml | 26 + app/src/main/res/values/strings.xml | 1294 +++++++++++++++ app/src/main/res/values/styles.xml | 319 ++++ app/src/main/res/values/themes.xml | 11 + .../res/xml-v28/widget_clock_day_details.xml | 13 + .../xml-v28/widget_clock_day_horizontal.xml | 13 + .../res/xml-v28/widget_clock_day_vertical.xml | 13 + .../res/xml-v28/widget_clock_day_week.xml | 13 + app/src/main/res/xml-v28/widget_day.xml | 13 + app/src/main/res/xml-v28/widget_day_week.xml | 13 + .../main/res/xml-v28/widget_multi_city.xml | 13 + app/src/main/res/xml-v28/widget_text.xml | 13 + .../main/res/xml-v28/widget_trend_daily.xml | 13 + .../main/res/xml-v28/widget_trend_hourly.xml | 13 + app/src/main/res/xml-v28/widget_week.xml | 13 + .../res/xml/icon_provider_animator_filter.xml | 76 + app/src/main/res/xml/icon_provider_config.xml | 14 + .../res/xml/icon_provider_drawable_filter.xml | 178 ++ .../res/xml/icon_provider_shortcut_filter.xml | 64 + .../res/xml/icon_provider_sun_moon_filter.xml | 7 + app/src/main/res/xml/live_wallpaper.xml | 5 + app/src/main/res/xml/provider_paths.xml | 6 + .../main/res/xml/widget_clock_day_details.xml | 12 + .../res/xml/widget_clock_day_horizontal.xml | 12 + .../res/xml/widget_clock_day_vertical.xml | 12 + .../main/res/xml/widget_clock_day_week.xml | 12 + app/src/main/res/xml/widget_day.xml | 12 + app/src/main/res/xml/widget_day_week.xml | 12 + .../res/xml/widget_material_you_current.xml | 15 + .../res/xml/widget_material_you_forecast.xml | 14 + app/src/main/res/xml/widget_multi_city.xml | 12 + app/src/main/res/xml/widget_text.xml | 12 + app/src/main/res/xml/widget_trend_daily.xml | 12 + app/src/main/res/xml/widget_trend_hourly.xml | 12 + app/src/main/res/xml/widget_week.xml | 12 + .../xml/network_security_config.xml | 14 + .../drawable-night/accu_icon.xml | 18 + app/src/res_nonfreenet/drawable/accu_icon.xml | 18 + .../xml/network_security_config.xml | 14 + .../breezyweather/sources/accu/AccuService.kt | 74 + .../sources/aemet/AemetService.kt | 60 + .../sources/android/AndroidGeocoderService.kt | 34 + .../sources/atmo/AtmoAuraService.kt | 85 + .../sources/atmo/AtmoFranceService.kt | 57 + .../sources/atmo/AtmoGrandEstService.kt | 75 + .../sources/atmo/AtmoHdfService.kt | 62 + .../breezyweather/sources/atmo/AtmoService.kt | 43 + .../sources/atmo/AtmoSudService.kt | 80 + .../sources/baiduip/BaiduIPLocationService.kt | 41 + .../breezyweather/sources/bmd/BmdService.kt | 61 + .../breezyweather/sources/bmkg/BmkgService.kt | 54 + .../sources/china/ChinaService.kt | 70 + .../breezyweather/sources/cwa/CwaService.kt | 69 + .../breezyweather/sources/dmi/DmiService.kt | 63 + .../breezyweather/sources/eccc/EcccService.kt | 56 + .../breezyweather/sources/ekuk/EkukService.kt | 54 + .../sources/epdhk/EpdHkService.kt | 41 + .../sources/geonames/GeoNamesService.kt | 39 + .../sources/geosphereat/GeoSphereAtService.kt | 41 + .../breezyweather/sources/hko/HkoService.kt | 63 + .../ilmateenistus/IlmateenistusService.kt | 50 + .../breezyweather/sources/imd/ImdService.kt | 39 + .../breezyweather/sources/ims/ImsService.kt | 63 + .../breezyweather/sources/ipma/IpmaService.kt | 63 + .../sources/ipsb/IpSbLocationService.kt | 32 + .../breezyweather/sources/jma/JmaService.kt | 63 + .../breezyweather/sources/lhmt/LhmtService.kt | 63 + .../sources/lvgmc/LvgmcService.kt | 61 + .../sources/meteoam/MeteoAmService.kt | 50 + .../sources/meteolux/MeteoLuxService.kt | 50 + .../sources/metie/MetIeService.kt | 69 + .../sources/metno/MetNoService.kt | 41 + .../sources/metoffice/MetOfficeService.kt | 47 + .../org/breezyweather/sources/mf/MfService.kt | 69 + .../breezyweather/sources/mgm/MgmService.kt | 63 + .../sources/namem/NamemService.kt | 61 + .../breezyweather/sources/ncdr/NcdrService.kt | 54 + .../breezyweather/sources/ncei/NceiService.kt | 51 + .../breezyweather/sources/nlsc/NlscService.kt | 39 + .../breezyweather/sources/nws/NwsService.kt | 63 + .../sources/openweather/OpenWeatherService.kt | 44 + .../sources/pagasa/PagasaService.kt | 52 + .../sources/polleninfo/PollenInfoService.kt | 46 + .../breezyweather/sources/smg/SmgService.kt | 41 + .../breezyweather/sources/smhi/SmhiService.kt | 41 + .../sources/veduris/VedurIsService.kt | 63 + .../WmoSevereWeatherService.kt | 41 + .../sources/accu/AccuDeveloperApi.kt | 85 + .../sources/accu/AccuEnterpriseApi.kt | 67 + .../breezyweather/sources/accu/AccuService.kt | 1079 ++++++++++++ .../accu/json/AccuAirQualityConcentration.kt | 24 + .../sources/accu/json/AccuAirQualityData.kt | 28 + .../accu/json/AccuAirQualityPollutant.kt | 25 + .../sources/accu/json/AccuAirQualityResult.kt | 24 + .../sources/accu/json/AccuAlertArea.kt | 26 + .../sources/accu/json/AccuAlertDescription.kt | 24 + .../sources/accu/json/AccuAlertResult.kt | 36 + .../sources/accu/json/AccuClimoNormals.kt | 24 + .../accu/json/AccuClimoNormalsTemperatures.kt | 25 + .../accu/json/AccuClimoSummaryResult.kt | 27 + .../sources/accu/json/AccuColor.kt | 26 + .../json/AccuCurrentPrecipitationSummary.kt | 24 + .../sources/accu/json/AccuCurrentResult.kt | 47 + .../AccuCurrentTemperaturePast24HourRange.kt | 25 + .../json/AccuCurrentTemperatureSummary.kt | 24 + .../sources/accu/json/AccuCurrentWind.kt | 25 + .../accu/json/AccuCurrentWindDirection.kt | 25 + .../sources/accu/json/AccuCurrentWindGust.kt | 24 + .../accu/json/AccuForecastAirAndPollen.kt | 28 + .../accu/json/AccuForecastDailyForecast.kt | 33 + .../accu/json/AccuForecastDailyResult.kt | 28 + .../accu/json/AccuForecastDegreeDaySummary.kt | 25 + .../sources/accu/json/AccuForecastHalfDay.kt | 43 + .../sources/accu/json/AccuForecastHeadline.kt | 24 + .../accu/json/AccuForecastHourlyResult.kt | 51 + .../accu/json/AccuForecastTemperature.kt | 25 + .../sources/accu/json/AccuForecastWind.kt | 25 + .../accu/json/AccuForecastWindDirection.kt | 25 + .../sources/accu/json/AccuLocationArea.kt | 26 + .../accu/json/AccuLocationGeoPosition.kt | 25 + .../sources/accu/json/AccuLocationResult.kt | 35 + .../sources/accu/json/AccuLocationTimeZone.kt | 24 + .../sources/accu/json/AccuMinutelyInterval.kt | 26 + .../sources/accu/json/AccuMinutelyResult.kt | 25 + .../sources/accu/json/AccuMinutelySummary.kt | 24 + .../sources/accu/json/AccuValue.kt | 25 + .../sources/accu/json/AccuValueContainer.kt | 24 + .../accu/preferences/AccuDaysPreference.kt | 47 + .../accu/preferences/AccuHoursPreference.kt | 49 + .../breezyweather/sources/aemet/AemetApi.kt | 84 + .../sources/aemet/AemetService.kt | 700 ++++++++ .../sources/aemet/json/AemetApiResult.kt | 24 + .../sources/aemet/json/AemetCurrentResult.kt | 32 + .../sources/aemet/json/AemetDailyData.kt | 29 + .../sources/aemet/json/AemetDailyDay.kt | 32 + .../aemet/json/AemetDailyPrediction.kt | 24 + .../sources/aemet/json/AemetDailyResult.kt | 24 + .../sources/aemet/json/AemetForecastData.kt | 27 + .../sources/aemet/json/AemetHourlyDay.kt | 36 + .../aemet/json/AemetHourlyPrediction.kt | 24 + .../sources/aemet/json/AemetHourlyResult.kt | 24 + .../sources/aemet/json/AemetNormalsResult.kt | 27 + .../sources/aemet/json/AemetStationsResult.kt | 28 + .../sources/android/AndroidGeocoderService.kt | 54 + .../org/breezyweather/sources/atmo/AtmoApi.kt | 39 + .../sources/atmo/AtmoAuraService.kt | 101 ++ .../sources/atmo/AtmoFranceApi.kt | 40 + .../sources/atmo/AtmoFranceService.kt | 242 +++ .../sources/atmo/AtmoGrandEstService.kt | 92 ++ .../sources/atmo/AtmoHdfService.kt | 79 + .../breezyweather/sources/atmo/AtmoService.kt | 169 ++ .../sources/atmo/AtmoSudService.kt | 96 ++ .../org/breezyweather/sources/atmo/GeoApi.kt | 34 + .../atmo/json/AtmoFrancePollenFeature.kt | 24 + .../atmo/json/AtmoFrancePollenProperties.kt | 30 + .../atmo/json/AtmoFrancePollenResult.kt | 24 + .../sources/atmo/json/AtmoPointHoraire.kt | 28 + .../sources/atmo/json/AtmoPointPolluant.kt | 25 + .../sources/atmo/json/AtmoPointResult.kt | 24 + .../sources/atmo/json/GeoFeature.kt | 24 + .../sources/atmo/json/GeoProperties.kt | 24 + .../sources/atmo/json/GeoResult.kt | 24 + .../sources/baiduip/BaiduIPLocationApi.kt | 30 + .../sources/baiduip/BaiduIPLocationService.kt | 124 ++ .../baiduip/json/BaiduIPLocationContent.kt | 24 + .../json/BaiduIPLocationContentPoint.kt | 25 + .../baiduip/json/BaiduIPLocationResult.kt | 25 + .../org/breezyweather/sources/bmd/BmdApi.kt | 36 + .../breezyweather/sources/bmd/BmdService.kt | 417 +++++ .../breezyweather/sources/bmd/json/BmdData.kt | 28 + .../sources/bmd/json/BmdForecast.kt | 30 + .../sources/bmd/json/BmdForecastData.kt | 30 + .../sources/bmd/json/BmdForecastResult.kt | 24 + .../org/breezyweather/sources/bmkg/BmkgApi.kt | 62 + .../breezyweather/sources/bmkg/BmkgAppApi.kt | 26 + .../breezyweather/sources/bmkg/BmkgService.kt | 592 +++++++ .../sources/bmkg/json/BmkgCuaca.kt | 35 + .../sources/bmkg/json/BmkgCurrentData.kt | 24 + .../sources/bmkg/json/BmkgCurrentResult.kt | 24 + .../sources/bmkg/json/BmkgForecastData.kt | 24 + .../sources/bmkg/json/BmkgForecastResult.kt | 24 + .../sources/bmkg/json/BmkgIbfData.kt | 32 + .../sources/bmkg/json/BmkgIbfMessage.kt | 25 + .../sources/bmkg/json/BmkgIbfResponse.kt | 24 + .../sources/bmkg/json/BmkgIbfResult.kt | 24 + .../sources/bmkg/json/BmkgLocationResult.kt | 32 + .../sources/bmkg/json/BmkgPm25Result.kt | 26 + .../sources/bmkg/json/BmkgWarningData.kt | 24 + .../bmkg/json/BmkgWarningDescription.kt | 29 + .../sources/bmkg/json/BmkgWarningResult.kt | 24 + .../sources/bmkg/json/BmkgWarningToday.kt | 24 + .../breezyweather/sources/china/ChinaApi.kt | 63 + .../sources/china/ChinaService.kt | 595 +++++++ .../sources/china/json/ChinaAlert.kt | 32 + .../sources/china/json/ChinaAqi.kt | 39 + .../sources/china/json/ChinaCurrent.kt | 34 + .../sources/china/json/ChinaCurrentWind.kt | 25 + .../sources/china/json/ChinaDailyWind.kt | 25 + .../sources/china/json/ChinaForecastDaily.kt | 28 + .../sources/china/json/ChinaForecastHourly.kt | 27 + .../sources/china/json/ChinaForecastResult.kt | 30 + .../sources/china/json/ChinaFromTo.kt | 25 + .../sources/china/json/ChinaHourlyWind.kt | 24 + .../china/json/ChinaHourlyWindValue.kt | 25 + .../sources/china/json/ChinaLocationResult.kt | 31 + .../china/json/ChinaMinutelyPrecipitation.kt | 29 + .../sources/china/json/ChinaMinutelyResult.kt | 24 + .../json/ChinaPrecipitationProbability.kt | 24 + .../sources/china/json/ChinaUnitValue.kt | 25 + .../china/json/ChinaValueListChinaFromTo.kt | 24 + .../sources/china/json/ChinaValueListInt.kt | 27 + .../sources/china/json/ChinaYesterday.kt | 27 + .../org/breezyweather/sources/cwa/CwaApi.kt | 83 + .../breezyweather/sources/cwa/CwaGeoLookup.kt | 234 +++ .../breezyweather/sources/cwa/CwaService.kt | 1015 ++++++++++++ .../sources/cwa/json/CwaAirQualityAqi.kt | 30 + .../sources/cwa/json/CwaAirQualityData.kt | 24 + .../sources/cwa/json/CwaAirQualityResult.kt | 24 + .../sources/cwa/json/CwaAlertAffectedAreas.kt | 24 + .../sources/cwa/json/CwaAlertContent.kt | 24 + .../sources/cwa/json/CwaAlertContents.kt | 24 + .../sources/cwa/json/CwaAlertDatasetInfo.kt | 25 + .../sources/cwa/json/CwaAlertHazard.kt | 24 + .../cwa/json/CwaAlertHazardConditions.kt | 24 + .../sources/cwa/json/CwaAlertHazards.kt | 24 + .../sources/cwa/json/CwaAlertInfo.kt | 26 + .../sources/cwa/json/CwaAlertLocation.kt | 24 + .../sources/cwa/json/CwaAlertRecord.kt | 26 + .../sources/cwa/json/CwaAlertRecords.kt | 24 + .../sources/cwa/json/CwaAlertResult.kt | 24 + .../sources/cwa/json/CwaAlertValidTime.kt | 25 + .../sources/cwa/json/CwaAssistantDataSet.kt | 24 + .../sources/cwa/json/CwaAssistantOpenData.kt | 24 + .../sources/cwa/json/CwaAssistantParameter.kt | 24 + .../cwa/json/CwaAssistantParameterSet.kt | 24 + .../sources/cwa/json/CwaAssistantResult.kt | 25 + .../sources/cwa/json/CwaCurrentCoordinates.kt | 26 + .../sources/cwa/json/CwaCurrentGeoInfo.kt | 26 + .../sources/cwa/json/CwaCurrentGustInfo.kt | 25 + .../sources/cwa/json/CwaCurrentRecords.kt | 25 + .../sources/cwa/json/CwaCurrentResult.kt | 24 + .../sources/cwa/json/CwaCurrentStation.kt | 26 + .../cwa/json/CwaCurrentWeatherElement.kt | 31 + .../cwa/json/CwaForecastElementValue.kt | 38 + .../sources/cwa/json/CwaForecastLocation.kt | 25 + .../sources/cwa/json/CwaForecastLocations.kt | 25 + .../sources/cwa/json/CwaForecastRecords.kt | 25 + .../sources/cwa/json/CwaForecastResult.kt | 24 + .../sources/cwa/json/CwaForecastTime.kt | 29 + .../cwa/json/CwaForecastWeatherElement.kt | 26 + .../sources/cwa/json/CwaLocationAqi.kt | 25 + .../sources/cwa/json/CwaLocationData.kt | 24 + .../sources/cwa/json/CwaLocationResult.kt | 24 + .../sources/cwa/json/CwaLocationStation.kt | 24 + .../sources/cwa/json/CwaLocationTown.kt | 27 + .../cwa/json/CwaNormalsAirTemperature.kt | 24 + .../sources/cwa/json/CwaNormalsData.kt | 24 + .../sources/cwa/json/CwaNormalsLocation.kt | 24 + .../sources/cwa/json/CwaNormalsMonthly.kt | 26 + .../sources/cwa/json/CwaNormalsRecords.kt | 24 + .../sources/cwa/json/CwaNormalsResult.kt | 24 + .../json/CwaNormalsStationObsStatistics.kt | 24 + .../sources/cwa/json/CwaNormalsSurfaceObs.kt | 24 + .../org/breezyweather/sources/dmi/DmiApi.kt | 38 + .../breezyweather/sources/dmi/DmiService.kt | 296 ++++ .../sources/dmi/json/DmiResult.kt | 30 + .../sources/dmi/json/DmiTimeserie.kt | 36 + .../sources/dmi/json/DmiWarning.kt | 31 + .../sources/dmi/json/DmiWarningResult.kt | 24 + .../org/breezyweather/sources/eccc/EcccApi.kt | 34 + .../breezyweather/sources/eccc/EcccService.kt | 425 +++++ .../sources/eccc/json/EcccAlert.kt | 34 + .../sources/eccc/json/EcccAlertSpecialText.kt | 25 + .../sources/eccc/json/EcccAlerts.kt | 24 + .../sources/eccc/json/EcccDaily.kt | 35 + .../sources/eccc/json/EcccDailyFcst.kt | 26 + .../sources/eccc/json/EcccDailyTemperature.kt | 25 + .../sources/eccc/json/EcccEpochTime.kt | 25 + .../sources/eccc/json/EcccHourly.kt | 37 + .../sources/eccc/json/EcccHourlyFcst.kt | 24 + .../sources/eccc/json/EcccObservation.kt | 38 + .../sources/eccc/json/EcccRegionalNormals.kt | 24 + .../eccc/json/EcccRegionalNormalsMetric.kt | 25 + .../sources/eccc/json/EcccResult.kt | 50 + .../sources/eccc/json/EcccRiseSet.kt | 26 + .../sources/eccc/json/EcccSun.kt | 26 + .../sources/eccc/json/EcccUnit.kt | 25 + .../breezyweather/sources/eccc/json/EcccUv.kt | 24 + .../sources/eccc/json/EcccValueUnit.kt | 25 + .../serializers/EcccEpochTimeSerializer.kt | 38 + .../eccc/serializers/EcccSunSerializer.kt | 37 + .../org/breezyweather/sources/ekuk/EkukApi.kt | 36 + .../breezyweather/sources/ekuk/EkukService.kt | 267 +++ .../ekuk/json/EkukObservationsResult.kt | 26 + .../sources/ekuk/json/EkukStation.kt | 26 + .../sources/ekuk/json/EkukStationGeometry.kt | 24 + .../ekuk/json/EkukStationProperties.kt | 25 + .../sources/ekuk/json/EkukStationsResult.kt | 24 + .../breezyweather/sources/epdhk/EpdHkApi.kt | 26 + .../sources/epdhk/EpdHkService.kt | 104 ++ .../epdhk/xml/EpdHkConcentrationsResult.kt | 88 + .../sources/geonames/GeoNamesApi.kt | 34 + .../sources/geonames/GeoNamesService.kt | 152 ++ .../geonames/json/GeoNamesAlternateName.kt | 25 + .../sources/geonames/json/GeoNamesLocation.kt | 39 + .../geonames/json/GeoNamesSearchResult.kt | 25 + .../sources/geonames/json/GeoNamesStatus.kt | 25 + .../sources/geonames/json/GeoNamesTimeZone.kt | 24 + .../sources/geosphereat/GeoSphereAtApi.kt | 45 + .../sources/geosphereat/GeoSphereAtService.kt | 460 ++++++ .../geosphereat/GeoSphereAtWarningApi.kt | 32 + .../json/GeoSphereAtHourlyDoubleParameter.kt | 24 + .../json/GeoSphereAtHourlyFeature.kt | 24 + .../json/GeoSphereAtHourlyParameters.kt | 46 + .../json/GeoSphereAtHourlyProperties.kt | 24 + .../json/GeoSphereAtTimeseriesResult.kt | 27 + .../json/GeoSphereAtWarningsProperties.kt | 24 + .../json/GeoSphereAtWarningsResult.kt | 24 + .../json/GeoSphereAtWarningsWarning.kt | 24 + .../GeoSphereAtWarningsWarningProperties.kt | 30 + ...phereAtWarningsWarningPropertiesRawInfo.kt | 26 + .../org/breezyweather/sources/hko/HkoApi.kt | 68 + .../breezyweather/sources/hko/HkoMapsApi.kt | 32 + .../breezyweather/sources/hko/HkoService.kt | 1190 ++++++++++++++ .../sources/hko/json/HkoCurrentPressure.kt | 24 + .../sources/hko/json/HkoCurrentRH.kt | 24 + .../hko/json/HkoCurrentRegionalWeather.kt | 28 + .../sources/hko/json/HkoCurrentResult.kt | 24 + .../sources/hko/json/HkoCurrentTemp.kt | 25 + .../sources/hko/json/HkoCurrentWind.kt | 26 + .../sources/hko/json/HkoDailyForecast.kt | 26 + .../sources/hko/json/HkoForecastResult.kt | 25 + .../hko/json/HkoHourlyWeatherForecast.kt | 29 + .../sources/hko/json/HkoLocationsResult.kt | 29 + .../sources/hko/json/HkoNormalsData.kt | 25 + .../sources/hko/json/HkoNormalsResult.kt | 24 + .../sources/hko/json/HkoNormalsStn.kt | 24 + .../sources/hko/json/HkoOneJsonF9d.kt | 24 + .../sources/hko/json/HkoOneJsonFlw.kt | 24 + .../sources/hko/json/HkoOneJsonResult.kt | 26 + .../sources/hko/json/HkoOneJsonRhrread.kt | 24 + .../hko/json/HkoOneJsonWeatherForecast.kt | 26 + .../sources/hko/json/HkoWarningResult.kt | 36 + .../sources/hko/json/HkoWarningSummary.kt | 34 + .../hko/json/HkoWarningSummaryResult.kt | 24 + .../hko/serializers/HkoAnySerializer.kt | 34 + .../sources/ilmateenistus/IlmateenistusApi.kt | 29 + .../ilmateenistus/IlmateenistusService.kt | 240 +++ .../json/IlmateenistusForecast.kt | 24 + .../json/IlmateenistusForecastAttributes.kt | 28 + .../json/IlmateenistusForecastItem.kt | 25 + .../json/IlmateenistusForecastResult.kt | 25 + .../json/IlmateenistusForecastTabular.kt | 24 + .../json/IlmateenistusForecastTime.kt | 31 + .../org/breezyweather/sources/imd/ImdApi.kt | 31 + .../breezyweather/sources/imd/ImdService.kt | 262 +++ .../sources/imd/json/ImdWeatherResult.kt | 32 + .../imd/serializers/ImdAnySerializer.kt | 51 + .../org/breezyweather/sources/ims/ImsApi.kt | 37 + .../breezyweather/sources/ims/ImsService.kt | 429 +++++ .../sources/ims/json/ImsAnalysis.kt | 34 + .../sources/ims/json/ImsCountry.kt | 24 + .../sources/ims/json/ImsDaily.kt | 25 + .../sources/ims/json/ImsForecastData.kt | 26 + .../sources/ims/json/ImsHourly.kt | 33 + .../sources/ims/json/ImsLocation.kt | 27 + .../sources/ims/json/ImsLocationResult.kt | 24 + .../sources/ims/json/ImsWarning.kt | 30 + .../sources/ims/json/ImsWarningSeverity.kt | 24 + .../sources/ims/json/ImsWarningType.kt | 24 + .../sources/ims/json/ImsWarningsMetadata.kt | 26 + .../sources/ims/json/ImsWeatherCode.kt | 25 + .../sources/ims/json/ImsWeatherData.kt | 30 + .../sources/ims/json/ImsWeatherResult.kt | 24 + .../sources/ims/json/ImsWindDirection.kt | 24 + .../org/breezyweather/sources/ipma/IpmaApi.kt | 41 + .../breezyweather/sources/ipma/IpmaService.kt | 400 +++++ .../sources/ipma/json/IpmaAlert.kt | 29 + .../sources/ipma/json/IpmaAlertResult.kt | 24 + .../sources/ipma/json/IpmaDistrictResult.kt | 26 + .../sources/ipma/json/IpmaForecastResult.kt | 35 + .../sources/ipma/json/IpmaLocationResult.kt | 30 + .../sources/ipsb/IpSbLocationApi.kt | 26 + .../sources/ipsb/IpSbLocationService.kt | 58 + .../sources/ipsb/json/IpSbLocationResult.kt | 32 + .../org/breezyweather/sources/jma/JmaApi.kt | 83 + .../breezyweather/sources/jma/JmaConstants.kt | 970 +++++++++++ .../breezyweather/sources/jma/JmaService.kt | 1106 +++++++++++++ .../sources/jma/json/JmaAlertArea.kt | 25 + .../sources/jma/json/JmaAlertAreaTypes.kt | 24 + .../sources/jma/json/JmaAlertResult.kt | 29 + .../sources/jma/json/JmaAlertWarning.kt | 25 + .../sources/jma/json/JmaAmedasResult.kt | 27 + .../breezyweather/sources/jma/json/JmaArea.kt | 26 + .../sources/jma/json/JmaAreasResult.kt | 28 + .../sources/jma/json/JmaBulletinResult.kt | 24 + .../sources/jma/json/JmaClass20sResult.kt | 29 + .../sources/jma/json/JmaCurrentResult.kt | 30 + .../sources/jma/json/JmaDailyArea.kt | 32 + .../sources/jma/json/JmaDailyAreaArea.kt | 24 + .../sources/jma/json/JmaDailyResult.kt | 25 + .../sources/jma/json/JmaDailyTempAverage.kt | 24 + .../sources/jma/json/JmaDailyTimeSeries.kt | 27 + .../sources/jma/json/JmaForecastAreaResult.kt | 25 + .../jma/json/JmaHourlyAreaTimeSeries.kt | 26 + .../jma/json/JmaHourlyPointTimeSeries.kt | 25 + .../sources/jma/json/JmaHourlyResult.kt | 25 + .../sources/jma/json/JmaHourlyTimeDefines.kt | 26 + .../sources/jma/json/JmaHourlyWind.kt | 25 + .../sources/jma/json/JmaRelmResult.kt | 25 + .../sources/jma/json/JmaWeekAreaResult.kt | 25 + .../jma/serializers/JmaAnySerializer.kt | 34 + .../org/breezyweather/sources/lhmt/LhmtApi.kt | 41 + .../breezyweather/sources/lhmt/LhmtService.kt | 663 ++++++++ .../breezyweather/sources/lhmt/LhmtWwwApi.kt | 32 + .../sources/lhmt/json/LhmtAlertArea.kt | 24 + .../sources/lhmt/json/LhmtAlertAreaGroup.kt | 26 + .../lhmt/json/LhmtAlertPhenomenonGroup.kt | 25 + .../lhmt/json/LhmtAlertResponseType.kt | 24 + .../sources/lhmt/json/LhmtAlertSingleAlert.kt | 38 + .../sources/lhmt/json/LhmtAlertText.kt | 25 + .../sources/lhmt/json/LhmtAlertsResult.kt | 25 + .../sources/lhmt/json/LhmtCoordinates.kt | 25 + .../sources/lhmt/json/LhmtLocationsResult.kt | 28 + .../sources/lhmt/json/LhmtWeather.kt | 41 + .../sources/lhmt/json/LhmtWeatherResult.kt | 25 + .../breezyweather/sources/lvgmc/LvgmcApi.kt | 56 + .../sources/lvgmc/LvgmcService.kt | 569 +++++++ .../json/LvgmcAirQualityLocationResult.kt | 29 + .../lvgmc/json/LvgmcAirQualityResult.kt | 27 + .../lvgmc/json/LvgmcCurrentLocation.kt | 28 + .../sources/lvgmc/json/LvgmcCurrentResult.kt | 34 + .../sources/lvgmc/json/LvgmcForecastResult.kt | 45 + .../sources/meteoam/MeteoAmApi.kt | 44 + .../sources/meteoam/MeteoAmService.kt | 320 ++++ .../meteoam/json/MeteoAmForecastDatasets.kt | 27 + .../meteoam/json/MeteoAmForecastExtraInfo.kt | 25 + .../meteoam/json/MeteoAmForecastResult.kt | 29 + .../meteoam/json/MeteoAmForecastStats.kt | 27 + .../meteoam/json/MeteoAmObservationResult.kt | 28 + .../meteoam/json/MeteoAmReverseLocation.kt | 28 + .../json/MeteoAmReverseLocationResult.kt | 24 + .../serializers/MeteoAmAnySerializer.kt | 51 + .../sources/meteolux/MeteoLuxApi.kt | 31 + .../sources/meteolux/MeteoLuxService.kt | 440 +++++ .../meteolux/json/MeteoLuxWeatherCity.kt | 28 + .../meteolux/json/MeteoLuxWeatherCurrent.kt | 26 + .../meteolux/json/MeteoLuxWeatherDaily.kt | 32 + .../meteolux/json/MeteoLuxWeatherForecast.kt | 26 + .../meteolux/json/MeteoLuxWeatherHourly.kt | 29 + .../json/MeteoLuxWeatherHourlyTemperature.kt | 25 + .../meteolux/json/MeteoLuxWeatherIcon.kt | 25 + .../meteolux/json/MeteoLuxWeatherResult.kt | 26 + .../json/MeteoLuxWeatherTemperature.kt | 25 + .../meteolux/json/MeteoLuxWeatherVigilance.kt | 32 + .../meteolux/json/MeteoLuxWeatherWind.kt | 26 + .../breezyweather/sources/metie/MetIeApi.kt | 57 + .../sources/metie/MetIeService.kt | 466 ++++++ .../sources/metie/json/MetIeForecastHourly.kt | 36 + .../metie/json/MetIeForecastPercent.kt | 25 + .../metie/json/MetIeForecastPrecipitation.kt | 26 + .../sources/metie/json/MetIeForecastResult.kt | 25 + .../sources/metie/json/MetIeForecastSymbol.kt | 26 + .../sources/metie/json/MetIeForecastValue.kt | 25 + .../metie/json/MetIeForecastWindDirection.kt | 25 + .../metie/json/MetIeForecastWindSpeed.kt | 24 + .../sources/metie/json/MetIeLocationResult.kt | 26 + .../sources/metie/json/MetIeWarning.kt | 33 + .../sources/metie/json/MetIeWarningResult.kt | 24 + .../sources/metie/json/MetIeWarnings.kt | 24 + .../breezyweather/sources/metno/MetNoApi.kt | 61 + .../sources/metno/MetNoService.kt | 424 +++++ .../json/MetNoAirQualityConcentration.kt | 24 + .../sources/metno/json/MetNoAirQualityData.kt | 24 + .../metno/json/MetNoAirQualityResult.kt | 28 + .../sources/metno/json/MetNoAirQualityTime.kt | 28 + .../metno/json/MetNoAirQualityVariables.kt | 29 + .../sources/metno/json/MetNoAlert.kt | 29 + .../metno/json/MetNoAlertProperties.kt | 34 + .../sources/metno/json/MetNoAlertResult.kt | 27 + .../sources/metno/json/MetNoAlertWhen.kt | 30 + .../metno/json/MetNoEphemerisProperty.kt | 26 + .../sources/metno/json/MetNoForecastData.kt | 33 + .../metno/json/MetNoForecastDataDetails.kt | 36 + .../metno/json/MetNoForecastDataInstant.kt | 24 + .../metno/json/MetNoForecastDataNextHours.kt | 25 + .../metno/json/MetNoForecastDataSummary.kt | 25 + .../metno/json/MetNoForecastProperties.kt | 25 + .../metno/json/MetNoForecastPropertiesMeta.kt | 27 + .../sources/metno/json/MetNoForecastResult.kt | 27 + .../metno/json/MetNoForecastTimeseries.kt | 27 + .../metno/json/MetNoNowcastProperties.kt | 24 + .../sources/metno/json/MetNoNowcastResult.kt | 27 + .../sources/metoffice/MetOfficeApi.kt | 48 + .../sources/metoffice/MetOfficeService.kt | 249 +++ .../sources/metoffice/json/MetOfficeDaily.kt | 56 + .../metoffice/json/MetOfficeForecast.kt | 52 + .../sources/metoffice/json/MetOfficeHourly.kt | 45 + .../org/breezyweather/sources/mf/MfApi.kt | 97 ++ .../org/breezyweather/sources/mf/MfService.kt | 1237 ++++++++++++++ .../sources/mf/json/MfCurrentGridded.kt | 30 + .../sources/mf/json/MfCurrentProperties.kt | 24 + .../sources/mf/json/MfCurrentResult.kt | 27 + .../sources/mf/json/MfForecastDaily.kt | 36 + .../sources/mf/json/MfForecastHourly.kt | 48 + .../sources/mf/json/MfForecastProbability.kt | 33 + .../sources/mf/json/MfForecastProperties.kt | 32 + .../sources/mf/json/MfForecastResult.kt | 32 + .../sources/mf/json/MfGeometry.kt | 24 + .../sources/mf/json/MfHistory.kt | 28 + .../sources/mf/json/MfHistoryResult.kt | 27 + .../sources/mf/json/MfHistoryTemperature.kt | 26 + .../sources/mf/json/MfNormalsProperties.kt | 24 + .../sources/mf/json/MfNormalsResult.kt | 28 + .../sources/mf/json/MfNormalsStats.kt | 27 + .../sources/mf/json/MfRainForecast.kt | 28 + .../sources/mf/json/MfRainProperties.kt | 25 + .../sources/mf/json/MfRainResult.kt | 28 + .../sources/mf/json/MfWarningAdvice.kt | 27 + .../sources/mf/json/MfWarningComments.kt | 26 + .../sources/mf/json/MfWarningConsequence.kt | 27 + .../mf/json/MfWarningDictionaryColor.kt | 26 + .../mf/json/MfWarningDictionaryPhenomenon.kt | 25 + .../mf/json/MfWarningDictionaryResult.kt | 28 + .../sources/mf/json/MfWarningMaxCountItems.kt | 27 + .../mf/json/MfWarningOverseasAdvice.kt | 27 + .../mf/json/MfWarningOverseasComments.kt | 30 + .../mf/json/MfWarningOverseasConsequence.kt | 27 + .../mf/json/MfWarningOverseasTextBlocItem.kt | 26 + .../mf/json/MfWarningOverseasTimelaps.kt | 26 + .../mf/json/MfWarningPhenomenonMaxColor.kt | 26 + .../mf/json/MfWarningSubdivisionText.kt | 27 + .../sources/mf/json/MfWarningTermItem.kt | 26 + .../sources/mf/json/MfWarningTextBlocItem.kt | 26 + .../sources/mf/json/MfWarningTextItem.kt | 26 + .../sources/mf/json/MfWarningTimelaps.kt | 26 + .../sources/mf/json/MfWarningTimelapsItem.kt | 29 + .../mf/json/MfWarningsOverseasResult.kt | 40 + .../sources/mf/json/MfWarningsResult.kt | 39 + .../org/breezyweather/sources/mgm/MgmApi.kt | 74 + .../breezyweather/sources/mgm/MgmService.kt | 587 +++++++ .../sources/mgm/json/MgmAlertResult.kt | 32 + .../sources/mgm/json/MgmAlertText.kt | 26 + .../sources/mgm/json/MgmAlertTowns.kt | 26 + .../sources/mgm/json/MgmAlertWeather.kt | 26 + .../sources/mgm/json/MgmCurrentResult.kt | 30 + .../mgm/json/MgmDailyForecastResult.kt | 57 + .../sources/mgm/json/MgmHourlyForecast.kt | 34 + .../mgm/json/MgmHourlyForecastResult.kt | 25 + .../sources/mgm/json/MgmLocationResult.kt | 31 + .../sources/mgm/json/MgmNormalsResult.kt | 28 + .../breezyweather/sources/namem/NamemApi.kt | 67 + .../sources/namem/NamemService.kt | 557 +++++++ .../sources/namem/json/NamemAirQuality.kt | 26 + .../namem/json/NamemAirQualityResult.kt | 24 + .../sources/namem/json/NamemCurrent.kt | 31 + .../sources/namem/json/NamemCurrentResult.kt | 24 + .../sources/namem/json/NamemDailyForecast.kt | 37 + .../sources/namem/json/NamemDailyResult.kt | 25 + .../sources/namem/json/NamemHourlyForecast.kt | 31 + .../sources/namem/json/NamemHourlyResult.kt | 25 + .../sources/namem/json/NamemNormals.kt | 29 + .../sources/namem/json/NamemNormalsResult.kt | 25 + .../sources/namem/json/NamemStation.kt | 32 + .../sources/namem/json/NamemStationsResult.kt | 25 + .../org/breezyweather/sources/ncdr/NcdrApi.kt | 34 + .../breezyweather/sources/ncdr/NcdrService.kt | 217 +++ .../sources/ncdr/xml/NcdrAlertsResult.kt | 92 ++ .../org/breezyweather/sources/ncei/NceiApi.kt | 50 + .../breezyweather/sources/ncei/NceiService.kt | 220 +++ .../sources/ncei/json/NceiDataResult.kt | 28 + .../sources/ncei/json/NceiStationsCentroid.kt | 24 + .../sources/ncei/json/NceiStationsResult.kt | 24 + .../ncei/json/NceiStationsResultResults.kt | 25 + .../json/NceiStationsResultResultsStation.kt | 25 + .../org/breezyweather/sources/nlsc/NlscApi.kt | 30 + .../breezyweather/sources/nlsc/NlscService.kt | 63 + .../nlsc/xml/NlscLocationCodesResult.kt | 67 + .../org/breezyweather/sources/nws/NwsApi.kt | 75 + .../breezyweather/sources/nws/NwsForecast.kt | 44 + .../breezyweather/sources/nws/NwsService.kt | 1046 ++++++++++++ .../sources/nws/json/NwsAlert.kt | 24 + .../sources/nws/json/NwsAlertProperties.kt | 37 + .../sources/nws/json/NwsAlertsResult.kt | 24 + .../sources/nws/json/NwsCurrentProperties.kt | 40 + .../sources/nws/json/NwsCurrentResult.kt | 26 + .../sources/nws/json/NwsCurrentValue.kt | 26 + .../sources/nws/json/NwsDailyPeriods.kt | 35 + .../sources/nws/json/NwsDailyProperties.kt | 25 + .../sources/nws/json/NwsDailyResult.kt | 25 + .../nws/json/NwsGridPointProperties.kt | 43 + .../sources/nws/json/NwsGridPointResult.kt | 24 + .../sources/nws/json/NwsPointLocation.kt | 25 + .../nws/json/NwsPointLocationGeometry.kt | 24 + .../nws/json/NwsPointLocationProperties.kt | 26 + .../sources/nws/json/NwsPointProperties.kt | 29 + .../sources/nws/json/NwsPointResult.kt | 24 + .../sources/nws/json/NwsStationsResult.kt | 24 + .../sources/nws/json/NwsValueDouble.kt | 26 + .../nws/json/NwsValueDoubleContainer.kt | 25 + .../sources/nws/json/NwsValueInt.kt | 26 + .../sources/nws/json/NwsValueIntContainer.kt | 25 + .../sources/nws/json/NwsValueWeather.kt | 26 + .../nws/json/NwsValueWeatherContainer.kt | 25 + .../sources/nws/json/NwsValueWeatherValue.kt | 28 + .../sources/openweather/OpenWeatherApi.kt | 55 + .../sources/openweather/OpenWeatherService.kt | 330 ++++ .../json/OpenWeatherAirPollution.kt | 25 + .../json/OpenWeatherAirPollutionComponents.kt | 32 + .../json/OpenWeatherAirPollutionResult.kt | 27 + .../openweather/json/OpenWeatherForecast.kt | 32 + .../json/OpenWeatherForecastClouds.kt | 24 + .../json/OpenWeatherForecastMain.kt | 28 + .../json/OpenWeatherForecastPrecipitation.kt | 25 + .../json/OpenWeatherForecastResult.kt | 27 + .../json/OpenWeatherForecastWeather.kt | 27 + .../json/OpenWeatherForecastWind.kt | 26 + .../breezyweather/sources/pagasa/PagasaApi.kt | 43 + .../sources/pagasa/PagasaService.kt | 367 +++++ .../pagasa/json/PagasaCurrentResult.kt | 32 + .../pagasa/json/PagasaHourlyAttributes.kt | 29 + .../pagasa/json/PagasaHourlyElement.kt | 25 + .../pagasa/json/PagasaHourlyForecast.kt | 24 + .../sources/pagasa/json/PagasaHourlyResult.kt | 24 + .../pagasa/json/PagasaHourlyTabular.kt | 24 + .../sources/pagasa/json/PagasaHourlyTime.kt | 31 + .../pagasa/json/PagasaLocationResult.kt | 27 + .../sources/polleninfo/PollenInfoApi.kt | 36 + .../sources/polleninfo/PollenInfoService.kt | 165 ++ .../polleninfo/json/PollenInfoResult.kt | 43 + .../org/breezyweather/sources/smg/SmgApi.kt | 63 + .../breezyweather/sources/smg/SmgCmsApi.kt | 29 + .../breezyweather/sources/smg/SmgService.kt | 660 ++++++++ .../sources/smg/json/SmgAirQuality.kt | 29 + .../sources/smg/json/SmgAirQualityResult.kt | 29 + .../sources/smg/json/SmgBulletinCustom.kt | 24 + .../sources/smg/json/SmgBulletinResult.kt | 25 + .../sources/smg/json/SmgBulletinRoot.kt | 24 + .../sources/smg/json/SmgCurrentCustom.kt | 24 + .../sources/smg/json/SmgCurrentResult.kt | 25 + .../sources/smg/json/SmgCurrentRoot.kt | 24 + .../sources/smg/json/SmgCurrentStation.kt | 31 + .../smg/json/SmgCurrentWeatherReport.kt | 24 + .../sources/smg/json/SmgForecastCustom.kt | 24 + .../sources/smg/json/SmgForecastResult.kt | 25 + .../sources/smg/json/SmgForecastRoot.kt | 24 + .../smg/json/SmgForecastWeatherForecast.kt | 33 + .../sources/smg/json/SmgUvCustom.kt | 24 + .../sources/smg/json/SmgUvReport.kt | 25 + .../sources/smg/json/SmgUvResult.kt | 25 + .../sources/smg/json/SmgUvRoot.kt | 24 + .../sources/smg/json/SmgValue.kt | 27 + .../sources/smg/json/SmgWarning.kt | 28 + .../sources/smg/json/SmgWarningCustom.kt | 29 + .../sources/smg/json/SmgWarningResult.kt | 29 + .../sources/smg/json/SmgWarningRoot.kt | 24 + .../org/breezyweather/sources/smhi/SmhiApi.kt | 30 + .../breezyweather/sources/smhi/SmhiService.kt | 160 ++ .../sources/smhi/json/SmhiForecastResult.kt | 24 + .../sources/smhi/json/SmhiParameter.kt | 25 + .../sources/smhi/json/SmhiTimeSeries.kt | 27 + .../sources/veduris/VedurIsApi.kt | 44 + .../sources/veduris/VedurIsService.kt | 493 ++++++ .../sources/veduris/json/VedurIsAlert.kt | 16 + .../veduris/json/VedurIsAlertRegionsResult.kt | 10 + .../veduris/json/VedurIsAlertResult.kt | 8 + .../veduris/json/VedurIsDailyForecast.kt | 9 + .../sources/veduris/json/VedurIsFeature.kt | 9 + .../veduris/json/VedurIsFeatureCollection.kt | 8 + .../sources/veduris/json/VedurIsForecast.kt | 8 + .../sources/veduris/json/VedurIsGeometry.kt | 8 + .../veduris/json/VedurIsHourlyForecast.kt | 15 + .../veduris/json/VedurIsLatestObservation.kt | 17 + .../sources/veduris/json/VedurIsPageProps.kt | 9 + .../sources/veduris/json/VedurIsProperties.kt | 8 + .../sources/veduris/json/VedurIsResult.kt | 8 + .../sources/veduris/json/VedurIsStation.kt | 11 + .../veduris/json/VedurIsStationForecast.kt | 9 + .../veduris/json/VedurIsStationResult.kt | 8 + .../serializers/VedurIsAnySerializer.kt | 18 + .../WmoSevereWeatherJsonApi.kt | 36 + .../WmoSevereWeatherService.kt | 247 +++ .../WmoSevereWeatherXmlApi.kt | 33 + .../json/WmoSevereWeatherAlertFeatures.kt | 25 + .../json/WmoSevereWeatherAlertProperties.kt | 36 + .../json/WmoSevereWeatherAlertResult.kt | 24 + .../java/org/breezyweather/LocationTest.kt | 19 + .../test/java/org/breezyweather/MatchTest.kt | 29 + .../option/appearance/CardDisplayTest.kt | 95 ++ .../appearance/DailyTrendDisplayTest.kt | 73 + .../breezyweather/option/utils/UtilsTest.kt | 37 + .../sources/CommonConverterTest.kt | 19 + app/work/ne_50m_admin_0_countries.json | 244 +++ build.gradle.kts | 22 + buildSrc/build.gradle.kts | 21 + buildSrc/settings.gradle.kts | 7 + ...ezy.android.application.compose.gradle.kts | 12 + .../breezy.android.application.gradle.kts | 18 + .../main/kotlin/breezy.code.lint.gradle.kts | 39 + .../src/main/kotlin/breezy.library.gradle.kts | 13 + .../kotlin/breezy/buildlogic/AndroidConfig.kt | 15 + .../main/kotlin/breezy/buildlogic/Commands.kt | 25 + .../breezy/buildlogic/LocalesConfigPlugin.kt | 47 + .../buildlogic/NaturalEarthConfigPlugin.kt | 133 ++ .../breezy/buildlogic/ProjectExtensions.kt | 64 + config/libraries/app_geometric_weather.json | 15 + config/libraries/app_mihon.json | 18 + config/libraries/lib_font_asap.json | 15 + .../lib_google_android_maps_utils.json | 15 + data/.gitignore | 1 + data/build.gradle.kts | 31 + data/consumer-rules.pro | 0 data/proguard-rules.pro | 21 + data/src/main/AndroidManifest.xml | 2 + .../data/AndroidDatabaseHandler.kt | 107 ++ .../breezyweather/data/DatabaseAdapter.kt | 132 ++ .../breezyweather/data/DatabaseHandler.kt | 58 + .../breezyweather/data/TransactionContext.kt | 177 ++ .../data/location/LocationMapper.kt | 83 + .../data/location/LocationRepository.kt | 295 ++++ .../data/weather/WeatherMapper.kt | 516 ++++++ .../data/weather/WeatherRepository.kt | 422 +++++ .../sqldelight/breezyweather/data/alerts.sq | 41 + .../sqldelight/breezyweather/data/dailys.sq | 182 ++ .../sqldelight/breezyweather/data/hourlys.sq | 79 + .../breezyweather/data/location_parameters.sq | 40 + .../breezyweather/data/locations.sq | 145 ++ .../breezyweather/data/minutelys.sq | 29 + .../sqldelight/breezyweather/data/normals.sq | 30 + .../sqldelight/breezyweather/data/weathers.sq | 118 ++ .../sqldelight/breezyweather/migrations/1.sqm | 38 + .../breezyweather/migrations/10.sqm | 5 + .../breezyweather/migrations/11.sqm | 37 + .../breezyweather/migrations/12.sqm | 8 + .../breezyweather/migrations/13.sqm | 14 + .../breezyweather/migrations/14.sqm | 5 + .../breezyweather/migrations/15.sqm | 44 + .../breezyweather/migrations/16.sqm | 5 + .../breezyweather/migrations/17.sqm | 5 + .../breezyweather/migrations/18.sqm | 29 + .../breezyweather/migrations/19.sqm | 65 + .../sqldelight/breezyweather/migrations/2.sqm | 2 + .../breezyweather/migrations/20.sqm | 35 + .../breezyweather/migrations/21.sqm | 95 ++ .../breezyweather/migrations/22.sqm | 107 ++ .../breezyweather/migrations/23.sqm | 49 + .../breezyweather/migrations/24.sqm | 179 ++ .../breezyweather/migrations/25.sqm | 123 ++ .../sqldelight/breezyweather/migrations/3.sqm | 14 + .../sqldelight/breezyweather/migrations/4.sqm | 5 + .../sqldelight/breezyweather/migrations/5.sqm | 2 + .../sqldelight/breezyweather/migrations/6.sqm | 23 + .../sqldelight/breezyweather/migrations/7.sqm | 5 + .../sqldelight/breezyweather/migrations/8.sqm | 2 + .../sqldelight/breezyweather/migrations/9.sqm | 2 + docs/CHANGELOG_4.x.md | 546 ++++++ docs/CHANGELOG_5.x.md | 796 +++++++++ docs/COVERAGE.md | 329 ++++ docs/DAY_DETAILS.md | 18 + docs/FullHomepageScreenshot.png | Bin 0 -> 259544 bytes docs/HOMEPAGE.md | 454 +++++ docs/RADAR.md | 123 ++ docs/SOURCES.md | 1241 ++++++++++++++ docs/TECHNICAL.md | 268 +++ docs/UPDATES.md | 98 ++ docs/fdroid_client_config.png | Bin 0 -> 36552 bytes domain/.gitignore | 1 + domain/build.gradle.kts | 21 + domain/consumer-rules.pro | 0 domain/proguard-rules.pro | 21 + domain/src/main/AndroidManifest.xml | 2 + .../domain/location/model/Location.kt | 385 +++++ .../location/model/LocationAddressInfo.kt | 80 + .../domain/source/SourceContinent.kt | 39 + .../domain/source/SourceFeature.kt | 40 + .../domain/weather/model/AirQuality.kt | 43 + .../domain/weather/model/Alert.kt | 61 + .../domain/weather/model/Astro.kt | 42 + .../domain/weather/model/Base.kt | 37 + .../domain/weather/model/Current.kt | 66 + .../domain/weather/model/Daily.kt | 58 + .../domain/weather/model/DailyAvgMinMax.kt | 24 + .../domain/weather/model/DailyCloudCover.kt | 26 + .../domain/weather/model/DailyDewPoint.kt | 26 + .../domain/weather/model/DailyPressure.kt | 26 + .../weather/model/DailyRelativeHumidity.kt | 26 + .../domain/weather/model/DailyVisibility.kt | 26 + .../domain/weather/model/DegreeDay.kt | 30 + .../domain/weather/model/HalfDay.kt | 54 + .../domain/weather/model/Hourly.kt | 68 + .../domain/weather/model/Minutely.kt | 63 + .../domain/weather/model/MoonPhase.kt | 33 + .../domain/weather/model/Normals.kt | 32 + .../domain/weather/model/Pollen.kt | 74 + .../domain/weather/model/Precipitation.kt | 47 + .../weather/model/PrecipitationDuration.kt | 31 + .../weather/model/PrecipitationProbability.kt | 35 + .../domain/weather/model/Temperature.kt | 42 + .../breezyweather/domain/weather/model/UV.kt | 46 + .../domain/weather/model/Weather.kt | 189 +++ .../domain/weather/model/Wind.kt | 63 + .../domain/weather/reference/AlertSeverity.kt | 34 + .../domain/weather/reference/Month.kt | 217 +++ .../domain/weather/reference/WeatherCode.kt | 42 + .../weather/wrappers/AirQualityWrapper.kt | 26 + .../domain/weather/wrappers/CurrentWrapper.kt | 68 + .../domain/weather/wrappers/DailyWrapper.kt | 66 + .../domain/weather/wrappers/HalfDayWrapper.kt | 56 + .../domain/weather/wrappers/HourlyWrapper.kt | 81 + .../domain/weather/wrappers/PollenWrapper.kt | 29 + .../weather/wrappers/TemperatureWrapper.kt | 39 + .../domain/weather/wrappers/WeatherWrapper.kt | 43 + .../android/cs/changelogs/default.txt | 2 + .../metadata/android/cs/full_description.txt | 47 + .../metadata/android/cs/short_description.txt | 1 + .../android/de-DE/changelogs/default.txt | 2 + .../android/de-DE/full_description.txt | 47 + .../android/de-DE/short_description.txt | 1 + .../android/en-US/changelogs/default.txt | 2 + .../android/en-US/full_description.txt | 47 + .../metadata/android/en-US/images/icon.png | Bin 0 -> 15451 bytes .../phoneScreenshots/01-main-header-light.png | Bin 0 -> 156974 bytes .../phoneScreenshots/02-main-header-dark.png | Bin 0 -> 130342 bytes .../phoneScreenshots/03-main-blocks-1.png | Bin 0 -> 180242 bytes .../phoneScreenshots/04-main-blocks-2.png | Bin 0 -> 185390 bytes .../images/phoneScreenshots/05-settings.png | Bin 0 -> 97300 bytes .../images/phoneScreenshots/06-sources.png | Bin 0 -> 95913 bytes .../images/phoneScreenshots/07-details.png | Bin 0 -> 169103 bytes .../android/en-US/short_description.txt | 1 + .../android/eo/changelogs/default.txt | 2 + .../metadata/android/eo/full_description.txt | 47 + .../metadata/android/eo/short_description.txt | 1 + .../android/es-ES/changelogs/default.txt | 2 + .../android/es-ES/full_description.txt | 47 + .../android/es-ES/short_description.txt | 1 + .../android/fr/changelogs/default.txt | 2 + .../metadata/android/fr/full_description.txt | 47 + .../phoneScreenshots/01-main-header-light.png | Bin 0 -> 132573 bytes .../phoneScreenshots/02-main-header-dark.png | Bin 0 -> 132711 bytes .../phoneScreenshots/03-main-blocks-1.png | Bin 0 -> 186572 bytes .../phoneScreenshots/04-main-blocks-2.png | Bin 0 -> 193853 bytes .../images/phoneScreenshots/05-warnings.png | Bin 0 -> 171013 bytes .../images/phoneScreenshots/06-settings.png | Bin 0 -> 97648 bytes .../fr/images/phoneScreenshots/07-sources.png | Bin 0 -> 93073 bytes .../fr/images/phoneScreenshots/08-details.png | Bin 0 -> 157976 bytes .../metadata/android/fr/short_description.txt | 1 + .../android/hr/changelogs/default.txt | 2 + .../metadata/android/hr/short_description.txt | 1 + .../android/hu/changelogs/default.txt | 2 + .../metadata/android/hu/full_description.txt | 47 + .../metadata/android/hu/short_description.txt | 1 + .../android/it/changelogs/default.txt | 2 + .../metadata/android/it/full_description.txt | 47 + .../metadata/android/it/short_description.txt | 1 + .../android/ja-JP/changelogs/default.txt | 2 + .../android/pl-PL/changelogs/default.txt | 2 + .../android/pl-PL/full_description.txt | 47 + .../android/pl-PL/short_description.txt | 1 + .../android/pt/changelogs/default.txt | 2 + .../metadata/android/pt/short_description.txt | 1 + .../android/pt_BR/changelogs/default.txt | 2 + .../android/pt_BR/full_description.txt | 47 + .../android/pt_BR/short_description.txt | 1 + .../android/ru-RU/changelogs/default.txt | 2 + .../android/ru-RU/full_description.txt | 47 + .../android/ru-RU/short_description.txt | 1 + .../metadata/android/th/short_description.txt | 1 + .../android/uk/changelogs/default.txt | 2 + .../metadata/android/uk/full_description.txt | 47 + .../metadata/android/uk/short_description.txt | 1 + .../android/zh_Hans/changelogs/default.txt | 2 + .../android/zh_Hans/full_description.txt | 47 + .../android/zh_Hans/short_description.txt | 1 + .../android/zh_Hant/changelogs/default.txt | 2 + .../android/zh_Hant/full_description.txt | 47 + .../android/zh_Hant/short_description.txt | 1 + .../android/zh_Hant_HK/changelogs/default.txt | 2 + .../android/zh_Hant_HK/full_description.txt | 47 + .../android/zh_Hant_HK/short_description.txt | 1 + gradle.properties | 30 + gradle/libs.versions.toml | 150 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 248 +++ gradlew.bat | 93 ++ maps-utils/.gitignore | 1 + maps-utils/README.md | 10 + maps-utils/build.gradle.kts | 18 + maps-utils/consumer-rules.pro | 0 maps-utils/proguard-rules.pro | 21 + .../maps/android/EncodedPolylineUtil.kt | 68 + .../java/com/google/maps/android/MathUtil.kt | 89 + .../java/com/google/maps/android/PolyUtil.kt | 143 ++ .../com/google/maps/android/SphericalUtil.kt | 50 + .../google/maps/android/data/DataPolygon.kt | 42 + .../com/google/maps/android/data/Feature.kt | 131 ++ .../com/google/maps/android/data/Geometry.kt | 37 + .../google/maps/android/data/LineString.kt | 47 + .../google/maps/android/data/MultiGeometry.kt | 64 + .../com/google/maps/android/data/Point.kt | 53 + .../android/data/geojson/GeoJsonFeature.kt | 71 + .../data/geojson/GeoJsonGeometryCollection.kt | 44 + .../android/data/geojson/GeoJsonLineString.kt | 44 + .../data/geojson/GeoJsonMultiLineString.kt | 43 + .../android/data/geojson/GeoJsonMultiPoint.kt | 43 + .../data/geojson/GeoJsonMultiPolygon.kt | 59 + .../android/data/geojson/GeoJsonParser.kt | 491 ++++++ .../maps/android/data/geojson/GeoJsonPoint.kt | 36 + .../android/data/geojson/GeoJsonPolygon.kt | 82 + .../maps/android/data/geojson/GeoJsonStyle.kt | 29 + .../com/google/maps/android/model/LatLng.kt | 83 + .../google/maps/android/model/LatLngBounds.kt | 37 + .../maps/android/EncodedPolylineUtilTest.kt | 44 + settings.gradle.kts | 46 + ui-weather-view/.gitignore | 1 + ui-weather-view/build.gradle.kts | 16 + ui-weather-view/consumer-rules.pro | 0 ui-weather-view/proguard-rules.pro | 21 + .../ui/theme/weatherView/Extensions.kt | 36 + .../theme/weatherView/WeatherThemeDelegate.kt | 61 + .../ui/theme/weatherView/WeatherView.kt | 69 + .../DelayRotateController.kt | 96 ++ .../materialWeatherView/IntervalComputer.kt | 40 + .../MaterialPainterView.kt | 354 ++++ .../MaterialWeatherThemeDelegate.kt | 114 ++ .../MaterialWeatherView.kt | 212 +++ .../WeatherImplementorFactory.kt | 174 ++ .../implementor/CloudImplementor.kt | 603 +++++++ .../implementor/HailImplementor.kt | 175 ++ .../implementor/MeteorShowerImplementor.kt | 269 +++ .../implementor/RainImplementor.kt | 308 ++++ .../implementor/SnowImplementor.kt | 165 ++ .../implementor/SunImplementor.kt | 98 ++ .../implementor/WindImplementor.kt | 179 ++ .../weather_background_clear_day.xml | 12 + .../weather_background_cloudy.xml | 12 + .../drawable-night/weather_background_fog.xml | 12 + .../weather_background_hail.xml | 12 + .../weather_background_haze.xml | 12 + .../weather_background_partly_cloudy.xml | 12 + .../weather_background_rain.xml | 12 + .../weather_background_sleet.xml | 12 + .../weather_background_snow.xml | 12 + .../weather_background_thunder.xml | 12 + .../weather_background_wind.xml | 12 + .../drawable/weather_background_clear_day.xml | 12 + .../weather_background_clear_night.xml | 12 + .../drawable/weather_background_cloudy.xml | 12 + .../drawable/weather_background_default.xml | 8 + .../res/drawable/weather_background_fog.xml | 12 + .../res/drawable/weather_background_hail.xml | 12 + .../res/drawable/weather_background_haze.xml | 12 + .../weather_background_partly_cloudy.xml | 12 + .../res/drawable/weather_background_rain.xml | 12 + .../res/drawable/weather_background_sleet.xml | 12 + .../res/drawable/weather_background_snow.xml | 12 + .../drawable/weather_background_thunder.xml | 12 + .../res/drawable/weather_background_wind.xml | 12 + weather-unit/.gitignore | 1 + weather-unit/README.md | 192 +++ weather-unit/build.gradle.kts | 21 + weather-unit/consumer-rules.pro | 0 weather-unit/proguard-rules.pro | 21 + weather-unit/src/main/AndroidManifest.xml | 4 + .../java/org/breezyweather/unit/SdkCheck.kt | 60 + .../org/breezyweather/unit/WeatherUnit.kt | 232 +++ .../org/breezyweather/unit/WeatherValue.kt | 115 ++ .../unit/computing/HumidityComputing.kt | 65 + .../unit/computing/PollutantComputing.kt | 76 + .../unit/computing/PressureComputing.kt | 79 + .../unit/computing/TemperatureComputing.kt | 104 ++ .../breezyweather/unit/distance/Distance.kt | 221 +++ .../unit/distance/DistanceUnit.kt | 224 +++ .../breezyweather/unit/duration/Duration.kt | 322 ++++ .../unit/duration/DurationUnit.kt | 78 + .../unit/formatting/MeasureUnitFormatting.kt | 101 ++ .../unit/formatting/NumberFormatting.kt | 58 + .../unit/formatting/UnitDecimals.kt | 45 + .../unit/formatting/UnitTranslation.kt | 25 + .../unit/formatting/UnitWidth.kt | 44 + .../unit/pollen/PollenConcentration.kt | 169 ++ .../unit/pollen/PollenConcentrationUnit.kt | 80 + .../unit/pollutant/PollutantConcentration.kt | 187 +++ .../pollutant/PollutantConcentrationUnit.kt | 163 ++ .../unit/precipitation/Precipitation.kt | 278 ++++ .../unit/precipitation/PrecipitationUnit.kt | 340 ++++ .../breezyweather/unit/pressure/Pressure.kt | 246 +++ .../unit/pressure/PressureUnit.kt | 260 +++ .../org/breezyweather/unit/ratio/Ratio.kt | 194 +++ .../org/breezyweather/unit/ratio/RatioUnit.kt | 168 ++ .../org/breezyweather/unit/speed/Speed.kt | 247 +++ .../org/breezyweather/unit/speed/SpeedUnit.kt | 363 ++++ .../unit/temperature/Temperature.kt | 214 +++ .../unit/temperature/TemperatureUnit.kt | 250 +++ .../src/main/res/values-ar/strings.xml | 178 ++ .../src/main/res/values-be/strings.xml | 187 +++ .../src/main/res/values-bg/strings.xml | 143 ++ .../src/main/res/values-bn/strings.xml | 150 ++ .../src/main/res/values-bs/strings.xml | 141 ++ .../src/main/res/values-ca/strings.xml | 137 ++ .../src/main/res/values-ckb/strings.xml | 114 ++ .../src/main/res/values-cs/strings.xml | 141 ++ .../src/main/res/values-da/strings.xml | 148 ++ .../src/main/res/values-de/strings.xml | 145 ++ .../src/main/res/values-el/strings.xml | 167 ++ .../src/main/res/values-en-rAU/strings.xml | 148 ++ .../src/main/res/values-en-rCA/strings.xml | 148 ++ .../src/main/res/values-en-rGB/strings.xml | 148 ++ .../src/main/res/values-en-rUS/strings.xml | 148 ++ .../src/main/res/values-eo/strings.xml | 147 ++ .../src/main/res/values-es/strings.xml | 139 ++ .../src/main/res/values-et/strings.xml | 145 ++ .../src/main/res/values-eu/strings.xml | 143 ++ .../src/main/res/values-fa/strings.xml | 146 ++ .../src/main/res/values-fi/strings.xml | 141 ++ .../src/main/res/values-fr/strings.xml | 143 ++ .../src/main/res/values-ga/strings.xml | 146 ++ .../src/main/res/values-gl/strings.xml | 141 ++ .../src/main/res/values-he/strings.xml | 159 ++ .../src/main/res/values-hi/strings.xml | 170 ++ .../src/main/res/values-hr/strings.xml | 138 ++ .../src/main/res/values-hu/strings.xml | 146 ++ .../src/main/res/values-ia/strings.xml | 138 ++ .../src/main/res/values-id/strings.xml | 144 ++ .../src/main/res/values-is/strings.xml | 152 ++ .../src/main/res/values-it/strings.xml | 140 ++ .../src/main/res/values-ja/strings.xml | 144 ++ .../src/main/res/values-kab/strings.xml | 143 ++ .../src/main/res/values-ko/strings.xml | 158 ++ .../src/main/res/values-lt/strings.xml | 144 ++ .../src/main/res/values-lv/strings.xml | 139 ++ .../src/main/res/values-mk/strings.xml | 143 ++ .../src/main/res/values-mr/strings.xml | 166 ++ .../src/main/res/values-nb-rNO/strings.xml | 142 ++ .../src/main/res/values-nl/strings.xml | 142 ++ .../src/main/res/values-oc/strings.xml | 131 ++ .../src/main/res/values-pl/strings.xml | 146 ++ .../src/main/res/values-pt-rBR/strings.xml | 144 ++ .../src/main/res/values-pt/strings.xml | 142 ++ .../src/main/res/values-ro/strings.xml | 143 ++ .../src/main/res/values-ru/strings.xml | 186 +++ .../src/main/res/values-sk/strings.xml | 141 ++ .../src/main/res/values-sl-rSI/strings.xml | 140 ++ .../src/main/res/values-sr/strings.xml | 146 ++ .../src/main/res/values-sv/strings.xml | 144 ++ .../src/main/res/values-ta/strings.xml | 188 +++ .../src/main/res/values-th/strings.xml | 161 ++ .../src/main/res/values-tr/strings.xml | 142 ++ .../src/main/res/values-uk/strings.xml | 184 +++ .../src/main/res/values-vi/strings.xml | 141 ++ .../src/main/res/values-zh-rCN/strings.xml | 144 ++ .../src/main/res/values-zh-rHK/strings.xml | 145 ++ .../src/main/res/values-zh-rTW/strings.xml | 149 ++ weather-unit/src/main/res/values/strings.xml | 253 +++ .../computing/TemperatureComputingTest.kt | 37 + .../unit/formatting/NumberFormattingTest.kt | 82 + .../unit/temperature/TemperatureTest.kt | 235 +++ work/DEFAULT_UNITS_PER_COUNTRY.md | 266 +++ work/ic_location_list2.svg | 16 + work/ic_location_list3.svg | 20 + work/ic_shortcut_cloud_day_foreground.psd | Bin 0 -> 224713 bytes work/ic_shortcut_cloud_night_foreground.psd | Bin 0 -> 197678 bytes work/ic_shortcut_cloudy_foreground.psd | Bin 0 -> 242681 bytes work/ic_shortcut_fog_foreground.psd | Bin 0 -> 167064 bytes work/ic_shortcut_hail_foreground.psd | Bin 0 -> 253299 bytes work/ic_shortcut_haze_foreground.psd | Bin 0 -> 202408 bytes work/ic_shortcut_rain_foreground.psd | Bin 0 -> 254271 bytes work/ic_shortcut_sleet_foreground.psd | Bin 0 -> 247717 bytes work/ic_shortcut_snow_foreground.psd | Bin 0 -> 248182 bytes work/ic_shortcut_sun_day_foreground.psd | Bin 0 -> 193423 bytes work/ic_shortcut_sun_night_foreground.psd | Bin 0 -> 161980 bytes work/ic_shortcut_thunder_foreground.psd | Bin 0 -> 248659 bytes work/ic_shortcut_thunderstorm_foreground.psd | Bin 0 -> 258007 bytes work/ic_shortcut_wind_foreground.psd | Bin 0 -> 186354 bytes work/ic_sunshine_duration.svg | 17 + ...sun_day_waifu2x_art_noise3_scale_tta_1.png | Bin 0 -> 61259 bytes 2145 files changed, 210227 insertions(+), 2 deletions(-) create mode 100644 .editorconfig create mode 100644 .idea/icon.png create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTE.md create mode 100644 HELP.md create mode 100644 INSTALL.md create mode 100644 LICENSE create mode 100644 LICENSE_ADDITIONAL create mode 100644 PRIVACY.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/debug/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/debug/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/debug/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/debug/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/debug/res/values-night-v31/colors.xml create mode 100644 app/src/debug/res/values/colors.xml create mode 100644 app/src/debug/res/values/ic_launcher_background.xml create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/org/breezyweather/BreezyWeather.kt create mode 100644 app/src/main/java/org/breezyweather/Migrations.kt create mode 100644 app/src/main/java/org/breezyweather/background/forecast/ForecastNotificationNotifier.kt create mode 100644 app/src/main/java/org/breezyweather/background/forecast/TodayForecastNotificationJob.kt create mode 100644 app/src/main/java/org/breezyweather/background/forecast/TomorrowForecastNotificationJob.kt create mode 100644 app/src/main/java/org/breezyweather/background/interfaces/TileService.kt create mode 100644 app/src/main/java/org/breezyweather/background/provider/WeatherContentProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/BootReceiver.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/NotificationReceiver.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayDetailsProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayHorizontalProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayVerticalProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayWeekProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetDayProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetDayWeekProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMaterialYouCurrentProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMaterialYouForecastProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMultiCityProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTextProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTrendDailyProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTrendHourlyProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/receiver/widget/WidgetWeekProvider.kt create mode 100644 app/src/main/java/org/breezyweather/background/updater/AppUpdateChecker.kt create mode 100644 app/src/main/java/org/breezyweather/background/updater/AppUpdateNotifier.kt create mode 100644 app/src/main/java/org/breezyweather/background/updater/data/GithubApi.kt create mode 100644 app/src/main/java/org/breezyweather/background/updater/data/GithubRelease.kt create mode 100644 app/src/main/java/org/breezyweather/background/updater/data/ReleaseService.kt create mode 100644 app/src/main/java/org/breezyweather/background/updater/interactor/GetApplicationRelease.kt create mode 100644 app/src/main/java/org/breezyweather/background/updater/model/Release.kt create mode 100644 app/src/main/java/org/breezyweather/background/weather/WeatherUpdateJob.kt create mode 100644 app/src/main/java/org/breezyweather/background/weather/WeatherUpdateNotifier.kt create mode 100644 app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyFloatingTextActionModeCallback.kt create mode 100644 app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyPrimaryTextActionModeCallback.kt create mode 100644 app/src/main/java/org/breezyweather/common/actionmodecallback/BreezySelectionContainer.kt create mode 100644 app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyTextActionModeCallback.kt create mode 100644 app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyTextToolbar.kt create mode 100644 app/src/main/java/org/breezyweather/common/activities/BreezyActivity.kt create mode 100644 app/src/main/java/org/breezyweather/common/activities/BreezyFragment.kt create mode 100644 app/src/main/java/org/breezyweather/common/activities/BreezyViewModel.kt create mode 100644 app/src/main/java/org/breezyweather/common/activities/livedata/BusLiveData.kt create mode 100644 app/src/main/java/org/breezyweather/common/activities/livedata/EqualtableLiveData.kt create mode 100644 app/src/main/java/org/breezyweather/common/bus/EventBus.kt create mode 100644 app/src/main/java/org/breezyweather/common/bus/MyObserverWrapper.kt create mode 100644 app/src/main/java/org/breezyweather/common/di/DbModule.kt create mode 100644 app/src/main/java/org/breezyweather/common/di/HttpModule.kt create mode 100644 app/src/main/java/org/breezyweather/common/di/RxModule.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/ApiKeyMissingException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/ApiLimitReachedException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/ApiUnauthorizedException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/InvalidLocationException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/InvalidOrIncompleteDataException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/LocationAccessOffException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/LocationException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/LocationSearchException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/MissingPermissionLocationBackgroundException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/MissingPermissionLocationException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/NoNetworkException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/NonFreeNetSourceException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/OutdatedServerDataException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/ParsingException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/ReverseGeocodingException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/SourceNotInstalledException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/UnsupportedFeatureException.kt create mode 100644 app/src/main/java/org/breezyweather/common/exceptions/WeatherException.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/ContextExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/CoroutinesExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/DataSharingExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/DateExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/DateOldExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/DisplayExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/FileExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/LanguageExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/ModifierExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/NetworkExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/NotificationExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/NumberExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/StringExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/UnitExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/WindowInsetsExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/extensions/WorkManagerExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/BaseEnum.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/DarkMode.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/DarkModeLocation.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/NotificationStyle.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/NotificationTextColor.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/UpdateInterval.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/WidgetWeekIconMode.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/appearance/BackgroundAnimationMode.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/appearance/CalendarHelper.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/appearance/CardDisplay.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/appearance/DailyTrendDisplay.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/appearance/DetailScreen.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/appearance/HourlyTrendDisplay.kt create mode 100644 app/src/main/java/org/breezyweather/common/options/appearance/LocaleHelper.kt create mode 100644 app/src/main/java/org/breezyweather/common/preference/EditTextPreference.kt create mode 100644 app/src/main/java/org/breezyweather/common/preference/ListPreference.kt create mode 100644 app/src/main/java/org/breezyweather/common/preference/Preference.kt create mode 100644 app/src/main/java/org/breezyweather/common/rxjava/ObserverContainer.kt create mode 100644 app/src/main/java/org/breezyweather/common/rxjava/SchedulerTransformer.kt create mode 100644 app/src/main/java/org/breezyweather/common/serializer/DateSerializer.kt create mode 100644 app/src/main/java/org/breezyweather/common/serializer/DateUtcSerializer.kt create mode 100644 app/src/main/java/org/breezyweather/common/serializer/LatLngSerializer.kt create mode 100644 app/src/main/java/org/breezyweather/common/serializer/StringOrStringListSerializer.kt create mode 100644 app/src/main/java/org/breezyweather/common/snackbar/Snackbar.kt create mode 100644 app/src/main/java/org/breezyweather/common/snackbar/SnackbarAnimationUtils.kt create mode 100644 app/src/main/java/org/breezyweather/common/snackbar/SnackbarContainer.kt create mode 100644 app/src/main/java/org/breezyweather/common/snackbar/SnackbarManager.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/AddressSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/BroadcastSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/ConfigurableSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/FeatureSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/HttpSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/LocationParametersSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/LocationPositionWrapper.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/LocationResult.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/LocationSearchSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/LocationSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/NonFreeNetSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/PollenIndexSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/PreferencesParametersSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/RefreshError.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/ReverseGeocodingSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/Source.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/SourceExtensions.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/TimeZoneSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/WeatherResult.kt create mode 100644 app/src/main/java/org/breezyweather/common/source/WeatherSource.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/ColorUtils.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/CrashLogUtils.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/ISO8601Utils.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/UnitUtils.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/helpers/AsyncHelper.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/helpers/IntentHelper.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/helpers/LogHelper.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/helpers/PermissionHelper.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/helpers/ShortcutsHelper.kt create mode 100644 app/src/main/java/org/breezyweather/common/utils/helpers/SnackbarHelper.kt create mode 100644 app/src/main/java/org/breezyweather/data/Contributors.kt create mode 100644 app/src/main/java/org/breezyweather/domain/location/model/Location.kt create mode 100644 app/src/main/java/org/breezyweather/domain/settings/ConfigStore.kt create mode 100644 app/src/main/java/org/breezyweather/domain/settings/CurrentLocationStore.kt create mode 100644 app/src/main/java/org/breezyweather/domain/settings/SettingsManager.kt create mode 100644 app/src/main/java/org/breezyweather/domain/settings/SourceConfigStore.kt create mode 100644 app/src/main/java/org/breezyweather/domain/source/SourceContinent.kt create mode 100644 app/src/main/java/org/breezyweather/domain/source/SourceFeature.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/index/PollenIndex.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/index/PollutantIndex.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/AirQuality.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/Alert.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/Astro.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/Daily.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/DailyCloudCover.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/DailyDewPoint.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/DailyRelativeHumidity.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/DailyVisibility.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/Minutely.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/MoonPhase.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/Pollen.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/Precipitation.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/UV.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/Weather.kt create mode 100644 app/src/main/java/org/breezyweather/domain/weather/model/Wind.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/Notifications.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/Widgets.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/common/MaterialYouWidgetShape.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/common/WidgetSize.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/common/WidgetSizeUtils.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/AbstractWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/ClockDayDetailsWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/ClockDayHorizontalWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/ClockDayVerticalWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/ClockDayWeekWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/DailyTrendWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/DayWeekWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/DayWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/HourlyTrendWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/MultiCityWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/TextWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/config/WeekWidgetConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/AbstractRemoteViewsPresenter.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/ClockDayDetailsWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/ClockDayHorizontalWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/ClockDayVerticalWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/ClockDayWeekWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/DailyTrendWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/DayWeekWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/DayWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/HourlyTrendWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/MaterialYouCurrentWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/MaterialYouForecastWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/MultiCityWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/TextWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/WeekWidgetIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/notification/MultiCityWidgetNotificationIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/notification/NativeWidgetNotificationIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/presenters/notification/WidgetNotificationIMP.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/trend/TrendLinearLayout.kt create mode 100644 app/src/main/java/org/breezyweather/remoteviews/trend/WidgetItemView.kt create mode 100644 app/src/main/java/org/breezyweather/sources/CommonConverter.kt create mode 100644 app/src/main/java/org/breezyweather/sources/RefreshHelper.kt create mode 100644 app/src/main/java/org/breezyweather/sources/SourceManager.kt create mode 100644 app/src/main/java/org/breezyweather/sources/accu/AccuServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/accu/preferences/AccuPortalPreference.kt create mode 100644 app/src/main/java/org/breezyweather/sources/aemet/AemetServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/android/AndroidGeocoderServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/android/AndroidLocationService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/atmo/AtmoFranceServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/atmo/AtmoServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/baiduip/BaiduIPLocationServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/bmd/BmdServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/bmkg/BmkgServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/breezytz/BreezyTimeZoneService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/breezyupdatenotifier/BreezyUpdateNotifierService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/brightsky/BrightSkyApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/brightsky/BrightSkyService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/brightsky/json/BrightSkyAlert.kt create mode 100644 app/src/main/java/org/breezyweather/sources/brightsky/json/BrightSkyAlertsResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/brightsky/json/BrightSkyCurrentWeather.kt create mode 100644 app/src/main/java/org/breezyweather/sources/brightsky/json/BrightSkyCurrentWeatherResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/brightsky/json/BrightSkyWeather.kt create mode 100644 app/src/main/java/org/breezyweather/sources/brightsky/json/BrightSkyWeatherResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/china/ChinaServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/AnamBfService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/AnametService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/ClimWebApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/ClimWebService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/DccmsService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/DmnNeService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/DwrGmService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/EthioMetService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/GMetService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/IgebuService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/InmgbService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/MaliMeteoService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/MeteoBeninService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/MeteoTchadService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/MettelsatService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/MsdZwService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/SmaScService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/SmaSuService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/SsmsService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/json/ClimWebAlertsResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/json/ClimWebLocation.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/json/ClimWebNormals.kt create mode 100644 app/src/main/java/org/breezyweather/sources/climweb/serializers/ClimWebAnySerializer.kt create mode 100644 app/src/main/java/org/breezyweather/sources/common/xml/CapAlert.kt create mode 100644 app/src/main/java/org/breezyweather/sources/cwa/CwaServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/debug/DebugService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/dmi/DmiServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/eccc/EcccServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/ekuk/EkukServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/epdhk/EpdHkServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/fpas/FpasJsonApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/fpas/FpasService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/fpas/FpasXmlApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/gadgetbridge/GadgetbridgeService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/gadgetbridge/json/GadgetbridgeAirQuality.kt create mode 100644 app/src/main/java/org/breezyweather/sources/gadgetbridge/json/GadgetbridgeDailyForecast.kt create mode 100644 app/src/main/java/org/breezyweather/sources/gadgetbridge/json/GadgetbridgeData.kt create mode 100644 app/src/main/java/org/breezyweather/sources/gadgetbridge/json/GadgetbridgeHourlyForecast.kt create mode 100644 app/src/main/java/org/breezyweather/sources/geonames/GeoNamesServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/geosphereat/GeoSphereAtServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/hko/HkoServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/ilmateenistus/IlmateenistusServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/imd/ImdServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/ims/ImsServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/ipma/IpmaServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/ipsb/IpSbLocationServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/jma/JmaServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/lhmt/LhmtServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/lvgmc/LvgmcServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/meteoam/MeteoAmServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/meteolux/MeteoLuxServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/metie/MetIeServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/metno/MetNoServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/metoffice/MetOfficeServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/mf/MfServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/mgm/MgmServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/namem/NamemServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/naturalearth/NaturalEarthService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/ncdr/NcdrServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/ncei/NceiServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/nlsc/NlscServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/nominatim/NominatimApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/nominatim/NominatimService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/nominatim/json/NominatimAddress.kt create mode 100644 app/src/main/java/org/breezyweather/sources/nominatim/json/NominatimLocationResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/nws/NwsServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/OpenMeteoAirQualityApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/OpenMeteoForecastApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/OpenMeteoGeocodingApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/OpenMeteoService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/OpenMeteoWeatherModel.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoAirQualityHourly.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoAirQualityResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoLocationResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoLocationResults.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoWeatherCurrent.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoWeatherDaily.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoWeatherHourly.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoWeatherMinutely.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openmeteo/json/OpenMeteoWeatherResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/openweather/OpenWeatherServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pagasa/PagasaServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/PirateWeatherApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/PirateWeatherService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/json/PirateWeatherAlert.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/json/PirateWeatherCurrently.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/json/PirateWeatherDaily.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/json/PirateWeatherForecast.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/json/PirateWeatherForecastResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/json/PirateWeatherHourly.kt create mode 100644 app/src/main/java/org/breezyweather/sources/pirateweather/json/PirateWeatherMinutely.kt create mode 100644 app/src/main/java/org/breezyweather/sources/polleninfo/PollenInfoServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/GeoApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/RecosanteApi.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/RecosanteService.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/json/GeoCommune.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/json/RecosanteRaep.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/json/RecosanteRaepIndice.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/json/RecosanteRaepIndiceDetail.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/json/RecosanteRaepIndiceDetailIndice.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/json/RecosanteRaepValidity.kt create mode 100644 app/src/main/java/org/breezyweather/sources/recosante/json/RecosanteResult.kt create mode 100644 app/src/main/java/org/breezyweather/sources/smg/SmgServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/smhi/SmhiServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/veduris/VedurIsServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/sources/wmosevereweather/WmoSevereWeatherServiceStub.kt create mode 100644 app/src/main/java/org/breezyweather/ui/about/AboutActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/about/AboutScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/about/AboutViewModel.kt create mode 100644 app/src/main/java/org/breezyweather/ui/alert/AlertActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/alert/AlertScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/alert/AlertUiState.kt create mode 100644 app/src/main/java/org/breezyweather/ui/alert/AlertViewModel.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/adapters/ButtonAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/adapters/SyncListAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/adapters/TagAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/behaviors/FloatingAboveSnackbarBehavior.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/charts/AxisItemPlacer.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/charts/BreezyBarChart.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/charts/BreezyLineChart.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/charts/EphemerisChart.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/composables/AllergenComposables.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/composables/AnimatedVisibility.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/composables/Dialogs.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/composables/LocationPreferences.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/composables/NotificationCard.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/decorations/GridMarginsDecoration.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/decorations/ListDecoration.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/decorations/Material3ListItemDecoration.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/images/MoonDrawable.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/images/RotateDrawable.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/images/SunDrawable.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/AnimatableIconView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/ArcProgress.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/DayNightShaderWrapper.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/DrawerLayout.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/InkPageIndicator.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/Material3Widgets.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/NumberAnimTextView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/RoundProgress.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/SquareFrameLayout.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/SwipeSwitchLayout.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/astro/MoonPhaseView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/astro/SunMoonView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/insets/FitSystemBarAppBarLayout.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/insets/FitSystemBarComposeWrappers.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/slidingItem/SlidingItemContainerLayout.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/slidingItem/SlidingItemTouchCallback.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/TrendLayoutManager.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/TrendRecyclerView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/TrendRecyclerViewAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/chart/AbsChartItemView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/chart/DoubleHistogramView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/chart/PolylineAndHistogramView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/item/AbsTrendItemView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/item/DailyTrendItemView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/common/widgets/trend/item/HourlyTrendItemView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/DetailsActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/DetailsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/DetailsUiState.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/DetailsViewModel.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsAirQuality.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsCloudCover.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsCommon.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsConditions.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsHumidity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsPollen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsPrecipitation.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsPressure.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsSunMoon.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsUV.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsVisibility.kt create mode 100644 app/src/main/java/org/breezyweather/ui/details/components/DetailsWind.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/MainActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/MainActivityModels.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/MainActivityViewModel.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/location/LocationAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/location/LocationHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/location/LocationModel.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/MainAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/ViewType.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/AbstractMainCardViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/AbstractMainViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/AirQualityViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/AlertViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/AstroViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/ClockViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/DailyViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/FooterViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/HeaderViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/HourlyViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/HumidityViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/MoonViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/PollenViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/PrecipitationNowcastViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/PrecipitationViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/PressureViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/SunViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/UvViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/VisibilityViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/main/holder/WindViewHolder.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/DailyTrendAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/HourlyTrendAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/daily/AbsDailyTrendAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/daily/DailyAirQualityAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/daily/DailyFeelsLikeAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/daily/DailyPrecipitationAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/daily/DailySunshineAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/daily/DailyTemperatureAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/daily/DailyUVAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/daily/DailyWindAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/AbsHourlyTrendAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyAirQualityAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyCloudCoverAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyFeelsLikeAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyHumidityAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyPrecipitationAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyPressureAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyTemperatureAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyUVAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyVisibilityAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/adapters/trend/hourly/HourlyWindAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/dialogs/ErrorHelpDialog.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/dialogs/LocationHelpDialog.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/dialogs/SourceNoLongerAvailableHelpDialog.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/fragments/HomeFragment.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/fragments/MainModuleFragment.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/fragments/ManagementFragment.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/layouts/MainLayoutManager.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/utils/MainModuleUtils.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/utils/RefreshErrorType.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/utils/StatementManager.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/widgets/FitTabletRecyclerView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/widgets/LocationItemTouchCallback.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/widgets/NestedHorizontalRecyclerView.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/widgets/TextRelativeClock.kt create mode 100644 app/src/main/java/org/breezyweather/ui/main/widgets/TrendRecyclerViewScrollBar.kt create mode 100644 app/src/main/java/org/breezyweather/ui/search/LoadableLocationStatus.kt create mode 100644 app/src/main/java/org/breezyweather/ui/search/SearchActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/search/SearchActivityRepository.kt create mode 100644 app/src/main/java/org/breezyweather/ui/search/SearchViewModel.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/activities/CardDisplayManageActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/activities/DailyTrendDisplayManageActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/activities/DependenciesActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/activities/HourlyTrendDisplayManageActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/activities/PreviewIconActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/activities/PrivacyPolicyActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/activities/SettingsActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/activities/WorkerInfoActivity.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/adapters/CardDisplayAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/adapters/DailyTrendDisplayAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/adapters/HourlyTrendDisplayAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/adapters/WeatherIconAdapter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/AppBar.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/AppearanceSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/BackgroundUpdatesSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/DebugSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/LocationSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/MainScreenSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/ModulesSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/NotificationsSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/RootSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/SettingsScreenRouter.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/UnitSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/compose/WeatherSourcesSettingsScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/dialogs/AdaptiveIconDialog.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/dialogs/AnimatableIconDialog.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/dialogs/MinimalIconDialog.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/PreferenceItems.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/PreferenceToken.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/composables/EditTextPreference.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/composables/ListPreference.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/composables/MultiListPreference.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/composables/Preference.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/composables/PreferenceScreen.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/composables/Section.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/composables/SwitchPreference.kt create mode 100644 app/src/main/java/org/breezyweather/ui/settings/preference/composables/TimePickerPreference.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/ThemeManager.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/compose/Color.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/compose/Theme.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/compose/Type.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/ResourceHelper.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/ResourcesProviderFactory.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/providers/ChronusResourceProvider.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/providers/DefaultResourceProvider.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/providers/IconPackResourcesProvider.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/providers/ResourceProvider.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/utils/Config.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/utils/Constants.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/utils/ResourceUtils.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/resource/utils/XmlHelper.kt create mode 100644 app/src/main/java/org/breezyweather/ui/theme/weatherView/WeatherViewController.kt create mode 100644 app/src/main/java/org/breezyweather/wallpaper/LiveWallpaperConfigActivity.kt create mode 100644 app/src/main/java/org/breezyweather/wallpaper/LiveWallpaperConfigManager.kt create mode 100644 app/src/main/java/org/breezyweather/wallpaper/MaterialLiveWallpaperService.kt create mode 100644 app/src/main/res/anim/slide_in_left.xml create mode 100644 app/src/main/res/anim/slide_in_right.xml create mode 100644 app/src/main/res/anim/slide_out_left.xml create mode 100644 app/src/main/res/anim/slide_out_right.xml create mode 100644 app/src/main/res/animator/start_shine_1.xml create mode 100644 app/src/main/res/animator/start_shine_2.xml create mode 100644 app/src/main/res/animator/touch_raise.xml create mode 100644 app/src/main/res/animator/weather_clear_day_1.xml create mode 100644 app/src/main/res/animator/weather_clear_day_2.xml create mode 100644 app/src/main/res/animator/weather_clear_night_1.xml create mode 100644 app/src/main/res/animator/weather_cloudy_1.xml create mode 100644 app/src/main/res/animator/weather_cloudy_2.xml create mode 100644 app/src/main/res/animator/weather_fog_1.xml create mode 100644 app/src/main/res/animator/weather_fog_2.xml create mode 100644 app/src/main/res/animator/weather_fog_3.xml create mode 100644 app/src/main/res/animator/weather_hail_1.xml create mode 100644 app/src/main/res/animator/weather_hail_2.xml create mode 100644 app/src/main/res/animator/weather_hail_3.xml create mode 100644 app/src/main/res/animator/weather_haze_1.xml create mode 100644 app/src/main/res/animator/weather_haze_2.xml create mode 100644 app/src/main/res/animator/weather_haze_3.xml create mode 100644 app/src/main/res/animator/weather_partly_cloudy_day_1.xml create mode 100644 app/src/main/res/animator/weather_partly_cloudy_day_2.xml create mode 100644 app/src/main/res/animator/weather_partly_cloudy_day_3.xml create mode 100644 app/src/main/res/animator/weather_partly_cloudy_night_1.xml create mode 100644 app/src/main/res/animator/weather_partly_cloudy_night_2.xml create mode 100644 app/src/main/res/animator/weather_rain_1.xml create mode 100644 app/src/main/res/animator/weather_rain_2.xml create mode 100644 app/src/main/res/animator/weather_rain_3.xml create mode 100644 app/src/main/res/animator/weather_sleet_1.xml create mode 100644 app/src/main/res/animator/weather_sleet_2.xml create mode 100644 app/src/main/res/animator/weather_sleet_3.xml create mode 100644 app/src/main/res/animator/weather_snow_1.xml create mode 100644 app/src/main/res/animator/weather_snow_2.xml create mode 100644 app/src/main/res/animator/weather_snow_3.xml create mode 100644 app/src/main/res/animator/weather_thunder_1.xml create mode 100644 app/src/main/res/animator/weather_thunder_2.xml create mode 100644 app/src/main/res/animator/weather_thunderstorm_1.xml create mode 100644 app/src/main/res/animator/weather_thunderstorm_2.xml create mode 100644 app/src/main/res/animator/weather_thunderstorm_3.xml create mode 100644 app/src/main/res/animator/weather_wind_1.xml create mode 100644 app/src/main/res/drawable/arrow_east.xml create mode 100644 app/src/main/res/drawable/arrow_north.xml create mode 100644 app/src/main/res/drawable/arrow_north_east.xml create mode 100644 app/src/main/res/drawable/arrow_north_west.xml create mode 100644 app/src/main/res/drawable/arrow_south.xml create mode 100644 app/src/main/res/drawable/arrow_south_east.xml create mode 100644 app/src/main/res/drawable/arrow_south_west.xml create mode 100644 app/src/main/res/drawable/arrow_west.xml create mode 100644 app/src/main/res/drawable/clock_dial_dark.png create mode 100644 app/src/main/res/drawable/clock_dial_light.png create mode 100644 app/src/main/res/drawable/clock_hour_dark.png create mode 100644 app/src/main/res/drawable/clock_hour_light.png create mode 100644 app/src/main/res/drawable/clock_minute_dark.png create mode 100644 app/src/main/res/drawable/clock_minute_light.png create mode 100644 app/src/main/res/drawable/clock_shape.xml create mode 100644 app/src/main/res/drawable/humidity_percent_30.xml create mode 100644 app/src/main/res/drawable/humidity_percent_50.xml create mode 100644 app/src/main/res/drawable/humidity_percent_7.xml create mode 100644 app/src/main/res/drawable/humidity_percent_75.xml create mode 100644 app/src/main/res/drawable/humidity_percent_90.xml create mode 100644 app/src/main/res/drawable/ic_about.xml create mode 100644 app/src/main/res/drawable/ic_alert.xml create mode 100644 app/src/main/res/drawable/ic_allergy.xml create mode 100644 app/src/main/res/drawable/ic_arrow_downward_alt.xml create mode 100644 app/src/main/res/drawable/ic_arrow_upward_alt.xml create mode 100644 app/src/main/res/drawable/ic_bug_report.xml create mode 100644 app/src/main/res/drawable/ic_calendar.xml create mode 100644 app/src/main/res/drawable/ic_circle.xml create mode 100644 app/src/main/res/drawable/ic_close.xml create mode 100644 app/src/main/res/drawable/ic_cloud.xml create mode 100644 app/src/main/res/drawable/ic_code.xml create mode 100644 app/src/main/res/drawable/ic_contract.xml create mode 100644 app/src/main/res/drawable/ic_delete.xml create mode 100644 app/src/main/res/drawable/ic_device_thermostat.xml create mode 100644 app/src/main/res/drawable/ic_dew_point.xml create mode 100644 app/src/main/res/drawable/ic_drag.xml create mode 100644 app/src/main/res/drawable/ic_edit.xml create mode 100644 app/src/main/res/drawable/ic_equal.xml create mode 100644 app/src/main/res/drawable/ic_error.xml create mode 100644 app/src/main/res/drawable/ic_eye.xml create mode 100644 app/src/main/res/drawable/ic_factory.xml create mode 100644 app/src/main/res/drawable/ic_forum.xml create mode 100644 app/src/main/res/drawable/ic_gauge.xml create mode 100644 app/src/main/res/drawable/ic_help.xml create mode 100644 app/src/main/res/drawable/ic_home.xml create mode 100644 app/src/main/res/drawable/ic_humidity_percentage.xml create mode 100644 app/src/main/res/drawable/ic_launcher.webp create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 app/src/main/res/drawable/ic_launcher_round.webp create mode 100644 app/src/main/res/drawable/ic_list.xml create mode 100644 app/src/main/res/drawable/ic_location.xml create mode 100644 app/src/main/res/drawable/ic_mode_cool.xml create mode 100644 app/src/main/res/drawable/ic_mode_heat.xml create mode 100644 app/src/main/res/drawable/ic_more_vert.xml create mode 100644 app/src/main/res/drawable/ic_notifications.xml create mode 100644 app/src/main/res/drawable/ic_open_in_new.xml create mode 100644 app/src/main/res/drawable/ic_palette.xml create mode 100644 app/src/main/res/drawable/ic_precipitation.xml create mode 100644 app/src/main/res/drawable/ic_replay.xml create mode 100644 app/src/main/res/drawable/ic_running_in_background.xml create mode 100644 app/src/main/res/drawable/ic_schedule.xml create mode 100644 app/src/main/res/drawable/ic_search.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/drawable/ic_shield_lock.xml create mode 100644 app/src/main/res/drawable/ic_sunshine_duration.xml create mode 100644 app/src/main/res/drawable/ic_sync.xml create mode 100644 app/src/main/res/drawable/ic_time.xml create mode 100644 app/src/main/res/drawable/ic_toolbar_back.xml create mode 100644 app/src/main/res/drawable/ic_top.xml create mode 100644 app/src/main/res/drawable/ic_twilight.xml create mode 100644 app/src/main/res/drawable/ic_umbrella.xml create mode 100644 app/src/main/res/drawable/ic_uv.xml create mode 100644 app/src/main/res/drawable/ic_warning.xml create mode 100644 app/src/main/res/drawable/ic_water.xml create mode 100644 app/src/main/res/drawable/ic_water_percent.xml create mode 100644 app/src/main/res/drawable/ic_widgets.xml create mode 100644 app/src/main/res/drawable/ic_wind.xml create mode 100644 app/src/main/res/drawable/live_wallpaper_thumbnail.png create mode 100644 app/src/main/res/drawable/selectable_item_background.xml create mode 100644 app/src/main/res/drawable/selectable_item_background_borderless.xml create mode 100644 app/src/main/res/drawable/shortcuts_clear_day.png create mode 100644 app/src/main/res/drawable/shortcuts_clear_day_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_clear_night.png create mode 100644 app/src/main/res/drawable/shortcuts_clear_night_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_cloudy.png create mode 100644 app/src/main/res/drawable/shortcuts_cloudy_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_fog.png create mode 100644 app/src/main/res/drawable/shortcuts_fog_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_hail.png create mode 100644 app/src/main/res/drawable/shortcuts_hail_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_haze.png create mode 100644 app/src/main/res/drawable/shortcuts_haze_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_partly_cloudy_day.png create mode 100644 app/src/main/res/drawable/shortcuts_partly_cloudy_day_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_partly_cloudy_night.png create mode 100644 app/src/main/res/drawable/shortcuts_partly_cloudy_night_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_rain.png create mode 100644 app/src/main/res/drawable/shortcuts_rain_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_refresh.png create mode 100644 app/src/main/res/drawable/shortcuts_refresh_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_sleet.png create mode 100644 app/src/main/res/drawable/shortcuts_sleet_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_snow.png create mode 100644 app/src/main/res/drawable/shortcuts_snow_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_thunder.png create mode 100644 app/src/main/res/drawable/shortcuts_thunder_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_thunderstorm.png create mode 100644 app/src/main/res/drawable/shortcuts_thunderstorm_foreground.png create mode 100644 app/src/main/res/drawable/shortcuts_wind.png create mode 100644 app/src/main/res/drawable/shortcuts_wind_foreground.png create mode 100644 app/src/main/res/drawable/star_1.png create mode 100644 app/src/main/res/drawable/star_2.png create mode 100644 app/src/main/res/drawable/uv_extreme.xml create mode 100644 app/src/main/res/drawable/uv_high.xml create mode 100644 app/src/main/res/drawable/uv_low.xml create mode 100644 app/src/main/res/drawable/uv_moderate.xml create mode 100644 app/src/main/res/drawable/uv_unknown.xml create mode 100644 app/src/main/res/drawable/uv_very_high.xml create mode 100644 app/src/main/res/drawable/visibility_shape.xml create mode 100644 app/src/main/res/drawable/weather_clear_day.png create mode 100644 app/src/main/res/drawable/weather_clear_day_1.png create mode 100644 app/src/main/res/drawable/weather_clear_day_2.png create mode 100644 app/src/main/res/drawable/weather_clear_day_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_clear_day_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_clear_day_mini_light.png create mode 100644 app/src/main/res/drawable/weather_clear_day_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_clear_night.png create mode 100644 app/src/main/res/drawable/weather_clear_night_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_clear_night_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_clear_night_mini_light.png create mode 100644 app/src/main/res/drawable/weather_clear_night_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_cloudy.png create mode 100644 app/src/main/res/drawable/weather_cloudy_1.png create mode 100644 app/src/main/res/drawable/weather_cloudy_2.png create mode 100644 app/src/main/res/drawable/weather_cloudy_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_cloudy_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_cloudy_mini_light.png create mode 100644 app/src/main/res/drawable/weather_cloudy_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_fog.png create mode 100644 app/src/main/res/drawable/weather_fog_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_fog_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_fog_mini_light.png create mode 100644 app/src/main/res/drawable/weather_fog_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_hail.png create mode 100644 app/src/main/res/drawable/weather_hail_1.png create mode 100644 app/src/main/res/drawable/weather_hail_2.png create mode 100644 app/src/main/res/drawable/weather_hail_3.png create mode 100644 app/src/main/res/drawable/weather_hail_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_hail_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_hail_mini_light.png create mode 100644 app/src/main/res/drawable/weather_hail_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_haze.png create mode 100644 app/src/main/res/drawable/weather_haze_1.png create mode 100644 app/src/main/res/drawable/weather_haze_2.png create mode 100644 app/src/main/res/drawable/weather_haze_3.png create mode 100644 app/src/main/res/drawable/weather_haze_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_haze_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_haze_mini_light.png create mode 100644 app/src/main/res/drawable/weather_haze_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_day.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_day_1.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_day_2.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_day_3.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_day_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_day_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_day_mini_light.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_day_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_night.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_night_1.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_night_2.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_night_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_night_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_night_mini_light.png create mode 100644 app/src/main/res/drawable/weather_partly_cloudy_night_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_rain.png create mode 100644 app/src/main/res/drawable/weather_rain_1.png create mode 100644 app/src/main/res/drawable/weather_rain_2.png create mode 100644 app/src/main/res/drawable/weather_rain_3.png create mode 100644 app/src/main/res/drawable/weather_rain_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_rain_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_rain_mini_light.png create mode 100644 app/src/main/res/drawable/weather_rain_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_sleet.png create mode 100644 app/src/main/res/drawable/weather_sleet_1.png create mode 100644 app/src/main/res/drawable/weather_sleet_2.png create mode 100644 app/src/main/res/drawable/weather_sleet_3.png create mode 100644 app/src/main/res/drawable/weather_sleet_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_sleet_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_sleet_mini_light.png create mode 100644 app/src/main/res/drawable/weather_sleet_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_snow.png create mode 100644 app/src/main/res/drawable/weather_snow_1.png create mode 100644 app/src/main/res/drawable/weather_snow_2.png create mode 100644 app/src/main/res/drawable/weather_snow_3.png create mode 100644 app/src/main/res/drawable/weather_snow_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_snow_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_snow_mini_light.png create mode 100644 app/src/main/res/drawable/weather_snow_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_thunder.png create mode 100644 app/src/main/res/drawable/weather_thunder_1.png create mode 100644 app/src/main/res/drawable/weather_thunder_2.png create mode 100644 app/src/main/res/drawable/weather_thunder_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_thunder_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_thunder_mini_light.png create mode 100644 app/src/main/res/drawable/weather_thunder_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_thunderstorm.png create mode 100644 app/src/main/res/drawable/weather_thunderstorm_1.png create mode 100644 app/src/main/res/drawable/weather_thunderstorm_2.png create mode 100644 app/src/main/res/drawable/weather_thunderstorm_3.png create mode 100644 app/src/main/res/drawable/weather_thunderstorm_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_thunderstorm_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_thunderstorm_mini_light.png create mode 100644 app/src/main/res/drawable/weather_thunderstorm_mini_xml.xml create mode 100644 app/src/main/res/drawable/weather_wind.png create mode 100644 app/src/main/res/drawable/weather_wind_mini_dark.png create mode 100644 app/src/main/res/drawable/weather_wind_mini_grey.png create mode 100644 app/src/main/res/drawable/weather_wind_mini_light.png create mode 100644 app/src/main/res/drawable/weather_wind_mini_xml.xml create mode 100644 app/src/main/res/drawable/widget_card_dark.xml create mode 100644 app/src/main/res/drawable/widget_card_follow_system.xml create mode 100644 app/src/main/res/drawable/widget_card_light.xml create mode 100644 app/src/main/res/drawable/widget_clock_day_details.png create mode 100644 app/src/main/res/drawable/widget_clock_day_horizontal.png create mode 100644 app/src/main/res/drawable/widget_clock_day_vertical.png create mode 100644 app/src/main/res/drawable/widget_clock_day_week.png create mode 100644 app/src/main/res/drawable/widget_day.png create mode 100644 app/src/main/res/drawable/widget_day_week.png create mode 100644 app/src/main/res/drawable/widget_m3_background.xml create mode 100644 app/src/main/res/drawable/widget_m3_current_background.xml create mode 100644 app/src/main/res/drawable/widget_multi_city.png create mode 100644 app/src/main/res/drawable/widget_text.png create mode 100644 app/src/main/res/drawable/widget_trend_daily.png create mode 100644 app/src/main/res/drawable/widget_trend_hourly.png create mode 100644 app/src/main/res/drawable/widget_week.png create mode 100644 app/src/main/res/drawable/wind_arrow.xml create mode 100644 app/src/main/res/drawable/wind_variable.xml create mode 100644 app/src/main/res/font-v26/robotoflex_family.xml create mode 100644 app/src/main/res/font/asap_condensed_bold.ttf create mode 100644 app/src/main/res/font/robotoflex.ttf create mode 100644 app/src/main/res/font/robotoflex_family.xml create mode 100644 app/src/main/res/layout-v26/container_main_pressure.xml create mode 100644 app/src/main/res/layout-w640dp/activity_main.xml create mode 100644 app/src/main/res/layout-w640dp/fragment_home.xml create mode 100644 app/src/main/res/layout/activity_card_display_manage.xml create mode 100644 app/src/main/res/layout/activity_daily_trend_display_manage.xml create mode 100644 app/src/main/res/layout/activity_hourly_trend_display_manage.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_preview_icon.xml create mode 100644 app/src/main/res/layout/activity_widget_config.xml create mode 100644 app/src/main/res/layout/container_main_air_quality.xml create mode 100644 app/src/main/res/layout/container_main_alert.xml create mode 100644 app/src/main/res/layout/container_main_astro.xml create mode 100644 app/src/main/res/layout/container_main_clock.xml create mode 100644 app/src/main/res/layout/container_main_daily_trend_card.xml create mode 100644 app/src/main/res/layout/container_main_header.xml create mode 100644 app/src/main/res/layout/container_main_hourly_trend_card.xml create mode 100644 app/src/main/res/layout/container_main_humidity.xml create mode 100644 app/src/main/res/layout/container_main_pollen.xml create mode 100644 app/src/main/res/layout/container_main_precipitation.xml create mode 100644 app/src/main/res/layout/container_main_precipitation_nowcast_card.xml create mode 100644 app/src/main/res/layout/container_main_pressure.xml create mode 100644 app/src/main/res/layout/container_main_uv.xml create mode 100644 app/src/main/res/layout/container_main_visibility.xml create mode 100644 app/src/main/res/layout/container_main_wind.xml create mode 100644 app/src/main/res/layout/container_snackbar_layout.xml create mode 100644 app/src/main/res/layout/container_snackbar_layout_card.xml create mode 100644 app/src/main/res/layout/container_snackbar_layout_inner.xml create mode 100644 app/src/main/res/layout/container_snackbar_layout_inner_card.xml create mode 100644 app/src/main/res/layout/dialog_adaptive_icon.xml create mode 100644 app/src/main/res/layout/dialog_animatable_icon.xml create mode 100644 app/src/main/res/layout/dialog_error_help.xml create mode 100644 app/src/main/res/layout/dialog_location_help.xml create mode 100644 app/src/main/res/layout/dialog_minimal_icon.xml create mode 100644 app/src/main/res/layout/dialog_source_no_longer_available_help.xml create mode 100644 app/src/main/res/layout/fragment_home.xml create mode 100644 app/src/main/res/layout/item_button.xml create mode 100644 app/src/main/res/layout/item_card_display.xml create mode 100644 app/src/main/res/layout/item_line.xml create mode 100644 app/src/main/res/layout/item_location_card.xml create mode 100644 app/src/main/res/layout/item_pollen_daily.xml create mode 100644 app/src/main/res/layout/item_tag.xml create mode 100644 app/src/main/res/layout/item_trend_daily.xml create mode 100644 app/src/main/res/layout/item_trend_hourly.xml create mode 100644 app/src/main/res/layout/item_weather_icon.xml create mode 100644 app/src/main/res/layout/item_weather_icon_title.xml create mode 100644 app/src/main/res/layout/notification_base.xml create mode 100644 app/src/main/res/layout/notification_big.xml create mode 100644 app/src/main/res/layout/notification_multi_city.xml create mode 100644 app/src/main/res/layout/widget_clock_day_details.xml create mode 100644 app/src/main/res/layout/widget_clock_day_details_card.xml create mode 100644 app/src/main/res/layout/widget_clock_day_horizontal.xml create mode 100644 app/src/main/res/layout/widget_clock_day_horizontal_card.xml create mode 100644 app/src/main/res/layout/widget_clock_day_mini.xml create mode 100644 app/src/main/res/layout/widget_clock_day_mini_card.xml create mode 100644 app/src/main/res/layout/widget_clock_day_rectangle.xml create mode 100644 app/src/main/res/layout/widget_clock_day_rectangle_card.xml create mode 100644 app/src/main/res/layout/widget_clock_day_symmetry.xml create mode 100644 app/src/main/res/layout/widget_clock_day_symmetry_card.xml create mode 100644 app/src/main/res/layout/widget_clock_day_temp.xml create mode 100644 app/src/main/res/layout/widget_clock_day_temp_card.xml create mode 100644 app/src/main/res/layout/widget_clock_day_tile.xml create mode 100644 app/src/main/res/layout/widget_clock_day_tile_card.xml create mode 100644 app/src/main/res/layout/widget_clock_day_vertical.xml create mode 100644 app/src/main/res/layout/widget_clock_day_vertical_card.xml create mode 100644 app/src/main/res/layout/widget_clock_day_week.xml create mode 100644 app/src/main/res/layout/widget_clock_day_week_card.xml create mode 100644 app/src/main/res/layout/widget_day_mini.xml create mode 100644 app/src/main/res/layout/widget_day_mini_card.xml create mode 100644 app/src/main/res/layout/widget_day_nano.xml create mode 100644 app/src/main/res/layout/widget_day_nano_card.xml create mode 100644 app/src/main/res/layout/widget_day_oreo.xml create mode 100644 app/src/main/res/layout/widget_day_oreo_card.xml create mode 100644 app/src/main/res/layout/widget_day_pixel.xml create mode 100644 app/src/main/res/layout/widget_day_pixel_card.xml create mode 100644 app/src/main/res/layout/widget_day_rectangle.xml create mode 100644 app/src/main/res/layout/widget_day_rectangle_card.xml create mode 100644 app/src/main/res/layout/widget_day_symmetry.xml create mode 100644 app/src/main/res/layout/widget_day_symmetry_card.xml create mode 100644 app/src/main/res/layout/widget_day_temp.xml create mode 100644 app/src/main/res/layout/widget_day_temp_card.xml create mode 100644 app/src/main/res/layout/widget_day_tile.xml create mode 100644 app/src/main/res/layout/widget_day_tile_card.xml create mode 100644 app/src/main/res/layout/widget_day_vertical.xml create mode 100644 app/src/main/res/layout/widget_day_vertical_card.xml create mode 100644 app/src/main/res/layout/widget_day_week_rectangle.xml create mode 100644 app/src/main/res/layout/widget_day_week_rectangle_card.xml create mode 100644 app/src/main/res/layout/widget_day_week_symmetry.xml create mode 100644 app/src/main/res/layout/widget_day_week_symmetry_card.xml create mode 100644 app/src/main/res/layout/widget_day_week_tile.xml create mode 100644 app/src/main/res/layout/widget_day_week_tile_card.xml create mode 100644 app/src/main/res/layout/widget_init.xml create mode 100644 app/src/main/res/layout/widget_material_you_current.xml create mode 100644 app/src/main/res/layout/widget_material_you_current_preview.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_1x1.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_2x1.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_2x2.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_3x1.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_3x2.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_4x1.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_4x2.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_4x3.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_5x2.xml create mode 100644 app/src/main/res/layout/widget_material_you_forecast_5x3.xml create mode 100644 app/src/main/res/layout/widget_multi_city_horizontal.xml create mode 100644 app/src/main/res/layout/widget_multi_city_horizontal_card.xml create mode 100644 app/src/main/res/layout/widget_remote.xml create mode 100644 app/src/main/res/layout/widget_text.xml create mode 100644 app/src/main/res/layout/widget_text_end.xml create mode 100644 app/src/main/res/layout/widget_trend_daily.xml create mode 100644 app/src/main/res/layout/widget_trend_hourly.xml create mode 100644 app/src/main/res/layout/widget_week.xml create mode 100644 app/src/main/res/layout/widget_week_3.xml create mode 100644 app/src/main/res/layout/widget_week_3_card.xml create mode 100644 app/src/main/res/layout/widget_week_card.xml create mode 100644 app/src/main/res/menu-w640dp/activity_main.xml create mode 100644 app/src/main/res/menu/activity_main.xml create mode 100644 app/src/main/res/menu/activity_preview_icon.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/raw/breezytz_ar.json create mode 100644 app/src/main/res/raw/breezytz_au.json create mode 100644 app/src/main/res/raw/breezytz_br.json create mode 100644 app/src/main/res/raw/breezytz_ca.json create mode 100644 app/src/main/res/raw/breezytz_cd.json create mode 100644 app/src/main/res/raw/breezytz_cl.json create mode 100644 app/src/main/res/raw/breezytz_cn.json create mode 100644 app/src/main/res/raw/breezytz_cy.json create mode 100644 app/src/main/res/raw/breezytz_ec.json create mode 100644 app/src/main/res/raw/breezytz_es.json create mode 100644 app/src/main/res/raw/breezytz_fm.json create mode 100644 app/src/main/res/raw/breezytz_id.json create mode 100644 app/src/main/res/raw/breezytz_ki.json create mode 100644 app/src/main/res/raw/breezytz_kz.json create mode 100644 app/src/main/res/raw/breezytz_mn.json create mode 100644 app/src/main/res/raw/breezytz_mx.json create mode 100644 app/src/main/res/raw/breezytz_my.json create mode 100644 app/src/main/res/raw/breezytz_nz.json create mode 100644 app/src/main/res/raw/breezytz_pf.json create mode 100644 app/src/main/res/raw/breezytz_pg.json create mode 100644 app/src/main/res/raw/breezytz_ps.json create mode 100644 app/src/main/res/raw/breezytz_pt.json create mode 100644 app/src/main/res/raw/breezytz_ru.json create mode 100644 app/src/main/res/raw/breezytz_ua.json create mode 100644 app/src/main/res/raw/breezytz_us.json create mode 100644 app/src/main/res/raw/isrg_root_x1.pem create mode 100644 app/src/main/res/raw/isrg_root_x2.pem create mode 100644 app/src/main/res/raw/ne_50m_admin_0_countries.json create mode 100644 app/src/main/res/values-ar/strings.xml create mode 100644 app/src/main/res/values-be/strings.xml create mode 100644 app/src/main/res/values-bg/strings.xml create mode 100644 app/src/main/res/values-bn/strings.xml create mode 100644 app/src/main/res/values-bs/strings.xml create mode 100644 app/src/main/res/values-ca/strings.xml create mode 100644 app/src/main/res/values-ckb/strings.xml create mode 100644 app/src/main/res/values-cs/strings.xml create mode 100644 app/src/main/res/values-da/strings.xml create mode 100644 app/src/main/res/values-de/strings.xml create mode 100644 app/src/main/res/values-el/strings.xml create mode 100644 app/src/main/res/values-en-rAU/strings.xml create mode 100644 app/src/main/res/values-en-rCA/strings.xml create mode 100644 app/src/main/res/values-en-rGB/strings.xml create mode 100644 app/src/main/res/values-en-rUS/strings.xml create mode 100644 app/src/main/res/values-eo/strings.xml create mode 100644 app/src/main/res/values-es/strings.xml create mode 100644 app/src/main/res/values-et/strings.xml create mode 100644 app/src/main/res/values-eu/strings.xml create mode 100644 app/src/main/res/values-fa/strings.xml create mode 100644 app/src/main/res/values-fi/strings.xml create mode 100644 app/src/main/res/values-fr/strings.xml create mode 100644 app/src/main/res/values-ga/strings.xml create mode 100644 app/src/main/res/values-gl/strings.xml create mode 100644 app/src/main/res/values-he/strings.xml create mode 100644 app/src/main/res/values-hi/strings.xml create mode 100644 app/src/main/res/values-hr/strings.xml create mode 100644 app/src/main/res/values-hu/strings.xml create mode 100644 app/src/main/res/values-ia/strings.xml create mode 100644 app/src/main/res/values-id/strings.xml create mode 100644 app/src/main/res/values-is/strings.xml create mode 100644 app/src/main/res/values-it/strings.xml create mode 100644 app/src/main/res/values-ja/strings.xml create mode 100644 app/src/main/res/values-kab/strings.xml create mode 100644 app/src/main/res/values-ko/strings.xml create mode 100644 app/src/main/res/values-lt/strings.xml create mode 100644 app/src/main/res/values-lv/strings.xml create mode 100644 app/src/main/res/values-mk/strings.xml create mode 100644 app/src/main/res/values-mr/strings.xml create mode 100644 app/src/main/res/values-nb-rNO/strings.xml create mode 100644 app/src/main/res/values-night-v31/colors.xml create mode 100644 app/src/main/res/values-night-v34/colors.xml create mode 100644 app/src/main/res/values-night-v35/colors.xml create mode 100644 app/src/main/res/values-night/colors.xml create mode 100644 app/src/main/res/values-night/styles.xml create mode 100644 app/src/main/res/values-nl/strings.xml create mode 100644 app/src/main/res/values-oc/strings.xml create mode 100644 app/src/main/res/values-pl/strings.xml create mode 100644 app/src/main/res/values-pt-rBR/strings.xml create mode 100644 app/src/main/res/values-pt/strings.xml create mode 100644 app/src/main/res/values-ro/strings.xml create mode 100644 app/src/main/res/values-ru/strings.xml create mode 100644 app/src/main/res/values-sk/strings.xml create mode 100644 app/src/main/res/values-sl-rSI/strings.xml create mode 100644 app/src/main/res/values-sr/strings.xml create mode 100644 app/src/main/res/values-sv/strings.xml create mode 100644 app/src/main/res/values-ta/strings.xml create mode 100644 app/src/main/res/values-th/strings.xml create mode 100644 app/src/main/res/values-tr/strings.xml create mode 100644 app/src/main/res/values-uk/strings.xml create mode 100644 app/src/main/res/values-v26/styles.xml create mode 100644 app/src/main/res/values-v28/styles.xml create mode 100644 app/src/main/res/values-v29/styles.xml create mode 100644 app/src/main/res/values-v31/colors.xml create mode 100644 app/src/main/res/values-v31/dimens.xml create mode 100644 app/src/main/res/values-v31/themes.xml create mode 100644 app/src/main/res/values-v34/colors.xml create mode 100644 app/src/main/res/values-v35/colors.xml create mode 100644 app/src/main/res/values-vi/strings.xml create mode 100644 app/src/main/res/values-xhdpi/dimens.xml create mode 100644 app/src/main/res/values-xxhdpi/dimens.xml create mode 100644 app/src/main/res/values-xxxhdpi/dimens.xml create mode 100644 app/src/main/res/values-zh-rCN/strings.xml create mode 100644 app/src/main/res/values-zh-rHK/strings.xml create mode 100644 app/src/main/res/values-zh-rTW/strings.xml create mode 100644 app/src/main/res/values/arrays.xml create mode 100644 app/src/main/res/values/attrs.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/ids.xml create mode 100644 app/src/main/res/values/keys.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml-v28/widget_clock_day_details.xml create mode 100644 app/src/main/res/xml-v28/widget_clock_day_horizontal.xml create mode 100644 app/src/main/res/xml-v28/widget_clock_day_vertical.xml create mode 100644 app/src/main/res/xml-v28/widget_clock_day_week.xml create mode 100644 app/src/main/res/xml-v28/widget_day.xml create mode 100644 app/src/main/res/xml-v28/widget_day_week.xml create mode 100644 app/src/main/res/xml-v28/widget_multi_city.xml create mode 100644 app/src/main/res/xml-v28/widget_text.xml create mode 100644 app/src/main/res/xml-v28/widget_trend_daily.xml create mode 100644 app/src/main/res/xml-v28/widget_trend_hourly.xml create mode 100644 app/src/main/res/xml-v28/widget_week.xml create mode 100644 app/src/main/res/xml/icon_provider_animator_filter.xml create mode 100644 app/src/main/res/xml/icon_provider_config.xml create mode 100644 app/src/main/res/xml/icon_provider_drawable_filter.xml create mode 100644 app/src/main/res/xml/icon_provider_shortcut_filter.xml create mode 100644 app/src/main/res/xml/icon_provider_sun_moon_filter.xml create mode 100644 app/src/main/res/xml/live_wallpaper.xml create mode 100644 app/src/main/res/xml/provider_paths.xml create mode 100644 app/src/main/res/xml/widget_clock_day_details.xml create mode 100644 app/src/main/res/xml/widget_clock_day_horizontal.xml create mode 100644 app/src/main/res/xml/widget_clock_day_vertical.xml create mode 100644 app/src/main/res/xml/widget_clock_day_week.xml create mode 100644 app/src/main/res/xml/widget_day.xml create mode 100644 app/src/main/res/xml/widget_day_week.xml create mode 100644 app/src/main/res/xml/widget_material_you_current.xml create mode 100644 app/src/main/res/xml/widget_material_you_forecast.xml create mode 100644 app/src/main/res/xml/widget_multi_city.xml create mode 100644 app/src/main/res/xml/widget_text.xml create mode 100644 app/src/main/res/xml/widget_trend_daily.xml create mode 100644 app/src/main/res/xml/widget_trend_hourly.xml create mode 100644 app/src/main/res/xml/widget_week.xml create mode 100644 app/src/res_freenet/xml/network_security_config.xml create mode 100644 app/src/res_nonfreenet/drawable-night/accu_icon.xml create mode 100644 app/src/res_nonfreenet/drawable/accu_icon.xml create mode 100644 app/src/res_nonfreenet/xml/network_security_config.xml create mode 100644 app/src/src_freenet/org/breezyweather/sources/accu/AccuService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/aemet/AemetService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/android/AndroidGeocoderService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/atmo/AtmoAuraService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/atmo/AtmoFranceService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/atmo/AtmoGrandEstService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/atmo/AtmoHdfService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/atmo/AtmoService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/atmo/AtmoSudService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/baiduip/BaiduIPLocationService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/bmd/BmdService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/bmkg/BmkgService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/china/ChinaService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/cwa/CwaService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/dmi/DmiService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/eccc/EcccService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/ekuk/EkukService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/epdhk/EpdHkService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/geonames/GeoNamesService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/geosphereat/GeoSphereAtService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/hko/HkoService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/ilmateenistus/IlmateenistusService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/imd/ImdService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/ims/ImsService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/ipma/IpmaService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/ipsb/IpSbLocationService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/jma/JmaService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/lhmt/LhmtService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/lvgmc/LvgmcService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/meteoam/MeteoAmService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/meteolux/MeteoLuxService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/metie/MetIeService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/metno/MetNoService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/metoffice/MetOfficeService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/mf/MfService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/mgm/MgmService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/namem/NamemService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/ncdr/NcdrService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/ncei/NceiService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/nlsc/NlscService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/nws/NwsService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/openweather/OpenWeatherService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/pagasa/PagasaService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/polleninfo/PollenInfoService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/smg/SmgService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/smhi/SmhiService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/veduris/VedurIsService.kt create mode 100644 app/src/src_freenet/org/breezyweather/sources/wmosevereweather/WmoSevereWeatherService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/AccuDeveloperApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/AccuEnterpriseApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/AccuService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuAirQualityConcentration.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuAirQualityData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuAirQualityPollutant.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuAirQualityResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuAlertArea.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuAlertDescription.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuAlertResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuClimoNormals.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuClimoNormalsTemperatures.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuClimoSummaryResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuColor.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuCurrentPrecipitationSummary.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuCurrentTemperaturePast24HourRange.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuCurrentTemperatureSummary.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuCurrentWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuCurrentWindDirection.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuCurrentWindGust.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastAirAndPollen.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastDailyForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastDailyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastDegreeDaySummary.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastHalfDay.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastHeadline.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastHourlyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastTemperature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuForecastWindDirection.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuLocationArea.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuLocationGeoPosition.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuLocationTimeZone.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuMinutelyInterval.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuMinutelyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuMinutelySummary.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuValue.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/json/AccuValueContainer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/preferences/AccuDaysPreference.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/accu/preferences/AccuHoursPreference.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/AemetApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/AemetService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetApiResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetDailyData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetDailyDay.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetDailyPrediction.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetDailyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetForecastData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetHourlyDay.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetHourlyPrediction.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetHourlyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetNormalsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/aemet/json/AemetStationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/android/AndroidGeocoderService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/AtmoApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/AtmoAuraService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/AtmoFranceApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/AtmoFranceService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/AtmoGrandEstService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/AtmoHdfService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/AtmoService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/AtmoSudService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/GeoApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/AtmoFrancePollenFeature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/AtmoFrancePollenProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/AtmoFrancePollenResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/AtmoPointHoraire.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/AtmoPointPolluant.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/AtmoPointResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/GeoFeature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/GeoProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/atmo/json/GeoResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/baiduip/BaiduIPLocationApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/baiduip/BaiduIPLocationService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/baiduip/json/BaiduIPLocationContent.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/baiduip/json/BaiduIPLocationContentPoint.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/baiduip/json/BaiduIPLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmd/BmdApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmd/BmdService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmd/json/BmdData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmd/json/BmdForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmd/json/BmdForecastData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmd/json/BmdForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/BmkgApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/BmkgAppApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/BmkgService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgCuaca.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgCurrentData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgForecastData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgIbfData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgIbfMessage.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgIbfResponse.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgIbfResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgPm25Result.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgWarningData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgWarningDescription.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgWarningResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/bmkg/json/BmkgWarningToday.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/ChinaApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/ChinaService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaAlert.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaAqi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaCurrent.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaCurrentWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaDailyWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaForecastDaily.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaForecastHourly.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaFromTo.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaHourlyWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaHourlyWindValue.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaMinutelyPrecipitation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaMinutelyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaPrecipitationProbability.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaUnitValue.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaValueListChinaFromTo.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaValueListInt.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/china/json/ChinaYesterday.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/CwaApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/CwaGeoLookup.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/CwaService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAirQualityAqi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAirQualityData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAirQualityResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertAffectedAreas.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertContent.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertContents.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertDatasetInfo.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertHazard.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertHazardConditions.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertHazards.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertInfo.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertLocation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertRecord.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertRecords.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAlertValidTime.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAssistantDataSet.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAssistantOpenData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAssistantParameter.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAssistantParameterSet.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaAssistantResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaCurrentCoordinates.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaCurrentGeoInfo.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaCurrentGustInfo.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaCurrentRecords.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaCurrentStation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaCurrentWeatherElement.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaForecastElementValue.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaForecastLocation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaForecastLocations.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaForecastRecords.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaForecastTime.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaForecastWeatherElement.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaLocationAqi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaLocationData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaLocationStation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaLocationTown.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaNormalsAirTemperature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaNormalsData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaNormalsLocation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaNormalsMonthly.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaNormalsRecords.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaNormalsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaNormalsStationObsStatistics.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/cwa/json/CwaNormalsSurfaceObs.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/dmi/DmiApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/dmi/DmiService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/dmi/json/DmiResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/dmi/json/DmiTimeserie.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/dmi/json/DmiWarning.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/dmi/json/DmiWarningResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/EcccApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/EcccService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccAlert.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccAlertSpecialText.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccAlerts.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccDaily.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccDailyFcst.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccDailyTemperature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccEpochTime.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccHourly.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccHourlyFcst.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccObservation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccRegionalNormals.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccRegionalNormalsMetric.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccRiseSet.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccSun.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccUnit.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccUv.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/json/EcccValueUnit.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/serializers/EcccEpochTimeSerializer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/eccc/serializers/EcccSunSerializer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ekuk/EkukApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ekuk/EkukService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ekuk/json/EkukObservationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ekuk/json/EkukStation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ekuk/json/EkukStationGeometry.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ekuk/json/EkukStationProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ekuk/json/EkukStationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/epdhk/EpdHkApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/epdhk/EpdHkService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/epdhk/xml/EpdHkConcentrationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geonames/GeoNamesApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geonames/GeoNamesService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geonames/json/GeoNamesAlternateName.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geonames/json/GeoNamesLocation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geonames/json/GeoNamesSearchResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geonames/json/GeoNamesStatus.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geonames/json/GeoNamesTimeZone.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/GeoSphereAtApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/GeoSphereAtService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/GeoSphereAtWarningApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtHourlyDoubleParameter.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtHourlyFeature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtHourlyParameters.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtHourlyProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtTimeseriesResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtWarningsProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtWarningsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtWarningsWarning.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtWarningsWarningProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/geosphereat/json/GeoSphereAtWarningsWarningPropertiesRawInfo.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/HkoApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/HkoMapsApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/HkoService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoCurrentPressure.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoCurrentRH.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoCurrentRegionalWeather.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoCurrentTemp.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoCurrentWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoDailyForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoHourlyWeatherForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoLocationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoNormalsData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoNormalsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoNormalsStn.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoOneJsonF9d.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoOneJsonFlw.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoOneJsonResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoOneJsonRhrread.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoOneJsonWeatherForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoWarningResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoWarningSummary.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/json/HkoWarningSummaryResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/hko/serializers/HkoAnySerializer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ilmateenistus/IlmateenistusApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ilmateenistus/IlmateenistusService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ilmateenistus/json/IlmateenistusForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ilmateenistus/json/IlmateenistusForecastAttributes.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ilmateenistus/json/IlmateenistusForecastItem.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ilmateenistus/json/IlmateenistusForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ilmateenistus/json/IlmateenistusForecastTabular.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ilmateenistus/json/IlmateenistusForecastTime.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/imd/ImdApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/imd/ImdService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/imd/json/ImdWeatherResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/imd/serializers/ImdAnySerializer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/ImsApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/ImsService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsAnalysis.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsCountry.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsDaily.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsForecastData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsHourly.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsLocation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsWarning.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsWarningSeverity.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsWarningType.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsWarningsMetadata.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsWeatherCode.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsWeatherData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsWeatherResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ims/json/ImsWindDirection.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipma/IpmaApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipma/IpmaService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipma/json/IpmaAlert.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipma/json/IpmaAlertResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipma/json/IpmaDistrictResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipma/json/IpmaForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipma/json/IpmaLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipsb/IpSbLocationApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipsb/IpSbLocationService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ipsb/json/IpSbLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/JmaApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/JmaConstants.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/JmaService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaAlertArea.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaAlertAreaTypes.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaAlertResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaAlertWarning.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaAmedasResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaArea.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaAreasResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaBulletinResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaClass20sResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaDailyArea.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaDailyAreaArea.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaDailyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaDailyTempAverage.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaDailyTimeSeries.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaForecastAreaResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaHourlyAreaTimeSeries.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaHourlyPointTimeSeries.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaHourlyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaHourlyTimeDefines.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaHourlyWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaRelmResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/json/JmaWeekAreaResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/jma/serializers/JmaAnySerializer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/LhmtApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/LhmtService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/LhmtWwwApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtAlertArea.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtAlertAreaGroup.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtAlertPhenomenonGroup.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtAlertResponseType.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtAlertSingleAlert.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtAlertText.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtAlertsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtCoordinates.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtLocationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtWeather.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lhmt/json/LhmtWeatherResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lvgmc/LvgmcApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lvgmc/LvgmcService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lvgmc/json/LvgmcAirQualityLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lvgmc/json/LvgmcAirQualityResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lvgmc/json/LvgmcCurrentLocation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lvgmc/json/LvgmcCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/lvgmc/json/LvgmcForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/MeteoAmApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/MeteoAmService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/json/MeteoAmForecastDatasets.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/json/MeteoAmForecastExtraInfo.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/json/MeteoAmForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/json/MeteoAmForecastStats.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/json/MeteoAmObservationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/json/MeteoAmReverseLocation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/json/MeteoAmReverseLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteoam/serializers/MeteoAmAnySerializer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/MeteoLuxApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/MeteoLuxService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherCity.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherCurrent.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherDaily.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherHourly.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherHourlyTemperature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherIcon.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherTemperature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherVigilance.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/meteolux/json/MeteoLuxWeatherWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/MetIeApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/MetIeService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeForecastHourly.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeForecastPercent.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeForecastPrecipitation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeForecastSymbol.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeForecastValue.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeForecastWindDirection.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeForecastWindSpeed.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeWarning.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeWarningResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metie/json/MetIeWarnings.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/MetNoApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/MetNoService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAirQualityConcentration.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAirQualityData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAirQualityResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAirQualityTime.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAirQualityVariables.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAlert.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAlertProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAlertResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoAlertWhen.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoEphemerisProperty.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastData.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastDataDetails.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastDataInstant.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastDataNextHours.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastDataSummary.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastPropertiesMeta.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoForecastTimeseries.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoNowcastProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metno/json/MetNoNowcastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metoffice/MetOfficeApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metoffice/MetOfficeService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metoffice/json/MetOfficeDaily.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metoffice/json/MetOfficeForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/metoffice/json/MetOfficeHourly.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/MfApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/MfService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfCurrentGridded.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfCurrentProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfForecastDaily.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfForecastHourly.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfForecastProbability.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfForecastProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfGeometry.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfHistory.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfHistoryResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfHistoryTemperature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfNormalsProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfNormalsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfNormalsStats.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfRainForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfRainProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfRainResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningAdvice.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningComments.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningConsequence.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningDictionaryColor.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningDictionaryPhenomenon.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningDictionaryResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningMaxCountItems.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningOverseasAdvice.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningOverseasComments.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningOverseasConsequence.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningOverseasTextBlocItem.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningOverseasTimelaps.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningPhenomenonMaxColor.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningSubdivisionText.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningTermItem.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningTextBlocItem.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningTextItem.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningTimelaps.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningTimelapsItem.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningsOverseasResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mf/json/MfWarningsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/MgmApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/MgmService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmAlertResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmAlertText.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmAlertTowns.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmAlertWeather.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmDailyForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmHourlyForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmHourlyForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/mgm/json/MgmNormalsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/NamemApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/NamemService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemAirQuality.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemAirQualityResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemCurrent.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemDailyForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemDailyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemHourlyForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemHourlyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemNormals.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemNormalsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemStation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/namem/json/NamemStationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncdr/NcdrApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncdr/NcdrService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncdr/xml/NcdrAlertsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncei/NceiApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncei/NceiService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncei/json/NceiDataResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncei/json/NceiStationsCentroid.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncei/json/NceiStationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncei/json/NceiStationsResultResults.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/ncei/json/NceiStationsResultResultsStation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nlsc/NlscApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nlsc/NlscService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nlsc/xml/NlscLocationCodesResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/NwsApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/NwsForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/NwsService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsAlert.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsAlertProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsAlertsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsCurrentProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsCurrentValue.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsDailyPeriods.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsDailyProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsDailyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsGridPointProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsGridPointResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsPointLocation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsPointLocationGeometry.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsPointLocationProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsPointProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsPointResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsStationsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsValueDouble.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsValueDoubleContainer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsValueInt.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsValueIntContainer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsValueWeather.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsValueWeatherContainer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/nws/json/NwsValueWeatherValue.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/OpenWeatherApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/OpenWeatherService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherAirPollution.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherAirPollutionComponents.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherAirPollutionResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherForecastClouds.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherForecastMain.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherForecastPrecipitation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherForecastWeather.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/openweather/json/OpenWeatherForecastWind.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/PagasaApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/PagasaService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/json/PagasaCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/json/PagasaHourlyAttributes.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/json/PagasaHourlyElement.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/json/PagasaHourlyForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/json/PagasaHourlyResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/json/PagasaHourlyTabular.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/json/PagasaHourlyTime.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/pagasa/json/PagasaLocationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/polleninfo/PollenInfoApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/polleninfo/PollenInfoService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/polleninfo/json/PollenInfoResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/SmgApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/SmgCmsApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/SmgService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgAirQuality.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgAirQualityResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgBulletinCustom.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgBulletinResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgBulletinRoot.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgCurrentCustom.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgCurrentResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgCurrentRoot.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgCurrentStation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgCurrentWeatherReport.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgForecastCustom.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgForecastRoot.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgForecastWeatherForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgUvCustom.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgUvReport.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgUvResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgUvRoot.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgValue.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgWarning.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgWarningCustom.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgWarningResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smg/json/SmgWarningRoot.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smhi/SmhiApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smhi/SmhiService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smhi/json/SmhiForecastResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smhi/json/SmhiParameter.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/smhi/json/SmhiTimeSeries.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/VedurIsApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/VedurIsService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsAlert.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsAlertRegionsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsAlertResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsDailyForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsFeature.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsFeatureCollection.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsGeometry.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsHourlyForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsLatestObservation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsPageProps.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsStation.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsStationForecast.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/json/VedurIsStationResult.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/veduris/serializers/VedurIsAnySerializer.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/wmosevereweather/WmoSevereWeatherJsonApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/wmosevereweather/WmoSevereWeatherService.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/wmosevereweather/WmoSevereWeatherXmlApi.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/wmosevereweather/json/WmoSevereWeatherAlertFeatures.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/wmosevereweather/json/WmoSevereWeatherAlertProperties.kt create mode 100644 app/src/src_nonfreenet/org/breezyweather/sources/wmosevereweather/json/WmoSevereWeatherAlertResult.kt create mode 100644 app/src/test/java/org/breezyweather/LocationTest.kt create mode 100644 app/src/test/java/org/breezyweather/MatchTest.kt create mode 100644 app/src/test/java/org/breezyweather/option/appearance/CardDisplayTest.kt create mode 100644 app/src/test/java/org/breezyweather/option/appearance/DailyTrendDisplayTest.kt create mode 100644 app/src/test/java/org/breezyweather/option/utils/UtilsTest.kt create mode 100644 app/src/test/java/org/breezyweather/sources/CommonConverterTest.kt create mode 100644 app/work/ne_50m_admin_0_countries.json create mode 100644 build.gradle.kts create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/settings.gradle.kts create mode 100644 buildSrc/src/main/kotlin/breezy.android.application.compose.gradle.kts create mode 100644 buildSrc/src/main/kotlin/breezy.android.application.gradle.kts create mode 100644 buildSrc/src/main/kotlin/breezy.code.lint.gradle.kts create mode 100644 buildSrc/src/main/kotlin/breezy.library.gradle.kts create mode 100644 buildSrc/src/main/kotlin/breezy/buildlogic/AndroidConfig.kt create mode 100644 buildSrc/src/main/kotlin/breezy/buildlogic/Commands.kt create mode 100644 buildSrc/src/main/kotlin/breezy/buildlogic/LocalesConfigPlugin.kt create mode 100644 buildSrc/src/main/kotlin/breezy/buildlogic/NaturalEarthConfigPlugin.kt create mode 100644 buildSrc/src/main/kotlin/breezy/buildlogic/ProjectExtensions.kt create mode 100644 config/libraries/app_geometric_weather.json create mode 100644 config/libraries/app_mihon.json create mode 100644 config/libraries/lib_font_asap.json create mode 100644 config/libraries/lib_google_android_maps_utils.json create mode 100644 data/.gitignore create mode 100644 data/build.gradle.kts create mode 100644 data/consumer-rules.pro create mode 100644 data/proguard-rules.pro create mode 100644 data/src/main/AndroidManifest.xml create mode 100644 data/src/main/java/breezyweather/data/AndroidDatabaseHandler.kt create mode 100644 data/src/main/java/breezyweather/data/DatabaseAdapter.kt create mode 100644 data/src/main/java/breezyweather/data/DatabaseHandler.kt create mode 100644 data/src/main/java/breezyweather/data/TransactionContext.kt create mode 100644 data/src/main/java/breezyweather/data/location/LocationMapper.kt create mode 100644 data/src/main/java/breezyweather/data/location/LocationRepository.kt create mode 100644 data/src/main/java/breezyweather/data/weather/WeatherMapper.kt create mode 100644 data/src/main/java/breezyweather/data/weather/WeatherRepository.kt create mode 100644 data/src/main/sqldelight/breezyweather/data/alerts.sq create mode 100644 data/src/main/sqldelight/breezyweather/data/dailys.sq create mode 100644 data/src/main/sqldelight/breezyweather/data/hourlys.sq create mode 100644 data/src/main/sqldelight/breezyweather/data/location_parameters.sq create mode 100644 data/src/main/sqldelight/breezyweather/data/locations.sq create mode 100644 data/src/main/sqldelight/breezyweather/data/minutelys.sq create mode 100644 data/src/main/sqldelight/breezyweather/data/normals.sq create mode 100644 data/src/main/sqldelight/breezyweather/data/weathers.sq create mode 100644 data/src/main/sqldelight/breezyweather/migrations/1.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/10.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/11.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/12.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/13.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/14.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/15.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/16.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/17.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/18.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/19.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/2.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/20.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/21.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/22.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/23.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/24.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/25.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/3.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/4.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/5.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/6.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/7.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/8.sqm create mode 100644 data/src/main/sqldelight/breezyweather/migrations/9.sqm create mode 100644 docs/CHANGELOG_4.x.md create mode 100644 docs/CHANGELOG_5.x.md create mode 100644 docs/COVERAGE.md create mode 100644 docs/DAY_DETAILS.md create mode 100644 docs/FullHomepageScreenshot.png create mode 100644 docs/HOMEPAGE.md create mode 100644 docs/RADAR.md create mode 100644 docs/SOURCES.md create mode 100644 docs/TECHNICAL.md create mode 100644 docs/UPDATES.md create mode 100644 docs/fdroid_client_config.png create mode 100644 domain/.gitignore create mode 100644 domain/build.gradle.kts create mode 100644 domain/consumer-rules.pro create mode 100644 domain/proguard-rules.pro create mode 100644 domain/src/main/AndroidManifest.xml create mode 100644 domain/src/main/java/breezyweather/domain/location/model/Location.kt create mode 100644 domain/src/main/java/breezyweather/domain/location/model/LocationAddressInfo.kt create mode 100644 domain/src/main/java/breezyweather/domain/source/SourceContinent.kt create mode 100644 domain/src/main/java/breezyweather/domain/source/SourceFeature.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/AirQuality.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Alert.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Astro.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Base.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Current.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Daily.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/DailyAvgMinMax.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/DailyCloudCover.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/DailyDewPoint.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/DailyPressure.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/DailyRelativeHumidity.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/DailyVisibility.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/DegreeDay.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/HalfDay.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Hourly.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Minutely.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/MoonPhase.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Normals.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Pollen.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Precipitation.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/PrecipitationDuration.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/PrecipitationProbability.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Temperature.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/UV.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Weather.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/model/Wind.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/reference/AlertSeverity.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/reference/Month.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/reference/WeatherCode.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/wrappers/AirQualityWrapper.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/wrappers/CurrentWrapper.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/wrappers/DailyWrapper.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/wrappers/HalfDayWrapper.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/wrappers/HourlyWrapper.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/wrappers/PollenWrapper.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/wrappers/TemperatureWrapper.kt create mode 100644 domain/src/main/java/breezyweather/domain/weather/wrappers/WeatherWrapper.kt create mode 100644 fastlane/metadata/android/cs/changelogs/default.txt create mode 100644 fastlane/metadata/android/cs/full_description.txt create mode 100644 fastlane/metadata/android/cs/short_description.txt create mode 100644 fastlane/metadata/android/de-DE/changelogs/default.txt create mode 100644 fastlane/metadata/android/de-DE/full_description.txt create mode 100644 fastlane/metadata/android/de-DE/short_description.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/default.txt create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/images/icon.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/01-main-header-light.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/02-main-header-dark.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/03-main-blocks-1.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/04-main-blocks-2.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/05-settings.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/06-sources.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/07-details.png create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 fastlane/metadata/android/eo/changelogs/default.txt create mode 100644 fastlane/metadata/android/eo/full_description.txt create mode 100644 fastlane/metadata/android/eo/short_description.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/default.txt create mode 100644 fastlane/metadata/android/es-ES/full_description.txt create mode 100644 fastlane/metadata/android/es-ES/short_description.txt create mode 100644 fastlane/metadata/android/fr/changelogs/default.txt create mode 100644 fastlane/metadata/android/fr/full_description.txt create mode 100644 fastlane/metadata/android/fr/images/phoneScreenshots/01-main-header-light.png create mode 100644 fastlane/metadata/android/fr/images/phoneScreenshots/02-main-header-dark.png create mode 100644 fastlane/metadata/android/fr/images/phoneScreenshots/03-main-blocks-1.png create mode 100644 fastlane/metadata/android/fr/images/phoneScreenshots/04-main-blocks-2.png create mode 100644 fastlane/metadata/android/fr/images/phoneScreenshots/05-warnings.png create mode 100644 fastlane/metadata/android/fr/images/phoneScreenshots/06-settings.png create mode 100644 fastlane/metadata/android/fr/images/phoneScreenshots/07-sources.png create mode 100644 fastlane/metadata/android/fr/images/phoneScreenshots/08-details.png create mode 100644 fastlane/metadata/android/fr/short_description.txt create mode 100644 fastlane/metadata/android/hr/changelogs/default.txt create mode 100644 fastlane/metadata/android/hr/short_description.txt create mode 100644 fastlane/metadata/android/hu/changelogs/default.txt create mode 100644 fastlane/metadata/android/hu/full_description.txt create mode 100644 fastlane/metadata/android/hu/short_description.txt create mode 100644 fastlane/metadata/android/it/changelogs/default.txt create mode 100644 fastlane/metadata/android/it/full_description.txt create mode 100644 fastlane/metadata/android/it/short_description.txt create mode 100644 fastlane/metadata/android/ja-JP/changelogs/default.txt create mode 100644 fastlane/metadata/android/pl-PL/changelogs/default.txt create mode 100644 fastlane/metadata/android/pl-PL/full_description.txt create mode 100644 fastlane/metadata/android/pl-PL/short_description.txt create mode 100644 fastlane/metadata/android/pt/changelogs/default.txt create mode 100644 fastlane/metadata/android/pt/short_description.txt create mode 100644 fastlane/metadata/android/pt_BR/changelogs/default.txt create mode 100644 fastlane/metadata/android/pt_BR/full_description.txt create mode 100644 fastlane/metadata/android/pt_BR/short_description.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/default.txt create mode 100644 fastlane/metadata/android/ru-RU/full_description.txt create mode 100644 fastlane/metadata/android/ru-RU/short_description.txt create mode 100644 fastlane/metadata/android/th/short_description.txt create mode 100644 fastlane/metadata/android/uk/changelogs/default.txt create mode 100644 fastlane/metadata/android/uk/full_description.txt create mode 100644 fastlane/metadata/android/uk/short_description.txt create mode 100644 fastlane/metadata/android/zh_Hans/changelogs/default.txt create mode 100644 fastlane/metadata/android/zh_Hans/full_description.txt create mode 100644 fastlane/metadata/android/zh_Hans/short_description.txt create mode 100644 fastlane/metadata/android/zh_Hant/changelogs/default.txt create mode 100644 fastlane/metadata/android/zh_Hant/full_description.txt create mode 100644 fastlane/metadata/android/zh_Hant/short_description.txt create mode 100644 fastlane/metadata/android/zh_Hant_HK/changelogs/default.txt create mode 100644 fastlane/metadata/android/zh_Hant_HK/full_description.txt create mode 100644 fastlane/metadata/android/zh_Hant_HK/short_description.txt create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 maps-utils/.gitignore create mode 100644 maps-utils/README.md create mode 100644 maps-utils/build.gradle.kts create mode 100644 maps-utils/consumer-rules.pro create mode 100644 maps-utils/proguard-rules.pro create mode 100644 maps-utils/src/main/java/com/google/maps/android/EncodedPolylineUtil.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/MathUtil.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/PolyUtil.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/SphericalUtil.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/DataPolygon.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/Feature.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/Geometry.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/LineString.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/MultiGeometry.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/Point.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonFeature.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonGeometryCollection.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonLineString.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiLineString.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiPoint.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonMultiPolygon.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonParser.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonPoint.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonPolygon.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/data/geojson/GeoJsonStyle.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/model/LatLng.kt create mode 100644 maps-utils/src/main/java/com/google/maps/android/model/LatLngBounds.kt create mode 100644 maps-utils/src/test/java/com/google/maps/android/EncodedPolylineUtilTest.kt create mode 100644 settings.gradle.kts create mode 100644 ui-weather-view/.gitignore create mode 100644 ui-weather-view/build.gradle.kts create mode 100644 ui-weather-view/consumer-rules.pro create mode 100644 ui-weather-view/proguard-rules.pro create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/Extensions.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/WeatherThemeDelegate.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/WeatherView.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/DelayRotateController.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/IntervalComputer.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/MaterialPainterView.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/MaterialWeatherThemeDelegate.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/MaterialWeatherView.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/WeatherImplementorFactory.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/implementor/CloudImplementor.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/implementor/HailImplementor.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/implementor/MeteorShowerImplementor.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/implementor/RainImplementor.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/implementor/SnowImplementor.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/implementor/SunImplementor.kt create mode 100644 ui-weather-view/src/main/java/org/breezyweather/ui/theme/weatherView/materialWeatherView/implementor/WindImplementor.kt create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_clear_day.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_cloudy.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_fog.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_hail.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_haze.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_partly_cloudy.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_rain.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_sleet.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_snow.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_thunder.xml create mode 100644 ui-weather-view/src/main/res/drawable-night/weather_background_wind.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_clear_day.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_clear_night.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_cloudy.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_default.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_fog.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_hail.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_haze.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_partly_cloudy.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_rain.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_sleet.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_snow.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_thunder.xml create mode 100644 ui-weather-view/src/main/res/drawable/weather_background_wind.xml create mode 100644 weather-unit/.gitignore create mode 100644 weather-unit/README.md create mode 100644 weather-unit/build.gradle.kts create mode 100644 weather-unit/consumer-rules.pro create mode 100644 weather-unit/proguard-rules.pro create mode 100644 weather-unit/src/main/AndroidManifest.xml create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/SdkCheck.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/WeatherUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/WeatherValue.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/computing/HumidityComputing.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/computing/PollutantComputing.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/computing/PressureComputing.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/computing/TemperatureComputing.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/distance/Distance.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/distance/DistanceUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/duration/Duration.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/duration/DurationUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/formatting/MeasureUnitFormatting.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/formatting/NumberFormatting.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/formatting/UnitDecimals.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/formatting/UnitTranslation.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/formatting/UnitWidth.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/pollen/PollenConcentration.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/pollen/PollenConcentrationUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/pollutant/PollutantConcentration.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/pollutant/PollutantConcentrationUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/precipitation/Precipitation.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/precipitation/PrecipitationUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/pressure/Pressure.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/pressure/PressureUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/ratio/Ratio.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/ratio/RatioUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/speed/Speed.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/speed/SpeedUnit.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/temperature/Temperature.kt create mode 100644 weather-unit/src/main/java/org/breezyweather/unit/temperature/TemperatureUnit.kt create mode 100644 weather-unit/src/main/res/values-ar/strings.xml create mode 100644 weather-unit/src/main/res/values-be/strings.xml create mode 100644 weather-unit/src/main/res/values-bg/strings.xml create mode 100644 weather-unit/src/main/res/values-bn/strings.xml create mode 100644 weather-unit/src/main/res/values-bs/strings.xml create mode 100644 weather-unit/src/main/res/values-ca/strings.xml create mode 100644 weather-unit/src/main/res/values-ckb/strings.xml create mode 100644 weather-unit/src/main/res/values-cs/strings.xml create mode 100644 weather-unit/src/main/res/values-da/strings.xml create mode 100644 weather-unit/src/main/res/values-de/strings.xml create mode 100644 weather-unit/src/main/res/values-el/strings.xml create mode 100644 weather-unit/src/main/res/values-en-rAU/strings.xml create mode 100644 weather-unit/src/main/res/values-en-rCA/strings.xml create mode 100644 weather-unit/src/main/res/values-en-rGB/strings.xml create mode 100644 weather-unit/src/main/res/values-en-rUS/strings.xml create mode 100644 weather-unit/src/main/res/values-eo/strings.xml create mode 100644 weather-unit/src/main/res/values-es/strings.xml create mode 100644 weather-unit/src/main/res/values-et/strings.xml create mode 100644 weather-unit/src/main/res/values-eu/strings.xml create mode 100644 weather-unit/src/main/res/values-fa/strings.xml create mode 100644 weather-unit/src/main/res/values-fi/strings.xml create mode 100644 weather-unit/src/main/res/values-fr/strings.xml create mode 100644 weather-unit/src/main/res/values-ga/strings.xml create mode 100644 weather-unit/src/main/res/values-gl/strings.xml create mode 100644 weather-unit/src/main/res/values-he/strings.xml create mode 100644 weather-unit/src/main/res/values-hi/strings.xml create mode 100644 weather-unit/src/main/res/values-hr/strings.xml create mode 100644 weather-unit/src/main/res/values-hu/strings.xml create mode 100644 weather-unit/src/main/res/values-ia/strings.xml create mode 100644 weather-unit/src/main/res/values-id/strings.xml create mode 100644 weather-unit/src/main/res/values-is/strings.xml create mode 100644 weather-unit/src/main/res/values-it/strings.xml create mode 100644 weather-unit/src/main/res/values-ja/strings.xml create mode 100644 weather-unit/src/main/res/values-kab/strings.xml create mode 100644 weather-unit/src/main/res/values-ko/strings.xml create mode 100644 weather-unit/src/main/res/values-lt/strings.xml create mode 100644 weather-unit/src/main/res/values-lv/strings.xml create mode 100644 weather-unit/src/main/res/values-mk/strings.xml create mode 100644 weather-unit/src/main/res/values-mr/strings.xml create mode 100644 weather-unit/src/main/res/values-nb-rNO/strings.xml create mode 100644 weather-unit/src/main/res/values-nl/strings.xml create mode 100644 weather-unit/src/main/res/values-oc/strings.xml create mode 100644 weather-unit/src/main/res/values-pl/strings.xml create mode 100644 weather-unit/src/main/res/values-pt-rBR/strings.xml create mode 100644 weather-unit/src/main/res/values-pt/strings.xml create mode 100644 weather-unit/src/main/res/values-ro/strings.xml create mode 100644 weather-unit/src/main/res/values-ru/strings.xml create mode 100644 weather-unit/src/main/res/values-sk/strings.xml create mode 100644 weather-unit/src/main/res/values-sl-rSI/strings.xml create mode 100644 weather-unit/src/main/res/values-sr/strings.xml create mode 100644 weather-unit/src/main/res/values-sv/strings.xml create mode 100644 weather-unit/src/main/res/values-ta/strings.xml create mode 100644 weather-unit/src/main/res/values-th/strings.xml create mode 100644 weather-unit/src/main/res/values-tr/strings.xml create mode 100644 weather-unit/src/main/res/values-uk/strings.xml create mode 100644 weather-unit/src/main/res/values-vi/strings.xml create mode 100644 weather-unit/src/main/res/values-zh-rCN/strings.xml create mode 100644 weather-unit/src/main/res/values-zh-rHK/strings.xml create mode 100644 weather-unit/src/main/res/values-zh-rTW/strings.xml create mode 100644 weather-unit/src/main/res/values/strings.xml create mode 100644 weather-unit/src/test/java/org/breezyweather/unit/computing/TemperatureComputingTest.kt create mode 100644 weather-unit/src/test/java/org/breezyweather/unit/formatting/NumberFormattingTest.kt create mode 100644 weather-unit/src/test/java/org/breezyweather/unit/temperature/TemperatureTest.kt create mode 100644 work/DEFAULT_UNITS_PER_COUNTRY.md create mode 100644 work/ic_location_list2.svg create mode 100644 work/ic_location_list3.svg create mode 100644 work/ic_shortcut_cloud_day_foreground.psd create mode 100644 work/ic_shortcut_cloud_night_foreground.psd create mode 100644 work/ic_shortcut_cloudy_foreground.psd create mode 100644 work/ic_shortcut_fog_foreground.psd create mode 100644 work/ic_shortcut_hail_foreground.psd create mode 100644 work/ic_shortcut_haze_foreground.psd create mode 100644 work/ic_shortcut_rain_foreground.psd create mode 100644 work/ic_shortcut_sleet_foreground.psd create mode 100644 work/ic_shortcut_snow_foreground.psd create mode 100644 work/ic_shortcut_sun_day_foreground.psd create mode 100644 work/ic_shortcut_sun_night_foreground.psd create mode 100644 work/ic_shortcut_thunder_foreground.psd create mode 100644 work/ic_shortcut_thunderstorm_foreground.psd create mode 100644 work/ic_shortcut_wind_foreground.psd create mode 100644 work/ic_sunshine_duration.svg create mode 100644 work/weather_sun_day_waifu2x_art_noise3_scale_tta_1.png diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..77217ee --- /dev/null +++ b/.editorconfig @@ -0,0 +1,31 @@ +root = true + +[*] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{xml,sq,sqm}] +indent_size = 4 + +# noinspection EditorConfigKeyCorrectness +[*.{kt,kts}] +indent_size = 4 +max_line_length = 120 + +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = false +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 + +ktlint_code_style = intellij_idea +ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_class-signature = disabled +ktlint_standard_comment-wrapping = disabled +ktlint_standard_discouraged-comment-location = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_type-argument-comment = disabled +ktlint_standard_type-parameter-comment = disabled diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..26298590d4cf4194da88eb684c1c74ce8237db79 GIT binary patch literal 5636 zcmYjU2Q-{P*PdOA#VV^ruOV8(YSDrvN+gIBC5WsVQCAQWZIRUl38Iq_Es5y8tWJoM zNP-ZnB%=4;zV-d*JLjKs-uKR(cji8KX70TAnFvDz9a?G*Y5)MO?lsNZBuO}X;1s0y z?Qv&H09bl;HP!F>j4ibUk=g(*`?!#`5PDYqU$Otmg_#X+1?h4&az!CyRm+*H_ElBO z)s>1el`+|HN6x5Rsf7{6)#b&*Y8e&tkW-;a_K;QIP5ZJxwAc0B^qpId*DhE0)4seK)X8+%30|(8p`1xz;X_C(SCn?1aA{O(I|gc(swlOlcW;NN z`+*LAG3+*7Qx4OS_oQ^SGlHom@Av2_v7Vn#&G1-GEg1H8twO~0kW9(1XP$~^nxE3; zB8I@B!MP`M65nUPQmPY#Oetc_y#5F%P!t!s@7vgqe0@H!dP2@nPzo3jeA|P*O7;`m zd>(z=9x)8%3-+h+y%O@%&j~m8k;XAF9A9bA-95dokImcp=Hq2aM6>)9neXKYM;4tW z)M4y>@Aop26lIPI02V>;C$xXrEqmsE0wJ1hB5Amas4(rpZ6#7WL%pB2;vP(@Kjn`z zVN#CeBQh$;4FyV8KWxo=Lo^HIb??lC7h;lUW87buJu4L!mkYZ!1L0#Mz#a=%>x>^v z5zS(2gd{4-i5yM;JlznW@C&HZHB-hULz0J?ajazB4_hAW^b0h^*p1FC?(U?~)U&mE zCa0vZxAyf#wCPm|n%nC*=b2*I?d&__r1~NqRdeS|S?-81r&p`$hUx>6n`-aHFeZ1om<5mFubuA5 z7>==0>I%|Ace0K4(C5cLDgZ?nroI;ijaK3o6{?*a$`CXVxhLLQmM#59hOKXB*LevB zAlY}9V_pP*OOf<-+dEU4xnB$sjD88O#$*CMkRTAfFa2DNq#*E zfk*@TjO$S;`mh{f0LLK<;%aPAL^vOSgGn+>T@ixg+a%M(9tjVl=@95C_;-zD4(A3X z>Lqt{oHe6&QgbAjmj|Or{%NPvbvo46s*&vS`D!ifU}XYGaB+<#-FVVhzjHX6A6=v& z1bIkuo>*9KmFP5k!yUsQ-f<5qvF(r+iok!L0z)sA=~9QZr&nV6q@b4X|knNF=xj0B4NbS})kP3~9w)bZDPSdJTWbW^X(T66;pHPq%n53wmm`lE3=jKLm?M%XrBCLIZkuNJw6M?}vbU6>ICmuR ziVM&^(e_`541k$jY{SOiF9OtH|2_);JVquXC$u|nYyv-gd!FB{n&$JJCPIls6H_ql z&n2d*I)A9e)zJ&nSym>>3a|DVbCXSQVk;sazUMIlPddWEL5Lg)zU>TNR<4Quy^T!h z(f6!8iLmoM93m1&bO-4->Fd3E(N$^|NX&e<4@-qG9pNjg{u5~i!+8PS9BQGmhl6bI z$T8V&h!G1KD|MgZu2m)R3b^beo$QbEE;Ya8chr7nP_-v6ycs~1(ACK=vR{*t3^5u* z@Ir8a&~(zgy_+@~Q_Kv;7c#)PIqf5_UBeF#=zi%_6cio~Z+g_h;E^5TvpNhY9cmdn zDu5@$DqLV*!CWqQB^OW#&@=M(ZtF`kVJv9J6|tJJZ8i@#ieQ0tKElS}=ivcN<>d@u zC=E7cpANeDQcv*63id#1CX{)iUwq_g=3F5Yb8Ol;dHRqXPTd{?@V>b-rTpV>bznHe zU86PjgQJ&ZxB$P~D`tDTMlF8!QK~$3%9hX89zKB)9WRK%YB)3(z2Asy0}&)HZg{2! zlE{TSDw{`%S0ry&&lyAdUw8E+G$~D~3>_UH3_-mFraUekZNj_!!^Sx&F0Wi8 zI;6E$KsHTMdO3w!mr2Zt1wd~y+x=scY&yQZ<|U{rVN!i@!x%^$l?5qH z+u>JUu?s!qRm_Ba_-wlp!6?=jTax59RQ+VF< z5Dsd^rM#EpC?4ndN^9F(IhYS)ti(1tMHYaia!nbGyAkUH`2vY$GAMdwD64G2ZPn(( zKx^inY3xrondA}%Ef`2{4EKEO?h#V5#dt@MWRJQntua4y_cMlG=#k2A8><>7F0IWe z``z|1ax`w9dmd8omz9<0u<%JNpJ!E-GfR``)x+JyzR&f_*R2v$T2 z;kq0Bng`d9cFk`sXlQ=~!&$I6O(X}L4=?$!%6=Z|G=xjI=qN@l;?nQ5YBkNE-r zqb?!&0B?xDgc_(bP`wGdjf{V4$aYS_)9zTsN16Nx@jtPq;hU4g-_h_g?d9@&enq#K*{{k~rTA3} zG6xwt>rPQ;dA5d?TA7<^^z-|@Z{8j(TJ_wMP7{T`jDUk2ccy`>9QFnHyN-o{ufOO% zJ`tvcw!Qn=uirw4G`x2#;_( zi*4DA@ot<;vaq(!*aamTtn;=ljC$>x>F*q6pH2*m|E|phaURSCQpfiLz-&zG-_n=R z!SCyg22Z)@K=fGJWU$NCbZxg`p{JFM&O_#;8IWbmIRB|fx+Xq3Z(+QBsp+){$S(H2 z*{PaWWA;pMM>+6s3je1@!C8@ykv)Q77D3=}5$i9VrbH)J9O7(59dAL<6U&@oOy^a8 z%PYv)c8ishVs~VUbAyTdxlx{9ryEQ6YiC{@zmBuF2qWkd5$aRza9}X+cKh{bJ(X=y z;wXAt3vfjbS+DA<0wQzrUL?e%ZsNH9_$um1k6j@af5^fwuZac4Rg z6^VU(GqN*$bwH&iUl4ry6lmKeX^-whh?kclcxd)ZkIGMnJ@(jlxuEhQ8|NUnF>aO+ ztsj1T-RyskmnN<1gx!B|@A0GqOs~9ms;AyH#Veb*LzzRSx3;b%N+iQF#L4NNGcb!sN`LOV~8sWAd!CHOV9;tN*Mvou5Q1IF4geg>mg+zLAI<3QD4{nnp{o15()H`9f|fk{0NQpUv4v!KC2rf>UfE6{lCkzocu_;PXoA zCk7I^pnxu+qc81gdgI5)LRnTk6HnkW!$Le^SW;GDQ4>}KD%_HUi(c^>BvO^Ux3gY@ zsj$H=$x<3?KcrP$qNC3k@01h*4o7k)D@|E*22Dh9U}&E31-VYtOOJ4k z_hCpsB!@a%n;UJCFG+Q^R`5G|nsnM)Juv)%dIvyE^8xFO&izvTpp+s;JQ*6GTyF(G zqTR{&`!_aSj}IE0+(68}HV8Rhk7t|aDpNSdzC2f_JZe1m65#Sj?E>_=<~VoOOSIEg zqJW{zhkFaw3S2pmD0i|a;-MslnQSVIMk>sHFqcghuj(0mwT-YfO(Lw1U`RBqc9Njt zoVCJJD<%hg!o3In$hRw#w+@-IK+qXesKz>GV<#CR_5;NLW65!(xqIj`n^ExgR1fOL zpZot90=K=*t=${MoDyz>LE(ZwC*LO5C*UMLeAh+FG`k`)L*@cI$R~4;olfKo!<~`l z0yCyGy5dM)ciEQ}0Mmav(KqJ?DHdW4*iI+00_@Jk_~ZowE6$+hdm#ftt;=QPj7HNR z=N1vlbl|!)q0^svu~4Brohw|RF(`P5#*rbqDK2J(fn@q3UX;T$tX?+@DETtn9rh!y z^vcrk?fqpqxrW@_f4<~(s_3L}so<*N4S-03Q#`8BOKNDMB4{nz1e+VH|5pJ| ze&*OJKnV{a@Ne9LXBP(;vCKs*FW}5GH>hD+q}9*s|99=6;DRu4d8bECQ;|Al3msod z{`lmK*{?!y+jP>$+rnTk3KZxa>%CYugo6_SP?GB6l&kBysteXwA-I@U0WaU#y-XMg zL;}378bQxXibPlUc!2)=YAe^imj8bTjmcPs?WbO;ZRCUy9>55NeXq%91`>byBEgXs zkj-x%BNkH3{C&; zL(vvUAa{yRJMg)&L9pSK@TYfK9*Ff{J1v6{A1q~%Mce7JX-__RpgM7ApE*{gSmpAo z%Ef$Wj3gt=f5*rbA2(-mOl-e!d@SChtA8c%AYDVI+{HY^_<>6^{#_1z!>Nj6BE&+^ z7Ua-34LrM4J2N+a?rN$4ZrJ{d-Yw|0wS)u*I4(STqY5S>I z&?5Dnt%1eghW&H2mjhobCx{Ee0V zII8(5tdNptxq8Ry^5e;0v^wk*tyqF$h30j7BHW@ynQ^0&ZLE-k4&g5-q*#MToPsCkrJVeIQubaZnKhw(`d)`Ua z@4Nr3A-To00lwCzXcKa=PcFP9yoLup`_1e_k)xJftSF7L!Q`n>WM`$rNbyfLYMuQ( z=lJ$Uw9oiv*lIuq?f4e+V(k{)57&+UPoI_aO%dKi25(UV)4+gZ3kwK9hwh#CXtEER{h--J$YLNCun%Dexs{aldFlyC*z5vcQ=6-91O$ zO^DV<3WJNzQnuHvqJkJ%Zx33p4f{O?5(KImhz^lDJxCcFUeC$ETN_IZV zMp8`8*ilkNl??C;x|8oHnvRpJP0hS;U@cjUVhx(nG=wM87kVHf#D^3)yfxgH_E`Cv z2l>{(rIDFviL;0s(-U;21f1z-;VXxcTZCI3sQ6lc1wmVsum H1}6AFjD-NQ literal 0 HcmV?d00001 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..28ae6b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,297 @@ +# Old changelogs + +- [Changelog for v5.x](docs/CHANGELOG_5.x.md) +- [Changelog for v4.x](docs/CHANGELOG_4.x.md) + + +# Version 6.1.x (not yet released) + +The following features are already available in the current branch, but will be removed before each v6.0.x release and restored after, during the testing phase. + +**New features** +- Content provider: allows (with your permission) other apps to query your weather data. [Read the announcement](https://github.com/breezy-weather/breezy-weather/discussions/2089) +- New broadcast: you can use `org.breezyweather.ACTION_UPDATE_NOTIFIER` (or `org.breezyweather.debug.ACTION_UPDATE_NOTIFIER` with the debug build) to be notified of updated locations (most common use case is coupled with the content provider) + + +# Version 6.0.12 (not yet released) + +**Improvements and fixes** +- Daily/hourly forecast - Ensure the maximum value is always at a minimum defined value to ensure data is put in perspective, and remove threshold lines that weren’t very useful and cluttering the interface (wind, precipitation, cloud cover) +- Make 24-hour charts and nowcasting charts less prone to swipe to next screen +- Main screen - Fix moon icon disappearing past midnight +- Main screen - Fix blocks not appearing after fade in animation was interrupted due to fast scrolling +- Main screen - Fix animations re-appearing when scrolling (@min7-i) +- Fix current air quality disappearing when refreshing too fast + +**Weather sources** +- China - Fix refresh error for some users (@kmod-midori) +- MET Éireann - Migrate to new API +- Nominatim - Add missing preference to change server instance +- Open-Meteo - Allow individual selection of new weather models: ECMWF IFS HRES 9 km, NCEP NAM U.S. Conus, MeteoSwiss +- OpenWeather - Fix current condition not translated +- Pirate Weather - Add support for thunderstorm icon (@cloneofghosts) +- Pollen Information AT - Add support as a pollen source for some European countries (@phileix) + +**Translations** +- Translations updated + + +# Version 6.0.11-rc (2025-09-03) + +**Improvements and fixes** +- Fix crash when entering Appearance settings using 12-hour format with scheduled dark mode +- Current location - Fix details sometimes not saved to database (previous location details restored on restart of the app) +- Remove animations in the pressure block as it caused flickering +- Change default distance unit for Germany to kilometer, as per DWD usage +- Change default speed unit for Netherlands to meter per second, as per KNMI usage +- Fix threshold value for scattered cloud cover (@cloneofghosts) + +**Translations** +- Translations updated + + +# Version 6.0.10-rc (2025-09-01) + +**Improvements and fixes** +- Add instructions to pull to refresh instead of leaving a blank screen when weather failed to load initially (@Amitesh-exp) + +**Weather sources** +- ECCC - Technical changes +- NCDR - Fix error when there is no alert (@chunshek) + +**Translations** +- Translations updated + + +# Version 6.0.9-beta (2025-08-31) + +**Improvements and fixes** +- Clarify which dark mode is currently used at system level in Appearance settings, which may help Xiaomi device owners detect a potential bug in the MIUI dark mode implementation +- Freenet - Improve wording of messages about non-free network services +- Freenet - Display the names of non-free network services in source lists to let the user know about the availability of other sources in the Standard flavor +- Android 11+ - Fix unneeded zeros sometimes showing in fractions + +**Weather sources** +- IP.SB / Baidu IP Location - Don’t require Android location to be on + +**Translations** +- Translations updated + +**Technical** +- Fallback to latest known current data rather than current hour forecast when last successful refresh was less than 30 min ago + + +# Version 6.0.8-beta (2025-08-27) + +**Improvements and fixes** +- Minor changes to weather blocks to improve accessibility (text size, color contrast, etc.) +- Widgets - Round temperature values +- Nowcasting block - Fix truncated start and end values + +**Translations** +- Translations updated + + +# Version 6.0.7-beta (2025-08-26) + +**Translations** +- Translations updated +- Add missing distance, speed and precipitation unit translations on Android < 7 + +**Technical** +- Added timezone deduction based on subdivision codes (@chunshek) + +# Version 6.0.6-alpha (2025-08-24) + +**Improvements and fixes** +- Fix crash on startup on Android 5.0, 5.1 and 6.0 +- Fix crash on Android 7.0/7.1 when formatting some units +- Widgets - Fix crash on Android 9.0 to 11.0 with font size set to something other than 100% + +**Weather sources** +- [HERE] Removed following recent restrictions on free API + +**Translations** +- Translations updated + + +# Version 6.0.5-alpha (2025-08-23) + +This version is still an experimental one, with a significant rewrite of the refresh process core, especially on current locations. Weather data for all locations will be reset due to a major technical change in the database. A simple refresh will bring it back. + +**Removed features** +- Mean daytime/nighttime temperatures as threshold lines. Use a normals source instead +- [Met Office UK] Removed address lookup feature +- Pressure unit - Kilogram force per square centimeter + +**Improvements and fixes** +- Main screen - Allow to move small blocks by drag & drop +- Main screen - The number of items displayed at once in daily/hourly forecast now depends on display size and font scale (previously always 5 in portrait, and 7 in landscape) +- Main screen - Show “Negligible” inside Pollen block if there is no pollen today instead of an empty block +- Main screen - Allow up to 5 blocks on a row depending on width display size and font scale +- Main screen - Move refresh time out of app bar when scrolling +- Main screen - Fix settings not applying immediately +- Main screen - Fix shooting stars getting stuck in the corner in landscape +- Details - Add a bottom margin at the end of each page, so that it doesn’t overlap with the floating button +- Details - Don’t animate charts when “Other element animations” is disabled +- Details - Air quality - Add individual charts for each pollutant +- Details - Humidity/Dewpoint/Cloud cover - Show min/max of the day +- Details - Pressure/Visibility - Fix sometimes wrong daily value +- Details - Fallback to current value on Today screen when daily value is missing +- Details - Add visibility and cloud cover scales +- Details - Fix top X-axis sometimes showing “-” for some sources +- Details - Charts are now slightly wider following the removal of start and end paddings by removing midnight labels +- Alerts - Add “Translate” and “Share” to text select actions +- Nowcasting chart/Precipitation notification - Fix slightly wrong ending time of precipitation report +- Settings - Improve the location-based dark mode preference to make it easier to understand +- Sources - Add a “Recommended” section to the Source selection screen +- Refresh - Fix a rare crash when Android fails to send us the current location +- Refresh - Add an error when air quality forecast times don’t match hourly forecast times (observed in India, for example) +- Refresh - Ensure range of (almost) all values provided by sources, so you no longer have to freak out when seeing -999° with PirateWeather or 1015° with Meteo AM +- Data sharing - Fix crash when sending too many locations (will now retry with less locations) +- Widgets - Improve UX of custom subtitle documentation (@codewithdipesh) +- Widgets - Improve line height on many widgets +- Widgets - Weekly - Spread day/night temperatures on 2 lines if necessary +- Widgets - Minor fixes +- Wallpaper - Due to some people running outdated versions of Breezy Weather just to see some gimmicks on their wallpaper, we bring back wallpaper animations behind a dangerous disabled-by-default option. We STRONGLY advise against enabling them. + +**Weather sources** +- [AccuWeather] Restrict pollen to USA, Canada and Europe as it’s only available there (@chunshek) +- [China] Fix reversed color and severity for alerts (@chunshek) +- [EKUK] Fix failure to refresh air quality +- [FOSS Public Alert Server] Add support for this experimental source for alerts (@chunshek) +- [GeoSphere AT] Fix missing info in warnings +- [GeoSphere AT] Use the newer better endpoint for air quality +- [JMA] Added Thai translations (@chunshek) +- [LVGMC] Fix current observations (@chunshek) +- [NCDR] Added as alert source for Taiwan (@chunshek) +- [NCEI] Added support for normals (@chunshek) +- [Nominatim] Added as another location search +- [NSLC] Added as address lookup source for Taiwan (@chunshek) +- [NWS] Alerts - Updated terminology for Extreme Heat (@chunshek) +- [Open-Meteo] Restrict pollen to Europe as it’s only available there (@chunshek) +- [Pirate Weather] Add support for daily/hourly summaries +- [Veðurstofa Íslands] Added as forecast, current, alert and address lookup source for Iceland (@chunshek) +- [WMO SWIC] Avoid missing alerts which expired date was updated +- [ANAM-BF, DCCMS, DMN, DWR, EMI, GMet, IGEBU, INM, Mali-Météo, Météo Benin, Météo Tchad, Météo Togo, Mettelsat, MSD, Pirate Weather, SMA (Seychelles), SMA (Sudan), SSMS] Add to ̀freenet` flavor (was missing despite being FOSS) + +**Translations** +- Initial translation added for Íslenska (@chunshek) +- Translations updated +- Alternate calendar: add Hebrew calendar +- Alternate calendar: add more defaults based on regional preferences + +**Technical** +- Current location process refactoring: coordinates, forced refresh when coordinates changed from more than 5 km +- Address lookup process refactoring to prepare for future ability to add a location manually by coordinates +- Experimental offline timezone deduction for address lookup sources missing the info or for Nominatim search service (@chunshek) +- Unit conversion/formatting refactoring. **Known temporary issue:** Some distance, speed and precipitation units are no longer translated on Android < 7 + + +# Version 6.0.4-alpha (2025-07-23) + +**Improvements and fixes** +- Main screen - Improvements to some cut off texts with different display sizes +- Main screen - Improve the “two blocks per row” threshold when using custom font scale +- Details - Fix precipitation probability details being expressed in precipitation unit instead of % +- Fix missing normals every other refresh + +**Translations** +- Translations updated + + +# Version 6.0.3-alpha (2025-07-22) + +**New features** +- Redesign of main screen in Material 3 Expressive +- New information previously not shown on main screen: current wind gusts, clock (block not enabled by default) +- Redesign background animations/colors to better adapt to the selected dark mode and avoid saturated colors with bad contrast + +**Removed features** +- Main screen - Details in header +- Main screen - Details block +- Custom weather and time per location +- Details of each different “feels like”. Will now just display the source-preferred feels like value, or if not available, our own computed feels like + +**Improvements and fixes** +- Fix nowcasting chart not honoring precipitation unit override +- Details - Fix feels like toggle not remembered through days +- Main screen - Fix tapping daily/hourly feels like forecast opening conditions with feels like toggle off +- Details - Display normals as deviation directly under daytime/nighttime temperature +- Improve display of precipitation details +- Details - Make tooltips persistent until you click outside the bounds of the tooltip +- Details - Show current air quality on Today page when no daily air quality is available + +**Translations** +- Translations updated + + +# Version 6.0.2-alpha (2025-07-19) + +**Improvements and fixes** +- Fix crash in some cases on old Android devices +- Fix notification icons not showing +- Make main screen top icons feel more intuitive + +**Weather sources** +- [Météo-France] Better formatting for warnings + + +# Version 6.0.1-alpha (2025-07-17) + +**New features** +- Twilight dates (dawn and dusk) + +**Removed features** +- Sun & Moon data from sources. Will now always be computed by Breezy Weather for consistency + +**Improvements and fixes** +- Details page - Fix floating action button not updating in real time (@min7-i) +- Details page - Charts - Fix area fill in Right to Left languages (@chunshek) +- Details page - Fix jumping of the chart when tapping on it +- Details page - Workaround missing top padding in the FAB menu for small device heights (@min7-i) +- Details page - Conditions - move long weather condition description to a dedicated Daily summary card (especially noticeable with AccuWeather source) +- Details page - Sun & Moon - Fix glitched charts (@chunshek) +- Main screen - Attempt to make horizontal swipes in daily/hourly trends less prone to switch to prev/next locations +- Main screen - Move “Settings” icon to location list to be able to display icons on main screen without a submenu. +- Main screen - Better animation for main screen current temperature when using Fahrenheit or Kelvin +- Main screen - Use Material 3 Expressive buttons for forecast buttons +- Main screen - Fix sun & moon direction in RtL languages +- Main screen - Fix air quality direction in RtL languages +- Main screen - Fix missing hourly visibility in some cases +- Settings - Material 3 Expressive theme +- Settings - Add shortcuts to daily/trend configuration from cards configuration +- Fix tint of “Open in another app” icon in landscape mode +- Improve the formatting of today/tomorrow notification +- Live wallpaper - Fix wallpaper animating when switching between apps +- Fix specific language for the app not remembered after reboot +- UV - Better computing of missing hourly UV from day UV (@chunshek) + +**Translations** +- Translations updated +- Default units are now based on system region. It does not support Android 16 “Measurement system” preference yet, as there seems to be no way to access this value for now. +- Better number formatting on Android >= 7 +- Better measure formatting on Android >= 7 + + +# Version 6.0.0-alpha (2025-06-26) + +**New features** +- Complete overhaul of the daily details page to offer a better visualization of the data, and more explanations about the different types of weather data +- Past hourly forecast can now be viewed in the details page + +**Removed features** +- Main screen hourly forecast card will now only show the next 24 hours, as the rest of the forecast can now be seen with more readability in the daily details page. +- The dedicated pollen page accessed when tapping on the pollen card now no longer exists, and was replaced by the pollen page in daily details. +- Tapping on the main screen air quality card no longer show more details, but open the air quality page in daily details instead. +- Tapping on an hourly item in the main screen hourly forecast no longer opens a dialog, but now opens the day details page of the currently selected type of data + +**Improvements and fixes** +- Redesigned main screen footer to support links to the sources, a link to the privacy policy, and icons for the sources for which it is mandatory +- Fix crash when using “Open in another app” when no app on the phone is able to open it + +**Weather sources** +- [ECCC] Added UV index + +**Translations** +- Translations updated diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6d0877a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +github.com/papjul. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md new file mode 100644 index 0000000..9c9d074 --- /dev/null +++ b/CONTRIBUTE.md @@ -0,0 +1,171 @@ +# Contributions + +## Rules for contributions + +While we welcome pull requests, before implementing any new feature/improvement, we ask you to come talk to us, to be sure it goes in the right direction. We don’t want you to spend time implementing something we don’t want (see “Rules for new features/improvements requests” section below) or implementing it the wrong way. + +You can also contribute to [existing issues tagged “Open to contributions”](https://github.com/breezy-weather/breezy-weather/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22Open%20to%20contributions%22), or [existing ideas tagged “Open to contributions”](https://github.com/breezy-weather/breezy-weather/discussions?discussions_q=is%3Aopen+label%3A%22Open+to+contributions%22). + +Prerequisites for pull requests contributions: +1. You are contributing to an issue/idea tagged “Open to contributions”, or an [org member](https://github.com/orgs/breezy-weather/people) gave you permission to work on it. + + +## Rules for new features/improvements requests + +### General direction + +Breezy Weather wants to be: +- a general weather app covering most of what you can expect from a weather app, but not *all* of what you can expect. For advanced usage, some specialized apps will always cover it better +- usable without having to be an expert to find anything in the app +- mainly target small displays, so we don’t want to fit too many things, as we also want to let the design breathe a bit + + +### New features + +Probably, the most requested thing. “If you don’t want to make that feature for everyone, you can still make it a preference”. + +Currently, we already have more than 50 preferences (not even counting widgets preferences and sources preferences!), which already provides a lot of customizability. + +I know what you’re going to tell me “If there are already that many options, just ONE additional option won’t hurt”, but truth is if I had on top of existing preferences implemented every ONE preference people requested since this project began, I would have doubled the number of preferences (and I’m only writing this 1.5 months after the project began), and things people are mostly looking for would be hard to find in the myriad of options. + +At the same time, with the existing preferences, some people can’t even find things, we already spend a lot of time helping people to find what they are looking for, and it shouldn’t be that way. Some people may even just drop the app because it's too hard to use. This is really not something we want. + +Additionally, any added preferences means implementing it, make the code execute conditionally for everyone, and maintain it (test it, handle bug reports, etc). What looks like a simple option can represent a lot of work. + +So, the idea is to make a fair use of preferences, so if it covers too narrow of a case, it won’t be implemented. + +You can read [Niagara’s design principles](https://help.niagaralauncher.app/article/8-niagaras-design-principles) for a similar take on the matter (although due to the nature of this weather app, the “universal” criteria doesn’t always apply to us). + + +### New weather sources + +To be candidate for inclusion in the project, a weather source must not require private information such as credit card or phone number to have a free key. + +To be accepted as a main source, a source must have hourly forecast. A source can be implemented as a secondary-only source if they don’t have hourly data but other secondary features. + +Only features behind a free-tier will be accepted inside the project, so that any contributor can keep maintaining it in the long term. + +Additionally, we usually don’t accept sources that are just frontends to other sources (for example, if they use AccuWeather data, we will just use AccuWeather directly). + +Examples of weather sources that don’t fit: +- Apple WeatherKit (no free-tier) +- Microsoft Azure (free-tier requires credit card info) +- Weatherbit (free-tier only has “current” feature, with only 50 requests per day, so it’s not worth the maintenance cost) + +Note that some national sources don’t have endpoints by coordinates, or reverse geocoding (find nearest city/station), so we can’t support them. + + +## Git setup for pull requests + +### Init + +Fork the project on GitHub. + +Clone the project locally, then add our repository as `upstream` remote: +``` +git remote add upstream https://github.com/breezy-weather/breezy-weather +``` + +Create a new branch for your pull request, for example: +``` +git checkout -B mynewprovider +``` + +You can start working on it! + + +### Submit + +Since you started working on your pull request, many commits might have been added, so you will need to rebase: +``` +git fetch upstream +git rebase upstream main +``` + +(it it can’t find `upstream`, check instructions at the top of this document) + +If you are working on a new provider, you will usually not have any conflict, unless a new provider was added in the meantime in `SourceManager`, but in that case, you will find it easy to fix the conflict. + +Then, you can push (with `--force` argument as you are rewriting history). + +Please test your changes and if it works and you made multiple commits, please stash them as it makes reviewing easier. For example, if you made 2 commits, you can use: +``` +git reset --soft HEAD~2 +``` + +You can make a new commit, and once again, push your changes adding the `--force` argument. + + +## Weather sources + +### Create a new Weather source + +Choose a unique identifier for your weather source, with only lowercase letters. Examples: +- AccuWeather becomes `accu` +- Open-Meteo becomes `openmeteo` + +Copy: +``` +app/src/main/java/org/breezyweather/sources/pirateweather/ +``` +to: +``` +app/src/main/java/org/breezyweather/sources// +``` + +We will use Pirate Weather as a base as it is the most “apply to most situations” source, without having too many specific code that most sources don’t need. +But at each step, you can have a look at what already exists for this source if you feel like something you want to implement might already have been done on other sources. + + +### API key (optional) + +If you need an API key or any kind of secret, you will to need declare it in `app/build.gradle` as `breezy..key`. +Then declare the value in `local.properties` which is private and will not be committed. + + +### API + +Let’s edit the API interface, and only implement the forecast API as a starting point. + +In `app/src/main/java/org/breezyweather/source//json/`, add the data class that will be constructed from the json returned by the API. + +Use `@SerialName` when the name of the field is not the same as what is in the json returned by the API. +Example: +```kotlin +@SerialName("is_day") val isDay: Boolean? +``` + +As in the example, make as many fields as possible nullable so that in case the API doesn’t return some fields for some locations, it doesn’t fail. The serializer is configured to make nullable fields null in case the field is not in the JSON response, so you don’t need to declare `= null` as default value. + + +### Service and converter + +Rename `PirateWeatherService` with your source name and completes basic information. + +As a starting point, we will only implement weather part, but here is the full list of interfaces/classes you can implement: + +| Class/Interface | Use case | +|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `HttpSource()` | Currently does nothing except requiring to provide a link to privacy policy, which will be mandatory to accept in the future | +| `WeatherSource` | Your source can provide weather data for a given lon/lat. If your source doesn’t accept lon/lat but cities-only, you will have to implement `LocationParametersSource` | +| `LocationParametersSource` | Your source needs location parameters, such as the code of a city. This code can be found by calling an endpoint with lon/lat, or a station list can be fetch to find the nearest station given the coordinates. | +| `LocationSearchSource` | Your source is able to return a list of `Location` object from a query, containing at least the TimeZone of the location. If your source doesn’t include TimeZone, don’t implement it, and this will default to Open-Meteo location search | +| `ReverseGeocodingSource` | Your source is able to return one `Location` (you can pick the first one if you have many) from lon/lat. If you don’t have this feature available, don’t implement it and locations created with your source will only have lon/lat | +| `ConfigurableSource` | You want to allow your user to change preferences, for example API key. | + +For most complex needs, always have a look at existing sources. If you need to add a new type of pollen for your source, please contact us first as it is a non-trivial change to the code. + +In the `requestWeather()`, all properties of the `WeatherWrapper` are optional, so you can start implementing bit by bit, so you can easily test the first data. + +Add your service in the constructor of the `SourceManager` class. + +You’re done, you can try building the app and test that you have empty data. + +**IMPORTANT**: please don’t try to “calculate” missing data. For example, if you have hourly air quality available in your source, but not daily air quality, don’t try to calculate the daily air quality from hourly data! The app already takes care of completing any missing data for you. And if you feel that something that could be completed is not, please open an issue and we will improve the app to do so for all sources. + +**Additional note**: the Daily object expects two half days, which most sources don’t provide. +As explained in other documents, the daytime half-day is expected from 06:00 to 17:59 and the nighttime half-day is expected from 18:00 to 05:59 (or 29:59 to keep current day notation). +- If your source has half days with different hours, please follow their recommendations (for example, ColorfulClouds uses 08:00 to 19:59 and 20:00 to 07:59 (or 31:59)). +- If your source has no half day, a typical mistake you can make is to put the minimum temperature of the day as temperature of the night. However, your source probably gives you the minimum temperature from the past overnight, not from the night to come, so make sure to pick the correct data! + +Once your source is complete (you use all available data from the API and available in Breezy Weather), please rebase and submit it as a pull request (see instructions above). Please allow Breezy Weather maintainers to make adjustments (but we won’t write the source for you, you will have to make significant implementation). diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..9e15a0b --- /dev/null +++ b/HELP.md @@ -0,0 +1,173 @@ +# Help / Frequently Asked Questions + +- [Locations](#locations) +- [Troubleshooting errors](#troubleshooting-errors) +- [Weather updates](#weather-updates) +- [Launcher](#launcher) +- [Design](#design) + +___ + +## Locations + +### App shows “Current location” instead of the address + +First of all, address lookup is absolutely not required to get an accurate forecast, since it’s based on your longitude and latitude, not on your address, which is a totally different process. + +If it’s still an important matter to you, you can select an address lookup source in the location settings. + +### How can I change sources for a location? + +Just swipe from right to left on location list, or tap the pencil icon on top right. + +___ + +## Troubleshooting errors + +### “Invalid or incomplete data received from server” / “Location search failed” / “Weather data refresh failed” / “Weather data refresh for a secondary weather source failed” + +The source may be temporarily unavailable, please retry a few hours later. If the problem persists, please open an issue on GitHub. + + +### “Request timed out” + +The source may be temporarily unavailable, please retry later or check your network. If the problem persists and you use a custom DNS, VPN or have a firewall, please check them as well. + + +### “Required API key missing” / “API requests limit reached” / “API access unauthorized” / “Update not yet available” + +For most sources, we only have a limited number of calls allowed for free for all users of our app. If too many users use the same source, the only way to be able to continue using it is to check instructions on the source website to have your own API key. This may be troublesome, but if you have your own API key, the rate-limit will only apply to you (one user vs all users of Breezy Weather). + +Regarding the “API access unauthorized”, this error may appear when you subscribed to the wrong product, or you’re trying to use features of the API that your subscription doesn’t allow. + + +### “Weather source failed to find a matching location” + +This error happens when app was able to find your longitude and latitude, but unfortunately, the weather source did not find any location close to this longitude and latitude. Unfortunately, the only workaround is to try with a different source or add your location manually. + + +### “Failed to parse weather data” + +This error should be reported as soon as possible to GitHub, mentioning the source and the location on which it is happening (or for privacy reasons, a nearby location that has the same issue). + + +### “Incompatible forecast source times” + +This error means that the hourly times of forecast data and your air quality or pollen data don’t match. +This can happen in the following case: +You live in India, with a timezone of UTC+05:30. +The forecast source you selected reports hourly forecast on the :00 time, while your air quality or pollen source reports on the :30 time (or the other way around). + + +### “Source no longer available” + +This error may happen when a source is no longer provided by Breezy Weather. In that case, you will need to add a new location with another source, and delete this location. It can also happen when you switch from the standard flavor of Breezy Weather to `freenet` one which has less sources supported. + + +### “Secure connection failed” + +This can mean many things. + +If this only happens with one source and not others: +1) If you are using an Android version lower than Android 14, it is possible the server is using a Certificate Authority that was not trusted by the old Android version back then. On Android 14 and later, an updated trust store should be available to Google Play users. Note that we have our own bundled trust store in the app, where we can add missing Certificate Authorities. +2) If you have a low Android version, the server may be communicating with a more modern protocol or cipher suites than is supported by your device +3) The certificate may be expired. In that case, all users are affected, and the source will probably fix it very soon as this means no one can use the source (in any project, not just Breezy Weather) + +If this is happening will all sources, and presumably with other apps, in the worst case, you may be a victim of a [man-in-middle attack](https://en.wikipedia.org/wiki/Man-in-the-middle_attack). + +If in doubt, [start a discussion to ask for help](https://github.com/breezy-weather/breezy-weather/discussions/new?category=general). + +___ + +## Weather updates + +### Background updates are not working + +If the app is installed in a work or private profile, turning off that profile will disable background updates, so if you are in that case and want background updates, make sure to not turn off the profile or move the app to the main profile. + +Certain manufacturers implement non-standard Android behaviors, which prevents the app from working properly. + +The first thing to try is to whitelist Breezy Weather from battery optimization. From the app, go to Settings > Background updates and tap on “Disable battery optimization” (don’t worry, our background update job is optimized to be very battery-friendly, and you can change “Refresh rate” to “Never” at any time!). + +If it still doesn’t work, you can find ways to circumvent aggressive manufacturer behaviors on the [Don’t kill my app! website](https://dontkillmyapp.com/). + + +### I used Geometric Weather before, and the “persistent notification” method worked fine for me, can you bring it back? + +If you don’t already have a widget, you can try adding one (you don’t need to have it on your main page). On some devices, this may help mimic the old “persistent notification” by avoiding app being killed for no reason, although the widget does not run anything like the old method, it just renders once and updates only on background updates or force refresh from the app. + +Otherwise, this “persistent notification” method was based on a foreground service which was running every minute to check if there was something to do. + +It is not battery-friendly at all. The worker method that we use just tells Android "we need something to run a task every 1 h 30, but if you are too busy to run it at that moment, you have a 10 minute margin to run it", so it’s much more efficient as Android takes care of running all jobs from all apps by itself at the moment it feels the most appropriate, instead of each app having their own foreground service. + +If your manufacturer thinks it’s a good idea to not run scheduled workers but has no problem letting foreground services drain battery, then the problem is the manufacturer, not Breezy Weather, not you. + +So we will not bring back/implement “persistent notification” for these reasons: +- it implies writing huge duplicate code (that was known to have duplicate run issues in Geometric Weather, btw) and maintaining it +- it is not battery-friendly + +But more generally, we recommend that you follow steps from “Background updates are not working” section to find a workaround. + + +### Can you make weather refresh less than every 30 minutes / every time I open the app / every time I tap on widget / every time I unlock my phone / every second? + +Short answer: no. + +Long answer: +Breezy Weather should honor the “refresh rate” setting from Settings > Background updates. If it does not, have a look at troubleshooting above. +If for any reason the background update failed, it will refresh if weather was updated more than “refresh rate time” ago. + +If you still want shorter refreshes: +- models are refreshed at best once an hour. Although there might be some little exceptions for some particular data, it’s mostly useless to refresh at intervals less than 30 minutes. Additionally, some providers send header instructions to not contact server again before X (datetime) so you would be served the same cached data anyway. +- we ask for fair usage of API and resources. This app and these API are provided for free and shared by all users of Breezy Weather. Due to noticed abuse, we even had to implement additional caching methods to prevent these abuses and ensure API can still be used by everyone. +- you can still force refresh from main screen by “swiping to refresh”. + +___ + +## Launcher + +### Why is the app not called “Breezy Weather” on my launcher? + +The app name is “Breezy Weather”, however in the launcher we use the translated word for “weather”. + +The rationale behind this is to offer a better user experience: +- You don’t have to recall what was the app name to find it in the list. You just have to remind you want to access the weather. +- It better adapts to other languages, as we use the translated word for “weather” and you don’t have to recall a non-native word (Breezy). + +This choice is aligned with Breezy Weather principles to make it easy to use as a new user. Many other apps make the same decision. + +For users with advanced needs not happy with this choice, we recommend using a launcher that allows customisation of app names. + +___ + +## Design + +### I hate the new Material 3 Expressive design update + +As always when there is a major design change, there are early adopters who fully embrace the changes, and more conservative users, with the majority of users being in-between. + +If you’ve been using the new design only for a few days, we encourage to **give yourself a few more weeks**. + +Here is why: + +#### Research + +Material 3 Expressive is the **most studied design system** by Google, with 46 studies involving more than 18,000 participants. + +Top research takeaways include: +1. Expressive designs are **preferred by people of all ages**, with a strong preference from users in the 18-34 age group. +2. Expressive designs consistently score higher on user attributes like **playfulness**, **energy**, **creativity**, and **friendliness**, which give a positive perception of the app by users. +3. Users are **more likely to switch to products that use M3 Expressive** components and techniques. +4. Expressive designs are **easier to use**, with participants spotting key UI elements up to **four times faster** in expressive screens, among users with varying abilities. + +[Learn more about the Expressive research](https://design.google/library/expressive-material-design-google-research) + +Each new design made by Google is more studied, and always gradually adopted by apps (we almost never see the old Holo design in apps anymore). + +With clear design toolkit guidelines, Material 3 Expressive provides a **consistent developer and user experience** across the Android system and the other apps also adopting it. + +#### Alternative design options + +Regarding the ability to make an option to switch between the new and old design, it’s not possible, because there were significant technical changes during the migration to get rid of most of the technical debt, which allows for easier maintenance of the app. + +Maintaining more than 1 design has a high maintenance cost. We do provide some abilities to customize this design for flexibility, but for more significant changes, we provide the [ability to make 3rd party designs](https://github.com/breezy-weather/breezy-weather/discussions/2089), either by yourself or by commissioning someone. If you concretize it, we would be happy if you could share it in the [Show & Tell section](https://github.com/breezy-weather/breezy-weather/discussions/categories/show-and-tell)! diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..2613cc5 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,82 @@ +# Simple instructions + +Go to [Releases page](https://github.com/breezy-weather/breezy-weather/releases) and download the file with the following format `breezy-weather-vX.Y.Z_standard.apk`. + +Install it and you’re done! + +After adding your first location, you will be asked if you want to be notified of app updates. We highly recommend you enable it. + + +# Detailed instructions + +## Flavors + +The recommended flavor of **Breezy Weather** is the standard version. It is fully open source and contains no proprietary components. + +For specific needs, we also offer a flavor with only free-network sources (libre and self-hostable): Open-Meteo, Pirate Weather, Bright Sky (DWD), Recosanté and ClimWeb (used by many African countries). + +Both flavors are signed with the same signature, so you can easily try/switch between both. + + +## Sources to get Breezy Weather from + +**Breezy Weather** releases are available from the following sources: +- **[GitHub releases](https://github.com/breezy-weather/breezy-weather/releases)** is where releases built by GitHub are published under APK format. Any Android device can install APK files without needing any particular app. If you have a GitHub account, you can subscribe to be notified of updates, however it’s more convenient to use a store app to track updates. Due to technical limitations, this is also the only source to provide architecture-specific APKs, although the difference between them and the universal APK is of a negligible 2 MB, so it should not be a criteria of choice. +- **[Breezy Weather’s F-Droid repositories](https://github.com/breezy-weather/fdroid-repo/blob/main/README.md)** are maintained by Breezy Weather developers and get updates from a F-Droid client that doesn’t support receiving updates from GitHub. +- **[Izzy F-Droid repository](https://apt.izzysoft.de/fdroid/index/info)** offers the standard flavor which is our recommended choice if you would like someone to independently review the app before it gets published. Updates are fast (less than 24 hours). +- **[F-Droid default repository](https://f-droid.org/packages/org.breezyweather/)** offers the flavor with only free-network sources. Updates are slower as it requires someone on F-Droid team to manually add new versions, as autoupdates cannot be enabled for technical reasons. If you decide to use this source and you want to report an issue, you will be asked to update to the latest version before making the report. + +| Differences | GitHub releases | [F-Droid repo] Breezy Weather | [F-Droid repo] Izzy | [F-Droid repo] Default | +|----------------------------|-----------------|-------------------------------|------------------------|---------------------------| +| Available flavors | All | All | Standard | Free network sources-only | +| Pre-releases | Optional | Optional | ❌ | ❌ | +| Delay for updates | Immediate | Immediate | Every day at 18:00 UTC | Very slow (manual) | +| APK matches source code | ✅ | ✅ | ✅ | ✅ | +| Independently reviewed | ❌ | ❌ | ✅ | ✅ | +| Architecture-specific APKs | ✅ | ❌ | ❌ | ❌ | + + +### Other not supported well-known sources + +- Google Play Store: + - Costs money + - Is privacy invasive for the developer (requires sending your ID and giving your phone number) + - We don’t [comply with Google Play policy](https://github.com/breezy-weather/breezy-weather/issues/31) +- Accrescent: waiting for it to become stable (no ETA announced by upstream) + + +## Client configuration instructions + +### Obtainium + +[Link to Obtainium page](https://github.com/ImranR98/Obtainium/blob/main/README.md) + +#### Getting updates from GitHub releases + +In the “Add App” screen: +1. Add the following URL: `https://github.com/breezy-weather/breezy-weather` +2. To receive updates for prereleases, enable “Include prereleases” +3. (Optional) If you want the flavor with only free network sources, add `freenet` in the “Filter APKs by Regular Expression” +4. Tap the “Add” button at the very top, and you’re done! + +#### Getting updates from a F-Droid repository + +In the “Add App” screen, just add as App Source URL the following URL depending on the repository you want to use: + - Standard flavor from Izzy repo: `https://apt.izzysoft.de/fdroid/index/apk/org.breezyweather` + - Standard flavor from Breezy Weather repo: configure Obtainium to use GitHub releases instead (see previous section) + - Free-net flavor from Breezy Weather repo: configure Obtainium to use GitHub releases instead (see previous section) + - Free-net flavor from default F-Droid repo: `https://f-droid.org/packages/org.breezyweather/` + +Tap the “Add” button at the very top, and you’re done! + + +### F-Droid client + +1) Look for the Repositories option from your F-Droid client and add a new repository depending on the source you want to use: + - Standard flavor from Izzy repo: `https://apt.izzysoft.de/fdroid/repo` + - Standard flavor from Breezy Weather repo: `https://breezy-weather.github.io/fdroid-repo/fdroid/repo` + - Free-net flavor from Breezy Weather repo: `https://breezy-weather.github.io/fdroid-repo/fdroid-version/fdroid/repo` + - Free-net flavor from default F-Droid repo: should already be enabled by default on your F-Droid client + +2) After adding the app, go to the app details and make sure to select which repo you want to get updates from to avoid cross-updates between flavors and repos. This is how you do it: +![F-Droid preferred repo feature](docs/fdroid_client_config.png) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65c5ca8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/LICENSE_ADDITIONAL b/LICENSE_ADDITIONAL new file mode 100644 index 0000000..a87e056 --- /dev/null +++ b/LICENSE_ADDITIONAL @@ -0,0 +1,3 @@ +This License does not grant any rights in the trademarks, service marks, or logos of any Contributor. + +Misrepresentation of the origin of that material is prohibited, and modified versions of such material must be marked in reasonable ways as different from the original version. \ No newline at end of file diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..fd569c8 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,13 @@ +Breezy Weather doesn’t collect any personal data. + +Optionally, it can access your approximate or precise location to find weather data for your position. This data is shared with weather sources. If you don’t want to share your location, you can deny permissions and choose a city manually. + +If you enable the “check for app updates” feature, the app will connect to GitHub every 24 hours at most to get the latest version of Breezy Weather. The version you currently have installed will not be shared with GitHub (it will be compared on device). [GitHub Privacy Policy](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement) + +The icon packs feature (which allows you to customize weather icons) requires to list packages installed on your device. It will ignore all packages that don’t match one of these packs: Breezy Weather icon pack, Geometric Weather icon pack, Chronus icon pack. No data is collected, it is only processed at the moment it is needed. + +Breezy Weather relies on third-party APIs to get various data such as your current location (if you decide to not use native GPS), location search or weather, please review their privacy policy (from Settings > Info icon > Privacy policy) and only use the ones you agree with. + +Breezy Weather can optionally share your location and weather data with other apps. Please review their privacy policy before you decide to grant them permission. + +This privacy policy may be updated any time, for example to clarify a use case or in case a feature gets added. In that case, it will be mentioned in the changelog. You can always access the latest version of the privacy policy from Settings > Info icon > Privacy policy. diff --git a/README.md b/README.md index 3c890a5..d23163f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,208 @@ -# breezy +
+
+Logo +
-Android Weather App \ No newline at end of file +

Breezy Weather

+ +
+ + + + +

Breezy Weather is a feature-rich free and open source Material 3 Expressive weather app with well-though-out visualizations, supporting forecast, observations, nowcasting, air quality, pollen, alerts, from more than 50 weather sources.

+ +
+ +# Download + + + + + + + + + + +
+ + + +
+

SHA-256 hash of the signing certificate: 29d435f70aa9aec3c1faff7f7ffa6e15785088d87f06ecfcab9c3cc62dc269d8
+ SHA-256 checksums are also provided per file on the GitHub releases page.

+
+ +
+ +
+ + + + + + + +
+ +# Features + +- Weather data + - Daily and hourly forecasts up to 16 days + - Precipitation in the next hour + - Severe weather and precipitation alerts + - Temperature / Feels like temperature / Normals + - Precipitation + - Wind + - Air quality + - Pollen & Mold + - Humidity + - UV index + - Visibility + - Pressure + - Sun + - Moon +- Visualization + - Detailed 24-hour charts + - Material 3 Expressive blocks +- More than 50 weather sources supported (full list) +- Large selection of widgets +- Live wallpaper +- Custom icon packs + - [Geometric Weather icon packs](https://github.com/breezy-weather/breezy-weather-icon-packs/blob/main/README.md) + - Chronus Weather icon packs +- Automatic dark mode +- Opt-in data sharing with other apps (such as Gadgetbridge) +-
Accessibility + + - Localization + - Number formatting (different numeral systems, decimal separator, thousand separator) + - Unit formatting + - Alternate calendar + - Readability + - Good content descriptions for screen readers + - Navigation with screen readers: most things should work, features depending on drag & drop not yet supported + - Custom display settings: basic support +
+ +-
Free and Open Source + + - No proprietary blobs/dependencies + - Releases generated by GitHub actions, guaranteeing it matches the source code + - Fully works with Open-Meteo (FOSS source) +
+ +-
Privacy-friendly + + - No personal data collected by the app ([link to app privacy policy](https://github.com/breezy-weather/breezy-weather/blob/main/PRIVACY.md)) + - Multiple sources are available, with links to their privacy policies for transparency + - Current location is optional and not added by default + - If using current location, an IP location service can be used instead of GPS to send less accurate coordinates to weather source + - No trackers/automatic crash reporters +
+ + +# Help + +* [Frequently Asked Questions / Help](HELP.md) +* [Main screen explanations](docs/HOMEPAGE.md) +* [Weather sources comparison](docs/SOURCES.md) + + +# Contribute + +Pull requests are welcome. You can have a look at [issues opened to contributions](https://github.com/breezy-weather/breezy-weather/issues?q=is%3Aissue+is%3Aopen+label%3A%22Open+to+contributions%22). For other changes, please open an issue first to discuss what you would like to change. + +* [Contribution guide (includes a guide to create a new weather source)](CONTRIBUTE.md) + +## Features currently being worked on by a contributor + +- [Announcement](https://github.com/breezy-weather/breezy-weather/discussions/2089) - Make Breezy weather data available through a ContentProvider. Currently in testing phase + +## Features lacking an active contributor + +- [#10](https://github.com/breezy-weather/breezy-weather/issues/10) - “Add location” page needs a new design, in the spirit of Google Maps where you can select location points on the map, or search manually - No mockup done yet +- [#937](https://github.com/breezy-weather/breezy-weather/issues/937) - Widget overhaul (prerequisite for any new widget improvement) - Some mockups were done but no one is working on it anymore + +## Features that will not be implemented + +- Paid-only sources, too limited free-tier, or free-tier that requires privacy-invasive information (credit card info, phone number, etc) +- Radar; [please check out this document for alternatives](docs/RADAR.md) +- Adding `standard` flavor or non-free sources to the F-Droid default repo: please use the `standard` flavor from a different store/source instead +- Changes to the [background updates process](docs/UPDATES.md), including but not limited: options for refreshing less than every 30 minutes, every time you open the app, every time you tap on widget, every time you unlock your phone +- “Circular sky” interface: please set a fixed background per location instead +- Publish to Google Play Store: please [check alternatives](INSTALL.md) +- Allow different flavors to be installed in parallel +- Implement features that are no longer available in latest Android versions +- Backport features/fixes from latest Android versions to older Android versions +- Donations: if you have extra money to spare, consider [donating to Open-Meteo](https://github.com/sponsors/open-meteo) to support infrastructure costs and future developments (we currently lack a libre and gratis worldwide alternative for the following features: [Reverse geocoding](https://github.com/open-meteo/geocoding-api/issues/6), [Alerts](https://github.com/open-meteo/open-meteo/issues/351), [Normals](https://github.com/open-meteo/open-meteo/issues/361)) + + +# Translations + +Translation is done externally [on Weblate](https://hosted.weblate.org/projects/breezy-weather/breezy-weather-android/#information). Please read carefully project instructions if you want to help. + +[![Translation progress report](https://hosted.weblate.org/widget/breezy-weather/breezy-weather-android/multi-auto.svg)](https://hosted.weblate.org/projects/breezy-weather/breezy-weather-android/#information) + +English (and regional variants) and French translations are maintained by repo maintainers, but they are open to proofreading/improvements. You will need to make a pull request, as we didn’t find a way to make these languages in suggestion-only mode in Weblate (let us know if you find anything). + +For unit formatting, we use [Unicode data](https://www.unicode.org/cldr/charts/47/summary/root.html) as much as possible. If you believe there is an error, please [open a discussion](https://github.com/breezy-weather/breezy-weather/discussions/categories/general) with evidences that the changes you suggest is the recommendation for your language. + + +# Contact us + +* If you’d like to report a bug or suggest a new feature, GitHub discussions or issues are best for organization. +* We’ve also created a Matrix/Element space with a number of different channels for more general discussion: [`#breezy-weather-space:matrix.org`](https://matrix.to/#/#breezy-weather-space:matrix.org). + * If you are not comfortable writing a GitHub discussion/issue in English, you can ask on the channel if someone can help you in your language. + * We also have a dedicated help channel in French: [`#breezy-weather-francais:matrix.org`](https://matrix.to/#/#breezy-weather-francais:matrix.org) + * If you’d prefer a direct channel link instead of a space link, here’s the main Breezy Weather Matrix channel: [`#breezy-weather:matrix.org`](https://matrix.to/#/#breezy-weather:matrix.org) + + +# License + +* [GNU Lesser General Public License v3.0](/LICENSE) +* This License does not grant any rights in the trademarks, service marks, or logos of any Contributor. +* Misrepresentation of the origin of that material is prohibited, and modified versions of such material must be marked in reasonable ways as different from the original version. + +Before creating a fork, check if the intent action `nodomain.freeyourgadget.gadgetbridge.ACTION_GENERIC_WEATHER` can cover your need (for example, you want to re-use our weather data in your own customized widget). It can be enabled from Settings > Widgets & Live Wallpaper > Data sharing. You can also [help testing our `ContentProvider` exposing the full weather data of Breezy Weather](https://github.com/breezy-weather/breezy-weather/discussions/2089). + +Otherwise, remember to: + +- Respect the project’s LICENSE +- Avoid confusion with Breezy Weather app: + - Change the app name + - Change the app icon +- Avoid installation conflicts: + - Change the `applicationId` in [`build.gradle.kts`](https://github.com/breezy-weather/breezy-weather/blob/main/app/build.gradle.kts#L24) diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..adb058a --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,5 @@ +/build +/src/main/res/values-in/ +/src/main/res/values-iw/ +# /src/main/res/raw/ne_50m_admin_0_countries.json +locales_config.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..8f791fc --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,408 @@ +@file:Suppress("ChromeOsAbiSupport") + +import breezy.buildlogic.getCommitCount +import breezy.buildlogic.getGitSha +import breezy.buildlogic.registerLocalesConfigTask +import java.util.Properties + +plugins { + id("breezy.android.application") + id("breezy.android.application.compose") + id("com.android.application") + id("com.google.devtools.ksp") + id("com.google.dagger.hilt.android") + kotlin("plugin.serialization") + id("com.mikepenz.aboutlibraries.plugin.android") +} + +val supportedAbi = setOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + +android { + namespace = "org.breezyweather" + + defaultConfig { + applicationId = "org.breezyweather" + versionCode = 60012 + versionName = "6.0.12" + + buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") + buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"") + + multiDexEnabled = true + ndk { + abiFilters += supportedAbi + } + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + vectorDrawables.useSupportLibrary = true + } + + splits { + abi { + isEnable = true + reset() + include(*supportedAbi.toTypedArray()) + isUniversalApk = true + } + } + + buildTypes { + named("debug") { + applicationIdSuffix = ".debug" + versionNameSuffix = "-r${getCommitCount()}" + } + named("release") { + isShrinkResources = true + isMinifyEnabled = true + isDebuggable = false + isCrunchPngs = false // No need to do that, we already optimized them + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + val properties = Properties() + if (project.rootProject.file("local.properties").canRead()) { + properties.load(project.rootProject.file("local.properties").inputStream()) + } + buildTypes.forEach { + it.buildConfigField( + "String", + "DEFAULT_LOCATION_SOURCE", + "\"${properties.getProperty("breezy.source.default_location") ?: "native"}\"" + ) + it.buildConfigField( + "String", + "DEFAULT_LOCATION_SEARCH_SOURCE", + "\"${properties.getProperty("breezy.source.default_location_search") ?: "openmeteo"}\"" + ) + it.buildConfigField( + "String", + "DEFAULT_GEOCODING_SOURCE", + "\"${properties.getProperty("breezy.source.default_geocoding") ?: "naturalearth"}\"" + ) + it.buildConfigField( + "String", + "DEFAULT_FORECAST_SOURCE", + "\"${properties.getProperty("breezy.source.default_weather") ?: "auto"}\"" + ) + it.buildConfigField( + "String", + "ACCU_WEATHER_KEY", + "\"${properties.getProperty("breezy.accu.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "AEMET_KEY", + "\"${properties.getProperty("breezy.aemet.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "ATMO_AURA_KEY", + "\"${properties.getProperty("breezy.atmoaura.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "ATMO_FRANCE_KEY", + "\"${properties.getProperty("breezy.atmofrance.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "ATMO_GRAND_EST_KEY", + "\"${properties.getProperty("breezy.atmograndest.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "ATMO_HDF_KEY", + "\"${properties.getProperty("breezy.atmohdf.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "ATMO_SUD_KEY", + "\"${properties.getProperty("breezy.atmosud.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "BAIDU_IP_LOCATION_AK", + "\"${properties.getProperty("breezy.baiduip.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "BMKG_KEY", + "\"${properties.getProperty("breezy.bmkg.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "CWA_KEY", + "\"${properties.getProperty("breezy.cwa.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "ECCC_KEY", + "\"${properties.getProperty("breezy.eccc.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "GEO_NAMES_KEY", + "\"${properties.getProperty("breezy.geonames.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "MET_IE_KEY", + "\"${properties.getProperty("breezy.metie.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "MET_OFFICE_KEY", + "\"${properties.getProperty("breezy.metoffice.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "MF_WSFT_JWT_KEY", + "\"${properties.getProperty("breezy.mf.jwtKey") ?: ""}\"" + ) + it.buildConfigField( + "String", + "MF_WSFT_KEY", + "\"${properties.getProperty("breezy.mf.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "OPEN_WEATHER_KEY", + "\"${properties.getProperty("breezy.openweather.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "PIRATE_WEATHER_KEY", + "\"${properties.getProperty("breezy.pirateweather.key") ?: ""}\"" + ) + it.buildConfigField( + "String", + "POLLENINFO_KEY", + "\"${properties.getProperty("breezy.polleninfo.key") ?: ""}\"" + ) + } + + flavorDimensions.add("default") + + productFlavors { + create("basic") { + dimension = "default" + } + create("freenet") { + dimension = "default" + versionNameSuffix = "_freenet" + } + } + + sourceSets { + getByName("basic") { + java.srcDirs("src/src_nonfreenet") + res.srcDirs("src/res_nonfreenet") + } + getByName("freenet") { + java.srcDirs("src/src_freenet") + res.srcDirs("src/res_freenet") + } + } + + packaging { + resources.excludes.addAll( + listOf( + "kotlin-tooling-metadata.json", + "LICENSE.txt", + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/**/*.properties", + "META-INF/**/LICENSE.txt", + "META-INF/*.properties", + "META-INF/*.version", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/NOTICE", + "META-INF/README.md" + ) + ) + } + + dependenciesInfo { + includeInApk = false + } + + buildFeatures { + viewBinding = true + buildConfig = true + + // Disable some unused things + aidl = false + renderScript = false + shaders = false + } + + lint { + abortOnError = false + checkReleaseBuilds = false + disable.addAll(listOf("MissingTranslation", "ExtraTranslation")) + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + unitTests.isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll( + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + "-opt-in=androidx.compose.material3.ExperimentalMaterial3ExpressiveApi", + "-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi", + "-opt-in=kotlinx.serialization.ExperimentalSerializationApi", + "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi" + ) + } +} + +aboutLibraries { + offlineMode = true + + collect { + // Define the path configuration files are located in. E.g. additional libraries, licenses to add to the target .json + // Warning: Please do not use the parent folder of a module as path, as this can result in issues. More details: https://github.com/mikepenz/AboutLibraries/issues/936 + // The path provided is relative to the modules path (not project root) + configPath = file("../config") + } + + export { + // Remove the "generated" timestamp to allow for reproducible builds + excludeFields.add("generated") + } +} + +dependencies { + implementation(projects.data) + implementation(projects.domain) + implementation(projects.mapsUtils) + implementation(projects.uiWeatherView) + implementation(projects.weatherUnit) + implementation(libs.breezy.datasharing.lib) + + implementation(libs.core.ktx) + implementation(libs.appcompat) + implementation(libs.material) + implementation(libs.core.splashscreen) + + implementation(libs.cardview) + implementation(libs.swiperefreshlayout) + + implementation(platform(libs.compose.bom)) + implementation(libs.activity.compose) + implementation(libs.compose.material.ripple) + implementation(libs.compose.animation) + implementation(libs.compose.ui.tooling) + implementation(libs.compose.ui.util) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.material.icons) + implementation(libs.navigation.compose) + lintChecks(libs.compose.lint.checks) + + implementation(libs.accompanist.permissions) + + testImplementation(libs.bundles.test) + testRuntimeOnly(libs.junit.platform) + + // preference. + implementation(libs.preference.ktx) + + // db + implementation(libs.bundles.sqlite) + + // work. + implementation(libs.work.runtime) + + // lifecycle. + implementation(libs.bundles.lifecycle) + implementation(libs.recyclerview) + + // hilt. + implementation(libs.dagger.hilt.core) + ksp(libs.dagger.hilt.compiler) + implementation(libs.hilt.work) + ksp(libs.hilt.compiler) + + // HTTP + implementation(libs.bundles.retrofit) + implementation(libs.bundles.okhttp) + // implementation(libs.kotlinx.serialization.csv) // Can be reenabled if needed (see also HttpModule.kt) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.xml.core) + implementation(libs.kotlinx.serialization.xml) + + // data store + // implementation(libs.datastore) + + // jwt - Only used by MF at the moment + "basicImplementation"(libs.jjwt.api) + "basicRuntimeOnly"(libs.jjwt.impl) + "basicRuntimeOnly"(libs.jjwt.orgjson) { + exclude("org.json", "json") // provided by Android natively + } + + // rx java. + implementation(libs.rxjava) + implementation(libs.rxandroid) + implementation(libs.kotlinx.coroutines.rx3) + + // ui. + implementation(libs.vico.compose.m3) + implementation(libs.vico.views) + implementation(libs.adaptiveiconview) + implementation(libs.activity) + + // utils. + implementation(libs.suncalc) + implementation(libs.aboutLibraries) + + // Allows reflection of the relative time class to pass Locale as parameter + implementation(libs.restrictionBypass) + + // debugImplementation because LeakCanary should only run in debug builds. + // debugImplementation(libs.leakcanary) +} + +tasks { + // May be too heavy to run, so let’s keep the generated file in Git + // val naturalEarthConfigTask = registerNaturalEarthConfigTask(project) + val localesConfigTask = registerLocalesConfigTask(project) + + // Duplicating Hebrew string assets due to some locale code issues on different devices + val copyHebrewStrings by registering(Copy::class) { + from("./src/main/res/values-he") + into("./src/main/res/values-iw") + include("**/*") + } + + // Duplicating Indonesian string assets due to some locale code issues on different devices + val copyIndonesianStrings by registering(Copy::class) { + from("./src/main/res/values-id") + into("./src/main/res/values-in") + include("**/*") + } + + preBuild { + dependsOn( + // naturalEarthConfigTask, + copyHebrewStrings, + copyIndonesianStrings, + localesConfigTask + ) + } +} + +buildscript { + dependencies { + classpath(libs.kotlin.gradle) + } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..ff29168 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,73 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep class org.breezyweather.common.activities.models.** { *; } +-keep class org.breezyweather.db.entities.** { *; } +-keep interface org.breezyweather.sources.**.* { *; } +-keep class org.breezyweather.sources.**.json.** { *; } + +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver + +-keep class androidx.lifecycle.** {*;} +-keep class android.arch.lifecycle.** {*;} + +-keep class **.R$* {*;} + +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + !static !transient ; + !private ; + !private ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +-keepclassmembers class * { + void *(**On*Event); + void *(**On*Listener); +} + +-assumenosideeffects class android.util.Log { + public static int v(...); + public static int i(...); + public static int w(...); + public static int d(...); + public static int e(...); +} + +# suncalc +-dontwarn edu.umd.cs.findbugs.annotations.Nullable + +# RestrictionBypass +-keep class org.chickenhook.restrictionbypass.** { *; } + +# Jwt +-keep class io.jsonwebtoken.impl.** { *; } diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c78bee3 --- /dev/null +++ b/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.webp b/app/src/debug/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..50d2fbb6f785b9a6a868c2858c502c88eadeaa55 GIT binary patch literal 1492 zcmV;_1uObeNk&G@1pok7MM6+kP&iD#1pojqN5Byf;!x1GjpXo$z1;&LA|{|~exU59 zCfYAnIoh@zn`7ItZQEw&1=y}^+qP}nwvGRv*~j<3-!b#fw}kn35UyhSWCNKM#CEE* zPw}Vp*T>p`(>ZU<=g1nI&Z!|wl5N`h^lRI;IkU}Y+x9!|HF6jLOxkLKV4?1v4@i*y zPXN?f+oUJYN&j`v{FyHa$nP919ne7}JA75dO&QqTjm4q2J~oG1N1?VCi%bD-BS})E z_TrdPKL<{I`Qt@qW`>#ed`G}(wQb9Ces_1El6(V;93q*{3RIFya+k!<3^stSW6uQm z>N^Gj(Izp70U*+i)~xD|DnSiY59&bOq7T=vKHY0Zb;yR!D40Z@@4O~;cj$7A_?SHA z;|7r4Qy|*;DIb3wn?!y10vi?YNdUoD(+q!LZ+M?}!pq3Y7XoZ8PtpLIyRnsq04u9y z1`m_rbd=JY(*zEou@;<%wRH{;)1eI1GFEXYb@puY^Y1C7;1P5lHa2D0m{tD0ySJG7 z6QLCN^w`>uA4Ewo(LvMqA4-FkTogLEQ6hpyurEj=v=t)H=0u4w)xlAzurMkl4j>vz zJV>PQ39lg&!F(9d@n5DKK4{|QMiyW=qU#Gqo!Q=ciFPbN8`tDKM*2z z_zU^>?g*()X6t;jx8EXEM>G-Lum3g%WvG^U`-hq@Lv=%8Kcdp-PaCslN-n*;xrk1e zKU<@+F{;K#Z>7{Y=l=&12@pFmAxIcmfX8vuh4h2)*zBT;vF1N^dCz3L+yR{J2!8YPGuk#n*#bYsy5Jkgu?QO!?s%e>q`c zgz3L|y~yDL^%N2{a;9g*+Mtr;M`75I*OChW*kGgqh?VK%=LeDD<33Q(QEe@X5hpd* z!8M_g8bG|${pid@Y9A)KZlq86WOF^hxDOie6moWtpgspjTAqC7v&G%rJ!x)a#JAUe zMDj3V2Q*H<53rH*ng2Q8=(I8@OTDVQ_mVje7xs+kt7ZAi7iHZ7R1r0IUj(j@TCcnR zOEWEWx%$bIzSyVTG6M0cV?PiHry|o9awTT6CviULDcYH|N3$OJma1<*H?U%&gVJYD z%1~kjDoMV$fhN~Gcppw)nZt!VumXO3w%u<`eo@EQ*x`jcV4Hfqx zqIl|D^M+&K6tDy7CL@@$kL#E6lz9cvYmWF#u1) z2K`T4qlQ6D8U=P!q41>(B6emXl7H`!irSAKLk&np>h=N>(dPSqT_XBODMZwRG<4@< zI=6CiMjfDgoh1duHLaRGQ(r28Cq8VoTVmyN|O{^fbY2fa7wtEm8$LDBaB literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..455aa516021ec2abf9f9794eb65b228d4a98b76d GIT binary patch literal 2858 zcmV+_3)S>eNk&E@3jhFDMM6+kP&iB#3jhEwN5Byf-ayc{Z5;lvxBV0%Vgl?zp(V4G ze&B)fOwro*G;;GEd-neCSzg<+_XfUDVRGE4%*?IAocv)1l^JHYT$Nl}C|b58*`{sv zv2EM7d2QRi+gx62j6Ock_wD||{>fz^ZQD>-e+iOgo3<4j*Z0}BZQHhOTi^Ddf^FN@ z6m0t$XWw?_Y1wFQl=@6PoPv;CyE)@szO8Eo6O%}Ae~o3d@EY};1bw(U;#ePS3%QsVUCyt^xM z9Q}9THj*N_r_V30rlv<_H*s9B;6w{_ z0s4+YtA2ydzY(Mi@xDSSN}P&9?ScN78A>3eln`P<=$xkLx-d7ho4-6OLA0L&S%MOw zn-I(pQkvQ?ZaikTu}A75F-CF52|8JY=H{t+6}ab7#kjuF{3nw*F#yfEPR=b3RdotA z&xrxo#g5K31LzV8hFG%B8qH9bAMRvZf_$NOHcr-qrqP}z;Mc*j5E(DKG4`a<5cBvl zmA%8LQV5n>`)O;@>2gi0y?y$(t2mxwH`7i)dI(gHU}w zW2^^;Y#(!T1Npw(?)qv`XLS(-A@(d8zIhCR9+?mor2llgn;TTm^J^hO+*mSv?};#S z9i~EDYHv+L#v5#`f)GbG8m6lNX(Nk3zsDaH=?vVlab>6_0w8U)C5Zwi$Pt>g*8OU# zm*d~Q_%H=rvn07GHG?|d{wv2o;m*L_DDG@T0K=Juqah7Xd}u$(nsVOj;u7M8p>%ja zT!wN}9mEZfjc=%t^LZanl96-ARjH@p2@lqOG15w6%Mu#40CxeqZXf7q(oF|z+3KjT z5V186xWFIrO7%FtU2Z&nETv%sjYmNuWC`+V|i9i(vZA2e!)( zK^CrNLkROhx*1UnVhphPC8Uqm_&Zy&?Xa?se0XDCodtp)czc%~Om-Fx4tyY@M|oM! zg1|YSIO?jz_}6bIklPAacI(`&27MiN^6`7@j}i|co#Ve-Q$GLl<4I2?$biX6y8_TE zgAfrv7$d&gsG1oeLsC-h@b<#X!z$TInf6R6Jvj`0i7)Xt5XZ#MqSM$5N*-U9?_t@Ay3JX6tK! zu-!<2@oyg@nyZNE2ZwAGqxi}Z_1}jkgO$rE5%TJL=V3GQ1kTjz&I_*o6aU0rC@EJ- zkU{&9$O{WIf1R3?^I@ChF^>v9b0;wC(U2U0B87hR-W^kIr<;XVBARPuq`#CU4VLI> zw?pRVGt~@kCUa;b@|hh-PyLSY~<5zk-iE+DL$M@K8VnFE|sJtVjFe#Ef{Pr2HtCSmdfF06a>|F{_a)=xDtqj5#p9G##qboD#rj#3}n2ap0OhX8XS-6`#(pe1FWJ!j0{ud~)kL~@!YYGJH3~K3(lOUhssc^ohwIw6rIiT-;DPVvK!{Is(HvJwLCqR%6^X(0R z-7Z-k-gwx|zE3>xjY=$|LxA5-z2k0J5C`NWtZF!q1zSB}3lQk7Z$U@Sk;IPvrC<2C zXiqY&l-t9{|orISpOWQSvS)C;4Z&oQ9(yBJ$BcdMi&rL&^bc*`3o>2t9=` zR0(P$5O9D;%Kvr{A^>HH1D-Dp2g%WSoSC$>*H<{$3fQ(sQcz!hBAv`^{%5X&~r9eBQ{=gXIAkquL z%A{yZE>6ZT#sq$}`~!?vkH?Y1E#KM>^@$eL(djMm<3N5OoL(Oh)^X%Esiio9RD zSWJ@f+e5%=s<|?t$6aL?y4PefYQ$ha4rvPCF8$&+TYoKZu*a_zrH3* z*6`MR(MXvr_SZ4c%NU@NpE1b4{T@1hR#Vx9Y0}iMCU0N{A0I&gki)Y(R+P?=~pkwGkQvMZ> zwhi#x+`Evv7~RygvI5*^qnE)KmoOj)NQ%CpxE}HbpIn{|Ugn+GjxN+u0H=ouU=R3| zyEXB{{ge0*Ah8}>s~?N$-SFVaz2#OdtM`BUw>rxu(#~Q4{L8g!dG7=%-*6y@u)dyR zO=*2SgnVV&nR2zde}+RYwHE=5QTo@0)mi5y75BE9udb8w=}BC8&_Yf2;LlIq$v4-& z+_5@b+jv~P>2W6L9XbMdJTYs#xN@h~;>C9I$!Y$2O2P}$zL52sod3_iPsW#hckC8_@Xt4Z{UIjkJ+58_P(~>pJM_VifalqzvJYA+`>L@eER}ue?z5MF z{NT`IYiHEI&M-rM|J!shr-tRJ4*>h$e!KsJQyVY8%>=!4Mk}Vp84ht)$krI79M^{5 I&U9W<07iI{$p8QV literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.webp b/app/src/debug/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..2f8764e0b4e240d422cddcfe5fa897934449afd5 GIT binary patch literal 952 zcmV;p14sN)Nk&Gn0{{S5MM6+kP&iDa0{{RoFTe{BC+9YjBt@#8In>^N|42W2nrSy6 zw~^#Xip(Ch@~`*&J>$+9eT}YU(6*5zcaO~Rr}xEGZQH6c&%O8Ie{Ma9LP-ixg$(Wv z0e}DrR3JbC8FabpU_Uk^5Nw)E@Mp!F8GCK)kNvSfII-745;%elv7`i(533J*M;2f3 zLx2U5z>`Rz5I>}g{J;k(APEo#kU;`9&;l(cf9w|Bh9HdqAt8`hrjaHgG=es1GK7E$ z5kev~Z45&rJ?U99Ay^5Gga9E3O`2>YK){4Zlb#UiNi((?Jcz-=XR1=<-E;NdxA^v- z#gQ1U&(B`DU!KB;Edd00Q4$COf99|Ed+lIA7$5`(2ZMoN;%NdBKE%*azzD&e6vvE+ zrwIr+NrRyQVlWgK91NWh3=R&R7#yaMnR%WJd`6*R@@)P0A9?@(I&`Rt!HEH;Djkaa zMkorp(%~~gF!>J}x?=I^siea%>Eb`~OUCo%wm&SE=?Kr%7^j)0S(igFv>B%vJQ4y( z+qO-alEJRs1?*ND9@$ws?e6aGPP!hNvAbIzgR)!f!WO%mfAY>Q@62)VJ)-{;z-r4R zhw>!XIAkm#3-y7(pa8`|fsodk zB^ewn%L@WPc>p9gXAW6QUrjt+@TyVYDPK(SFfDe{FDlk4cupDVHL994`%an-`={Ha zVxpsacSF_UA>H(teEQh7JQN`C`skRE6DN#NHtOsKM8l(mrPt0C7gzsS9jPY%DoXAR zdMa?^VIn%SFkZ>vkA;!}uL}`z3lY(&xxothS0p^hF|1mTh?skji0E&kf+g9{20a!i z;3Z1;8EBq@w?b6*S^jV*To%&p#l~+6%s1n6v2l-(?t&}NSM;tF;--R`WZa9=TOe@l zlR+==y@Gy!3`g<}Maw`SX5SrJu~I?s>=GiPt%f_j3|R1PP>ceIn_o^uhc{x*je)Nu zc&P`dfavi*%TCbwi&cZ)tdnfIFis5steHAve9r;*QGmeQ&4*~3GHKM954T~*{sYH} zrt2XsCn72xtK08*%}&k&y-t3L*X#eH?qHU$DPI>1kAfO_I&0-+N{s^`*jb aSeiDh$C-0(t=~X-eJf`!JK89Bu-X#igu3Yf literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..3f2779bb772302321fc52c87d3496cb757672fbe GIT binary patch literal 1860 zcmV-K2fO%ENk&FI2LJ$9MM6+kP&iC52LJ#sFTe{B-Vg{R=g+-EL`*2lPr*Zl2a^Xs{ty)mMp7SEy z-QC^c=)HHZ{cwp~e!)`sysNNn+crg{dl#-`+or9$wrxH~HZt3`t<2^J*nY8XGiPjD z=PU<~BsESCm?a42(#mg;qix%GCMY~+Oma3kuv!L*0&PXDh zvxW9lAq4SPhhonLV(*toDpOGfsDG^y|5`$+*0Fm9#jQ}Q>Ojl$w(vS&!_S5_FBL+i zHK2-UFDmBFLnB%v5YL#pndJ1Qxo>{eUyLL}?u@mP8U--Hl0~34jou+b5@6|LL(Z-Z zoOnKpEE#(VBs#uG_CetVk*~FXe>E*+>f_4`fYvnvaEgpQSCazSjHy2>r9$57LU+!V5~PCIg(wafYIxsEAVy zJ@0WhH`B9`(>Bh#IVX@cw01sWY3`shD%4I)ig(5=rbOG4h}Gb`H1ql6`TbnZqTa%P z8-F(S6mN=u($*E4(Cx%}D~FOL+s#SMhvqvv#$Ml>Ih{fMgvfH_HD{Hbk`{*~bWv{_ zb$L&Fe8=59wX4-LA!RiPNHm}rOIPgMjbQ-2g?}n1%UeXizNZuvSLRMSa_zYETQ8v9 zW6ZMZFZS5mO+8dd3fK$4Q+Q_uI{@)-8Ih&9(rVhI0HX!12VDZ$h`IvLyrZMT-ouo&cnKAl7Yc7Uj$ifEQs0oy>U%au;5(MizC7o8L8_a9zoG2*Y>;U8<9q~uFquK zaUZYzHR>Oer#=fzoaS1QHvK)Y;M_1pEy4yJna3c8)~?MTG$N(NTIT4IY8VE%I62X=; zTt1zd8gs+Xp+bf(j~6U>`m=b2rGHdzy!M&~LWCoG098=I~*fo?%N0Ab40xOQv+i1 zNkCLVZizp&XbClKwMYT;ioZ-IVU#(U7yF(w|4YB4W37nn@I|y+_?Mo(_l<0H6c^gGOd_d^fQw35{}5howtbQ2JA z8EqGGH;Dn|z&gN^Y$jNdivZIN%GS+Roqq_pf_4<;4!`U>EFkdRkU=5z2GfeCQFxmN zsHVU<(}Mi_LRF7H7gR>iphzj(BPcsLZ{ft4WznY(xJk;fbE>GFM3VtBOG>;Ztgw<< zhXl+%>hkh^w6?TNsT_4OUlc&XsUsaI%X&^xWPBe3%DxjoE{wyuX>}_d0!aLr%Phy3 zt6RfNJR7MS?}rwlTiHPH^Dw|>PW=rCMIfbE^%nzjnj=MoP!hnTW`Lc&D&#PufRr^O zWB`T4LYT+tpH)Iqz)3G}uw;DaX!fdE0}ucW8zydarvlc9xcJleJ~*5rf+IufzQL*~ zK>Kb~LgEV!AOSj-jaJsZef0@N%uYc=1%d1lM{ zrO%%N&%4CnEr0@u03(`sH^!ozRpad1T`%4-j>soME^!FW4||FL literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp b/app/src/debug/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..5cd0fc73b6597dbb1cd8cd1e7c4fbf010fcb5ce5 GIT binary patch literal 1990 zcmV;%2RZmsNk&G#2LJ$9MM6+kP&iDn2LJ#sU%(d-=1|bK4U_PP-CYI|F##ZtPGpob z!;(^wCOx~9nr+*&Csz{c7|hX&@4Y#8U@%;7s@v{aBssp9<9S0VmyNFU?VQMg~xLH&*qG6WMe*PBPcv2E?x zb0_fIC~a5%JD0I-ThIFhL$GZpwFZ#cIH?%7k)$~4-DBqa^Bo-9){Z^DZQHh8{_CFr zgVAKAOrJVcG8$WzZSzck+*sR|O&Q$X-QC^Y-QC^Y-QC^Y-F@8!cX#XK|GsDCdz)Z$ zG78cHh^XLifeOgzgAJ*`A*BJQfTs=$*q8!bIw8~4AtZ$-V(8(Sp$r;eD1af>0w{nP z*peihwm$vXwr$(CZClsAd)5;JNsAtfJ`PxD_HZbQshRlW*>ApteCUg1u{kB<`s0fQ7fz=&P!ODbJgPH1S#Z9E{J!Xd zDkO_8Rd4`|{=b;`e>3*u#H`(ZO`F}CW(xt|lyB@(dMM6DX%*ykP_{r_@s$zBug$9O zY;c8Xi^~zO29%0OhNf6~tvftx4bEKYIh0!YlL#kdthDWHGY^s-!Hn;a!qz{s^}%J8 zv)h=1(f9|JocssP=68Ho=bBEG6;LKMFjxqQ>wg)lY_nSExrWC}hCoBiW5m4N#Qh;lC$?pC`n`H?|%b)IJ`S zO|3A*3h8wdR^(^}WB`x<_7M$QA>P>K0S#Tzbkaw|ctr9+OQ>*HpeN0_r4~Nh$oI(|gErTYX;SCVG^&hnb z4ZgxdXol#h1ev8@2$kx7`g)#!;HaB5dk*H$0j_a!h;7?ffeB9>1P_T_+#lB`*?&tIP4^;_P!pFU=N2LLUd3# zYJuvxU4rOPqsr-p&Cng4j$IidTKii%A2mq8vh@wZt0BYI{g%X*PKLf|o}3>(N6$>U zpXf)+rGSW!Q~m%l7ZObS?C;4D{r`u7MPlYtpf26VL}1hT4LP$B3S`57$>E<6LRu&1 ziAgbcU|Ri?6PY_9{={O$n*;Dt$dEB>o&tTMN^g19QRE}-7Q5S|UM*S#*k%)2vl{$3gy=KlS(ZQa+<4UKka-J7v$ zfwfR{A$(F2qB|zd7#ln7UkwNn@6Js7xY@q-9{S|+LDTOM!B#`jMUi(Z3(*yw1eVLR z;jz*5&SWw}-*=w2TFyz#2??in4PS^rhZjOR$9zC(@Y0EO(R(7$?$#xQ#4^>S3zbRwknA!dI2Ms;1dVU&?IGdR$U9e%`uuTMfPXt zt4$XfAECJZOvB)5*2Lt2MbS#7CH3`*J^l4ZXvNa3V`*^23DpW-sVA%Hf-)({olh8X zPewUfU5tg5Sz8AA^+jUz+Q-ZtQ$`#`X(t8r^qGf%@XefmKOD_51!^rUJ*UwC4IUK+ z9W#d}!aEe6(&{9Sg4G`j{0o=rl>2b;=Y=Z~w0`1O-n*NBLHjSo$1S1O& zX<|#eR9d9d5rZBX_QtT++oIp!X2{bu$}Kgqq&*;lp7pCbOaVD+uDd|4i6U(mxs!fT zFqtR{C1c^`Cz~kJ&dT_f=V|=)iTJN^s*9Hb0D*qT)c^nh literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b091df8e74d74af117fef06753e24c5e42c33383 GIT binary patch literal 3982 zcmV;94{`8PNk&G74*&pHMM6+kP&iC_4*&o!U%(d-=0MQ4Z5;lvxBV0%Vgi)4iGre) z=;WivAD0JKu?i3^M2>&>g`H8Mvm<0dxm2YusgQ2`hH`EN2Bt%&8bSlLL}~ z@93YkJFJQoFP4J72}}wEKp1___-qLPwj{}>txw;^v29=5wrw8UKC9Q01KGA2Sr?xU z%+NV~ItpL^CqQ;Pl0gvYfLqw4gF#r>3=nwjq(fKNgM~$K{6{!xi(p~O{G`JVCVXe{ z$9LT8@i&pSZr+97b9E3A1j!F(hS24` zPDDcOdaHIK0#oOk6xek{Az~B(FzEcIu!1AM@Guc^b?-PQ!aq`clK^`P>$0k<5FW;r zWmUn17-LnHWm)OO7kuf|yNEc~8_tO!^bz|J)mBxjDndl!>_Pv-zZJ%b56yVq|&d|Id zLajzM7>i|_Y;+$6l13#4SfXX#NG(KEPS9C3xkyBn>c0|DS}B266q-^SAcF(|fjzUc zYBGVq(`TIon3fR0^%_9QAVIGdNQ%|W$kNH51YBl+hH9?V9XYuOtX3;E8HZyhyzGIe zmEx=CjH0-XJmeq(Dm6N%w|zYfD1s*e=uY}hoDk4IN+E)o#bUY|LRiQxzCjdbQYWiD z2jPm32v+C$YyyHJm={tZLHG9H~3V1G*T6xqSKtPCMV2eJJfasl?B< z{YpQnR>WqREs79+M%_{G5)6bINm#j7rY=f5Kc_iOy+CYf#mqiZGCyK68SJSPM-?mN zWiVOo3B_6%WTpI0;;}+FHND!r5ow6n8^mg>|7sQAB?H<; z^g`Y?=c_AMe@2OQ`O$m!4{os5B~w5{%jKwE0T>x1v>OODs*pB?2*wtR>QPN-yo9xK zzq&LU0e3-9oBz`X*^AN!$*~fV61``Y`TuZQZn1T+OIJ98wVMYJJc(8_V>3JJGR@}_ z*2?&Rlj#H!i?GhLY1@v>%#qCy9hFoVeMdiTljtRBXxCFT&m+io+`s@2CGSrcah`1| zq#Dc}yf-s5`%q3j-x7;}I4oPl*VZUJeJ7-t)6uYYKM+k0y)Z14x(V zrs|eIKjv4aGgUw=hHW%!bG*1dW3t#{vD5Q_9NH(U+~VkAnPr=TD`t>Ea|J$pg!OsX z`$zzzXSLPqOqnZByV!uOm%0e;vgtcR+NDi9^7(&A!8ODR`=SlktRW-j!gg+PjIz$9 zO`l6P3oq8V_So7^J;tQEk{ous3jGh(&iUqm%umQ1lm7l#f5yaO7%(#Ln`g^zgPcI% z#<27-$EF>#G)Xlogr97^6|2z&S#Q^k0m%G}%*=hqR3-N;5l{n>P~fMWtzJ_6I&>2Z zR~ADvpBeUR5d>PihgeaPPs6?B&ye%9Ml^asI+e2bGX!?8ABd<756H~xP8nkmpyodyUkpUZ zAk4x6NnH1mVB7tH*1rcx!`*v(jKwetbB6mP98(!&#mvn&&h2arL7-WV;yEC-l?);x z>i`zjUWJI6YBGb&Q@cpg8cE~dG+F_gmz1-vwt-kN`WyCH$j}fb5&+Batt0zd#tL~;}1?Tk53k^X-W zihiFrVBmqflMWxSJHdRe)D#06qrn7W4F$+yx{(Y?6?kqGLB{-{DDdeoM?8*Sc)OST zzqKBiTrbVRgcow{$!6A2(iRo@Dv3oV|u2u$q?-O<1tg-kFpY{$s|_I zKSvzQZ@EaRh_f!YL9F9NX^-s41^;LY0x<4{z^avBj+31Zu=Fs% zSFTkjx+zt0-0{+Lm|2ne8LfgHk?YbRVnd@;Rny7rep@FKzF@CU$*}+vvaW4jj@U7rnd8{S>z^VC7X#9K;7q7X^R57~7&eooUMp!Xx;@%;V%L5u z_I&{vpU{iq0d+-gO4GxRSewcbfU1XQyl>AMDhYs`4^XG%dVmRCvW&~I8OKep-+XF+Vw^c@(Kt(M*>~Hx+>Cj)|Z|qpP zuZ`)uZJ>$VVgr_J`WsJ4`%VPF=_U^tQ1a%5P3lH{0E3~~mkXc1|D8_u;#$B%3+V3=mndMCk=?Zxh z(9E>9JAc)?Tw}^hxBZAN3bfJ#EEY`NV#*rU+cKmnly*HOjjf56ea3AVdFj3%(MN_} zx`5f(&^$T!{te3`gb!&6RSZ~w6CcNh=DlVgddZHwq|x^Q3HoUPM!Q-IZKR|M4`|GX zX&C0wZb4Dqns4*2L-$|Q_ma{ApznVSU_RdcuC0SEQ`a@8FtN!)cS7EL!p>3C)!Oaq zrOtlL{ZA_-y?%5weY6j~>e$1Dv`)H5P3M+i%W4=*g<)0NaIOEh4!ghL!G&G*&|4fm z@{(rXKaEaG^w0xLc8xt+aBHUvd{vGO_Ej5W9)}?$31JxX7_B7+tB-8$baCwALU)~b z#k-!?_mXnIZ4n(*7@>dSRmYp&xi#B5&b*_ldG)@*S{s|Ds;WG%s;X(CwKnYg2WpyL z{Oa+hce(0`t1iE2o0lFeMvLyh&!L|gZ+e@t(fPIJ+SoeoF^c@Na0qihTOQQ5)v)|$ zbGQUFJ-^K!cFs3`=Folazi3oQj}{|<-9SH0TIrt%SiW(*>75oly{K2Wc00qV7v%h- zq>#Eg8rm^*W9WO!L%#L$kCFF}Q_okm=34vc3%~K%C*J8P8}2tMq=$VUpniGRTO0!z zzT(-t#+%-Dyy+cYHr6}uvHJ^p^nw=`^UFt{KGrv{myI{Q!<}~lmao`y%q{K;NUrRA z>2Y6dTe*lHa?|_obEoegdg3cyv1ZMhSDbk0_wTgN{?qqav~pYFWufScZBqcV=#VGT oMe+XA)6>)UzuA)xS@glFZH1SGs`o|F?{C|-ZCk%z6y8f}0clFRY5)KL literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..28d8ef2fc296d84528eec0a8e4903924b5a44158 GIT binary patch literal 3010 zcmV;z3qABwNk&Gx3jhFDMM6+kP&iDk3jhEwkH8}k>QLLZ4HN!`zOySux)yZa38?(XjH?(XjH?(XjUxcC0PU+#SG?+{(<9NhOg zmBtEaG*-YGI7AYQ&^1`2)7Z>JlDm^<6=-y5nrLKJ;11FCoyas+zzY72=yHFo#U;D5 zhB;)KuECwT3D#nbnZzO6=bYIAmFm_wUtBlA3f$pe$(}T_7u^NRpvbaq+mxj2P*znR zsy*3;ux;D6ZAYZ7u+4M77)X+oRuCE>?k>n37e$dIHH0S$_<<0N;NL{<36PU*+vF-w zHvhKspTkGBeL_YB6+(_0>RkV^!ZJcw9ZZP`jN#?f>;e!GAz0WP{ZoPms$<~4qJb7o zTQ1}vV-+z%BH}A?g9QYnoQxG#Bddcf-Dp5WN|ewJWJ$73TXk*EZ0liU+qP{Rd#{K8 zeg@LE4Q1O8$J(}I&e*nX+tw27siumt0;fv$l9O`!u(X|itak!*KehZrB2YRz%fesw zZ4;UyFqc%sW#4(*L@HH95cnh25m?t@Srhy;KR+_;u*1G%5@W3pO~fhU8PU^W*w$g$ z68xj1Pk6xn?+kl}35=B=al|bGyhj5Gy?=s8=-Am)27H zN4!Uw1ThDm55S$AR!(NhF)ZTF4}zeBz=KEH5OwCF0!Y5JeGH-^rn~}9E!oJ$C4fdd zlP2;7xYPw0mY^NXjA>M~SKr9e1g?G3jBB(0UCKD2MeBzxS++cP228SNtNvAMj+mn~ zd|7zzO&RB3j~oCj8>}A$VnDEPFamg%I3%J^IAsaW1q+-K(JQi71qhqQA%SztkTR?J zp2jIz2p};sMSE2|A8PzO)%0~y53XoZsz9syk5gNxsC|@3+5E;+3wtGWHR>Neq(QY{ zQ~A?Q8dV7q^`$1}FR3o#J8P{S0;w(%dm63m1F0@TsjTMKo>Ui}*h;;Fhg2Em-EO4H zh+(O;b&gbqT`W^)_a;?F1ijPlB~cl6uuN(7g;W_1@LYQ+Q5#`3j#L{3r$?%z=)#0U z3meb8*~4+=v{%YHK}nYg^a!Y5$OZt?-5LNWJ~nCn`_s(pqol+nmagK9quIeQ=ExC| ztLgKkpaYb2g+O1B4FY5+_z_X>YXqRW>W+x0n`zR(-6fg1;fbM`A15TCj_%D!hbZYD zQ2&sTVZG{VJ3S)mWEd119XTTj=Ef0$tM2_|V2p7G1VYA@4RFY6sO`M56eF5rBDcmG zds)qZR(fNhH+}Dr4GBNuVrj4Ig;zKLV6~6uymGaS(cWC8))%$^UK$!d8XP^UemxXd zniZL!#Ie_$lLfmtMsS!*O4$hl50GL7gPln=l@0WRijDGei?)`5RJ?sMZ)-2HXu*V;>D{v`$38Gu27 z5QoW#dS8P$`id2>kK;;D&-&aTA|gxAfD}pD&Lyh#`w~oH32d)6cfs?-V?XL+gJSC{ zRv<@%50@b>KQd_P4v;b_^P~xN78lmym6tcfWwOcBIa(SJKu~C>>yh!n;nx`g46=<= zly73Nj;Jut;1|ND!raOso&)^x3mp#wQaTWj=^5k$1*Qj!iwdoLzPMy`wOmpJAV*6d zFH+tspfMO^yO5*_b`}@WK~9JQ5JOWM@R0FvN)mwAsw{7aad04=BETZsK1VSv6{~;} znQA4(CIG6f?1sGjH~`4c*UQdA{XjMbi)>>4aZQ)R_5m52OyFz?<`**fX$Xl0z$;4`X`mxfCS5&^b6enkbLEZ*ev!`Ap5r6wn5LqA6bXRGAI zq?xxzhl2n>VZnhd-`>`s?R9VrbJNRDV3{El;SS^Elq3{WoK*I-W3Kx7(9-X#&EAJ^ zQ*RFdNi4O(LxYU2&U^P6&FbDwDdw-&w{qC}R zDk-kQDI6;RM|duUwbA1OkkLU-B$l=!6wGZh733EN^!wVP#-47WWtIjYjLhnw9~kL& zmi!@>w4n}%J^=BtGyAj(pHcHxns00=BJ+Wh$#veCp6NNPO5)(rqYFNBmDiioxd zCA9$f;}tp`1_o+t9v~JMRH63Y3tQ(1Ov1hE%NDT1FS#)Fs*vD~GK|t0-?#2Tw@>qs`_b8#MW`#Aiy}^TS zbV(Lqf|&)7A=3MnW%|Pv4RR(I-3Q!xC6}Kj5tf!uymuypTzgUllb4*FyaxDSYi<2{ zwp^(5apVBUi3*GV+v3(^klg`T%t)3bmWBqnAPaSMPQ>Y8FC0Z!KnRspcmMX6GT93t zi(bwS2Hu?jrl_%gAWnTlHb$mcPhfCK6$(j@THOcA4vT-!?jZm0VjClpY0<8)rlRj_HWc^XE$`<>9VqlHdSnyV_oS{KqTcGKe z$KYo|e4bug>gUcSKGv~nYaNy%CH3B4jP(zs8wAFXhRqIM3obTlZyo-(7~9Ce;6R?f zatvwQ`~dhbzM)oCdDGh0r<-Xq@QJ*nXL)`=ASZT@z*$mhyAau!BLJFDwiZ)cSrtG9 z&DG3jjUgG&bVZfSl+jXZGs#}#=gAR~QyWF)Cr}%2tBunZRvV=?7K^Klb}mtHr3IBy z+0zaxBacskrZT=wAMQY1?IHf#MDTwKlDA^+qckj!vG5@6b5^Ul)(%MH&QWm=fL z*fP&yepum(W=Qv+jiG~Hm^pLoXnlFOF`!#$PAhExR&xqOeEA4yb6J|enIEDw^B?^K zD58jf&8u!enaP5f@d?;<4mkgF2h@=hj%=eqElOE)2bi&!P(g73kYwQj6E7|I-pVLQ z8IQI0^NpMFF-Zh-0I-B{fRu5ai)AdOD5Gzk2I#YT>to>pU;u?63DC9Yy2B;-RM*XI z0h_wsZJh+#V?8=dko5c7f#&lBA2l$y2fO-$8^AjVUcy~&L2Eld?VfJEf0fL0b!f$R zAi%=_tXZrIh`up{_gI5&LM+Q?0NtS4R1h0Y(Ed@sy}<|$wR z@iwsZEbr)9-qj6Vs#9Iw-ak3*UCSx>Z#vZ#9`r2lzHxv1?=;QTsR8tS3PjjwaI;`# z1GXDx@ZX=yM^8@YnYmHR=70vcKmwH5=&>>`C;W5R=x)AyxHHd8U0VilfV7hU-b8oB z>P}M&eW#r!7ZN}p-pfqgn`K1`h(p~KD_K7jde*GNmuZoi>4r4R^UMt9xtVTYGlROX EQeKm=tpET3 literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..ec7036d6b8260eccaa8efdffff083b9845a122fa GIT binary patch literal 6396 zcmVQK3knj<&%PWUit!I8j!-wjyo+Ye&v|uB831%QN?xK0LKaH1-R-$=SWpvfEUo!3ouj}=75J8 z=4{n%BIG!kIcot%gPR$v>Ws;s?#NMEKx3n% zGSnKH4s~5P%uE$~>v6*>P;V6?(XjH z?vA^=ySux)%XN2mmpcAy?e+hyb#?$!1ewgRU6~oq4r1KeB>7RhQcR2K9a%oyk~pws{0Wo^KHvsk=)-0vU1xgq}PAR#>;|J^n z>==1qBVqrgl({Y?n68|X^1{3`O5b@xb%u+!&3pY|zl>f|SwPb0ya?9cwu^~#47~@F zKVC{mPfl4zE-B^XpJ$vvLZG|DklnBbDSc%rT}poaCc}wg=570fTZZMa7$XfVMqgxo zO1^WqZOCycCkHA42y#+h#5fU48P3~$pnwJFu1XMe^N#D*wxgW&7W{L66>nn4?G# zIwi7nvQ!pyi#9S7q*O#TP9+%YXcFH}^MViP=Bv?-IpN(59ZIHaxZgB`uBRp^DXQhZcGyLp=e z1}sHDbs*uLcRyLyI@FmIMQJ#31RgYFUkef@U`6=wJm|s6T8dsMh(wDZ?1DUOyO59Z zSECdX(mxGyaIzMo&V&GDeFHG|uo&fC$SDcyiu){rdB>qIUeW9E z(+t}3$0aKVDu)b63k9(8#}r-2F#$mVoWpRppe(4#^~9ZbmW!QEg;Q}7CB82A=ASw^KMM@0d9-mcok!=;=86j7_&a|vVsL2!oVf*81 zGA?=2ZDbJl*0++Cia?Q3hl9q=m{;CGcqrnrxQT~DxllREfQVO4-h6c_>)jXs8Z^(M z?Kd7JBPq#3@EB^|$~cfOB3dW1df>fNGE}aem_O-^>6VgVazMbo-Kbkx*-!&y0ZtRm zHW>%k3CWMZqHKN8U2kh{eZ7#FO0%H_{S`fdTC@+&u-X*8E zlt}^G4ilVcdqzdE07^w5mxWRt{u=Nzm(m8&5(@@sXjbW3hV9$7YFGNyW(#LVNEJ$* zq$C5d8RiyE$w9a;=G#e5Ic6)#i6@4eo#0U*BX>I{`U!b zWhzO=B}t@T^F-`Ab!8K}e-wgL4l>uA2A6EJ&{p-b$f77|0!2zaT51<9ES;i5$qqrE zmJ;No;#wY*Dnnrm3d=n8kv+4ttDa8*;BnN!$DKM2yMP-)wCobH>vQ;1X27MY!Ov(| zVuK<@cd6O@VVB4FiY92po{0bGPTL0ePU&iU(@#fJPsfCQY`q&z>r&ZR<$|bd#`lOw z1n}-~Eae^Y21+I7rQE%?j1%ui-}42n;gBFJTYA6xSQtaa`_wSk&{h9y z=K1t%>-U0J(SxxV>l9;YW}1qNqP_(FXh=>g0!htii882qD;?h7Q*d^s=`@^k8nCIW z5oO~BG9_;%FKsu0yZH6^+)dAnIYx^NNf8r`xK+PvD{B{qq-J2spL2%PYL-EXn}HKi zJ!spMJ7*$M4oVh;GA$RxND50B0J{Vp5gF`Y#v0}wa)wDI2r~@Y=FhD?24irNMWr55 z(`zKNl{h56O?k-wQ-@)fz0aMd1(uOhv6vidSS(R;THJ@*WW}wf8cxMaNf;8V5wS!2 z_*GSjqNr0I$|xwx)C_Uf%!bc-$-%V~hb0WWiF{7k>vL4w}M4s@zJ;rNac3 z5HC0KW{{Q<^jTD!K8C%8SSewWP9R%{ z! z>q|E(1sSkQHVoyT08h$(J#G&Q#b`4N0UQ-RqGX&(ysA4y3~n9jLQwE(p?Cm{SWbih z0LM+ZH5PIghC}~5GSzo{i}2kP0>i3)w3G$L%A%7btrYr5XPAW zGP)ipiyWZ#2^3MKY!N$|DC0C?&Myjz)ETIu4-D&=5YajY1vUspXXu`VOiY~v5W?_8 zh>;M~bCC5&Qo49CBt0`qWQl~CQqbzq_KC4+zDGpQs&+*n(S0+6r7UC!vTRGhXob9i zMQE)koi>WH&X_7|ruaWB&+rM0fsM@8>DMJYBoN57K1tEdxVXSQn7_E~3ecYa>qKIQ| zy_9?tq{XL_y_E-Q`(D06l!X&AhCL4A5n87vMOZfVIHgRu7M3{1 zM<^0=*|UK&fh%5r*UN~9fm1Q>^-GN=NRzq!8#s9ZL7YkZ=o-jNvq({zAE5$wlnB*g zbf{3XvZtIpCa3013<8>l)+*;q+LkUDLoP;(qn5J{a;G(Ux!71kP42FF;%0TLTL=0LzjL=W*YJp&={h7JW>I5seb z;-ia{w!5ZS;~puV7!ev{F0;;K9k<@@M+%buXD0s^;mNm+PQ^8%c_wWlzqNmVwOl%q zM5Q$h6ujT={Q1!rOc&1&N{}W=Tob!`MH3e^2vsppEm z4lHA}bVnKc6mj|AWt@1!6frr8kk%h4K>|rk4J&MsT9{&k1X*G1>t5e03*vDR^!2MO zFewuF4%fDKchxaUt~m2(-g!3xt>jUO;-ia*Ql{uq^CJbYs7Q@{pV}V8CP%|*r+#S= zXBuW;6)o~_Fl}0G*86v8C7g z*9{^Y!m98?8MtMLD49{Nn%}syf?LOGMByBo&h02cJii{ZS3}=+Bg75@KqG3uiRa{R zLC6)M0H-x0x@Y1^g}oHUKCMP%8p~{JGFI}C$Y(S0@q(+ObD=t#04h%NOuR?b-xo50 zH;3uj<%uU9rte+T5t(`;4IN_{$6k^z4ZQ)8AaT{b@HUYnHzaP*ErNAK*);l z1<<4p?HD;t08Dp!Vl(fZ>ZqO>rAVtJuAKv8Iyr`q)O>)%2pO_G6?<5}t)Cl$_P?O1 zyZ84KP7{K9z=mEPQE$B4-bN|XqzEdem#%h&2-#`6kN_S9UdqIaghL7MePJMD6$nLl z=t|b`EFmz~bbco~HgOg=AW)L{?Kp%ykY2>J9f89fGQcL`iZmgWPfI@>5g?0Vkm+5E z>@bjNAd2gLi7$TH>`0f48 zoM=+>OpAgDBq8-I8dUugH;PuE6%`PX7hvv9Ylm1V0a(?pDcDoDmC9+FPKcdXb(d`x z7#P-6en&wB?bt;DQd&`0h$1`96bis6>a58(KUJKJ*atAmLlJ0M@sfX@nz&hJ!=Nq0 z;8cUs&c{u@q!fV{kOQdQpI{s{N~Au)BGMpFCi!L`l|AaZ?uLp!-md^fq2;I(zbYqI zXMlgr;8cM>2f_h&R-zvWTO~2w99j@H(wGBeKj^hs;uD9+eL1O5 zIGGVn$Cm5sPx&?UJSo@GX4sx(ihL+$o$)ZD7(P5J5$I#o9_>g!W+|%l34qIuaR?nM zX?~}cF^HRyowVN}&t8ywJ88Tvl#hBmpEo|$Wn5qzcdf!HcHaGz2wEjkoot)a5Pe3U zNYQ{bhpe#pzGYbAfOAFTM$;zD%Pni~+v8a8Tf5WF8!II|>f7fnuqn?)^I(r3uPy^~ zo8plF=UrvBl?Z}%Y~lgc)+r~t+`_we#BQG#D6vf2XnLE0F#%{B=R3sD548C=;nPz1 zdoW}#?ws9`KCFTpU3Qud$tzQWi(h>|U_-Adj5Ul6GjIYH2Yxlmm#>~CS_=O_ z;Z;e$J`J>^=;K&Jx^Vy*0gpUiiKSjZ<1lpBe((*ixbVhpS`PK|2ctXeAX1)B4xsjK zj4s@i6H9LC%z%)6V=3v7!r#^}b0Xr6q_-YFzB?7Ljsu7K;=}*?!$E$15E&uaMyn*K zlMU0?rUk9!#*&=|2z25BO2WSBjd)Dc$-3Vs4vVf{o?gE`yF89)1%m;4dWD*=XGD?y zrTY!yy*+;cdR~+6*Bf(e8^M5>V^b;Wvh;I?c{c5!`N*KO_4|m|O-e$#*|1=)9&JKt zfJi$5pvLcWov*9|$|<64J^!lc{Ox!}OWK5NCrcydN^BcPUW=6tkl~5oTHT1YO-lS) zSu|s%t+RTxnFb)yM%01J0xKDJ5Q$ zvI-zgJ{~HuvVo_$Q%$WH!~5Y9BR%#{LC;5U>g$o(-K?SFev4;KHd9)2-fQ zho~U|^aKTd-?BfffYHy|h+TTQ1)3?5fZ;|%S(B}8B%uR;600VX(&`1u8bG`j)H{k* z4V30h+x;g+W--?k04mMofG(u(8FCMd;d>;k)DCnfLLv zvcLk1iedL7b%{tz&nE%2*L;7CO9tf1#F(k#h7_hO%H zt1Y;;D4YU7Zg}Qm<()SGyO^oI(1Ysle%HPIZ|$ZLBlGLy?x|LPFU^pI@{Sv{*fSeO z3wj2DrE`J?TEJ8xJttTTRcvoc5tP)Uut6ve737glt0Dwa^CWqCjRB^H{?>ZFt0RNYpf8D>l9k5015Ss zPiYR__PpjxbH_jen8wjMGz3Da2`!u6^q%Z{)}Gy4a%`*6pHl(q#2o6GFI5w|>=DhCqU(Bo(!!3H^wnOP zBPF4;9wYz5%bSnJZ_SsMz3K)8r`B`#dELj=H2G7uw1qYMrQS1l zuoJ-PuJsruK$}?UVbrm0U-RM6b6;rA6cYm!feHdqhSwSdvi|><3_bQ`+4N@AY(M_6 zv59Kat_A2RYiIY5pS1#p*f})eutm8v<$wsKl!nm)tcm3%l_( zLKi*j{ipox^_y4I-1E=WZX+u_aIm^1o1i@d*GU(~N2>=PI0I1JYkDfRpAkCmwI^J< zy!nQe_uSaBzD+0_+@zQLOajbrY=_VxOWhzZDs=s_v{ zr7X0rVV`fDJ{0trk%@M*{a^n(iMzYIySux)ySux)ySpdH-QC^Y-OnE1@9+D4e{#;} z6F~={0HhPhZWpmg8v%yX9BFE&N9gV#{jJ0wnNgT#vS?t zOSsEx1Z+oD9W4W80x>8QO{`F7qCS)D09XL>>LVPUL5F)5gFsv$&7o5(C7eZdBs$%#wnP~Eg{2!iHRV{LH7-Ux!6OS3d>|$Op7L)^HkN7FZ`qyQ9pHc00 zg$-3P(U^4%>=p%4?{@v-W!0gmp(>`+Ifrr05iScvxi>cE&N)<<>QGQub+2=W5T4W; zTPV9rsTu+Z?TX2};bw>O%u}n};EB`$dHtcAODO_%vChp?%iO?G0z{;RLP)LviP><6 zQtLc{8X##OU_vgV2vj9G*RqdRN*3Ui7YM=#0Vu?m4ah&L#Kxh7aPxdjL;B_%yW?lAN4MEqOo?0SI_S+6(8>M?`IF%W1`=vnJHp7Xmm_sB1VLRqS%$}lIE$bsS4pf-mUfKcV|H6`CwnwOy zi+W9rGjwglV~BB$aZ!qgQb^n>T4j{36(b+-?FhT`+Eme+P|^uM&M2p#T`E}(EZApi z3bXUtqKefk!c(_Q97tN}+OUWP7O%FhUhO+ogoD>=_ljtjpw+Gg9glg7TJ7o);b7RO z)viW1oT+cM+8uuA>bj2NwQd$s&OxhPbqqLt*J{P<%%@`$QSqvvf>{Q)R=l>Xa%a^W z{b|>VR~-`u$DHc5>ySIEUXRg1tKL*iqkN(Moa$9h>B8+jMSPT!pqvclR48YKk{FMM zZX9~7;?{*twW8)t$!LvCXg4*oV8nyBl@O)mC})S74_Yy3CE;gIk7dJ1*_D~|9BZWh zH`}x6%&O`akjluNy41l(k&eNdvq3hfMWC^{1!K-bnfdYR+@@+^Kvk)ek1aVVQO*OE zE!tq__*371XsVJ5)TA~Zva&%wsBGCD7bpIav#VE0E`3KA2~f%gr8xX7CQRpTO~bvg z7*q=fUgO?%3-KkYXTnO!oeG@!MrOMNvRylw|MQ^Hxf(iv`Fd?yMNhGDir?!l;rG7c z?vYPn_ucQf*@DY=@Y2$`Pd6;5vP0;9XK*q?DF#1_i8gReNU0abDkz|PS3l>c4U4(x zm#D2j;W_ipo^C>YodPVk8M;Pm?L4gn{48YSY46V3IS9t;=&;Yg5NqO#-5M8!wu=p| zbzDfnw%<^dwE+o{+4@@h=YYzTRtjC(X!+F#0KRtgB5ddNFf#|HK-reo`f)u*hV|st zzC%64`74XL@x5TIi3KMp@3{_z%>#EK(A!MaG7Uw?|>#KF_)^15*zSiv#Q=Dky6gRJ-RSi$Mj%+zEREI)u(^G7yoB|}(t8ds? z%v`UwIywY^);0X7Z1b-qS`SiOR+dw39F%Mog(RGZx_KA?+R};q=gM|HsZplUC$EYI zq=aEpi-=*8b9)8}0L>{`mjBH5i$e3Kc|u?5$t$J0csRj42&k8jeE@1^!7@zwG#VZK z8awuA*?2GG2GE;tC~|Fd#;R#x=I4t^J>_Wh)tXi_$+Vzk*>~k`0IH?K*Y8uz8apQe)s@1C?v_(QA9m_oOl_S*pn1xqHTJKk5Xw!MDE0AYeOpl zpd+(9DqW?dt~`^Un@ESiU0nh!lW~eWGsm6yI=KkICcS!xvyr_ggDRm*_#XkNiQ1|B3j!T8Ohw?S@;VvZZh_{l3OqcDm^$DrRC~e~vcd^5#7sE^X z3ysHZQ!m^WYGtEIs>c$)kIh-0&D|u?Z+6nuuVb-MN1tKL;>Mm_$C79ff;FFhLSv&6 zYKF)Bss!<~<@mEuK@zrcQj{!We~37MNvVsWvPtB>5^H*cS5#_q_=KteI+)TzFblb7_2(N}ox{1wp`h zPxZKN0N6*SzigImmm8B%X*4V&6{*rg7Sf};C*doPl3fkx*)yELWbvP;N(zt$rfwRl z#3*|NY`r`UZ(M#20Nyj23=y9pk9O^HeIn=90!AxrAmS^JkXd{Hs-=SkKW}Efaid}) zk(H-w8zP++3f=c#NX7*(?dJgS@9Q(q^=5q%L2D-Qa3cReD$kIK8Fc)h_KXsMyZ`XW z+?iEDCTAY;ZX?pCqYBl2KO=&p4c!3%?0ShZpO6aHntgN=`4@vm^Um8s2#;6V-2hhU z&Z!|6d?3;((CC|!+^L`72mt#MkZo65(Qqv3OA9zhEF#i4Whth-JAD9xmJ`79eORxQ zyicalegXFGxds6A8ywSPS;YPRFC|n)>7GoZk7jG}-)9p5Xi>$o?y?Y`?%|XZyuaUu z#Ce^yjpzNa4+DUPge37Iu$aSE!vJ!EyAzKNxAPRb+`p{s=MFH`% zOklivnJ=-BfYUj8k<}+JpeZ#Q<9XlAp7tvYjk(aZg>1bYnq?4-jfhFSsb?cyz(E|X zpYt$@p;A)UxRMTBoP%NRA$D}4TS|eaIp0jDR<#C<-g~E9M;5cU)Q}ym^%onG5t#v~ zh7LM#st;Z`+}?ljA?p~fd&B1}vHgaru6L+~gJ6EX#~+c!l-)oZW8Gp$ZY4QmPy9d-jn0I8&lBh!*%84t|OC-JN4|E#*-7Oqs@CujtW5flSO|X4~r=P%}=|2 zt(r6`rSpH>#fe`?**`j3b+BvmN;d7?`3$mSr$xnITUHgo`?cdP_;shkE%>H@&~@0Qhk#bCcrxXu@6;v@Dx!{~*rFtly*LOWzi$mJYu`xiaOMwKzGk^)Zj z7{kE1IWbE9ns)ux%tCow_LrSBb;a6vNcF<(oLBXxY8t50{mHrs^~&@~s;}N&^``Hd zO%yLS5j*v2DBc{SdsTJ1Kg~n7ggSkaX6mL{Rq>wFtd>FTw&^iCK=H=^IIE@u#hZ0_ zSI2}Wn0MNqoCbQd0~q5-Ja{Vha2hl@6< zpeThE;ER+0Ty0py8c?!N8g_ocV8x{w4RfQ^me~ETd&*HB_P#Sy6Do>QUt*=rJ*Y!D z?T!_M8SkP{Ry#qtQ{rsj&6q=!z&3o$Av5B`*-1vw53@pN{|f*}W?*B$q2|;|vYIL| z-tK@XW(v*=0BELA2jq;VwUv+qJXsigUx{D>ZNRfa8o<%uu@8hNHIaaQ)dR-RC4`Dm zB)|r8GEVj(9GPuoQwqSmbqiDzhLNiv0H8*U;sOa*iuPyMTSitd8-Ku_njRx~qeKM2 z(~Chn3DEFvgfyVebA4tLS=}so#^tu7cq_}^il#H@aEo>2?zVLx~}W%BfqZeIwcQ`H!v5?1D|()oy23W z0z*;)8sO7S3ye+A`80M{YhJrQi(yfC&cXv4_US>Nk&k>YWqIBuG#{ zjsXjZX`!cu*JjgmL5;07ncJ4kThHdZe-q43n2RuH#GhrtIgGdeXU%QPjm@<*Jr}&W zbk}Rg$78P|LjrJsGU(AtODCU$P0#r?J?FpE{lj)lzk(f$uc)!O(x`7NuI!x0k2}10 zuzayMSZ^mmhK35@FCX-PnieKH`8NB`ujx78#lsf3c;kh1@Oyi$H@Lj4f=cmKdHDng z4|ITjX+#HqdIiucFybG&k4`>IPj(KRKe-&>D=$Waui7)f0Ng);3@Fw*KpXj;wVS^D zb7ghbu6$6r!6$t{SYMs20KxfxM*jJ?u1r?fNBzVH6&-xlFXP$eCj$D`)@p=rZEc-+ u`7AE`l@BUC1fNvne!E>pe7oHrSBVcn7hnk9`@{mh_aW%Q4Hl?JbP)j9=6DwX literal 0 HcmV?d00001 diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..d3a69b5897c04344054b8885709f4786584fef5a GIT binary patch literal 9242 zcmV+#B<0&uNk&EzBme+cMM6+kP&iBmBme*}zrZgLRR@B$ZIgsQ>u)=Sh?oG}5%8B? zH~}~U8~~MkFQ8fsM^G)Vs5p5=R8(mm&Xm$PuqkC(D$bRKruDEzDrl+*irR>KXb+Dg zsAU=GDJ`nBIIP9Ect)G>Cp>V8315^U{KO+Fn;cX;v*Bzt{=a3rqhET6%A^2hW=b0+>8=3dlq;Gc&0)a$omz-`Dkj?)z1NONE(9W#STbqSHX;xZEm5aN^EH-eI&y z$1yY6GjVYl#wMbe9jQ20X@I!{j(b!pK*wAGDdt3;NdXK+&|c3(N3S}wYW<-Owt#J6 zC4=8N>Yh`mAPgXKpqt949|-wNIa zf_~=|v7jDEwr!hsP8VW3MZ09PWZSmy-YZrM(%L|+1ptBacRP1?Jo2b6-3Nf|4$*1u z?vRhf2Ee&(+tl+RcG6i@(pr^T+jf;-;EtVb+qUh>w$0_hktC<-W&t0|vFDs0U`N_^ z*NvR-ToC);^SPI35chV+^6FBgc#0cPiFDJG+cokB6*MM zj2qxYjI@M_5NQSDN8WoO?ike}*D&7X2*<^;NaYQ0De_+7*j;b|Q;HCt9=ied3dTX+ zE`ifyR)DIGbTcY)Ou7`S3M!2uHXzHqJ3A6=+g97=mQ3GVZo|xA2$e#rgDYlRGKtO1 z7NtCwjxr>`wrx6+ZQI7r|3TZfZQHhOyT`U|+qN@mBTg;^Npj;h3os;s1q7dzDODao zH~xRsw!(9FcXxNg-QC^Y-QC^Y-QBJ{cXxNaw87bH@4ePK=QMz&L>dwk-Uus#*dZpe z%q3>VtpHMs7&2|*W>^4I1@FXOg=QveycJG9`enHS<3YV2;2)j`J0 zpNKvjB6vMehep)kPBAX81l&EO0FB7W9ZGmHt_vxFA=DS)E+yQE0-ykjfG7Z+EdoO_y!E*3Dkt{W=5*00=2Pt+Q>6t!&%2ZQHibR<_Np{}9|ZawN$;z-*R#L(g9L zfE<>B_$LHJ_|Ll@9{F5K5X3^6F-r3#@JBL;@s$({vJ)9wca-ZYUpD?p<53NU9)2P) zEb!=nAI>m5e)sB+On=okUo&EMWJR$I>N;Rt+~xxCOCB%)9Plg{NC1j~ZeRws1Dpa6 zzz6Ib_=Eie_{B57vDNW;*US0JFaP%A)0Mz4d2@A%;2uYrlt?TQu$}-JyfOmZfFxKQ zumN0U`VqpgGNO{qU;b(9bMVX+F#J5La=qOq6^5AUB8*)L3jnfn0{7-~r#S2n_)S56 z4bv8~DvG7jkbiSsZ_4?@`L0wHJ1Gbc*#3YVUfBE=7zS=kXGse7lS+G@;@0!h#R{Yp zEAWgb9XfDDW!-KzOoUQq)aGBz#xmpi^AlrHQ#mXLDW`G662 zCs81+qNvE|M(K;rXWRnabuY;J0hI787Fc8XDNjXNuNX_e8;HXfW$B`BLL(#Z)WEip z5Bzw_5qd0r&Z+ZC>5_YpAe14@4_I>08q+IK-1ncvGhbssE;t5j{Rq%+tff5VD7}>D z`;UA?I=|6agK7K_`11V~l>;r{`=9#ZwQOh(mbF3xfdgaBQ;yU#R$jGjA7Nmq6%7*-i3w~!Dt*{H-ef^; zXHTr=y`J&Sn3?k8O*No=!N5WrOK2EKh&^!ae}j0?CHbF{@BS$NlN#1cyVcg|p`lU|S54SdsDSPATa|TNuTMOy8q6g z1uIn;W^)WzP_gs41`@}VV9T^gIS{Lyt1>JD4bXjtgp8)`APhAumo_}}&KB`76^CUo z0W`PhFr*|~Bk`h5GJXH4sgciB9laiVbpxXfpShkq(ryuLr8F0f1p^>YB9HBX%NOemoqj$v8+ZC|Q3 z=nOr8kZ3l1^^aLd2t&W^=?%(arhQy(V3~bTdx%fs0C!B3LMF5nPxoeTUzK#K)Ck%^ zDl{=oN-*wW;eY~Uh58Lv2kLdJXpkDg?0ryuiCbxFVZ$)n#Dg&ErRqut$khqjfiE%} zmgF85PK98zOQDF#;SP3y4^gVMXA1UE(?^C2F^;t2Uu|)@rhihlxA>y27drp zS1@xZT_&<36$S}x=ypC7^(B&vXb;#`Zp~i5n9`#&bO3yk*^tG5o>hf@lK#^X|Fj zliLfP6hwQcB(uTvj35A96g!bUwEE*oJ_(BvQ)&c9ivh;7AxRd7p^m9=bteF&lio`k z{f(U53IzZX03fjiQ9YFJfR;#`*ERn4y52*baQ9ScMbeTxv6^|8415 zy>KZVd*^KaXv6H8k-Hg_`+gatch=@yK7HEe>A{5lN2evz8M*+rJnSy{9wxvK?Ti}N zHcu6w8^O;^WdZ|`qx&YwWW6WhYg`Asakc_i<7Et68ZX329I~x={A#6)+Vj~=yBEAv zYEm*vR{&x29`5$&5@D=(o~=|@+CYI651JOmej+H&CFY{)paS!k50R`<_Oq4hkpkVg zv|XUmh2&f=IzZ75tZwc%;xYGv$AG-2;ApIYb_}~YU08)uWvFp+)9&%M*?gqXIY?TZ z$b|wRC!QT!P)0uqW$Ot z_|B?ZWID&OFtjVGTw69Od(fol8!RbG;y?feB6{imcY2X&fUT~jpCIHVGSn#?FUL<} z`*=@U)DEF|7fTZEO?L~UX|Bp#vBKv=EAl;)+TlQf&<`BTj#-RX5;nYbH1Gt1C8$4(!esKm(2hTc0U>A?)w?sNm}U<#%>}}? z5bnwJ?Wd?#pPm7PWj1oB_@h-U*e7ejK0xDk2=Z+}Ec^>Q5>*I#3H4xIdkXQyBgOB| zfY@ce+CCan?LT~Ylld5D@)>qdo1;ud2%T_np#qwo{e~J@|H1MG$ZIq`2N~$=&mH64 z4%;otCyih&UAT(PTZDT`-$Eb+ABzetTs~U+ z)?%x{qQO!kd~|@Kq;V(i0?PZ*1!UWkg|s!XUsN#Jq{@8OuByA}xSCh}lp*9n>4Dpy2MvM#;_4JV!Ia1KfT1)}-9u zwFob#A)ww$8s1qH`#^k%DVgVNIIw`UdO{5tEd&tsSzt@O7Yt0^|7w8p^~o5Zu5=p7 z26{$Nq#p(Wyx`?1W?xFgnPC>1Nm?^IiHgm8)L1ohoGlASRCOm)P2GM41*5uCsd!T& z#uMd?P0hRwJv$^ZOV`>j*x^WQc`5 z<)*bSEzX39u%q0?bzp%o+YrFNv2l9DD$EDofpp6sY{0c(9>ab$=Ki-jQSC4fZ0Gzb zqG3~es6CU3F(D%I9Zmvi61q`oNroTBgwvO%$|5`WF^rJ5<|Ke9CVSLiUMQ-)QIp{) z+v+{yVdeBv1zIYciQM~qa zrnTcUsxn+OH(6#rM7ZLP6srM6%#R{uH$y zXm=zJO`^Acv%&Cg5f$S);F3FT*&md`1d*N(Guh48wg%!1RFy%5wh&E*v<|DXcoD=G zuqz}_;u1knDoA#sKt`MWf2E4+f96y;LuITVpH7P8rAe+pX7cM#0C}DfVZ|JX6|Bl4 zfZ|8^^n$KnAt4aNoj&9!3S_n1kf|Q{MdbZp73nNha;-hw14}~2jMj$?$g|HeB2y9b zL9EJxqw)(uELe}IKm`c|auo%NZFI|ZkK@4QI-c(Y^)aUZFO}15sU?4KBrk0!R!Ri1 zoCF}xr{6C^_02-8$}rdr)p>lguFQ76D)Ukh2_B(Nk)*bzS)s)*ehJ?X`DV zhac24zeBZ~m#_mEmY=U(mpoJEn4F`rg%w1^s@%!YpdjSt8uI}WWNly{Mll(vody~- zTzJKoxB?Qwaj^E^$Y?=>iI6WKf_o7cFz*~k)*kki(SkaI!gPW8fGB7?;60-Sbt)(y z-x60?;sb3BdF5pO*NRx$(GR5`enTK~G+;viyTU-Gi!uG*GwIB8G$Wr}Cky>#@b5=# zCqs>ba}Piiei7F|j0Cyok z@^7xwdBMH^Aq9tv0=|mtN~iZ^<@73QW0>LQVAP>x1YqDOFbYV=ikX_~WlawZiq+kRu zgVBx%K>UDZV;|;(T;U`>Kpv0gIr+lwB7AR=*9+UVpe_x-yG=x+*G4}@EMDI0NN2n# zWm9i5h0%_H0^pwmsy=KAxI`R~!}x;XW}>nNO(KLy2&Mtz6xZky%!{Tlp`U(E_WA*J9gaxXaz&eSR2|2!V3a(||Nd&G0N&TY|YgFh^BL z$x?`q)#FP|xm@w?(SG*^MmH1-z?}HpJD<~5oDPshx6fVJ({6t+GR1r$0cmvJq}>YU z8~`*FOBewWvY|bSe-^9tCOe8z4#y+A4XMqO=dmP=LkU0zwK8}2Z48>2Uq*!B2?3C1 zP{f=I2>dEq8#N00EI>7C$>uQH;R82EwEZA7Q{KQ5Y>ENW;A0Kurj~W~NdW;gq!%Q$ zz+H03h&Uh6%6DK-h|-(AAr|v8+R+WvIk?V4P|afvc0>UwL>!^7xV`bw+QbwB5U)J- zC6m)5-kd;-{$M-mouFh~2Rd6=;SkS;>6y`vU?{EPOW@N}SYl#I7?4E35b9^QfB02= z^u$gA{FuT5CEDWJu|5tKkvJ!M22ca>#MFY5Q6teffT2pPbFlwxm5A+%pJj8HuLx4cb)dX_ ztr8YZiV@HU$B{ufpEU@QsH6-ipjvL|x|?Mh*MXe&%e&)~mSE`_s?Pa6z7SD1KX4QB zX>I=c8TygqlQ1*tfyNm?3-HhtJw3}3{al{ykq$UM6Ykczbmg52e0bOp2YcJ7)=bvH z7G?9JUrw=pRzb@hCCJlu`l!c>A+4<%Ej>#2;9*xmF$>{RDY;z<5v7LD3{1LEt5#fGd(Kda*EF1K5E5jiRsH1bZftOC)h=+|A#~w8vsK1AP1;GEN4t_P{F^I z+i%bY3f%F&VguP&d#3+;+9#oqS~jJK{;qNdZc` zLiyLwy>GL(uYA{2pJJlvaVYU#6c4qyW;eQuq|0wGF$PfJv^2bydamWmo1#|)h_r}6 zKoK+2$+!*12D}za16t^xH{RILnm@0)RiXnFm^(d+W}@Cyid)tv5-hseo5TVK3iPe` zWrH?QI8iPXx!k@4VQvGu>G#VXn?*H$;RN^^uIr@M=5Am97(-R9QoMs0P2?}S;*XXS zTM^9$!(S0C;pr>FtL6X-#C^b}5sW?ZZbhy>5W{U>;==i=wj~^#(}K@VQ2u)2;)&5t ze?M{#&Ispc$8q7d&0@86rAW=8bhSSKIeJD=`?o-Zvp#Gpaz%g$3)uB*!AIF(*cNFf z;gyI2+GrhTf{MDO>)Ti*k2{(y#K`=&kTy^>P);s!+?6e{ZsGbNwlc_gy{Gs!Ejc4c z{h*4JCsfpOF+x&ThN6m-Ui*`AyixRn&JTF%>hr6%PxnsLi}Friscq|sEoa!>u;zMR z8H1K*JoC=3#+UlZ@U&GRrHa21Ax6&#N@|CPit;ofYHH*wV(IIODuAl@S%SDLfzhKI;Wi>@sj+1~mUa53J9u27c z@3kzSUYiU@0g$uEC%SL%B$vjp7X558nuq`qRuKqGEKlcdAsmET3N{r$549r!7);L86^G(gVeO${ z&-|eXSY+U;481lsNG6lfgl7&Ti3{1U-!K?<3r8^v<>vrexQWM@WeBFWn7e+xSp`zs z@=s7~2Oft-p80N{HC5I*CK+X5usM<0_?DByE9J7hXmTy8`DXxXfC1`P12)ruSUtWR z@kEOq)7<#Ur3NWTaRMu1jv@16?D2zr{{6G>b>H4WlFQa7CZQb;x7$eSK47%LYWb?= zCMX#%RRJADvU&}{G$PNIa{0?f6SD)9dex-6rcRz(C&_P)xjAS@A~-1l&dQtNRikV- zA(P>dOZ1?2J76=7$%MwASqWJ4?g^v=;PwbfKLJbT+2WDZU%0bOL!$+h?f+)y$g`WQc8jLPK~*_>%g#Lxmt$Lb|z{oFOlWQg`GTL3dz z-X1O;z!3Bx2B1%88WJIz$w$pf(O%b40o=|E!lk=?IbU1|+3kf@bqEAS`EdvaQ%fcx z4yGv2Gi@aK3O3>ptZLqd`DK^|U*n$!K_#KX2xuupgr@S*F60w>?p(Zx@C+R14*sdp zSBMLuSbK^v>$W4iH4B}sp-zN`7uO;Dz(*I;q`Up?FzXc+YmTU9A$~1bH2{6M2q?eo z)zIQ2K`PZVx7$eiJ)9ODuxMT@UND7&cF8rOaO(BoJP8e|h7$5KO%b|ZuyX+g z2us6Jb`PBT(KP^puqDn>tZE*RO2J_;SxSz+qf;}dFZ{GMKE{{nD%Zukr0Y$SS5KAl zRZlc!$f=qD9E9uHH)_Si%?Tf+E1cVlN&G&5HvLMD@d)G)Kw|vcJy>@``FrmhnKp!% zF1xy^Y3~%mHhY9Hzp&ub^X>7N9OD@=si_4H!^skrg*s&)jc_=b=jFS|%H|?OSgLeV zI{6MQ)Mk39DNP%|VFZ{-(eA|>S$#MuW7tAhH5UOQf%JW3)e~)?uz3ossc7_9Fc|>| zm|ZuYu89PEjQKCRs&$=|fZQK$eFiMr(W0g{y6L$;c#CIf-wL@)hjnV;1dNaWPeoU> zE>Hs!y$N9Qius40I=33#}tnYnoggV~Eo;zgauj*|M~S#eTK zPk<>0Fs)l~1OI+R18UDs4J>VP2w~0k%}MgJW6Jw$up4jCSWXg$H07lIu$!#v*bX}7 z2y`Y>TWo1>aFogp?;)(1XAUDY*|8e7>hJt%50fIMY9$sOhR3Yc;SYyMR$pys0}aSA zH_zKCzonCT2ciEsc4J%NL58ETO4TF}X_?i$cRcsuR);?t0$J?G@vD3G$_47wl%O4qxPh-|bx*f% zPSWHzx8Ad>SjrbvY0}&|f_m2@#vB_#r11Xc(AcUff-WTQtiO-eCnl(OWt$6LM_DY_ z1sXt-pUyM}53VcK7gwvvh~qV%c&u*54cSMFp>k9_15nT^8dP<=q>$xEc8B#puTNVm z7y2|3dsa8)H3m1@Ob?h;tx0w~1o>;VG=m^xbxv#0mzUbdW)qWeJ3xv+!Z+$1wqqc; z=9X*u)NE4f4cJ5joFE{w8)fL7sMN*;hydjFV67Dd>5^Mm%_hxLimDTsbkjp5%E%ja zHgO2o?KYC9O6P;!G+RpjCQVacDhHDZOh~u%HJ22=DN?vVE_5`2&_AT(qVA;^N*y`a&%rO@f z`FdrrYjpcqUOuGLlv9-ZivH}Tu;IIF4u2|%T_GX^ZFrG(2%HO>AGU-H?FxgjXLvC zVw_qFS{+GgZUiJ6Q^VOsAlTO`lgwWL*a4tXVmzYNK3*`R9KWmY% ze|k}NIMo=_u+^ky_u#%#dNkLusXTBj21}u7l774Q;$CJ8opUuTwz1F6iNqqQchdLS zN77M#(~jI|y5y6;t+jNt+6>D<(b2AsO+irS3w)$jXSJ0+Rgmx*k|n?E<{{QEJn2&8 z?Kk#$$CMjn#i(I&2EbkxAr|^sU1&6HtqE&#JU{iSTC1dLJ`VcKZD9LrVF9;wQWhnji0YA zeQR+{2X0-9toZ3e2D8JX^+(oGCJu2iNT>YGO^fgXRT%2rPPdk*F%rJ`bHc4oK?6p5 z1I!rc?-vu4{{8`ja!2NV;;SU{fkZFj%C^F*Y4B6jnUOZ4vZqJYB4d0?N=0u11= z*fb(j2scmYcVF3cW5-tV-)GXd>x^0dS~LjgW#D{3rZ85$0Fl?#U$o zjbuL%=EYk`^n&B=z!QEf1#|yVXLp!MU++lw%RYTbJGB7nlpl*%3X0K0C|buiFr{BB zngY~vLmfRU90G!Udd72QYOS{Q%s$FAF@JE$53vARP3{tT1g(6UN~Q9ze=WVjXwqrG@f}#qnr*`^LVKca3s9A5{2c%cMmtg-b!L;F_jVyhr{(spv7p@2cFAYdjJ3c literal 0 HcmV?d00001 diff --git a/app/src/debug/res/values-night-v31/colors.xml b/app/src/debug/res/values-night-v31/colors.xml new file mode 100644 index 0000000..45d51e8 --- /dev/null +++ b/app/src/debug/res/values-night-v31/colors.xml @@ -0,0 +1,7 @@ + + + + + #400000 + + diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml new file mode 100644 index 0000000..9d4d52b --- /dev/null +++ b/app/src/debug/res/values/colors.xml @@ -0,0 +1,7 @@ + + + + + #800000 + + diff --git a/app/src/debug/res/values/ic_launcher_background.xml b/app/src/debug/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..63de2d4 --- /dev/null +++ b/app/src/debug/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #800000 + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fa746be --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,576 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..4abecb029a63fc0a197ad956887e0e207aece76c GIT binary patch literal 15451 zcmbVzWn5HW@b}$iX=xAyBt($zlm-C_X;37k5$Tp%5TrpQrIC{Eu2rO@yGy#e>$&Ui z&GX`U@&7-3;BxQXbLPyMnKNh3cV$CXLLC60;4KuuK>qV2mo5eX zktgymBsJZqcg7@f0009-~+$Y;Y}S;ox|DN0a!!f)?9Qj zM~7e}d+^~0MMs9bQrs0KVFS(#LAM9@YybqF^Po1J%0|3~vxZaHX{lIZ`&>5e2cGO| zlx&WQY#@jEWvO|+o%y%IJKP&X2n^8X6dzAc=DYu1x;vdM$s5om2DioBfQNV6?wD6vG z#@MoI+eW*O^NHCMDg+cX>xrbT_S)a=J6AU-n>bnAih|)AMx^*pFF5JJo?GNLD^KGV*!1Td9;LL{C?YKEwqz4iD+St%-g7O!PJ_aQlx% z(|<4D)$mvos-LY!HBo$E-~OF1OP^iby0QI)`WR_5h-r{fTZQn2bnzprx|VQm zA{mxat^uZ_BipY+I!j-gKTvxFr9OfR643Xk} zGsAd&=n~R>F5S1XX7KOLynAQn%!O>v92$ z4!NwaMQs0uvJ16V5p+AUkQO@{!w0#BpDtLhMP9#;k4vQoWei`9aHD$uhV#U;oN`|p z$w-edk^a5vp6R#O^hg4AXHhHNSA`fzfjUICrS0f&7m@$de$ld}!j*!K77nr*XNSY+ zw}w{?TK{N-m!-cYNxvIo&0<07@tFumM9<~Z0hb{Hq_)G1vC}?pcPBDqf+YTglsmUssEL|8Z5uEw&um`su-suSfwm zof>`-m7BksKu!>7+s$)*I+Na=EdKl-pDte0GIpfjl>g&%&g`PX!i%qG6AL+bvUp8w zYLqmNnE)n`@f2807Pk;-HswV!ma!twA*6%7`da`BFk%8_LQH!}t<9_q$l3BH?(z3F zIV|)IBegKt9@lWH_M^Nv=Q994Chp=sW+h|L&3}q?I)oz=f4p14$-x05TcyP}OA?P1 zKu#n8&R$oBXHJDSF?0F(yk>Pxz3R*wOI27KT_ZK~MjW}fLe zCLAOX-?7@&J@AifIu7f!v~yn!3;;+3@M~lUgqR{TJn8o1-nI1(2JdfnOd6@~2ZQv|-%HpAl;C%xW zPH+}8al1SE>R$(tgLyoDa5i8Erp-bF+4ui-XnrmZVF0ZKVP=@OZ*B$pY<<99cv5|C z54qTk5_eA?2SR9&4N?*jZK_278Z0W!U2&@?|Km2%V_A$W4g>xcfDCSPc-k#ZXpi4n z-*Y`xt&NU!n|;4*@TmdYg>=jI1 zL`D>p7L9kYxgGVm-X?#VcKKWubPMh{MkgD=l5a^kc-|K(cOzP$LYPzvAZdQg(bUfoH88P9??r$J((Uef16-qd(>qOwx(H{WO#__LxO;`T6q`n^8o2u6&$at_ah_DZ`HAmFh1zt&ANFdV8Bt%uk)fYm2+NDhN~Dn)-DsQB2-}j zeP7QMBNwja!r?q`+D%^RO^xHyOVS{ks0nQNJ5$RW)rrQTt1Q}((8ogsal=nc6QH2` z`@%+e3*UQe=e5d#KRC8GqBsZHyKd1%MWjaOwGLC(@2&g{$adkz!iig!LAzYzeWfEuj=p(1%)-v52%CL%R+uNv@}o0_Xd$y2K4?gQq!#&}o?9!dNFT33>KF}u7 zd9Bu9;vPPQ+;ygQtup%m>;wP>?|r8q7yjmXKm#Be@OyV^6pVEFMyi5FO3)#ypXwVL z_LXyFRXx?t9!Y{8&nWIknaXuqPwkzExUL0oZZ8fS9KTr|E9!}TK;BjU`~hg!gJM#B zi+WVYo6A>!@gOHPzGV5rCw{l%}*~5vrfZJNm*k;;CKb?=m`8>sHpw4A#S75wWLF)?S#dba!=`5OC2FI0UU8qMOQlwvE&V z<_o;+bsm54;ItxI5Tu?u9i_)b&^x{pK5 zc+O?wJlV$RWT|)5Fgjz9`^)ojm$}}&jfJMe`??Z3b6n9?wyT9qPliz5Gs}hjVV}v& z%WSP%9r>&MiaH|QwyHPqG#C_k{Qg-~KoyDV@AU03ahOEbZ^kugmsZ90hi?5rlPro6 z+LYZYy2BYsuafy*J;>R;E_t_Qq5->y_%7&M!&^ z^NOrA`)t*GCF!K>whXEHl_|NnR04(FjgOvn4(ep;-^y2R6>jzJuQ+rv*$41Gn}9-q zu%mUkT!((9{r89KlYelaWP9m^))<){K#9-c0zq0_d|k8NrIYk{3VWa2`2&qQDp=|k z3Kn%V=2pcTX$}Wrdx~2KCTOmN%Wex?O6ymD4Oc9zm&wok0P?MM{l2CO2G#|c>p5zA6qtVrTQ!S-o7a4v?ZR#+Tajm$m|KR);VV6C8lomjTsF_0Yh#<$!}z=mEPp_ z{dRu9k6$c$PM;Y7;lq}Mvs)Z)r=~;v+k)HO%9orRcY5zTN4Hdn+&g}tU?|9c_tFSE znppX*uW5t0k^ooUQ>gde0jg0Zyu?eW#^n>hs^zQK^#vCCCzWhBlj~Q~uLzmdFgxew zTRZVoDOVel8OQ_Y7`6Nx$%Kl?t$urQ&;_==i4zx!#V}?0K9{Z`Zh--x|I?RKi2FNF zu<%#dFS+EE4#8!Dt5Yf*W%6A+sdczIM1LjR1xJKGqVUfREz`SmNe^SyjUPlmK>fwa z>)!DyQS8Rqg&@ZNSe*v}RJcmHEDJVjZ!MZnN9q<|?jFuotO_(*JiocFx}IT$diE!} z0S*(KfyyY|1H7CC*d(Cy#~w0f-ln$srIIY3(2NJIn-QQB^4x!K<)?Ay8awYKrvIK< z$EkVw@p$W34k37*MWSBGO~~6% zk&iP*{~N<|TwBBMvl_6U*|wm~UbcP}jDoQx7n|_Vo$GCtY)Ta0ot*>#FJRYixRyq4 zR0|s)pDFmxGLgppjDB8!rl~VVt@Js{X#=ZF>mn9Zi$iYkAg#wQ#h4;c1kADGYPB4s zyLb4A<)0y_*aQ2KXPEFF&V$g zn=7hFqoAO#O3wNGa4g}z%rTT{J?M51526~Jo40H7?Tq+*x-EDWll*%g77JbGd_~52$@<34YFT=ax_GSa9YKSXSop;}JB( zP~NkT(lP2eHB^Vl6yRB)$8vJOTF|tbO&RQ_9;Jn7;X&Z7!NJ1)Rj6iHH}+Hi=8Z1A z@QQPP{X%Yvs<@vSqhe{LMA{$Lo*tp_{4*tl*Po4*&+azj>$+#Kz_+#Q!vGi<2amJU@Kt=(l z_Y&Q)pe{}xRXRREZ>e6)k-#ACm${0*979gA_VMnT7QO0%M_y8_yUgKNd*a~gcS2S$ zJl=n~+<&sx+L_ZT4m+^ex8C#+_7@zzVo!*rRDD{@+kX~c8a$8oK5%}4(Z2_#ptd8U zi75eiS-cLYXxXC}7e<90Wca?<{yDx%ct#sY@|CcB%geA6eg2f~R7CLs-}NQuRc!S( z{gfpm4&;C|^lhvbI~{a zbPPjw(9B>_OJDzb91};Y`hbT^68vu2|ZT<8y|$cq%0%*In945Jh1OHS$x05_;?CMgI- zqoY?~eA%w&*a3oHds2E_u1N+3Mw!EKA@~SJR0%qr@tz7D-Hjsl!hB` zeIOu1>-G`#V{mD6_UUZB!-O=c12%nzY07bxk)(3lkv!CV9T`1v*#$R5u+C(0YPCu{ z76kB-j4?khR-}ddk^*M8F--YuVfr1rKLb`gZ$~<}&SeuI^*&zq5-w}Cjcp^e0D^8K zj-Sr1^R^|yRs|o?b?P8LkWm7sKnk) z&eu6B#G7$VanXC8-?mtZoucAKn0$mC`r-CX3g@&0~m1Oh>;<3{`s>9J|I^kW1U34ez7z0 zhITOo#WFsd-Auns6B`mPI=K7Im^6~1qzH>esb{J^=QRXYCiF|y2=i$5g0`Eus=!GE4SSQZ{qv zCre)0GwH8`fMgvbt-A`Df=@sbZW+6XK;DPdp7DYfOVO~%GnO0~!qdwH7d|*>KfT{P zbT?xz!?e1H(T)RX=XAM3+YDYm1RxpFC^@wAo|k$FawsuUmF(qR7}Lb>G)X>q@Csr= zFRtBuxTJXp@5D+`2e<6jCuk9+497G227D5rivUB^q0d?OE!yCwIM-HCORw)3|N8#y zS0E=0pE93^xHKzjl}{RS#tkXOg}C92jCNC?NEhS7YXR3z^V!GkZ#uJgM0aE<_JX_L z|42uN+@QaINS!at)$8oND0C|2NfiZ?;4Nr5`;K{p4^)T`WdD(^)TYK&YRM*MBTA^4 z-2d@PRjy|pACR`so_|yEu<_n7l~w`PM2HY=BewbTytEDkQxIzyCTV6Qxv1R?_bw_iY5}E`EKM#*No`>F%HleXsAZ>UF-HzJ0A^t@YE>%Npa!Wl1>Zg zJE;^ybHZt~|1d@HQPnSqr=~;Su(4ePP57$zm5xjN)^EUkX@CuJC(OPd5xa8_e)YAb z#?r(-HgZnpVFZrqnjJM&;xB24-XHvG0dK#@_tYJzq{j#N2knk(n6Sl12rT&!5D}OQ z&7qRy`ix(2Y9@OBLaT0bY0=aG6W*_+gUZ2lDYpr*{|}w^BUY7sPzZFYaAb9Ie=_I2JdJU+)}O^ic8adMTC-i z>>QtKxz_IUiD5`+)LmZU!GfWp{HUYmeV+h$;}k`Lw$?S>xr^d4U4C9){7cd6#*M*H zp8O?Vh51@k8jbY3^KwH(ee^M5`y_{O@+)W>1u8f=Egjjn?m-BKs z`qw$EjDfWjK_0+%H`ey|@-@L(7jOU9LmseKXVSrq`-o&-iGd>`d}XJZS10@v3iCuy zg(5Sl2wCEm2~;rQ;rQ^yDT@z`jNGCW|40ekMGddnk$t;kf3&!+cV^kS421Jhtz9%m zqi{RA&hC8dNq}Xr7>E}HyjE@Nw1(?mP2lz{(*lbOKME^Za-G$bHa|`XALdGHQX(8g zZZCP6_)-pVAcNfu6BS;X;;27FWA%)*2n&|88lJ0c{fPwQOr zS>{M$bhHoAUu98lLj{xYC+uzRe)wQ*teS8$Y6ZAhiTML?;SBTskTvfT-8Sbi;lV?n zBCdezt}q+5q|p@p*vGuNs9%hpb53_(7;z#Dy4$RQ_t=LYReyjPh&?huN3&%zrzepC zh(RIjMYnuNcFqDJf1|B-%d)B(sRl7kC~>A)L@T&pgg1W9M>h)r0UDw8ksRV2hg0s{ zH-oC{jn1PPZ}87E<2;RdssbtOT>B`Upc!hG?Ie*K4@VD6_;?Q|c}p0*X<>j~r5@-A!$|LsQogm{{Bc&Cf?g2pJ}RbCb^aD5gqg;?{4GJW)~_zwz$cM5M3so zI12!Sbh9_PYdfyTUvtI8t?Gq0DHn^rHRNfmUVV3|ZCE(*fenk>9zgrj%J z3+p{+qJjd~e}@&ANgI6SV^EOjNaUcfb-a#!HoKlv(iO|>e)?J;l(F#b;=urv&x;s% z6-wH!2M6Ga7QhSl0z~K)>LbPRod`uYW68FXH)ch&PZt_aB=0pjh!O9te9Za=KKM}X z`8m0}<53zZ=wm_vQb(cVtq9vF7o7|tL_0~?3!Q|N3l1#excy*lwGwYXzJo)=i&8=a zL`Gesjq<~WCHC__o{P49tRT9EpMRBNkvyylRkxdwTS}@-QG^+MV0=aXvS-SQU0-zY z&@TaFG4#mMZTlJSJ?mfxZ@}U^VoMQF8K{B%bk5I2Tbh_ePqYVm1(%mdz8YJs3u=|b ztaer9Im=MIO>TKQ<*p6nuj$vm8D)ptitz^+y_J>*5L$zZZrOK<39OEPmX&y^Cre+h zP73XrIb?=yJ{bmNQhq%2JF*vf0{>n?A9bZO2G9{zDRikoSY6blF~IB=QNoaC?Q1p{ zg|C^`At0bfdK)7kLuP^_SJ9&#_xfIe!Ci!RC>8qS$K4eF@2$>voB#)r-v*Vtn>!lF zIP`R^yLVyBrtsGMT&ezS<+%>~2=6Z$nB04l2suYKmJKC|A=*FWj-P-p(BI^qY}BX+ zzgMV>G5kOkP3FCHu_6G1l9vwUr>^myJso`1emYmi#`|ervmyzXdNez;dXt4*OuBEH z2Eag2TuG7Ua)3)rQ84+T@+;yd*FFLM-kb+&%qRN2vsiU$I2Aj~-9z1L&F!mUR+S|s z*X3E4$1`(wxaTT4k(kbuI*5& zji$Va5%4`5&;)*9vBnyhG^ccQ)Yr$GfycO}3x9EL2S?syE8(=6PO*McX$K7#x$ z2*Te-o&bGia+M)p;e|}hR-wXb6qu0!;N@VN>0hQ7nIi#zV8{y;d4Yg`aO8~y@?M@zX-TJkBFg8?l_Dr zI8;G<20VOF(@K#g_IJ?4obm0*40mDW

CrTFyU}wz`D6?B$mAI>`|qZnrtk-mIX9 z6zOJFInH(8MV>c%J$Yk%c1K^5_yCIraM-B_!-J+|K?P0hsbbl|p`)qH3wMw!FU3Lo z`&d%3^~1#gaZiJ7>)W#WcdjmdQM`#QzZ)34%PcDOR3!0gMegIkKo0aK)88X{2Tk^$ z&O76KvcMJzm1SQ%pm-()WT98_dOxPjU!{R3aB~aZnRn_s3o-!L!aCF?eR3D5Y5mDBBxeSbMWz6^{l!JBk27RuK`{WC7(vpl5gE;wVJ0D5v#5($Tcv#}eN!%+V%;GwmDazGh$_$Lc6_XVOB=fTUjq7yb?+}x zFpNAbH*Iboq%lBo1miJa1=haV&Pw~lfV9V0Dc4n2>;Tb9)B6hd5lF+svzM;;>Tnt$ zf%MnxXzNco$Q#res!k9avreQi+9v@H%X}T9T|yYQ|Bk;b)^L<%lIB%+`<}HLCif|3 z_R=QC@%u?SkXVclHeE26AA;77nS5V90khuv?-<`weEpHpVyCRb zKzNE;;73N4xYbWN$nx#AUgHe}_9gk@@6JdG51$%q+*y=Qnf-1g9nvN;z=Md><0sY6 z++Gx>sPo#h>%Y~yUjB4vwRgE%*`sY6rCsj|PIghDH%A<2S7>lRuZ!7X$z@K-U>-&I3kHnY+80bV zajwA-gHuO3AXcm!+rdN+T^(zk-%sBgHgq?&^w;wY6{-VWbM=X(_@aqWqKwft#h55( z#yAE&N!W(kqbw*56^;^npbX(cC^74D`GX=qnB_X7<3c@#3eH{0!}SNxLMH=?0<0jm~_kO&;$_p@Wv9z zRn`9SAyu885rq`-MqH|3r$(t4^P@u?2q#l9$yh76&V`h9Mbg=A-lr^wl9z+9jgo%= zFe?|c)k6^TC2G~^6F!2aZb9IcZe27LVLAc8#m!q%xEiC+^^NkGYN!_J)wD%4UFy`j zt>yQZF5TWe)Dpl@C|&Jo>7g>v&WP^G_qZ=zU(Mi<3&N?l|8ZT@@x_;5jn>EC&Q4O( z1X6_Ej^8*wsL{kg6jv3Buf8+1^t@y#fk>p8J*u8NmxtiHU7P=YgohpqM&F^q!_=OF zi&Z`F@SDfIB&eW3JFAQ8Rg~^-6w%uG5G>D!6BS;MEH7n2jY?p-iJ=qrmjdj##b36S zMIR+?gi|CqsP_rECBPvV5`>AM>J>JTT1yiE8hDsNUaR42V;s`_bwJ)BPqHka2(MPs^ zuD+URcCeaMp#|_%Q(6`?9YpZ5B<|~oe|~DQ`ZK$GVss!0grk(WwMg^eO``cTtK`0L zt@i0%b*syMaH<4lgafXhS!PDlLFt*jMDx}W8vaxL?(&8J&^Q^K<@5~94ww^Rl5 z2f4kQrf2MrlbK+PTQwl!d2U(N#(T?xPtVB55QhZ*IJMIGEg zo?l6;uvM*-KA#*cL6!>R)CW5MNyE8=HLnoXE+Q9GImd!smHr6_+C|#+f z+)qsjg2)jz5yKzdk&dibFz@-X@Vtzl0s-ok9q^RLcN^Z1Nj^Tz^!`*RAm+1w0cWm!vx2H7 z52q9AEAj$$$ zKHOx8BY1veAiH}n%J{RrLO<173q%4i7=H9uFUtJkLW%zn>5rPdYY;a&0r#N+4MTC? z)+cf8IBfa6^Huk8T(u_h-WzbU^?WNr#DGYS{cXa}ncB)@BQlKt5TmYx(5}5I=}-zn~Cfg3I@XS4HNTlUXi!u-=QXV3N2K(gA?Wp*YjlK zF&V5E5&*}t_PJ8Qn6ZP@_j@A!p{s-SjB6DC6*dA}}=_Ix1^M$3Ee2Q)oS!c?yQDS`!C zy_hPzbi}Z+3iE(Ak} zT58j~NkKn3zYMB{R!q=2M(CGPV8B1uW}EK6BM^w$Rvzy1OP=EqkRv+;l^XsC^xsE} zbc{3S!qVXnA~>qhY@^gis7awlW4u=LR@$#(rjuHgx~C$>@9`0n-;boK>U6JZ4Q-(n z-Xf2KlUjBo`;2ZyB!JOhF}Vo4uN04Ol}kW)hFGWEN(wM}kd*NFq1#oB51JvRs%+rT zmilXr=U6y5-)A0Uz_G!kn(US`ry(62@JhFN_nsM#ieGsp5Wq0pArPGvpnvNYE_KiP zIZfk=4t5{{Ze@v7l3)q}72UL(F#WSr2?f9IBCYkvsgw8Rz|4w3-wU%Ko@p%ZscqSFFY z6TR6if|sdan0z!{4iB^?45h#BiWSTEu4~>T5ggDqt-NSjF&M zUG@|rCdSp#!;FG9Enb)6>QbEzZ*G-lA<32YJ!?@;M}w3t%dYFnfA*e)xw8RfyrYf` z+_MQ3avHL#S)^KM&Q)GMyj-^Ri5ld_*>{4cb$KeEfH&M^2W1Y%4&%@r@XSbQB*dsl zKH6-oBHAj&?s>*bBWt{al|6C6mXfs@(Uc%z1-|u$x8i25V5*V+@!g5YpvF*E zstYq=y~F?rGN&Cl+Z;83k$lT0oX`<&@zozqc)q6>d!8s+*==9%P9lM)lwY7fSBLB5 z8$O`e0R0f(s@#AVK@>VnYP@uh3Il3hx_q6iL}{q?K9;H*MZvW!O81oU2>iD93pIL6 zU1OQ@J(*lOCdl^308NZtp?h!UBu&G2W75Fl<=GIu#rI$273Sjn5yz6~npku3WD2_P ztuFn)z}g)hFgwNn1U_((ZWCcxrG^^Gs{lCxQ%?8m(Hp~FRXnUjg?An_fF`D9K-aQm zQ4^jrQPx)3+e`{VY4n)|jdh#(9-*DK7d~6tt&gaT_$@KuBqyY$B-6(RUQ`>HQEHSw zzxonj03UEhf{od$3dHkvXbcsx8W^0HZn`oZ#tRlWEc9^Vx9jgoED;>-x8J((U>I(wnw62_3xgaKp06dk6-g8pB;coW(FKaAJ2gDwH8%)VQ{fg?>`qH%ww9h@ZX# z;t!cR2{T~vr>!?FaMtMiskQhZhl9&zYoKh{5DNyl2IT+xtE@46LZks#5z6?v5Q1tP zg~p(k<)Jp-{>m{+{mgmCEnjb{u;VTh2$~;(3Gw(7(XbohmM3rzeSu?RO~&yH z7B~Q$Mz>Hp1*+$HGdwY+L)Tk&ex@W-Kb2Fc%pg}3ODx2)!!VKtX3lXd!A$;+op&jh z!~N@nLBW=%w{AsyY7hu~_gw*_Ot9AFv3?t|;h@A_Wm5)K&|~{j&yOu;Ow9#$U(AHJ z8*`?B(ak000Scusq%qD7IQksP2}x@XX5PWJry<~K6BZF=bMOp2>YTZH)bfSm?XHA3 zVz)#ftcNrw*$q4eq+Z0?hG}<_M~YA`)(}Von!8#|M%-d@^0nsLMnC9qlEB3fugA7P zk`|p(SEzT%v#K%E;Xb138HgCa#6o}I(xjmYqIPu_F}-z@9z3Y|&Bs161^LdW%bA!b z8Z>}%D%GDrrRs@|&54#q<&;k(U@0yCC9`h!Rd#@Ae7K<}Z)HarisQ`(Gmc%7p{Wxc zhfCwYB^sh_wsb;sP8fOLT1P9ck1EEEMR$=5?hP8#_ISmv^5}Wk5foX|1`;T%5AJbHe z6!aveM-w+`6}VVQVaVL%^Eq3N=YUDq!8=rl{j2jTZb{z~Rcz-8FvS3t;1E{wo_+9n zYgD-;(iQ1F0P?(my7uu zPH$ar>xGY4taKiDBF?ZekSZyF4aU82Wwef1t*4*Z`+~sAP%DD@(N?f-C_lWwY!#;DdReAzmoY&u~Es#=+iP`%4@FZeoC`fIt~Ipr=7Yp zn)Qb@A@sG+n)Z_S=gF?&m;!SZmhEFARr<1eqPWe-wg1fHHdZ zbUXd66wodhwROuk^Mn-kJMwz!1%uzJY@_B($-f3?ZUH z7GPHPgT!w`!=^ifg$MuW-{n>6CH5EyU>PZx&Z>!YgSaZIMSb@AZqwK?0}QcQkwAFk z^zDef)^UMTh}MUL$^^t_&mQ~K$Zicgx(!LlVIY=@)5#l1uDObD=I~BGWy#XGe+FkF z=;Cg`#P;2D(S776nCzp0tI=QbUd|}OC=GAwu_eAWJ;LJzZ5ZH|d@UbVBV%RR2rUC3c(fg-O?njv(<QH#CyGO_54{@jFaR1u%k(F^dT%dQ zdj~;m(G+Tkk5Gl%&>)oK^t3ZmulPypF8vdvIcVykQS>lC1i%Wxqkn3k9=g&H!^^Sb zI*{yaBGLhq2)Rjx)I9&VKss5R=DDywh~pfl86`&m1rVPm+H+>USCR7kMjqerS9Rhw z9&X;=ZLXkzS)MK<#K@vg%%Jjl1IbCt{-t+%j|j3r+Z_suvdJ_t+{{90e3Q5SjfN{?e}Q!+-n*51XU($S8!4>78ft6b!uW<=-Xw0$q2@?W3;e5~DCKw% z64D>EH%@z4hG$%7{U3oAkkg|7yCmObgqFc#tDAOpn2B zWE~FBfXIIugJqrlchQA`bA#P`JI`~(%G{=JC`)Y%=yQinzOx-*Ac*{GkNoMbM! zl7jxKg{4ZVScGSG5B!E2g7os$9KN72n0~l;T017B&+}g!v0mH8y4_6sMr@F;eKXew z0u5lALRERVv~QQh))@l87O#z+d``Kez?BLKzQBQFH#}>LUdy#Z)wAnx8iQ5rK;c(rvP`GY^R^W&rdh8)ns z{(*NFUljs}!xpPAMSZ7qs#*XER;VgdPFk|i#qY+nnig;<-VRU52H^-c5I_f%_U<~# zz8*h;0_7MI^zx#7-jnifqGlf<@Heo7BEPf+y9%UB})nf%K+o_F6T8S=hkuCxD%WYyI3|I#}Y4iD--I zWV3Ev_=_A3(fBR(ZICTb?LQ9X)z4K@Zbr_od)WZ|FrbOAwXpOqhz=CvcFgpzEi=Vw z>auEN%Y*3~0|7keN6#OEGP%f{x zl8x<{B+m}glsP^NV;FL7r| zaEh@1baQ%jXVBB}1mwqXO`zFn12^cd6Zy3Lb^4HGy0DL$e z_l)7#`m5HTAWei@`x*t>N9FgYxeG600L(~V-ECrU7kiqj+MEX2mp#WqJM}Cq@+;H7 zzCjF8;>|pEoo~Rlpd!ZC1^3v~0&aKNSVp8oB$nwMaH9eVKUU0Yr*o}0#k1Sp9Nb%o~CDKoG3 z`h>G0-WUHGc=#N5=0!C)ZS)6<9pFiUoZKCBHgnewOdf^J^$Yw*SKY3E-+;{Uqt?P; z_ud8}m}>=MW0KB!ghfD$$GH_XSdIcDg28tu*WY&2Zc_TEh2!LcI&iBojssOfV{ zQ{O(>QGC#!J^P{E$NEx(*de;ke9_O?^{`_ypm>)Zg*U7vtHj8 z&^d4s06(SyO@(|u`P+%W2(ccU!(TgoZ6*nx)ErP?>=9gnshK@rU*iL~22S+LS7T=5 zFUJ%Ic6!rRZ1iqg(Lq1. + */ + +package org.breezyweather + +import android.app.Application +import android.app.UiModeManager +import android.content.pm.ApplicationInfo +import android.os.Build +import android.os.Process +import androidx.appcompat.app.AppCompatDelegate +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import androidx.work.WorkInfo +import androidx.work.WorkQuery +import dagger.hilt.android.HiltAndroidApp +import org.breezyweather.common.activities.BreezyActivity +import org.breezyweather.common.extensions.uiModeManager +import org.breezyweather.common.extensions.workManager +import org.breezyweather.common.utils.helpers.LogHelper +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.remoteviews.Notifications +import java.io.BufferedReader +import java.io.File +import java.io.FileReader +import javax.inject.Inject + +@HiltAndroidApp +class BreezyWeather : Application(), Configuration.Provider { + + companion object { + + lateinit var instance: BreezyWeather + private set + + fun getProcessName() = try { + val file = File("/proc/" + Process.myPid() + "/" + "cmdline") + val mBufferedReader = BufferedReader(FileReader(file)) + val processName = mBufferedReader.readLine().trim { + it <= ' ' + } + mBufferedReader.close() + + processName + } catch (e: Exception) { + e.printStackTrace() + + null + } + } + + private val activitySet: MutableSet by lazy { + HashSet() + } + var topActivity: BreezyActivity? = null + private set + + val debugMode: Boolean by lazy { + applicationInfo != null && applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + } + + @Inject lateinit var workerFactory: HiltWorkerFactory + + override fun onCreate() { + super.onCreate() + + instance = this + + setupNotificationChannels() + + if (getProcessName().equals(packageName)) { + // Sets and persists the night mode setting for this app. This allows the system to know + // if the app wants to be displayed in dark mode before it launches so that the splash + // screen can be displayed accordingly. + setDayNightMode() + } + + /** + * We don’t use the return value, but querying the work manager might help bringing back + * scheduled workers after the app has been killed/shutdown on some devices + */ + this.workManager.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.ENQUEUED)) + } + + fun addActivity(a: BreezyActivity) { + activitySet.add(a) + } + + fun removeActivity(a: BreezyActivity) { + activitySet.remove(a) + } + + fun setTopActivity(a: BreezyActivity) { + topActivity = a + } + + fun checkToCleanTopActivity(a: BreezyActivity) { + if (topActivity === a) { + topActivity = null + } + } + + fun recreateAllActivities() { + val topA = topActivity + for (a in activitySet) { + if (a != topA) a.recreate() + } + // ensure that top activity stays on top by recreating it last + topA?.recreate() + } + + private fun setDayNightMode() { + updateDayNightMode(SettingsManager.getInstance(this).darkMode.value) + } + + fun updateDayNightMode(dayNightMode: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + uiModeManager?.setApplicationNightMode( + when (dayNightMode) { + AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO + AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES + else -> UiModeManager.MODE_NIGHT_AUTO + } + ) + } else { + AppCompatDelegate.setDefaultNightMode(dayNightMode) + } + } + + private fun setupNotificationChannels() { + try { + Notifications.createChannels(this) + } catch (e: Exception) { + LogHelper.log(msg = "Failed to setup notification channels") + } + } + + override val workManagerConfiguration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() +} diff --git a/app/src/main/java/org/breezyweather/Migrations.kt b/app/src/main/java/org/breezyweather/Migrations.kt new file mode 100644 index 0000000..7afa110 --- /dev/null +++ b/app/src/main/java/org/breezyweather/Migrations.kt @@ -0,0 +1,307 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather + +import android.Manifest +import android.content.Context +import android.os.Build +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import breezyweather.domain.source.SourceFeature +import kotlinx.coroutines.runBlocking +import org.breezyweather.background.forecast.TodayForecastNotificationJob +import org.breezyweather.background.forecast.TomorrowForecastNotificationJob +import org.breezyweather.background.weather.WeatherUpdateJob +import org.breezyweather.common.options.appearance.DailyTrendDisplay +import org.breezyweather.common.options.appearance.HourlyTrendDisplay +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.sources.SourceManager +import org.breezyweather.ui.main.utils.StatementManager +import java.io.File + +object Migrations { + + /** + * Performs a migration when the application is updated. + * + * @return true if a migration is performed, false otherwise. + */ + fun upgrade( + context: Context, + sourceManager: SourceManager, + locationRepository: LocationRepository, + weatherRepository: WeatherRepository, + ): Boolean { + val lastVersionCode = SettingsManager.getInstance(context).lastVersionCode + val oldVersion = lastVersionCode + if (oldVersion < BuildConfig.VERSION_CODE) { + if (oldVersion > 0) { // Not fresh install + if (oldVersion < 50000) { + // V5.0.0 adds many new charts + // Adding it to people who customized their hourly trends tabs so they don't miss + // this new feature. This can still be removed by user from settings + // as this code is only executed once, after migrating from a version < 5.0.0 + try { + val curHourlyTrendDisplayList = HourlyTrendDisplay.toValue( + SettingsManager.getInstance(context).hourlyTrendDisplayList + ) + if (curHourlyTrendDisplayList != SettingsManager.DEFAULT_HOURLY_TREND_DISPLAY) { + SettingsManager.getInstance(context).hourlyTrendDisplayList = + HourlyTrendDisplay.toHourlyTrendDisplayList( + "$curHourlyTrendDisplayList&feels_like&humidity&pressure&cloud_cover&visibility" + ) + } + val curDailyTrendDisplayList = DailyTrendDisplay.toValue( + SettingsManager.getInstance(context).dailyTrendDisplayList + ) + if (curDailyTrendDisplayList != SettingsManager.DEFAULT_DAILY_TREND_DISPLAY) { + SettingsManager.getInstance(context).dailyTrendDisplayList = + DailyTrendDisplay.toDailyTrendDisplayList("$curDailyTrendDisplayList&feels_like") + } + } catch (ignored: Throwable) { + // ignored + } + + // Delete old ObjectBox database + context.applicationInfo?.dataDir?.let { + val file = File("$it/files/objectbox/") + if (file.exists() && file.isDirectory) { + file.deleteRecursively() + } + } + } + + if (oldVersion < 50102) { + // V5.1.2 adds daily sunshine chart + try { + val curDailyTrendDisplayList = + DailyTrendDisplay.toValue(SettingsManager.getInstance(context).dailyTrendDisplayList) + if (curDailyTrendDisplayList != SettingsManager.DEFAULT_DAILY_TREND_DISPLAY) { + SettingsManager.getInstance(context).dailyTrendDisplayList = + DailyTrendDisplay.toDailyTrendDisplayList("$curDailyTrendDisplayList&sunshine") + } + } catch (ignored: Throwable) { + // ignored + } + } + + if (oldVersion < 50400) { + // V5.4.0 changes the way empty source value work on locations + runBlocking { + locationRepository.getAllLocations(withParameters = false) + .forEach { + val source = sourceManager.getWeatherSource(it.forecastSource) + if (source != null) { + locationRepository.update( + it.copy( + currentSource = if (it.currentSource.isNullOrEmpty() && + SourceFeature.CURRENT in source.supportedFeatures && + source.isFeatureSupportedForLocation(it, SourceFeature.CURRENT) + ) { + source.id + } else { + it.currentSource + }, + airQualitySource = if (it.airQualitySource.isNullOrEmpty() && + SourceFeature.AIR_QUALITY in source.supportedFeatures && + source.isFeatureSupportedForLocation(it, SourceFeature.AIR_QUALITY) + ) { + source.id + } else { + it.airQualitySource + }, + pollenSource = if (it.pollenSource.isNullOrEmpty() && + SourceFeature.POLLEN in source.supportedFeatures && + source.isFeatureSupportedForLocation(it, SourceFeature.POLLEN) + ) { + source.id + } else { + it.pollenSource + }, + minutelySource = if (it.minutelySource.isNullOrEmpty() && + SourceFeature.MINUTELY in source.supportedFeatures && + source.isFeatureSupportedForLocation(it, SourceFeature.MINUTELY) + ) { + source.id + } else { + it.minutelySource + }, + alertSource = if (it.alertSource.isNullOrEmpty() && + SourceFeature.ALERT in source.supportedFeatures && + source.isFeatureSupportedForLocation(it, SourceFeature.ALERT) + ) { + source.id + } else { + it.alertSource + }, + normalsSource = if (it.normalsSource.isNullOrEmpty() && + SourceFeature.NORMALS in source.supportedFeatures && + source.isFeatureSupportedForLocation(it, SourceFeature.NORMALS) + ) { + source.id + } else { + it.normalsSource + } + ) + ) + } + } + } + } + + if (oldVersion < 50402) { + try { + // We cannot determine if the permission was permanently denied in the past. That is why we + // need to update the state for all users updating from an older version. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + StatementManager(context).setPermissionDenied(Manifest.permission.POST_NOTIFICATIONS) + } + } catch (ignored: Throwable) { + // ignored + } + } + + if (oldVersion < 50403) { + // V5.4.3 no longer uses forecastSource as reverseGeocodingSource. Migrates current location + runBlocking { + locationRepository.getAllLocations(withParameters = false) + .forEach { + if (it.isCurrentPosition) { + val source = sourceManager.getReverseGeocodingSource(it.forecastSource) + if (source != null && + source.isFeatureSupportedForLocation(it, SourceFeature.REVERSE_GEOCODING) + ) { + locationRepository.update( + it.copy( + reverseGeocodingSource = source.id + ) + ) + } + } + } + } + } + + if (oldVersion < 50407) { + runBlocking { + // V5.4.7 removes incorrect INSEE code for Paris, Marseille, Lyon with Atmo France + locationRepository.updateParameters( + source = "atmofrance", + parameter = "citycode", + values = mapOf( + "75101" to "75056", // Paris + "75102" to "75056", // Paris + "75103" to "75056", // Paris + "75104" to "75056", // Paris + "75105" to "75056", // Paris + "75106" to "75056", // Paris + "75107" to "75056", // Paris + "75108" to "75056", // Paris + "75109" to "75056", // Paris + "75110" to "75056", // Paris + "75111" to "75056", // Paris + "75112" to "75056", // Paris + "75113" to "75056", // Paris + "75114" to "75056", // Paris + "75115" to "75056", // Paris + "75116" to "75056", // Paris + "75117" to "75056", // Paris + "75118" to "75056", // Paris + "75119" to "75056", // Paris + "75120" to "75056", // Paris + "13201" to "13055", // Marseille + "13202" to "13055", // Marseille + "13203" to "13055", // Marseille + "13204" to "13055", // Marseille + "13205" to "13055", // Marseille + "13206" to "13055", // Marseille + "13207" to "13055", // Marseille + "13208" to "13055", // Marseille + "13209" to "13055", // Marseille + "13210" to "13055", // Marseille + "13211" to "13055", // Marseille + "13212" to "13055", // Marseille + "13213" to "13055", // Marseille + "13214" to "13055", // Marseille + "13215" to "13055", // Marseille + "13216" to "13055", // Marseille + "69381" to "69123", // Lyon + "69382" to "69123", // Lyon + "69383" to "69123", // Lyon + "69384" to "69123", // Lyon + "69385" to "69123", // Lyon + "69386" to "69123", // Lyon + "69387" to "69123", // Lyon + "69388" to "69123", // Lyon + "69389" to "69123" // Lyon + ) + ) + + // V5.4.7 migrates some Open-Meteo weather models + locationRepository.updateParameters( + source = "openmeteo", + parameter = "weatherModels", + values = mapOf( + "ecmwf_ifs04" to "ecmwf_ifs025", + "ecmwf_aifs025" to "ecmwf_aifs025_single", + "arpae_cosmo_seamless" to "italia_meteo_arpae_icon_2i", + "arpae_cosmo_2i" to "italia_meteo_arpae_icon_2i", + "arpae_cosmo_5m" to "italia_meteo_arpae_icon_2i" + ) + ) + } + } + + if (oldVersion < 60005) { + runBlocking { + // V6.0.5 makes so many database migrations that the data from previous versions is unusable + // so let’s force a refresh + weatherRepository.deleteAllWeathers() + + // V6.0.5 restricts Open-Meteo pollen to Europe, and Accu to US/Europe + locationRepository.getAllLocations(withParameters = false) + .forEach { + if (it.pollenSource in arrayOf("openmeteo", "accu")) { + val source = sourceManager.getWeatherSource(it.pollenSource!!) + if (source == null || + !source.isFeatureSupportedForLocation(it, SourceFeature.POLLEN) + ) { + locationRepository.update( + it.copy( + pollenSource = "" + ) + ) + } + } + } + } + } + } + + SettingsManager.getInstance(context).lastVersionCode = BuildConfig.VERSION_CODE + + // Always set up background tasks to ensure they're running + WeatherUpdateJob.setupTask(context) // This will also refresh data immediately + TodayForecastNotificationJob.setupTask(context, false) + TomorrowForecastNotificationJob.setupTask(context, false) + + return oldVersion != 0 + } + + return false + } +} diff --git a/app/src/main/java/org/breezyweather/background/forecast/ForecastNotificationNotifier.kt b/app/src/main/java/org/breezyweather/background/forecast/ForecastNotificationNotifier.kt new file mode 100644 index 0000000..536c9f5 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/forecast/ForecastNotificationNotifier.kt @@ -0,0 +1,179 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.forecast + +import android.app.Notification +import android.content.Context +import android.graphics.drawable.Icon +import android.os.Build +import androidx.core.app.NotificationCompat +import breezyweather.domain.location.model.Location +import breezyweather.domain.weather.model.Daily +import breezyweather.domain.weather.reference.WeatherCode +import org.breezyweather.R +import org.breezyweather.common.extensions.cancelNotification +import org.breezyweather.common.extensions.formatMeasure +import org.breezyweather.common.extensions.notificationBuilder +import org.breezyweather.common.extensions.notify +import org.breezyweather.common.extensions.toBitmap +import org.breezyweather.domain.location.model.isDaylight +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.remoteviews.Notifications +import org.breezyweather.remoteviews.presenters.AbstractRemoteViewsPresenter +import org.breezyweather.ui.theme.resource.ResourceHelper +import org.breezyweather.ui.theme.resource.ResourcesProviderFactory +import org.breezyweather.unit.formatting.UnitWidth +import org.breezyweather.unit.temperature.TemperatureUnit + +class ForecastNotificationNotifier( + private val context: Context, +) { + + private val progressNotificationBuilder = context + .notificationBuilder(Notifications.CHANNEL_FORECAST) { + setSmallIcon(R.drawable.ic_running_in_background) + setAutoCancel(false) + setOngoing(true) + setOnlyAlertOnce(true) + } + + private val completeNotificationBuilder = context + .notificationBuilder(Notifications.CHANNEL_FORECAST) { + setAutoCancel(false) + } + + fun showProgress(): Notification { + return progressNotificationBuilder + // prevent Android from muting notifications ('muting recently noisy') + // and only play a sound for the actual forecast notification + .setSilent(true) + .setContentTitle(context.getString(R.string.notification_running_in_background)) + .build() + } + + fun showComplete(location: Location, today: Boolean) { + context.cancelNotification( + if (today) { + Notifications.ID_UPDATING_TODAY_FORECAST + } else { + Notifications.ID_UPDATING_TOMORROW_FORECAST + } + ) + + val weather = location.weather ?: return + val daily = (if (today) weather.today else weather.tomorrow) ?: return + + val provider = ResourcesProviderFactory.newInstance + + val daytime: Boolean = if (today) location.isDaylight else true + val weatherCode: WeatherCode? = if (today) { + if (daytime) daily.day?.weatherCode else daily.night?.weatherCode + } else { + daily.day?.weatherCode + } + val temperatureUnit = SettingsManager.getInstance(context).getTemperatureUnit(context) + + val notification: Notification = with(completeNotificationBuilder) { + priority = NotificationCompat.PRIORITY_MAX + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + setSubText( + if (today) { + context.getString(R.string.daily_today_short) + } else { + context.getString(R.string.daily_tomorrow_short) + } + ) + setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE) + setAutoCancel(true) + setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) + setSmallIcon(ResourceHelper.getDefaultMinimalXmlIconId(weatherCode, daytime)) + weatherCode?.let { + setLargeIcon(ResourceHelper.getWeatherIcon(provider, it, daytime).toBitmap()) + } + + setContentTitle(getDayString(daily, temperatureUnit)) + setContentText(getNightString(daily, temperatureUnit)) + setStyle( + NotificationCompat.BigTextStyle() + .bigText( + getDayString(daily, temperatureUnit) + + "\n\n" + + getNightString(daily, temperatureUnit) + ) + // do not show any title when expanding the notification + .setBigContentTitle("") + ) + setContentIntent( + AbstractRemoteViewsPresenter.getWeatherPendingIntent( + context, + null, + if (today) { + Notifications.ID_TODAY_FORECAST + } else { + Notifications.ID_TOMORROW_FORECAST + } + ) + ) + }.build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && + weather.current?.weatherCode != null + ) { + try { + notification.javaClass + .getMethod("setSmallIcon", Icon::class.java) + .invoke( + notification, + ResourceHelper.getMinimalIcon( + provider, + weather.current!!.weatherCode!!, + daytime + ) + ) + } catch (ignore: Exception) { + // do nothing. + } + } + + context.notify( + if (today) Notifications.ID_TODAY_FORECAST else Notifications.ID_TOMORROW_FORECAST, + notification + ) + } + + private fun getDayString(daily: Daily, temperatureUnit: TemperatureUnit) = + context.getString(R.string.daytime) + + context.getString(R.string.colon_separator) + + daily.day?.temperature?.temperature?.formatMeasure( + context, + temperatureUnit, + valueWidth = UnitWidth.NARROW + ) + + context.getString(R.string.dot_separator) + + daily.day?.weatherText + + private fun getNightString(daily: Daily, temperatureUnit: TemperatureUnit) = + context.getString(R.string.nighttime) + + context.getString(R.string.colon_separator) + + daily.night?.temperature?.temperature?.formatMeasure( + context, + temperatureUnit, + valueWidth = UnitWidth.NARROW + ) + + context.getString(R.string.dot_separator) + + daily.night?.weatherText +} diff --git a/app/src/main/java/org/breezyweather/background/forecast/TodayForecastNotificationJob.kt b/app/src/main/java/org/breezyweather/background/forecast/TodayForecastNotificationJob.kt new file mode 100644 index 0000000..e7128d6 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/forecast/TodayForecastNotificationJob.kt @@ -0,0 +1,148 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.forecast + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkerParameters +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.breezyweather.common.extensions.cancelNotification +import org.breezyweather.common.extensions.hasNotificationPermission +import org.breezyweather.common.extensions.isRunning +import org.breezyweather.common.extensions.setForegroundSafely +import org.breezyweather.common.extensions.workManager +import org.breezyweather.common.utils.helpers.LogHelper +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.remoteviews.Notifications +import java.util.Calendar +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +@HiltWorker +class TodayForecastNotificationJob @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val locationRepository: LocationRepository, + private val weatherRepository: WeatherRepository, +) : CoroutineWorker(context, workerParams) { + + private val notifier = ForecastNotificationNotifier(context) + + override suspend fun doWork(): Result { + setForegroundSafely() + + return try { + if (SettingsManager.getInstance(context).isTodayForecastEnabled) { + val location = locationRepository.getFirstLocation(withParameters = false) + if (location != null) { + notifier.showComplete( + location.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ), + today = true + ) + } + } + Result.success() + } catch (e: Exception) { + e.message?.let { LogHelper.log(msg = it) } + Result.failure() + } finally { + context.cancelNotification(Notifications.ID_UPDATING_TODAY_FORECAST) + + // Add a new job in 24 hours + setupTask(context, nextDay = true) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return ForegroundInfo( + Notifications.ID_UPDATING_TODAY_FORECAST, + notifier.showProgress(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } else { + 0 + } + ) + } + + companion object { + private const val TAG = "ForecastNotificationToday" + + fun isRunning(context: Context): Boolean { + return context.workManager.isRunning(TAG) + } + + fun setupTask(context: Context, nextDay: Boolean) { + val settings = SettingsManager.getInstance(context) + if (settings.isTodayForecastEnabled) { + if (context.hasNotificationPermission) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay( + getForecastAlarmDelayInMinutes(settings.todayForecastTime, nextDay), + TimeUnit.MINUTES + ) + .addTag(TAG) + .build() + context.workManager.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) + return + } else { + settings.isTodayForecastEnabled = false + } + } + context.workManager.cancelUniqueWork(TAG) + } + + fun stop(context: Context) { + context.workManager.cancelUniqueWork(TAG) + } + + private fun getForecastAlarmDelayInMinutes(time: String, nextDay: Boolean): Long { + val realTimes = intArrayOf( + Calendar.getInstance()[Calendar.HOUR_OF_DAY], + Calendar.getInstance()[Calendar.MINUTE] + ) + val setTimes = intArrayOf( + time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0].toInt(), + time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1].toInt() + ) + var delay = (setTimes[0] - realTimes[0]).hours.inWholeMinutes + (setTimes[1] - realTimes[1]) + if (delay <= 0 || nextDay) { + delay += 1.days.inWholeMinutes + } + return delay + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/forecast/TomorrowForecastNotificationJob.kt b/app/src/main/java/org/breezyweather/background/forecast/TomorrowForecastNotificationJob.kt new file mode 100644 index 0000000..58d8259 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/forecast/TomorrowForecastNotificationJob.kt @@ -0,0 +1,148 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.forecast + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkerParameters +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import org.breezyweather.common.extensions.cancelNotification +import org.breezyweather.common.extensions.hasNotificationPermission +import org.breezyweather.common.extensions.isRunning +import org.breezyweather.common.extensions.setForegroundSafely +import org.breezyweather.common.extensions.workManager +import org.breezyweather.common.utils.helpers.LogHelper +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.remoteviews.Notifications +import java.util.Calendar +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +@HiltWorker +class TomorrowForecastNotificationJob @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val locationRepository: LocationRepository, + private val weatherRepository: WeatherRepository, +) : CoroutineWorker(context, workerParams) { + + private val notifier = ForecastNotificationNotifier(context) + + override suspend fun doWork(): Result { + setForegroundSafely() + + return try { + if (SettingsManager.getInstance(context).isTomorrowForecastEnabled) { + val location = locationRepository.getFirstLocation(withParameters = false) + if (location != null) { + notifier.showComplete( + location.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ), + today = false + ) + } + } + Result.success() + } catch (e: Exception) { + e.message?.let { LogHelper.log(msg = it) } + Result.failure() + } finally { + context.cancelNotification(Notifications.ID_UPDATING_TOMORROW_FORECAST) + + // Add a new job in 24 hours + setupTask(context, nextDay = true) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return ForegroundInfo( + Notifications.ID_UPDATING_TOMORROW_FORECAST, + notifier.showProgress(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } else { + 0 + } + ) + } + + companion object { + private const val TAG = "ForecastNotificationTomorrow" + + fun isRunning(context: Context): Boolean { + return context.workManager.isRunning(TAG) + } + + fun setupTask(context: Context, nextDay: Boolean) { + val settings = SettingsManager.getInstance(context) + if (settings.isTomorrowForecastEnabled) { + if (context.hasNotificationPermission) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay( + getForecastAlarmDelayInMinutes(settings.tomorrowForecastTime, nextDay), + TimeUnit.MINUTES + ) + .addTag(TAG) + .build() + context.workManager.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) + return + } else { + settings.isTomorrowForecastEnabled = false + } + } + context.workManager.cancelUniqueWork(TAG) + } + + fun stop(context: Context) { + context.workManager.cancelUniqueWork(TAG) + } + + private fun getForecastAlarmDelayInMinutes(time: String, nextDay: Boolean): Long { + val realTimes = intArrayOf( + Calendar.getInstance()[Calendar.HOUR_OF_DAY], + Calendar.getInstance()[Calendar.MINUTE] + ) + val setTimes = intArrayOf( + time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[0].toInt(), + time.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()[1].toInt() + ) + var delay = (setTimes[0] - realTimes[0]).hours.inWholeMinutes + (setTimes[1] - realTimes[1]) + if (delay <= 0 || nextDay) { + delay += 1.days.inWholeMinutes + } + return delay + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/interfaces/TileService.kt b/app/src/main/java/org/breezyweather/background/interfaces/TileService.kt new file mode 100644 index 0000000..3bfea9f --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/interfaces/TileService.kt @@ -0,0 +1,123 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.interfaces + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.breezyweather.common.extensions.formatMeasure +import org.breezyweather.domain.location.model.isDaylight +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.ui.main.MainActivity +import org.breezyweather.ui.theme.resource.ResourceHelper +import org.breezyweather.ui.theme.resource.ResourcesProviderFactory +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext + +/** + * Tile service. + * TODO: Memory leak + */ +@AndroidEntryPoint +@RequiresApi(Build.VERSION_CODES.N) +class TileService : TileService(), CoroutineScope { + + override val coroutineContext: CoroutineContext + get() = Dispatchers.Main + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + override fun onTileAdded() { + refreshTile(this, qsTile) + } + + override fun onTileRemoved() { + // do nothing. + } + + override fun onStartListening() { + refreshTile(this, qsTile) + } + + override fun onStopListening() { + refreshTile(this, qsTile) + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + override fun onClick() { + val intent = Intent(MainActivity.ACTION_MAIN) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + this.startActivityAndCollapse( + PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + ) + } else { + @Suppress("DEPRECATION") + this.startActivityAndCollapse(intent) + } + } + + private fun refreshTile(context: Context, tile: Tile?) { + if (tile == null) return + launch { + val location = locationRepository.getFirstLocation(withParameters = false) ?: return@launch + val locationRefreshed = location.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, // isDaylight + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ) + locationRefreshed.weather?.current?.let { current -> + tile.apply { + current.weatherCode?.let { + icon = ResourceHelper.getMinimalIcon( + ResourcesProviderFactory.newInstance, + it, + locationRefreshed.isDaylight + ) + } + tile.label = current.temperature?.temperature?.formatMeasure( + context, + SettingsManager.getInstance(context).getTemperatureUnit(context) + ) + state = Tile.STATE_INACTIVE + } + tile.updateTile() + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/provider/WeatherContentProvider.kt b/app/src/main/java/org/breezyweather/background/provider/WeatherContentProvider.kt new file mode 100644 index 0000000..f066703 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/provider/WeatherContentProvider.kt @@ -0,0 +1,1014 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, either version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.provider + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.UriMatcher +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.ColorInt +import androidx.core.graphics.ColorUtils +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import breezyweather.domain.location.model.Location +import breezyweather.domain.source.SourceFeature +import breezyweather.domain.weather.model.AirQuality +import breezyweather.domain.weather.model.Alert +import breezyweather.domain.weather.model.Current +import breezyweather.domain.weather.model.Daily +import breezyweather.domain.weather.model.HalfDay +import breezyweather.domain.weather.model.Hourly +import breezyweather.domain.weather.model.Minutely +import breezyweather.domain.weather.model.Normals +import breezyweather.domain.weather.model.Pollen +import breezyweather.domain.weather.model.PrecipitationDuration +import breezyweather.domain.weather.model.PrecipitationProbability +import breezyweather.domain.weather.model.UV +import breezyweather.domain.weather.model.Wind +import breezyweather.domain.weather.reference.Month +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.breezyweather.BuildConfig +import org.breezyweather.common.extensions.getBeaufortScaleColor +import org.breezyweather.common.extensions.getBeaufortScaleStrength +import org.breezyweather.common.extensions.getCloudCoverDescription +import org.breezyweather.common.extensions.getVisibilityDescription +import org.breezyweather.common.extensions.gzipCompress +import org.breezyweather.common.extensions.roundDecimals +import org.breezyweather.common.source.HttpSource +import org.breezyweather.common.source.PollenIndexSource +import org.breezyweather.datasharing.json.BreezyAirQuality +import org.breezyweather.datasharing.json.BreezyAlert +import org.breezyweather.datasharing.json.BreezyBulletin +import org.breezyweather.datasharing.json.BreezyCurrent +import org.breezyweather.datasharing.json.BreezyDaily +import org.breezyweather.datasharing.json.BreezyDailyUnit +import org.breezyweather.datasharing.json.BreezyDegreeDay +import org.breezyweather.datasharing.json.BreezyHalfDay +import org.breezyweather.datasharing.json.BreezyHourly +import org.breezyweather.datasharing.json.BreezyMinutely +import org.breezyweather.datasharing.json.BreezyNormals +import org.breezyweather.datasharing.json.BreezyPollen +import org.breezyweather.datasharing.json.BreezyPollutant +import org.breezyweather.datasharing.json.BreezyPrecipitation +import org.breezyweather.datasharing.json.BreezyPrecipitationDuration +import org.breezyweather.datasharing.json.BreezyPrecipitationProbability +import org.breezyweather.datasharing.json.BreezySource +import org.breezyweather.datasharing.json.BreezyTemperature +import org.breezyweather.datasharing.json.BreezyUnit +import org.breezyweather.datasharing.json.BreezyWeather +import org.breezyweather.datasharing.json.BreezyWind +import org.breezyweather.datasharing.provider.ProviderLocation +import org.breezyweather.datasharing.provider.ProviderUri +import org.breezyweather.datasharing.provider.ProviderVersion +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.domain.source.resourceName +import org.breezyweather.domain.weather.index.PollutantIndex +import org.breezyweather.domain.weather.model.getColor +import org.breezyweather.domain.weather.model.getConcentration +import org.breezyweather.domain.weather.model.getIndex +import org.breezyweather.domain.weather.model.getIndexName +import org.breezyweather.domain.weather.model.getLevel +import org.breezyweather.domain.weather.model.getMinutelyDescription +import org.breezyweather.domain.weather.model.getMinutelyTitle +import org.breezyweather.domain.weather.model.getName +import org.breezyweather.domain.weather.model.getUVColor +import org.breezyweather.domain.weather.model.validPollens +import org.breezyweather.domain.weather.model.validPollutants +import org.breezyweather.sources.SourceManager +import org.breezyweather.unit.distance.Distance +import org.breezyweather.unit.distance.DistanceUnit +import org.breezyweather.unit.pollen.PollenConcentrationUnit +import org.breezyweather.unit.precipitation.Precipitation +import org.breezyweather.unit.precipitation.PrecipitationUnit +import org.breezyweather.unit.pressure.Pressure +import org.breezyweather.unit.pressure.PressureUnit +import org.breezyweather.unit.ratio.Ratio +import org.breezyweather.unit.speed.Speed +import org.breezyweather.unit.speed.SpeedUnit +import org.breezyweather.unit.temperature.Temperature +import org.breezyweather.unit.temperature.TemperatureUnit +import kotlin.time.Duration + +class WeatherContentProvider : ContentProvider() { + + private val hexFormat: HexFormat = HexFormat { + upperCase = false + number { + prefix = "#" + minLength = 6 + removeLeadingZeros = true + } + } + + @InstallIn(SingletonComponent::class) + @EntryPoint + interface LocationRepositoryContentProviderEntryPoint { + fun locationRepository(): LocationRepository + } + + private fun getLocationRepository(appContext: Context): LocationRepository { + val hiltEntryPoint = EntryPointAccessors.fromApplication( + appContext, + LocationRepositoryContentProviderEntryPoint::class.java + ) + return hiltEntryPoint.locationRepository() + } + + @InstallIn(SingletonComponent::class) + @EntryPoint + interface WeatherRepositoryContentProviderEntryPoint { + fun weatherRepository(): WeatherRepository + } + + private fun getWeatherRepository(appContext: Context): WeatherRepository { + val hiltEntryPoint = EntryPointAccessors.fromApplication( + appContext, + WeatherRepositoryContentProviderEntryPoint::class.java + ) + return hiltEntryPoint.weatherRepository() + } + + @InstallIn(SingletonComponent::class) + @EntryPoint + interface SourceManagerContentProviderEntryPoint { + fun sourceManager(): SourceManager + } + + private fun getSourceManager(appContext: Context): SourceManager { + val hiltEntryPoint = EntryPointAccessors.fromApplication( + appContext, + SourceManagerContentProviderEntryPoint::class.java + ) + return hiltEntryPoint.sourceManager() + } + + override fun onCreate(): Boolean { + return true + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // Disable the content provider on SDK < 23 since it grants dangerous + // permissions at install-time + Log.w(TAG, "Content provider read is only available for SDK >= 23") + return null + } + + if (!isAllowedPackage()) { + Log.w(TAG, "Content provider is disabled for this app") + return null + } + + return when (uriMatcher.match(uri)) { + ProviderUri.VERSION_CODE -> queryVersion() + ProviderUri.LOCATIONS_CODE -> queryLocations( + limit = uri.getQueryParameter("limit")?.toIntOrNull() + ) + ProviderUri.WEATHER_CODE -> queryWeather( + selection = selection, + withDailyQuery = uri.getQueryParameter("withDaily"), + withHourlyQuery = uri.getQueryParameter("withHourly"), + withMinutelyQuery = uri.getQueryParameter("withMinutely"), + withAlertsQuery = uri.getQueryParameter("withAlerts"), + withNormalsQuery = uri.getQueryParameter("withNormals"), + temperatureUnitQuery = uri.getQueryParameter("temperatureUnit"), + precipitationUnitQuery = uri.getQueryParameter("precipitationUnit"), + speedUnitQuery = uri.getQueryParameter("speedUnit"), + distanceUnitQuery = uri.getQueryParameter("distanceUnit"), + pressureUnitQuery = uri.getQueryParameter("pressureUnit") + ) + else -> { + Log.w(TAG, "Unrecognized URI $uri") + null + } + } + } + + private fun isAllowedPackage(): Boolean { + // Can check callingPackage here + return true + } + + private fun queryVersion(): Cursor { + val columns = arrayOf(ProviderVersion.COLUMN_MAJOR, ProviderVersion.COLUMN_MINOR) + val matrixCursor = MatrixCursor(columns).apply { + addRow(arrayOf(ProviderVersion.MAJOR, ProviderVersion.MINOR)) + } + return matrixCursor + } + + private fun queryLocations( + limit: Int?, + ): Cursor { + val columns = arrayOf( + ProviderLocation.COLUMN_ID, + ProviderLocation.COLUMN_LATITUDE, + ProviderLocation.COLUMN_LONGITUDE, + ProviderLocation.COLUMN_IS_CURRENT_POSITION, + ProviderLocation.COLUMN_TIMEZONE, + ProviderLocation.COLUMN_CUSTOM_NAME, + ProviderLocation.COLUMN_COUNTRY, + ProviderLocation.COLUMN_COUNTRY_CODE, + ProviderLocation.COLUMN_ADMIN1, + ProviderLocation.COLUMN_ADMIN1_CODE, + ProviderLocation.COLUMN_ADMIN2, + ProviderLocation.COLUMN_ADMIN2_CODE, + ProviderLocation.COLUMN_ADMIN3, + ProviderLocation.COLUMN_ADMIN3_CODE, + ProviderLocation.COLUMN_ADMIN4, + ProviderLocation.COLUMN_ADMIN4_CODE, + ProviderLocation.COLUMN_CITY, + ProviderLocation.COLUMN_DISTRICT, + ProviderLocation.COLUMN_WEATHER + ) + val locations = runBlocking { + if (limit != null && limit > 0) { + getLocationRepository(context!!).getXLocations(limit, withParameters = false) + } else { + getLocationRepository(context!!).getAllLocations(withParameters = false) + } + } + val matrixCursor = MatrixCursor(columns).apply { + locations.forEach { location -> + addRow( + arrayOf( + location.formattedId, + location.latitude, + location.longitude, + location.isCurrentPosition, + location.timeZone.id, + location.customName, + location.country, + location.countryCode, + location.admin1, + location.admin1Code, + location.admin2, + location.admin2Code, + location.admin3, + location.admin3Code, + location.admin4, + location.admin4Code, + location.city, + location.district, + null + ) + ) + } + } + return matrixCursor + } + + private fun queryWeather( + selection: String?, + withDailyQuery: String?, + withHourlyQuery: String?, + withMinutelyQuery: String?, + withAlertsQuery: String?, + withNormalsQuery: String?, + temperatureUnitQuery: String?, + precipitationUnitQuery: String?, + speedUnitQuery: String?, + distanceUnitQuery: String?, + pressureUnitQuery: String?, + ): Cursor { + val columns = arrayOf( + ProviderLocation.COLUMN_ID, + ProviderLocation.COLUMN_LATITUDE, + ProviderLocation.COLUMN_LONGITUDE, + ProviderLocation.COLUMN_IS_CURRENT_POSITION, + ProviderLocation.COLUMN_TIMEZONE, + ProviderLocation.COLUMN_CUSTOM_NAME, + ProviderLocation.COLUMN_COUNTRY, + ProviderLocation.COLUMN_COUNTRY_CODE, + ProviderLocation.COLUMN_ADMIN1, + ProviderLocation.COLUMN_ADMIN1_CODE, + ProviderLocation.COLUMN_ADMIN2, + ProviderLocation.COLUMN_ADMIN2_CODE, + ProviderLocation.COLUMN_ADMIN3, + ProviderLocation.COLUMN_ADMIN3_CODE, + ProviderLocation.COLUMN_ADMIN4, + ProviderLocation.COLUMN_ADMIN4_CODE, + ProviderLocation.COLUMN_CITY, + ProviderLocation.COLUMN_DISTRICT, + ProviderLocation.COLUMN_WEATHER + ) + val matrixCursor = MatrixCursor(columns) + if (selection == null) return matrixCursor + + val regex = Regex("id *= *(-?[0-9.]+)&(-?[0-9.]+)&([a-z]+)") + val matching = regex.find(selection) + if (matching == null) return matrixCursor + + val locationId = "${matching.groups[1]!!.value}&${matching.groups[2]!!.value}&${matching.groups[3]!!.value}" + + val location = runBlocking { + getLocationRepository(context!!) + .getLocation(locationId, withParameters = false) + ?.copy( + weather = getWeatherRepository(context!!) + .getWeatherByLocationId( + locationId, + withDaily = !withDailyQuery.equals("false", ignoreCase = true), + withHourly = !withHourlyQuery.equals("false", ignoreCase = true), + withMinutely = !withMinutelyQuery.equals("false", ignoreCase = true), + withAlerts = !withAlertsQuery.equals("false", ignoreCase = true), + withNormals = !withNormalsQuery.equals("false", ignoreCase = true) + ) + ) + } + if (location != null) { + val pollenIndexSource = location.pollenSource?.let { + getSourceManager(context!!).getPollenIndexSource(it) + } + matrixCursor.addRow( + arrayOf( + location.formattedId, + location.latitude, + location.longitude, + location.isCurrentPosition, + location.timeZone.id, + location.customName, + location.country, + location.countryCode, + location.admin1, + location.admin1Code, + location.admin2, + location.admin2Code, + location.admin3, + location.admin3Code, + location.admin4, + location.admin4Code, + location.city, + location.district, + Json.encodeToString( + getWeatherData( + location, + pollenIndexSource, + temperatureUnitQuery, + precipitationUnitQuery, + speedUnitQuery, + distanceUnitQuery, + pressureUnitQuery + ) + ).gzipCompress() + ) + ) + } + return matrixCursor + } + + private fun getWeatherData( + location: Location, + pollenIndexSource: PollenIndexSource?, + temperatureUnitQuery: String?, + precipitationUnitQuery: String?, + speedUnitQuery: String?, + distanceUnitQuery: String?, + pressureUnitQuery: String?, + ): BreezyWeather? { + val settings = SettingsManager.getInstance(context!!) + val temperatureUnit = temperatureUnitQuery?.let { TemperatureUnit.getUnit(it) } + ?: settings.getTemperatureUnit(context!!) + val precipitationUnit = precipitationUnitQuery?.let { PrecipitationUnit.getUnit(it) } + ?: settings.getPrecipitationUnit(context!!) + val snowfallUnit = precipitationUnitQuery?.let { PrecipitationUnit.getUnit(it) } + ?: settings.getPrecipitationUnit(context!!) + val speedUnit = speedUnitQuery?.let { SpeedUnit.getUnit(it) } + ?: settings.getSpeedUnit(context!!) + val distanceUnit = distanceUnitQuery?.let { DistanceUnit.getUnit(it) } + ?: settings.getDistanceUnit(context!!) + val pressureUnit = pressureUnitQuery?.let { PressureUnit.getUnit(it) } + ?: settings.getPressureUnit(context!!) + + return location.weather?.let { weather -> + BreezyWeather( + refreshTime = weather.base.refreshTime?.time, + bulletin = BreezyBulletin( + weekly = weather.current?.dailyForecast, + nextHours = weather.current?.hourlyForecast, + nowcastingHeadline = weather.getMinutelyTitle(context!!), + nowcastingDescription = weather.getMinutelyDescription(context!!, location) + ), + current = getCurrent( + weather.current, + temperatureUnit, + speedUnit, + distanceUnit, + pressureUnit + ), + daily = getDaily( + weather.dailyForecast, + temperatureUnit, + precipitationUnit, + snowfallUnit, + speedUnit, + distanceUnit, + pressureUnit, + pollenIndexSource + ), + hourly = getHourly( + weather.hourlyForecast, + temperatureUnit, + precipitationUnit, + snowfallUnit, + speedUnit, + distanceUnit, + pressureUnit + ), + minutely = getMinutely( + weather.minutelyForecast, + precipitationUnit + ), + alerts = getAlerts( + weather.alertList + ), + normals = getNormals( + weather.normals, + temperatureUnit + ), + sources = getSources(location) + ) + } + } + + private fun getCurrent( + current: Current?, + temperatureUnit: TemperatureUnit, + speedUnit: SpeedUnit, + distanceUnit: DistanceUnit, + pressureUnit: PressureUnit, + ): BreezyCurrent? { + return current?.let { cur -> + BreezyCurrent( + weatherText = cur.weatherText, + weatherCode = cur.weatherCode?.id, + temperature = getTemperature(cur.temperature, temperatureUnit), + wind = getWind(cur.wind, speedUnit), + uV = getUV(cur.uV), + airQuality = getAirQuality(cur.airQuality), + relativeHumidity = getPercentUnit(cur.relativeHumidity), + dewPoint = getTemperatureUnit(cur.dewPoint, temperatureUnit), + pressure = getPressureUnit(cur.pressure, pressureUnit), + cloudCover = getPercentUnit(cur.cloudCover, cur.cloudCover?.getCloudCoverDescription(context!!)), + visibility = getDistanceUnit(cur.visibility, distanceUnit), + ceiling = getDistanceUnit(cur.ceiling, distanceUnit) + ) + } + } + + private fun getDaily( + daily: List?, + temperatureUnit: TemperatureUnit, + precipitationUnit: PrecipitationUnit, + snowfallUnit: PrecipitationUnit, + speedUnit: SpeedUnit, + distanceUnit: DistanceUnit, + pressureUnit: PressureUnit, + pollenIndexSource: PollenIndexSource?, + ): List? { + return daily?.map { day -> + BreezyDaily( + date = day.date.time, + day = getHalfDay( + day.day, + temperatureUnit, + precipitationUnit, + snowfallUnit, + speedUnit + ), + night = getHalfDay( + day.night, + temperatureUnit, + precipitationUnit, + snowfallUnit, + speedUnit + ), + degreeDay = day.degreeDay?.let { + BreezyDegreeDay( + heating = getDegreeDayTemperatureUnit( + it.heating, + temperatureUnit + ), + cooling = getDegreeDayTemperatureUnit( + it.cooling, + temperatureUnit + ) + ) + }, + /*sun = getAstro(day.sun), + twilight = getAstro(day.twilight), + moon = getAstro(day.moon), + moonPhase = day.moonPhase?.let { + BreezyMoonPhase( + angle = it.angle, + description = it.getDescription(context!!) + ) + },*/ + airQuality = getAirQuality(day.airQuality), + pollen = getPollen(day.pollen, pollenIndexSource), + uV = getUV(day.uV), + sunshineDuration = getDurationUnit(day.sunshineDuration), + relativeHumidity = BreezyDailyUnit( + avg = getPercentUnit(day.relativeHumidity?.average), + max = getPercentUnit(day.relativeHumidity?.max), + min = getPercentUnit(day.relativeHumidity?.min), + summary = null + ), + dewPoint = BreezyDailyUnit( + avg = getTemperatureUnit(day.dewPoint?.average, temperatureUnit), + max = getTemperatureUnit(day.dewPoint?.max, temperatureUnit), + min = getTemperatureUnit(day.dewPoint?.min, temperatureUnit), + summary = null + ), + pressure = BreezyDailyUnit( + avg = getPressureUnit(day.pressure?.average, pressureUnit), + max = getPressureUnit(day.pressure?.max, pressureUnit), + min = getPressureUnit(day.pressure?.min, pressureUnit), + summary = null + ), + cloudCover = BreezyDailyUnit( + avg = getPercentUnit( + day.cloudCover?.average, + day.cloudCover?.average?.getCloudCoverDescription(context!!) + ), + max = getPercentUnit( + day.cloudCover?.max, + day.cloudCover?.max?.getCloudCoverDescription(context!!) + ), + min = getPercentUnit( + day.cloudCover?.min, + day.cloudCover?.min?.getCloudCoverDescription(context!!) + ), + summary = null + ), + visibility = BreezyDailyUnit( + avg = getDistanceUnit(day.visibility?.average, distanceUnit), + max = getDistanceUnit(day.visibility?.max, distanceUnit), + min = getDistanceUnit(day.visibility?.min, distanceUnit), + summary = null + ) + ) + } + } + + private fun getHourly( + hourly: List?, + temperatureUnit: TemperatureUnit, + precipitationUnit: PrecipitationUnit, + snowfallUnit: PrecipitationUnit, + speedUnit: SpeedUnit, + distanceUnit: DistanceUnit, + pressureUnit: PressureUnit, + ): List? { + return hourly?.map { hour -> + BreezyHourly( + date = hour.date.time, + isDaylight = hour.isDaylight, + weatherText = hour.weatherText, + weatherCode = hour.weatherCode?.id, + temperature = getTemperature(hour.temperature, temperatureUnit), + precipitation = getPrecipitation(hour.precipitation, precipitationUnit, snowfallUnit), + precipitationProbability = getPrecipitationProbability(hour.precipitationProbability), + wind = getWind(hour.wind, speedUnit), + airQuality = getAirQuality(hour.airQuality), + uV = getUV(hour.uV), + relativeHumidity = getPercentUnit(hour.relativeHumidity), + dewPoint = getTemperatureUnit(hour.dewPoint, temperatureUnit), + pressure = getPressureUnit(hour.pressure, pressureUnit), + cloudCover = getPercentUnit(hour.cloudCover, hour.cloudCover?.getCloudCoverDescription(context!!)), + visibility = getDistanceUnit(hour.visibility, distanceUnit) + ) + } + } + + private fun getMinutely( + minutely: List?, + precipitationUnit: PrecipitationUnit, + ): List? { + return minutely?.map { minute -> + BreezyMinutely( + date = minute.date.time, + minuteInterval = minute.minuteInterval, + precipitationIntensity = getPrecipitationUnit( + minute.precipitationIntensity, + precipitationUnit + ) + ) + } + } + + private fun getNormals( + normals: Map?, + temperatureUnit: TemperatureUnit, + ): Map? { + return normals?.entries?.associate { + it.key.value to BreezyNormals( + daytimeTemperature = getTemperatureUnit(it.value.daytimeTemperature, temperatureUnit), + nighttimeTemperature = getTemperatureUnit(it.value.nighttimeTemperature, temperatureUnit) + ) + } + } + + private fun getSources( + location: Location, + ): Map? { + return mapOf( + SourceFeature.FORECAST to location.forecastSource, + SourceFeature.CURRENT to location.currentSource, + SourceFeature.AIR_QUALITY to location.airQualitySource, + SourceFeature.POLLEN to location.pollenSource, + SourceFeature.MINUTELY to location.minutelySource, + SourceFeature.ALERT to location.alertSource, + SourceFeature.NORMALS to location.normalsSource, + SourceFeature.REVERSE_GEOCODING to location.reverseGeocodingSource + ).filter { !it.value.isNullOrEmpty() }.mapNotNull { + getSourceManager(context!!).getFeatureSource(it.value!!)?.let { source -> + if (source.supportedFeatures.containsKey(it.key)) { + it.key.id to BreezySource( + type = context!!.getString(it.key.resourceName), + text = source.supportedFeatures[it.key], + links = if (source is HttpSource) { + source.attributionLinks.filter { link -> + source.supportedFeatures[it.key]?.contains(link.key) == true + } + } else { + null + } + ) + } else { + null + } + } + }.toMap() + } + + /*private fun getAstro(astro: Astro?): BreezyAstro? { + return astro?.let { + BreezyAstro( + riseDate = it.riseDate?.time, + setDate = it.setDate?.time + ) + } + }*/ + + private fun getHalfDay( + halfDay: HalfDay?, + temperatureUnit: TemperatureUnit, + precipitationUnit: PrecipitationUnit, + snowfallUnit: PrecipitationUnit, + speedUnit: SpeedUnit, + ): BreezyHalfDay? { + return halfDay?.let { hd -> + BreezyHalfDay( + weatherCode = hd.weatherCode?.id, + weatherText = hd.weatherText, + weatherSummary = hd.weatherSummary, + temperature = getTemperature(hd.temperature, temperatureUnit), + precipitation = getPrecipitation(hd.precipitation, precipitationUnit, snowfallUnit), + precipitationProbability = getPrecipitationProbability(hd.precipitationProbability), + precipitationDuration = getPrecipitationDuration(hd.precipitationDuration), + wind = getWind(hd.wind, speedUnit) + ) + } + } + + private fun getTemperature( + temperature: breezyweather.domain.weather.model.Temperature?, + temperatureUnit: TemperatureUnit, + ): BreezyTemperature? { + return temperature?.let { + BreezyTemperature( + temperature = getTemperatureUnit(it.temperature, temperatureUnit), + sourceFeelsLike = getTemperatureUnit(it.sourceFeelsLike, temperatureUnit), + computedApparent = getTemperatureUnit(it.computedApparent, temperatureUnit), + computedWindChill = getTemperatureUnit(it.computedWindChill, temperatureUnit), + computedHumidex = getTemperatureUnit(it.computedHumidex, temperatureUnit) + ) + } + } + + private fun getTemperatureUnit( + temperature: Temperature?, + temperatureUnit: TemperatureUnit, + ): BreezyUnit? { + return temperature?.let { + BreezyUnit( + value = it.toDouble(temperatureUnit).roundDecimals(temperatureUnit.decimals.long), + unit = temperatureUnit.id + ) + } + } + + private fun getDegreeDayTemperatureUnit( + temperature: Temperature?, + temperatureUnit: TemperatureUnit, + ): BreezyUnit? { + return temperature?.let { + BreezyUnit( + value = it.toDoubleDeviation(temperatureUnit).roundDecimals(temperatureUnit.decimals.long), + unit = temperatureUnit.id + ) + } + } + + private fun getPrecipitation( + precipitation: breezyweather.domain.weather.model.Precipitation?, + precipitationUnit: PrecipitationUnit, + snowfallUnit: PrecipitationUnit, + ): BreezyPrecipitation? { + return precipitation?.let { + BreezyPrecipitation( + total = getPrecipitationUnit(it.total, precipitationUnit), + thunderstorm = getPrecipitationUnit(it.thunderstorm, precipitationUnit), + rain = getPrecipitationUnit(it.rain, precipitationUnit), + snow = getPrecipitationUnit(it.snow, snowfallUnit), + ice = getPrecipitationUnit(it.ice, precipitationUnit) + ) + } + } + + private fun getPrecipitationUnit( + precipitation: Precipitation?, + precipitationUnit: PrecipitationUnit, + ): BreezyUnit? { + return precipitation?.let { + BreezyUnit( + value = it.toDouble(precipitationUnit).roundDecimals(precipitationUnit.decimals.long), + unit = precipitationUnit.id + ) + } + } + + private fun getPrecipitationProbability( + precipitationProbability: PrecipitationProbability?, + ): BreezyPrecipitationProbability? { + return precipitationProbability?.let { + BreezyPrecipitationProbability( + total = getPercentUnit(it.total), + thunderstorm = getPercentUnit(it.thunderstorm), + rain = getPercentUnit(it.rain), + snow = getPercentUnit(it.snow), + ice = getPercentUnit(it.ice) + ) + } + } + + private fun getPrecipitationDuration( + precipitationDuration: PrecipitationDuration?, + ): BreezyPrecipitationDuration? { + return precipitationDuration?.let { + BreezyPrecipitationDuration( + total = getDurationUnit(it.total), + thunderstorm = getDurationUnit(it.thunderstorm), + rain = getDurationUnit(it.rain), + snow = getDurationUnit(it.snow), + ice = getDurationUnit(it.ice) + ) + } + } + + private fun getWind( + wind: Wind?, + speedUnit: SpeedUnit, + ): BreezyWind? { + return wind?.let { + BreezyWind( + degree = wind.degree?.roundDecimals(1), + speed = getSpeedUnit(wind.speed, speedUnit), + gusts = getSpeedUnit(wind.gusts, speedUnit) + ) + } + } + + private fun getUV( + uV: UV?, + ): BreezyUnit? { + return uV?.index?.let { + BreezyUnit( + value = it.roundDecimals(1), + unit = "uvi", + description = uV.getLevel(context!!), + color = colorToHex(uV.getUVColor(context!!)) + ) + } + } + + private fun getSpeedUnit( + speed: Speed?, + speedUnit: SpeedUnit, + ): BreezyUnit? { + return speed?.let { + BreezyUnit( + value = it.toDouble(speedUnit).roundDecimals(speedUnit.decimals.long), + unit = speedUnit.id, + description = it.getBeaufortScaleStrength(context!!), + color = colorToHex(it.getBeaufortScaleColor(context!!)) + ) + } + } + + private fun getDistanceUnit( + distance: Distance?, + distanceUnit: DistanceUnit, + ): BreezyUnit? { + return distance?.let { + BreezyUnit( + value = it.toDouble(distanceUnit).roundDecimals(distanceUnit.decimals.long), + unit = distanceUnit.id, + description = it.getVisibilityDescription(context!!) + ) + } + } + + private fun getPressureUnit( + pressure: Pressure?, + pressureUnit: PressureUnit, + ): BreezyUnit? { + return pressure?.let { + BreezyUnit( + value = it.toDouble(pressureUnit).roundDecimals(pressureUnit.decimals.long), + unit = pressureUnit.id + ) + } + } + + private fun getPercentUnit( + percent: Ratio?, + description: String? = null, + ): BreezyUnit? { + return percent?.let { + BreezyUnit( + value = it.inPercent.roundDecimals(1), + unit = "percent", + description = description + ) + } + } + + private fun getDurationUnit( + duration: Duration?, + ): BreezyUnit? { + return duration?.let { + BreezyUnit( + value = it.inWholeMinutes.toDouble(), + unit = "m" + ) + } + } + + private fun getAirQuality( + airQuality: AirQuality?, + ): BreezyAirQuality? { + return airQuality?.let { + if (airQuality.isValid) { + BreezyAirQuality( + index = BreezyUnit( + value = airQuality.getIndex()?.toDouble(), + unit = "aqi", + description = airQuality.getName(context!!), + color = colorToHex(airQuality.getColor(context!!)) + ), + pollutants = airQuality.validPollutants.associate { + it.id to BreezyPollutant( + index = BreezyUnit( + value = airQuality.getIndex(it)?.toDouble(), + unit = "aqi", + description = airQuality.getName(context!!, it), + color = colorToHex(airQuality.getColor(context!!, it)) + ), + concentration = BreezyUnit( + value = airQuality.getConcentration(it)?.roundDecimals(1), + unit = PollutantIndex.getUnit(it).id + ) + ) + } + ) + } else { + null + } + } + } + + private fun getPollen( + pollen: Pollen?, + pollenIndexSource: PollenIndexSource?, + ): Map? { + return pollen?.let { + if (it.isValid) { + it.validPollens.associate { component -> + component.id to BreezyPollen( + name = it.getName(context!!, component), + concentration = BreezyUnit( + value = if (pollenIndexSource == null) { + it.getConcentration(component)?.value?.toDouble() + } else { + null + }, + unit = PollenConcentrationUnit.PER_CUBIC_METER.id, + description = it.getIndexName(context!!, component, pollenIndexSource), + color = colorToHex(it.getColor(context!!, component, pollenIndexSource)) + ) + ) + } + } else { + null + } + } + } + + private fun getAlerts(alertList: List?): List? { + return alertList?.map { alert -> + BreezyAlert( + alertId = alert.alertId, + startDate = alert.startDate?.time, + endDate = alert.endDate?.time, + headline = alert.headline, + description = alert.description, + instruction = alert.instruction, + source = alert.source, + severity = alert.severity.id, + color = colorToHex(alert.color) + ) + } + } + + private fun colorToHex(@ColorInt colorInt: Int): String { + return ColorUtils.setAlphaComponent(colorInt, 0).toHexString(hexFormat) + } + + override fun getType(uri: Uri): String? { + // MIME types are not relevant (for now at least) + return null + } + + override fun insert( + uri: Uri, + values: ContentValues?, + ): Uri? { + // This content provider is read-only for now, so we always return null + return null + } + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int { + // This content provider is read-only for now, so we always return 0 + return 0 + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int { + // This content provider is read-only for now, so we always return 0 + return 0 + } + + companion object { + private const val TAG = "Breezy" + + const val AUTHORITY = BuildConfig.APPLICATION_ID + ".provider.weather" + + private val uriMatcher = object : UriMatcher(NO_MATCH) { + init { + addURI(AUTHORITY, ProviderUri.VERSION_PATH, ProviderUri.VERSION_CODE) + addURI(AUTHORITY, ProviderUri.LOCATIONS_PATH, ProviderUri.LOCATIONS_CODE) + addURI(AUTHORITY, ProviderUri.WEATHER_PATH, ProviderUri.WEATHER_CODE) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/BootReceiver.kt b/app/src/main/java/org/breezyweather/background/receiver/BootReceiver.kt new file mode 100644 index 0000000..798389e --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/BootReceiver.kt @@ -0,0 +1,66 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.work.WorkInfo +import androidx.work.WorkQuery +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.common.extensions.workManager +import org.breezyweather.remoteviews.presenters.notification.WidgetNotificationIMP +import org.breezyweather.sources.RefreshHelper +import javax.inject.Inject + +/** + * Receiver to force app to autostart on boot + * Does nothing, it’s just that some OEM do not respect Android policy to keep scheduled workers + * regardless of if the app is started or not + */ +@AndroidEntryPoint +class BootReceiver : BroadcastReceiver() { + + @Inject + lateinit var refreshHelper: RefreshHelper + + @OptIn(DelicateCoroutinesApi::class) + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + if (action.isNullOrEmpty()) return + when (action) { + Intent.ACTION_BOOT_COMPLETED -> { + /** + * We don’t use the return value, but querying the work manager might help bringing back + * scheduled workers after the app has been killed/shutdown on some devices + */ + context.workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED)) + + // Bring back notification-widget if necessary + if (WidgetNotificationIMP.isEnabled(context)) { + GlobalScope.launch(Dispatchers.IO) { + refreshHelper.updateNotificationIfNecessary(context) + } + } + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/NotificationReceiver.kt b/app/src/main/java/org/breezyweather/background/receiver/NotificationReceiver.kt new file mode 100644 index 0000000..c91a189 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/NotificationReceiver.kt @@ -0,0 +1,150 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import org.breezyweather.background.weather.WeatherUpdateJob +import org.breezyweather.common.extensions.cancelNotification +import org.breezyweather.common.extensions.notificationManager +import org.breezyweather.BuildConfig.APPLICATION_ID as ID + +/** + * Taken partially from Mihon + * License Apache, Version 2.0 + * https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt + */ + +/** + * Global [BroadcastReceiver] that runs on UI thread + * Pending Broadcasts should be made from here. + * NOTE: Use local broadcasts if possible. + */ +class NotificationReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + // Cancel weather update and dismiss notification + ACTION_CANCEL_WEATHER_UPDATE -> cancelWeatherUpdate(context) + } + } + + /** + * Dismiss the notification + * + * @param notificationId the id of the notification + */ + private fun dismissNotification(context: Context, notificationId: Int) { + context.cancelNotification(notificationId) + } + + /** + * Method called when user wants to stop a weather update + * + * @param context context of application + */ + private fun cancelWeatherUpdate(context: Context) { + WeatherUpdateJob.stop(context) + } + + companion object { + private const val NAME = "NotificationReceiver" + + private const val ACTION_CANCEL_WEATHER_UPDATE = "$ID.$NAME.CANCEL_WEATHER_UPDATE" + + /** + * Returns [PendingIntent] that starts a service which stops the weather update + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun cancelWeatherUpdatePendingBroadcast(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CANCEL_WEATHER_UPDATE + } + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /** + * Returns [PendingIntent] that starts a service which dismissed the notification + * + * @param context context of application + * @param notificationId id of notification + * @return [PendingIntent] + */ + internal fun dismissNotification(context: Context, notificationId: Int, groupId: Int? = null) { + /* + Group notifications always have at least 2 notifications: + - Group summary notification + - Single manga notification + + If the single notification is dismissed by the system, ie by a user swipe or tapping on the notification, + it will auto dismiss the group notification if there's no other single updates. + + When programmatically dismissing this notification, the group notification is not automatically dismissed. + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val groupKey = context.notificationManager.activeNotifications.find { + it.id == notificationId + }?.groupKey + + if (groupId != null && groupId != 0 && !groupKey.isNullOrEmpty()) { + val notifications = context.notificationManager.activeNotifications.filter { + it.groupKey == groupKey + } + + if (notifications.size == 2) { + context.cancelNotification(groupId) + return + } + } + } + + context.cancelNotification(notificationId) + } + + /** + * Returns [PendingIntent] that opens the error log file in an external viewer + * + * @param context context of application + * @param uri uri of error log file + * @return [PendingIntent] + */ + internal fun openErrorLogPendingActivity(context: Context, uri: Uri): PendingIntent { + val intent = Intent().apply { + action = Intent.ACTION_VIEW + setDataAndType(uri, "text/plain") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE + ) + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayDetailsProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayDetailsProvider.kt new file mode 100644 index 0000000..de9a1fe --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayDetailsProvider.kt @@ -0,0 +1,70 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.ClockDayDetailsWidgetIMP +import javax.inject.Inject + +/** + * Widget clock day details provider. + */ +@AndroidEntryPoint +class WidgetClockDayDetailsProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (ClockDayDetailsWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + ClockDayDetailsWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayHorizontalProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayHorizontalProvider.kt new file mode 100644 index 0000000..dc6e545 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayHorizontalProvider.kt @@ -0,0 +1,70 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.ClockDayHorizontalWidgetIMP +import javax.inject.Inject + +/** + * Widget clock day horizontal provider. + */ +@AndroidEntryPoint +class WidgetClockDayHorizontalProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (ClockDayHorizontalWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + ClockDayHorizontalWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, // isDaylight + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayVerticalProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayVerticalProvider.kt new file mode 100644 index 0000000..c98ed35 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayVerticalProvider.kt @@ -0,0 +1,79 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.ClockDayVerticalWidgetIMP +import org.breezyweather.sources.SourceManager +import javax.inject.Inject + +/** + * Widget clock day vertical provider. + */ +@AndroidEntryPoint +class WidgetClockDayVerticalProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @Inject + lateinit var sourceManager: SourceManager + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (ClockDayVerticalWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + ClockDayVerticalWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = true, // Custom subtitle + withNormals = false + ) + ), + location?.let { locationNow -> + sourceManager.getPollenIndexSource( + (locationNow.pollenSource ?: "").ifEmpty { locationNow.forecastSource } + ) + } + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayWeekProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayWeekProvider.kt new file mode 100644 index 0000000..bfc3f5c --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetClockDayWeekProvider.kt @@ -0,0 +1,70 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.ClockDayWeekWidgetIMP +import javax.inject.Inject + +/** + * Widget clock day week provider. + */ +@AndroidEntryPoint +class WidgetClockDayWeekProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (ClockDayWeekWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + ClockDayWeekWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetDayProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetDayProvider.kt new file mode 100644 index 0000000..73725e3 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetDayProvider.kt @@ -0,0 +1,79 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.DayWidgetIMP +import org.breezyweather.sources.SourceManager +import javax.inject.Inject + +/** + * Widget day provider. + */ +@AndroidEntryPoint +class WidgetDayProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @Inject + lateinit var sourceManager: SourceManager + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (DayWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + DayWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = true, // Custom subtitle + withNormals = false + ) + ), + location?.let { locationNow -> + sourceManager.getPollenIndexSource( + (locationNow.pollenSource ?: "").ifEmpty { locationNow.forecastSource } + ) + } + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetDayWeekProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetDayWeekProvider.kt new file mode 100644 index 0000000..ded983b --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetDayWeekProvider.kt @@ -0,0 +1,79 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.DayWeekWidgetIMP +import org.breezyweather.sources.SourceManager +import javax.inject.Inject + +/** + * Widget day week provider. + */ +@AndroidEntryPoint +class WidgetDayWeekProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @Inject + lateinit var sourceManager: SourceManager + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (DayWeekWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + DayWeekWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = true, // Custom subtitle + withNormals = false + ) + ), + location?.let { locationNow -> + sourceManager.getPollenIndexSource( + (locationNow.pollenSource ?: "").ifEmpty { locationNow.forecastSource } + ) + } + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMaterialYouCurrentProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMaterialYouCurrentProvider.kt new file mode 100644 index 0000000..b0ec66f --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMaterialYouCurrentProvider.kt @@ -0,0 +1,77 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.os.Bundle +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.MaterialYouCurrentWidgetIMP +import javax.inject.Inject + +@AndroidEntryPoint +class WidgetMaterialYouCurrentProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (MaterialYouCurrentWidgetIMP.isEnabled(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + MaterialYouCurrentWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, // isDaylight + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ) + ) + } + } + } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: Bundle, + ) { + onUpdate(context, appWidgetManager, intArrayOf(appWidgetId)) + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMaterialYouForecastProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMaterialYouForecastProvider.kt new file mode 100644 index 0000000..cd5cde6 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMaterialYouForecastProvider.kt @@ -0,0 +1,67 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.MaterialYouForecastWidgetIMP +import javax.inject.Inject + +@AndroidEntryPoint +class WidgetMaterialYouForecastProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (MaterialYouForecastWidgetIMP.isEnabled(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + MaterialYouForecastWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = true, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMultiCityProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMultiCityProvider.kt new file mode 100644 index 0000000..9f9a4e6 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetMultiCityProvider.kt @@ -0,0 +1,70 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.MultiCityWidgetIMP +import javax.inject.Inject + +/** + * Widget multi city provider. + */ +@AndroidEntryPoint +class WidgetMultiCityProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (MultiCityWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val locationList = locationRepository.getXLocations(3, withParameters = false).toMutableList() + for (i in locationList.indices) { + locationList[i] = locationList[i].copy( + weather = weatherRepository.getWeatherByLocationId( + locationList[i].formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ) + } + MultiCityWidgetIMP.updateWidgetView(context, locationList) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTextProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTextProvider.kt new file mode 100644 index 0000000..b897178 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTextProvider.kt @@ -0,0 +1,79 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.TextWidgetIMP +import org.breezyweather.sources.SourceManager +import javax.inject.Inject + +/** + * Widget text provider. + */ +@AndroidEntryPoint +class WidgetTextProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @Inject + lateinit var sourceManager: SourceManager + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (TextWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + TextWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, // isDaylight + withHourly = false, + withMinutely = false, + withAlerts = true, // Custom subtitle + withNormals = false + ) + ), + location?.let { locationNow -> + sourceManager.getPollenIndexSource( + (locationNow.pollenSource ?: "").ifEmpty { locationNow.forecastSource } + ) + } + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTrendDailyProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTrendDailyProvider.kt new file mode 100644 index 0000000..ab4e030 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTrendDailyProvider.kt @@ -0,0 +1,70 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.DailyTrendWidgetIMP +import javax.inject.Inject + +/** + * Widget trend daily provider. + */ +@AndroidEntryPoint +class WidgetTrendDailyProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (DailyTrendWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + DailyTrendWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = true // Threshold lines + ) + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTrendHourlyProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTrendHourlyProvider.kt new file mode 100644 index 0000000..032835b --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetTrendHourlyProvider.kt @@ -0,0 +1,70 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.HourlyTrendWidgetIMP +import javax.inject.Inject + +/** + * Widget trend hourly provider. + */ +@AndroidEntryPoint +class WidgetTrendHourlyProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (HourlyTrendWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + HourlyTrendWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, // isDaylight + withHourly = true, + withMinutely = false, + withAlerts = false, + withNormals = true // Threshold lines + ) + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetWeekProvider.kt b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetWeekProvider.kt new file mode 100644 index 0000000..a284e1f --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/receiver/widget/WidgetWeekProvider.kt @@ -0,0 +1,70 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.receiver.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.breezyweather.remoteviews.presenters.WeekWidgetIMP +import javax.inject.Inject + +/** + * Widget week provider. + */ +@AndroidEntryPoint +class WidgetWeekProvider : AppWidgetProvider() { + + @Inject + lateinit var locationRepository: LocationRepository + + @Inject + lateinit var weatherRepository: WeatherRepository + + @OptIn(DelicateCoroutinesApi::class) + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + if (WeekWidgetIMP.isInUse(context)) { + GlobalScope.launch(Dispatchers.IO) { + val location = locationRepository.getFirstLocation(withParameters = false) + WeekWidgetIMP.updateWidgetView( + context, + location?.copy( + weather = weatherRepository.getWeatherByLocationId( + location.formattedId, + withDaily = true, + withHourly = false, + withMinutely = false, + withAlerts = false, + withNormals = false + ) + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/updater/AppUpdateChecker.kt b/app/src/main/java/org/breezyweather/background/updater/AppUpdateChecker.kt new file mode 100644 index 0000000..3106cb1 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/updater/AppUpdateChecker.kt @@ -0,0 +1,69 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.updater + +import android.content.Context +import android.os.Build +import org.breezyweather.BuildConfig +import org.breezyweather.background.updater.interactor.GetApplicationRelease +import org.breezyweather.common.extensions.withIOContext +import javax.inject.Inject + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/data/updater/AppUpdateChecker.kt + */ +class AppUpdateChecker @Inject constructor( + private val getApplicationRelease: GetApplicationRelease, +) { + + suspend fun checkForUpdate( + context: Context, + forceCheck: Boolean = false, + ): GetApplicationRelease.Result { + // Disable app update checks for older Android versions that we're going to drop support for + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + AppUpdateNotifier(context).promptOldAndroidVersion() + return GetApplicationRelease.Result.OsTooOld + } + + return withIOContext { + val result = getApplicationRelease.await( + GetApplicationRelease.Arguments( + BuildConfig.VERSION_NAME, + GITHUB_ORG, + GITHUB_REPO, + forceCheck + ) + ) + + when (result) { + is GetApplicationRelease.Result.NewUpdate -> AppUpdateNotifier(context).promptUpdate(result.release) + else -> {} + } + + result + } + } +} + +val GITHUB_ORG = "breezy-weather" +val GITHUB_REPO = "breezy-weather" + +val RELEASE_URL = "https://github.com/${GITHUB_REPO}/releases/tag/v${BuildConfig.VERSION_NAME}" diff --git a/app/src/main/java/org/breezyweather/background/updater/AppUpdateNotifier.kt b/app/src/main/java/org/breezyweather/background/updater/AppUpdateNotifier.kt new file mode 100644 index 0000000..d3d84d2 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/updater/AppUpdateNotifier.kt @@ -0,0 +1,100 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.updater + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import org.breezyweather.R +import org.breezyweather.background.receiver.NotificationReceiver +import org.breezyweather.background.updater.model.Release +import org.breezyweather.common.extensions.notificationBuilder +import org.breezyweather.common.extensions.notify +import org.breezyweather.remoteviews.Notifications + +internal class AppUpdateNotifier( + private val context: Context, +) { + + private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_APP_UPDATE) + + /** + * Call to show notification. + * + * @param id id of the notification channel. + */ + private fun NotificationCompat.Builder.show(id: Int = Notifications.ID_APP_UPDATER) { + context.notify(id, build()) + } + + fun cancel() { + NotificationReceiver.dismissNotification(context, Notifications.ID_APP_UPDATER) + } + + fun promptOldAndroidVersion() { + with(notificationBuilder) { + setContentTitle(context.getString(R.string.about_update_check_eol)) + setSmallIcon(android.R.drawable.stat_sys_download_done) + clearActions() + } + notificationBuilder.show() + } + + @SuppressLint("LaunchActivityFromNotification") + fun promptUpdate(release: Release) { + /*val updateIntent = NotificationReceiver.downloadAppUpdatePendingBroadcast( + context, + release.getDownloadLink(), + release.version, + )*/ + + val releaseIntent = Intent(Intent.ACTION_VIEW, release.releaseLink.toUri()).run { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + PendingIntent.getActivity( + context, + release.hashCode(), + this, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + with(notificationBuilder) { + setContentTitle(context.getString(R.string.notification_app_update_available)) + setContentText(release.version) + setSmallIcon(android.R.drawable.stat_sys_download_done) + // setContentIntent(updateIntent) + setContentIntent(releaseIntent) + + clearActions() + addAction( + android.R.drawable.stat_sys_download_done, + context.getString(R.string.action_download), + // updateIntent, + releaseIntent + ) + /*addAction( + R.drawable.ic_info_24dp, + context.getString(R.string.whats_new), + releaseIntent, + )*/ + } + notificationBuilder.show() + } +} diff --git a/app/src/main/java/org/breezyweather/background/updater/data/GithubApi.kt b/app/src/main/java/org/breezyweather/background/updater/data/GithubApi.kt new file mode 100644 index 0000000..6bc61ea --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/updater/data/GithubApi.kt @@ -0,0 +1,31 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.updater.data + +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Open-Meteo API + */ +interface GithubApi { + @GET("repos/{org}/{repository}/releases/latest") + suspend fun getLatest( + @Path("org") org: String, + @Path("repository") repository: String, + ): GithubRelease +} diff --git a/app/src/main/java/org/breezyweather/background/updater/data/GithubRelease.kt b/app/src/main/java/org/breezyweather/background/updater/data/GithubRelease.kt new file mode 100644 index 0000000..016d336 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/updater/data/GithubRelease.kt @@ -0,0 +1,56 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.updater.data + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/d29b7c4e5735dc137d578d3bcb3da1f0a02573e8/data/src/main/java/tachiyomi/data/release/GithubRelease.kt + */ + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.breezyweather.background.updater.model.Release + +/** + * Contains information about the latest release from GitHub. + */ +@Serializable +data class GithubRelease( + @SerialName("tag_name") val version: String, + @SerialName("body") val info: String, + @SerialName("html_url") val releaseLink: String, + @SerialName("assets") val assets: List, +) + +/** + * Assets class containing download url. + */ +@Serializable +data class GitHubAssets( + @SerialName("browser_download_url") val downloadLink: String, +) + +val releaseMapper: (GithubRelease) -> Release = { + Release( + it.version, + it.info, + it.releaseLink, + it.assets.map(GitHubAssets::downloadLink) + ) +} diff --git a/app/src/main/java/org/breezyweather/background/updater/data/ReleaseService.kt b/app/src/main/java/org/breezyweather/background/updater/data/ReleaseService.kt new file mode 100644 index 0000000..bf0ef38 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/updater/data/ReleaseService.kt @@ -0,0 +1,42 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.updater.data + +import org.breezyweather.background.updater.model.Release +import retrofit2.Retrofit +import javax.inject.Inject +import javax.inject.Named + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/02864ebd60ac9eb974a1b54b06368d20b0ca3ce5/data/src/main/java/tachiyomi/data/release/ReleaseServiceImpl.kt + */ +class ReleaseService @Inject constructor( + @Named("JsonClient") val client: Retrofit.Builder, +) { + + suspend fun latest(org: String, repository: String): Release { + return client + .baseUrl("https://api.github.com/") + .build() + .create(GithubApi::class.java) + .getLatest(org, repository) + .let(releaseMapper) + } +} diff --git a/app/src/main/java/org/breezyweather/background/updater/interactor/GetApplicationRelease.kt b/app/src/main/java/org/breezyweather/background/updater/interactor/GetApplicationRelease.kt new file mode 100644 index 0000000..e38a0e5 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/updater/interactor/GetApplicationRelease.kt @@ -0,0 +1,102 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.updater.interactor + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.breezyweather.background.updater.data.ReleaseService +import org.breezyweather.background.updater.model.Release +import org.breezyweather.domain.settings.SettingsManager +import java.util.Date +import javax.inject.Inject +import kotlin.time.Duration.Companion.days + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/domain/src/main/java/tachiyomi/domain/release/interactor/GetApplicationRelease.kt + */ +class GetApplicationRelease @Inject constructor( + @ApplicationContext val context: Context, + val service: ReleaseService, +) { + + suspend fun await( + arguments: Arguments, + ): Result { + val now = Date().time + + val lastChecked = SettingsManager.getInstance(context).appUpdateCheckLastTimestamp + + // Limit checks to once every day at most + if (!arguments.forceCheck && now < lastChecked + 1.days.inWholeMilliseconds) { + return Result.NoNewUpdate + } + + val release = service.latest(arguments.org, arguments.repository) + + SettingsManager.getInstance(context).appUpdateCheckLastTimestamp = now + + // Check if latest version is different from current version + val isNewVersion = isNewVersion( + arguments.versionName, + release.version + ) + return when { + isNewVersion -> Result.NewUpdate(release) + else -> Result.NoNewUpdate + } + } + + private fun isNewVersion( + versionName: String, + versionTag: String, + ): Boolean { + // Removes "v" prefixes + val newVersion = versionTag.replace("[^\\d.]".toRegex(), "") + val oldVersion = versionName.replace("[^\\d.]".toRegex(), "") + + val newSemVer = newVersion.split(".").map { it.toInt() } + val oldSemVer = oldVersion.split(".").map { it.toInt() } + + oldSemVer.mapIndexed { index, i -> + // Useful in case of pre-releases, where the newer stable version is older than the pre-release + if (newSemVer[index] < i) { + return false + } + if (newSemVer[index] > i) { + return true + } + } + + return false + } + + data class Arguments( + val versionName: String, + val org: String, + val repository: String, + val forceCheck: Boolean = false, + ) + + sealed interface Result { + data class NewUpdate(val release: Release) : Result + data object NoNewUpdate : Result + data object OsTooOld : Result + } +} diff --git a/app/src/main/java/org/breezyweather/background/updater/model/Release.kt b/app/src/main/java/org/breezyweather/background/updater/model/Release.kt new file mode 100644 index 0000000..ff75a67 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/updater/model/Release.kt @@ -0,0 +1,59 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.updater.model + +import android.os.Build + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/c83037eeab3b180c7b82355331131df6950f5d45/domain/src/main/java/tachiyomi/domain/release/model/Release.kt + */ +/** + * Contains information about the latest release. + */ +data class Release( + val version: String, + val info: String, + val releaseLink: String, + private val assets: List, +) { + + /** + * Get download link of latest release from the assets. + * @return download link of latest release. + */ + fun getDownloadLink(): String { + val apkVariant = when (Build.SUPPORTED_ABIS[0]) { + "arm64-v8a" -> "-arm64-v8a" + "armeabi-v7a" -> "-armeabi-v7a" + "x86" -> "-x86" + "x86_64" -> "-x86_64" + else -> "" + } + + return assets.find { + it.startsWith("breezy-weather$apkVariant-") && !it.contains("freenet") + } ?: assets[0] // FIXME + } + + /** + * Assets class containing download url. + */ + data class Assets(val downloadLink: String) +} diff --git a/app/src/main/java/org/breezyweather/background/weather/WeatherUpdateJob.kt b/app/src/main/java/org/breezyweather/background/weather/WeatherUpdateJob.kt new file mode 100644 index 0000000..5c532d4 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/weather/WeatherUpdateJob.kt @@ -0,0 +1,530 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.weather + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkQuery +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import breezyweather.domain.location.model.Location +import com.google.maps.android.SphericalUtil +import com.google.maps.android.model.LatLng +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import org.breezyweather.BuildConfig +import org.breezyweather.background.updater.AppUpdateChecker +import org.breezyweather.common.bus.EventBus +import org.breezyweather.common.extensions.createFileInCacheDir +import org.breezyweather.common.extensions.getFormattedDate +import org.breezyweather.common.extensions.getIsoFormattedDate +import org.breezyweather.common.extensions.getUriCompat +import org.breezyweather.common.extensions.isOnline +import org.breezyweather.common.extensions.isRunning +import org.breezyweather.common.extensions.setForegroundSafely +import org.breezyweather.common.extensions.withIOContext +import org.breezyweather.common.extensions.workManager +import org.breezyweather.common.options.NotificationStyle +import org.breezyweather.common.source.LocationResult +import org.breezyweather.common.source.RefreshError +import org.breezyweather.common.source.WeatherResult +import org.breezyweather.domain.location.model.getPlace +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.remoteviews.Notifications +import org.breezyweather.remoteviews.presenters.MultiCityWidgetIMP +import org.breezyweather.sources.RefreshHelper +import org.breezyweather.sources.SourceManager +import org.breezyweather.ui.main.utils.RefreshErrorType +import java.io.File +import java.util.Date +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.minutes + +/** + * Based on Mihon LibraryUpdateJob + * Licensed under Apache License, Version 2.0 + * https://github.com/mihonapp/mihon/blob/88e9fefa59b3f7f77ab3ddcab1b039f81534c83e/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt + */ +@HiltWorker +class WeatherUpdateJob @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val refreshHelper: RefreshHelper, + private val sourceManager: SourceManager, + private val locationRepository: LocationRepository, + private val weatherRepository: WeatherRepository, + private val updateChecker: AppUpdateChecker, +) : CoroutineWorker(context, workerParams) { + + private val notifier = WeatherUpdateNotifier(context) + + private var locationsToUpdate: List = mutableListOf() + + override suspend fun doWork(): Result { + if (tags.contains(WORK_NAME_AUTO)) { + // Find a running manual worker. If exists, try again later + if (context.workManager.isRunning(WORK_NAME_MANUAL)) { + return Result.retry() + } + } + + // Exit early in case there is no network and Android still executes the job + if (!context.isOnline()) { + return Result.retry() + } + + setForegroundSafely() + + // Set the last update time to now + SettingsManager.getInstance(context).weatherUpdateLastTimestamp = Date().time + + val locationFormattedId = inputData.getString(KEY_LOCATION) + addLocationToQueue(locationFormattedId) + + return withIOContext { + try { + updateWeatherData() + Result.success() + } catch (e: Exception) { + if (e is CancellationException) { + // Assume success although cancelled + Result.success() + } else { + e.printStackTrace() + Result.failure() + } + } finally { + notifier.cancelProgressNotification() + // if (BuildConfig.FLAVOR != "freenet" && SettingsManager.getInstance(context).isAppUpdateCheckEnabled) { + if ((BuildConfig.FLAVOR != "freenet" && SettingsManager.getInstance(context).isAppUpdateCheckEnabled) || + Build.VERSION.SDK_INT < Build.VERSION_CODES.M + ) { + try { + updateChecker.checkForUpdate(context, forceCheck = false) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notifier = WeatherUpdateNotifier(context) + return ForegroundInfo( + Notifications.ID_WEATHER_PROGRESS, + notifier.progressNotificationBuilder.build(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + } + ) + } + + /** + * Adds list of locations to be updated. + * + * @param locationFormattedId the ID of the location to update, or null if automatic detection. + */ + private suspend fun addLocationToQueue(locationFormattedId: String?) { + locationsToUpdate = if (locationFormattedId != null) { + val location = locationRepository.getLocation(locationFormattedId) + if (location != null) { + listOf( + location.copy( + weather = weatherRepository.getWeatherByLocationId(location.formattedId) + ) + ) + } else { + emptyList() + } + } else { + val locationList = when { + // Should be getAllLocations(), but some rare users have 100+ locations. No need to refresh all of them + // in that case, they don't actually use them every day, they just add them as "bookmarks" + refreshHelper.isBroadcastSourcesEnabled(context) -> locationRepository.getXLocations(5) + SettingsManager.getInstance(context).isWidgetNotificationEnabled && + SettingsManager.getInstance(context).widgetNotificationStyle == NotificationStyle.CITIES -> + locationRepository.getXLocations(4) + MultiCityWidgetIMP.isInUse(context) -> locationRepository.getXLocations(3) + else -> locationRepository.getXLocations(1) + } + + locationList + .map { + it.copy( + weather = weatherRepository.getWeatherByLocationId(it.formattedId) + ) + } + .filterIndexed { i, location -> + // Only refresh secondary locations once a day as we only need daily info + i == 0 || + location.weather?.base?.refreshTime == null || + location.weather!!.base.refreshTime!!.getIsoFormattedDate(location) < + Date().getFormattedDate("yyyy-MM-dd") + } + .toMutableList() + } + } + + /** + * Method that updates weather in [locationsToUpdate]. It's called in a background thread, so it's safe + * to do heavy operations or network calls here. + * For each weather it calls [updateLocation] and updates the notification showing the current + * progress. + * + * @return an observable delivering the progress of each update. + */ + private suspend fun updateWeatherData() { + val progressCount = AtomicInteger(0) + val currentlyUpdatingLocation = CopyOnWriteArrayList() + val newUpdates = CopyOnWriteArrayList>() + val skippedUpdates = CopyOnWriteArrayList>() + val failedUpdates = CopyOnWriteArrayList>() + + /** + * Update coordinates if locations to update contains a current location + */ + val updateCoordinatesErrors = if (locationsToUpdate.any { it.isCurrentPosition }) { + updateCoordinates() + } else { + emptyList() + } + + locationsToUpdate.forEach { location -> + withUpdateNotification( + currentlyUpdatingLocation, + progressCount, + location + ) { + // TODO: Implement this, it’s a good idea + /*if (location.updateStrategy != UpdateStrategy.ALWAYS_UPDATE) { + skippedUpdates.add(location to context.getString(R.string.skipped_reason_not_always_update)) + } else {*/ + try { + val locationResult = updateLocation(location) + locationResult.errors.forEach { + val shortMessage = it.getMessage(context, sourceManager) + if (it.error != RefreshErrorType.NETWORK_UNAVAILABLE && + it.error != RefreshErrorType.SERVER_TIMEOUT + ) { + failedUpdates.add(locationResult.location to shortMessage) + } else { + skippedUpdates.add(locationResult.location to shortMessage) + } + } + if (!locationResult.location.isUsable) { + // Report coordinate update errors only if we can’t re-use last known coordinates + updateCoordinatesErrors.forEach { + val shortMessage = it.getMessage(context, sourceManager) + failedUpdates.add(locationResult.location to shortMessage) + } + } + if (locationResult.location.isUsable && !locationResult.location.needsGeocodeRefresh) { + val ignoreCaching = SphericalUtil.computeDistanceBetween( + LatLng(locationResult.location.latitude, locationResult.location.longitude), + LatLng(location.latitude, location.longitude) + ) > RefreshHelper.CACHING_DISTANCE_LIMIT + val weatherResult = updateWeather( + locationResult.location, + location.longitude != locationResult.location.longitude || + location.latitude != locationResult.location.latitude, + ignoreCaching + ) + newUpdates.add( + location to locationResult.location.copy(weather = weatherResult.weather) + ) + weatherResult.errors.forEach { + failedUpdates.add(location to it.getMessage(context, sourceManager)) + } + } + } catch (e: Throwable) { + e.printStackTrace() + val errorMessage = if (e.message.isNullOrEmpty()) { + context.getString(RefreshErrorType.DATA_REFRESH_FAILED.shortMessage) + } else { + e.message + } + failedUpdates.add(location to errorMessage) + } + // } + } + } + + notifier.cancelProgressNotification() + + if (newUpdates.isNotEmpty()) { + // We updated at least one location, so we need to reload location list and make some post-actions + val locationList = locationRepository.getAllLocations().toMutableList() + for (i in locationList.indices) { + locationList[i] = locationList[i].copy( + weather = weatherRepository.getWeatherByLocationId(locationList[i].formattedId) + ) + } + + // Update widgets and notification-widget + refreshHelper.updateWidgetIfNecessary(context, locationList) + refreshHelper.updateNotificationIfNecessary(context, locationList) + + // Update shortcuts + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + refreshHelper.refreshShortcuts(applicationContext, locationList) + } + + val location = locationList[0] + val indexOfFirstLocation = newUpdates.firstOrNull { it.first.formattedId == location.formattedId } + + // Send alert and precipitation for the first location + if (indexOfFirstLocation != null) { + Notifications.checkAndSendAlert( + applicationContext, + location, + locationsToUpdate.firstOrNull { it.formattedId == location.formattedId }?.weather + ) + Notifications.checkAndSendPrecipitation(applicationContext, location) + } + + refreshHelper.broadcastDataIfNecessary( + context, + locationList, + newUpdates.map { it.first.formattedId }.toTypedArray() + ) + + // Inform main activity that we updated location + newUpdates.forEach { + EventBus.instance + .with(Location::class.java) + .postValue(it.second) + } + } + + if (failedUpdates.isNotEmpty()) { + val errorFile = writeErrorFile(failedUpdates) + notifier.showUpdateErrorNotification( + failedUpdates.groupBy { it.first }.size, + errorFile.getUriCompat(context) + ) + } + /*if (skippedUpdates.isNotEmpty()) { + notifier.showUpdateSkippedNotification(skippedUpdates.size) + }*/ + } + + /** + * Updates the current location coordinates. + * + * @return errors if any + */ + private suspend fun updateCoordinates(): List { + return refreshHelper.updateCurrentCoordinates(context, true) + } + + /** + * Updates the location with updated coordinates and reverse geocoding. + * + * @param location the location to update. + * @return location updated. + */ + private suspend fun updateLocation(location: Location): LocationResult { + return refreshHelper.getLocation(context, location) + } + + /** + * Updates the weather for the given location and adds them to the database. + * + * @param location the location to update. + * @return weather. + */ + private suspend fun updateWeather( + location: Location, + coordinatesChanged: Boolean, + ignoreCaching: Boolean, + ): WeatherResult { + return refreshHelper.getWeather( + context, + location, + coordinatesChanged, + ignoreCaching + ) + } + + private suspend fun withUpdateNotification( + updatingLocation: CopyOnWriteArrayList, + completed: AtomicInteger, + location: Location, + block: suspend () -> Unit, + ) { + coroutineScope { + ensureActive() + + updatingLocation.add(location) + notifier.showProgressNotification( + updatingLocation, + completed.get(), + locationsToUpdate.size + ) + + block() + + ensureActive() + + updatingLocation.remove(location) + completed.getAndIncrement() + notifier.showProgressNotification( + updatingLocation, + completed.get(), + locationsToUpdate.size + ) + } + } + + /** + * Writes basic file of update errors to cache dir. + */ + private fun writeErrorFile(errors: List>): File { + try { + if (errors.isNotEmpty()) { + val file = context.createFileInCacheDir("breezyweather_update_errors.txt") + file.bufferedWriter().use { out -> + out.write("Errors during refresh\n\n") + // Error file format: + // ! Location + // - Error + errors.groupBy({ it.first }, { it.second }).forEach { (location, errors) -> + out.write("\n! ${location.getPlace(context, showCurrentPositionInPriority = true)}\n") + errors.forEach { + out.write(" - $it\n") + } + } + } + return file + } + } catch (_: Exception) {} + return File("") + } + + companion object { + private const val TAG = "WeatherUpdate" + private const val WORK_NAME_AUTO = "WeatherUpdate-auto" + private const val WORK_NAME_MANUAL = "WeatherUpdate-manual" + + /** + * Key for location to update. + */ + private const val KEY_LOCATION = "location" + + private const val MINUTES_PER_HOUR: Long = 60 + private const val BACKOFF_DELAY_MINUTES: Long = 10 + + fun cancelAllWorks(context: Context) { + context.workManager.cancelAllWorkByTag(TAG) + } + + fun setupTask( + context: Context, + ) { + val settings = SettingsManager.getInstance(context) + val pollingRate = settings.updateInterval.interval + if (pollingRate != null && pollingRate > 15.minutes) { + val constraints = Constraints( + requiredNetworkType = NetworkType.CONNECTED, + requiresBatteryNotLow = settings.ignoreUpdatesWhenBatteryLow + ) + + val request = PeriodicWorkRequestBuilder( + pollingRate.inWholeMinutes, + TimeUnit.MINUTES, + BACKOFF_DELAY_MINUTES, + TimeUnit.MINUTES + ) + .addTag(TAG) + .addTag(WORK_NAME_AUTO) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES) + .build() + + context.workManager.enqueueUniquePeriodicWork( + WORK_NAME_AUTO, + ExistingPeriodicWorkPolicy.UPDATE, + request + ) + } else { + context.workManager.cancelUniqueWork(WORK_NAME_AUTO) + } + } + + fun startNow( + context: Context, + location: Location? = null, + ): Boolean { + val wm = context.workManager + if (wm.isRunning(TAG)) { + // Already running either as a scheduled or manual job + return false + } + + val inputData = workDataOf( + KEY_LOCATION to location?.formattedId + ) + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .addTag(WORK_NAME_MANUAL) + .setInputData(inputData) + .build() + wm.enqueueUniqueWork(WORK_NAME_MANUAL, ExistingWorkPolicy.KEEP, request) + + return true + } + + fun stop(context: Context) { + val wm = context.workManager + val workQuery = WorkQuery.Builder.fromTags(listOf(TAG)) + .addStates(listOf(WorkInfo.State.RUNNING)) + .build() + wm.getWorkInfos(workQuery).get() + // Should only return one work but just in case + .forEach { + wm.cancelWorkById(it.id) + + // Re-enqueue cancelled scheduled work + if (it.tags.contains(WORK_NAME_AUTO)) { + setupTask(context) + } + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/background/weather/WeatherUpdateNotifier.kt b/app/src/main/java/org/breezyweather/background/weather/WeatherUpdateNotifier.kt new file mode 100644 index 0000000..9568934 --- /dev/null +++ b/app/src/main/java/org/breezyweather/background/weather/WeatherUpdateNotifier.kt @@ -0,0 +1,112 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.background.weather + +import android.content.Context +import android.net.Uri +import androidx.core.app.NotificationCompat +import breezyweather.domain.location.model.Location +import org.breezyweather.R +import org.breezyweather.background.receiver.NotificationReceiver +import org.breezyweather.common.extensions.cancelNotification +import org.breezyweather.common.extensions.chop +import org.breezyweather.common.extensions.notificationBuilder +import org.breezyweather.common.extensions.notify +import org.breezyweather.remoteviews.Notifications + +/** + * Based on Mihon + * Apache License, Version 2.0 + * https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateNotifier.kt + */ +class WeatherUpdateNotifier( + private val context: Context, +) { + + /** + * Pending intent of action that cancels the weather update + */ + private val cancelIntent by lazy { + NotificationReceiver.cancelWeatherUpdatePendingBroadcast(context) + } + + /** + * Cached progress notification to avoid creating a lot. + */ + val progressNotificationBuilder by lazy { + context.notificationBuilder(Notifications.CHANNEL_BACKGROUND) { + setContentTitle(context.getString(R.string.app_name)) + setSmallIcon(R.drawable.ic_running_in_background) + setOngoing(true) + setOnlyAlertOnce(true) + addAction(R.drawable.ic_close, context.getString(android.R.string.cancel), cancelIntent) + } + } + + /** + * Shows the notification containing the currently updating manga and the progress. + * + * @param locations the manga that are being updated. + * @param current the current progress. + * @param total the total progress. + */ + fun showProgressNotification(locations: List, current: Int, total: Int) { + val updatingText = locations.joinToString("\n") { it.city.chop(40) } + progressNotificationBuilder + .setContentTitle( + context.getString(R.string.notification_updating_weather_data, current, total) + ) + .setStyle(NotificationCompat.BigTextStyle().bigText(updatingText)) + + context.notify( + Notifications.ID_WEATHER_PROGRESS, + progressNotificationBuilder + .setProgress(total, current, false) + .build() + ) + } + + /** + * Shows notification containing update entries that failed with action to open full log. + * + * @param failed Number of entries that failed to update. + * @param uri Uri for error log file containing all titles that failed. + */ + fun showUpdateErrorNotification(failed: Int, uri: Uri) { + if (failed == 0) { + return + } + + context.notify( + Notifications.ID_WEATHER_ERROR, + Notifications.CHANNEL_BACKGROUND + ) { + setContentTitle(context.resources.getString(R.string.notification_update_error, failed)) + setContentText(context.getString(R.string.action_show_errors)) + setSmallIcon(R.drawable.ic_running_in_background) + + setContentIntent(NotificationReceiver.openErrorLogPendingActivity(context, uri)) + } + } + + /** + * Cancels the progress notification. + */ + fun cancelProgressNotification() { + context.cancelNotification(Notifications.ID_WEATHER_PROGRESS) + } +} diff --git a/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyFloatingTextActionModeCallback.kt b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyFloatingTextActionModeCallback.kt new file mode 100644 index 0000000..92e6e11 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyFloatingTextActionModeCallback.kt @@ -0,0 +1,51 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.actionmodecallback + +import android.graphics.Rect +import android.os.Build +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.annotation.RequiresApi + +@RequiresApi(Build.VERSION_CODES.M) +internal class BreezyFloatingTextActionModeCallback( + private val callback: BreezyTextActionModeCallback, +) : ActionMode.Callback2() { + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + return callback.onActionItemClicked(mode, item) + } + + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + return callback.onCreateActionMode(mode, menu) + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + return callback.onPrepareActionMode(mode, menu) + } + + override fun onDestroyActionMode(mode: ActionMode?) { + callback.onDestroyActionMode(mode) + } + + override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) { + val rect = callback.rect + outRect?.set(rect.left.toInt(), rect.top.toInt(), rect.right.toInt(), rect.bottom.toInt()) + } +} diff --git a/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyPrimaryTextActionModeCallback.kt b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyPrimaryTextActionModeCallback.kt new file mode 100644 index 0000000..2ffaa65 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyPrimaryTextActionModeCallback.kt @@ -0,0 +1,41 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.actionmodecallback + +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem + +internal class BreezyPrimaryTextActionModeCallback( + private val callback: BreezyTextActionModeCallback, +) : ActionMode.Callback { + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + return callback.onActionItemClicked(mode, item) + } + + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + return callback.onCreateActionMode(mode, menu) + } + + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + return callback.onPrepareActionMode(mode, menu) + } + + override fun onDestroyActionMode(mode: ActionMode?) { + callback.onDestroyActionMode(mode) + } +} diff --git a/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezySelectionContainer.kt b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezySelectionContainer.kt new file mode 100644 index 0000000..898c852 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezySelectionContainer.kt @@ -0,0 +1,45 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.actionmodecallback + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.platform.LocalView + +/** + * A selection container with the following options: + * - Copy + * - Select all + * - Translate + * - Share + */ +@Composable +fun BreezySelectionContainer( + content: @Composable () -> Unit, +) { + val view = LocalView.current + val breezyTextToolbar = remember { BreezyTextToolbar(view = view) } + + CompositionLocalProvider(LocalTextToolbar provides breezyTextToolbar) { + SelectionContainer { + content() + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyTextActionModeCallback.kt b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyTextActionModeCallback.kt new file mode 100644 index 0000000..9f4829a --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyTextActionModeCallback.kt @@ -0,0 +1,109 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.actionmodecallback + +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import androidx.annotation.VisibleForTesting +import androidx.compose.ui.geometry.Rect +import org.breezyweather.R + +internal class BreezyTextActionModeCallback( + val onActionModeDestroy: ((mode: ActionMode?) -> Unit)? = null, + var rect: Rect = Rect.Zero, + var onCopyRequested: (() -> Unit)? = null, + var onSelectAllRequested: (() -> Unit)? = null, + var onTranslateRequested: (() -> Unit)? = null, + var onShareRequested: (() -> Unit)? = null, +) : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean { + requireNotNull(menu) { "onCreateActionMode requires a non-null menu" } + requireNotNull(mode) { "onCreateActionMode requires a non-null mode" } + + onCopyRequested?.let { addMenuItem(menu, MenuItemOption.Copy) } + onSelectAllRequested?.let { addMenuItem(menu, MenuItemOption.SelectAll) } + onTranslateRequested?.let { addMenuItem(menu, MenuItemOption.Translate) } + onShareRequested?.let { addMenuItem(menu, MenuItemOption.Share) } + return true + } + + // this method is called to populate new menu items when the actionMode was invalidated + override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean { + if (mode == null || menu == null) return false + updateMenuItems(menu) + // should return true so that new menu items are populated + return true + } + + override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean { + when (item!!.itemId) { + MenuItemOption.Copy.id -> onCopyRequested?.invoke() + MenuItemOption.SelectAll.id -> onSelectAllRequested?.invoke() + MenuItemOption.Translate.id -> onTranslateRequested?.invoke() + MenuItemOption.Share.id -> onShareRequested?.invoke() + else -> return false + } + mode?.finish() + return true + } + + override fun onDestroyActionMode(mode: ActionMode?) { + onActionModeDestroy?.invoke(mode) + } + + @VisibleForTesting + internal fun updateMenuItems(menu: Menu) { + addOrRemoveMenuItem(menu, MenuItemOption.Copy, onCopyRequested) + addOrRemoveMenuItem(menu, MenuItemOption.SelectAll, onSelectAllRequested) + addOrRemoveMenuItem(menu, MenuItemOption.Translate, onTranslateRequested) + addOrRemoveMenuItem(menu, MenuItemOption.Share, onShareRequested) + } + + internal fun addMenuItem(menu: Menu, item: MenuItemOption) { + menu + .add(0, item.id, item.order, item.titleResource) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM) + } + + private fun addOrRemoveMenuItem(menu: Menu, item: MenuItemOption, callback: (() -> Unit)?) { + when { + callback != null && menu.findItem(item.id) == null -> addMenuItem(menu, item) + callback == null && menu.findItem(item.id) != null -> menu.removeItem(item.id) + } + } +} + +internal enum class MenuItemOption(val id: Int) { + Copy(0), + SelectAll(1), + Translate(2), + Share(3), + ; + + val titleResource: Int + get() = + when (this) { + Copy -> android.R.string.copy + SelectAll -> android.R.string.selectAll + Translate -> R.string.action_translate + Share -> R.string.action_share + } + + /** This item will be shown before all items that have order greater than this value. */ + val order = id +} diff --git a/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyTextToolbar.kt b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyTextToolbar.kt new file mode 100644 index 0000000..4bd4e3c --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/actionmodecallback/BreezyTextToolbar.kt @@ -0,0 +1,173 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.actionmodecallback + +import android.content.ClipData +import android.content.Intent +import android.os.Build +import android.view.ActionMode +import android.view.View +import androidx.annotation.RequiresApi +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.TextToolbar +import androidx.compose.ui.platform.TextToolbarStatus +import org.breezyweather.R +import org.breezyweather.common.extensions.clipboardManager +import org.breezyweather.common.utils.helpers.SnackbarHelper + +internal class BreezyTextToolbar( + private val view: View, +) : TextToolbar { + private var actionMode: ActionMode? = null + private val textActionModeCallback: BreezyTextActionModeCallback = + BreezyTextActionModeCallback(onActionModeDestroy = { actionMode = null }) + override var status: TextToolbarStatus = TextToolbarStatus.Hidden + private set + + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + onAutofillRequested: (() -> Unit)?, + ) { + textActionModeCallback.rect = rect + textActionModeCallback.onCopyRequested = onCopyRequested + textActionModeCallback.onSelectAllRequested = onSelectAllRequested + textActionModeCallback.onTranslateRequested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + { + // Get selected text by copying it, then restore the previous clip + val clipboardManager = view.context.clipboardManager + val previousClipboard = clipboardManager.primaryClip + onCopyRequested?.invoke() + val text = clipboardManager.text + if (previousClipboard != null) { + clipboardManager.setPrimaryClip(previousClipboard) + } else { + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, " ")) + } + + val intent = Intent().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + action = Intent.ACTION_TRANSLATE + putExtra(Intent.EXTRA_TEXT, text.trim()) + } else { + action = Intent.ACTION_PROCESS_TEXT + type = "text/plain" + putExtra(Intent.EXTRA_PROCESS_TEXT, text.trim()) + putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) + } + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + try { + view.context.startActivity(Intent.createChooser(intent, "")) + } catch (e: Exception) { + SnackbarHelper.showSnackbar(view.context.getString(R.string.action_translate_no_app)) + } + } + } else { + null + } + textActionModeCallback.onShareRequested = { + // Get selected text by copying it, then restore the previous clip + val clipboardManager = view.context.clipboardManager + val previousClipboard = clipboardManager.primaryClip + onCopyRequested?.invoke() + val text = clipboardManager.text + if (previousClipboard != null) { + clipboardManager.setPrimaryClip(previousClipboard) + } else { + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, " ")) + } + + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, view.context.getString(R.string.app_name)) + putExtra(Intent.EXTRA_TEXT, text.trim()) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + try { + view.context.startActivity(Intent.createChooser(intent, "")) + } catch (e: Exception) { + SnackbarHelper.showSnackbar(view.context.getString(R.string.action_share_no_app)) + } + } + if (actionMode == null) { + status = TextToolbarStatus.Shown + actionMode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + TextToolbarHelperMethods.startActionMode( + view, + BreezyFloatingTextActionModeCallback(textActionModeCallback), + ActionMode.TYPE_FLOATING + ) + } else { + view.startActionMode(BreezyPrimaryTextActionModeCallback(textActionModeCallback)) + } + } else { + actionMode?.invalidate() + } + } + + override fun showMenu( + rect: Rect, + onCopyRequested: (() -> Unit)?, + onPasteRequested: (() -> Unit)?, + onCutRequested: (() -> Unit)?, + onSelectAllRequested: (() -> Unit)?, + ) { + showMenu( + rect = rect, + onCopyRequested = onCopyRequested, + onPasteRequested = onPasteRequested, + onCutRequested = onCutRequested, + onSelectAllRequested = onSelectAllRequested + ) + } + + override fun hide() { + status = TextToolbarStatus.Hidden + actionMode?.finish() + actionMode = null + } +} + +/** + * This class is here to ensure that the classes that use this API will get verified and can be AOT + * compiled. It is expected that this class will soft-fail verification, but the classes which use + * this method will pass. + */ +@RequiresApi(Build.VERSION_CODES.M) +internal object TextToolbarHelperMethods { + @RequiresApi(Build.VERSION_CODES.M) + fun startActionMode( + view: View, + actionModeCallback: ActionMode.Callback, + type: Int, + ): ActionMode? { + return view.startActionMode(actionModeCallback, type) + } + + @RequiresApi(Build.VERSION_CODES.M) + fun invalidateContentRect(actionMode: ActionMode) { + actionMode.invalidateContentRect() + } +} diff --git a/app/src/main/java/org/breezyweather/common/activities/BreezyActivity.kt b/app/src/main/java/org/breezyweather/common/activities/BreezyActivity.kt new file mode 100644 index 0000000..d5dbba0 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/activities/BreezyActivity.kt @@ -0,0 +1,93 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.activities + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.ViewGroup +import androidx.activity.enableEdgeToEdge +import androidx.annotation.CallSuper +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.Lifecycle +import org.breezyweather.BreezyWeather +import org.breezyweather.common.extensions.isDarkMode +import org.breezyweather.common.extensions.setSystemBarStyle +import org.breezyweather.common.snackbar.SnackbarContainer + +abstract class BreezyActivity : AppCompatActivity() { + + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + window.setSystemBarStyle(!isDarkMode) + } + + BreezyWeather.instance.addActivity(this) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + BreezyWeather.instance.setTopActivity(this) + } + + @CallSuper + override fun onResume() { + super.onResume() + BreezyWeather.instance.setTopActivity(this) + } + + @CallSuper + override fun onPause() { + super.onPause() + BreezyWeather.instance.checkToCleanTopActivity(this) + } + + @CallSuper + override fun onDestroy() { + super.onDestroy() + BreezyWeather.instance.removeActivity(this) + } + + fun updateLocalNightMode(expectedLightTheme: Boolean) { + getDelegate().localNightMode = if (expectedLightTheme) { + AppCompatDelegate.MODE_NIGHT_NO + } else { + AppCompatDelegate.MODE_NIGHT_YES + } + } + + open val snackbarContainer: SnackbarContainer + get() = SnackbarContainer( + this, + findViewById(android.R.id.content).getChildAt(0) as ViewGroup, + true + ) + + fun provideSnackbarContainer(): SnackbarContainer = snackbarContainer + + val isActivityCreated: Boolean + get() = lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) + val isActivityStarted: Boolean + get() = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + val isActivityResumed: Boolean + get() = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) +} diff --git a/app/src/main/java/org/breezyweather/common/activities/BreezyFragment.kt b/app/src/main/java/org/breezyweather/common/activities/BreezyFragment.kt new file mode 100644 index 0000000..68c03d3 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/activities/BreezyFragment.kt @@ -0,0 +1,43 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.activities + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import org.breezyweather.common.snackbar.SnackbarContainer + +open class BreezyFragment : Fragment() { + var isFragmentViewCreated = false + private set + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + isFragmentViewCreated = true + } + + val isFragmentCreated: Boolean + get() = lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED) + val isFragmentStarted: Boolean + get() = lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + val isFragmentResumed: Boolean + get() = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) + val snackbarContainer: SnackbarContainer + get() = SnackbarContainer(this, (requireView() as ViewGroup), true) +} diff --git a/app/src/main/java/org/breezyweather/common/activities/BreezyViewModel.kt b/app/src/main/java/org/breezyweather/common/activities/BreezyViewModel.kt new file mode 100644 index 0000000..89c4c6d --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/activities/BreezyViewModel.kt @@ -0,0 +1,32 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.activities + +import android.app.Application +import androidx.lifecycle.AndroidViewModel + +// TODO: Issue with getter on application when converted to Kotlin +open class BreezyViewModel( + application: Application, +) : AndroidViewModel(application) { + private var mNewInstance = true + fun checkIsNewInstance(): Boolean { + val result = mNewInstance + mNewInstance = false + return result + } +} diff --git a/app/src/main/java/org/breezyweather/common/activities/livedata/BusLiveData.kt b/app/src/main/java/org/breezyweather/common/activities/livedata/BusLiveData.kt new file mode 100644 index 0000000..f2b512d --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/activities/livedata/BusLiveData.kt @@ -0,0 +1,108 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.activities.livedata + +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import org.breezyweather.common.bus.MyObserverWrapper + +class BusLiveData( + private val mainHandler: Handler, +) : MutableLiveData() { + + companion object { + const val START_VERSION = -1 + } + + private val wrapperMap = HashMap, MyObserverWrapper>() + internal var version = START_VERSION + + override fun observe(owner: LifecycleOwner, observer: Observer) { + runOnMainThread { + innerObserver(owner, MyObserverWrapper(this, observer, version)) + } + } + + fun observeAutoRemove(owner: LifecycleOwner, observer: Observer) { + runOnMainThread { + owner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + removeObserver(observer) + } + }) + innerObserver(owner, MyObserverWrapper(this, observer, version)) + } + } + + fun observeStickily(owner: LifecycleOwner, observer: Observer) { + runOnMainThread { + innerObserver(owner, MyObserverWrapper(this, observer, START_VERSION)) + } + } + + private fun innerObserver(owner: LifecycleOwner, wrapper: MyObserverWrapper) { + wrapperMap[wrapper.observer] = wrapper + super.observe(owner, wrapper) + } + + override fun observeForever(observer: Observer) { + runOnMainThread { + innerObserverForever(MyObserverWrapper(this, observer, version)) + } + } + + fun observeStickilyForever(observer: Observer) { + runOnMainThread { + innerObserverForever(MyObserverWrapper(this, observer, START_VERSION)) + } + } + + private fun innerObserverForever(wrapper: MyObserverWrapper) { + wrapperMap[wrapper.observer] = wrapper + super.observeForever(wrapper) + } + + override fun removeObserver(observer: Observer) { + runOnMainThread { + val wrapper = wrapperMap.remove(observer) + if (wrapper != null) { + super.removeObserver(wrapper) + } + } + } + + override fun setValue(value: T) { + ++version + super.setValue(value) + } + + override fun postValue(value: T) { + runOnMainThread { setValue(value) } + } + + private fun runOnMainThread(r: Runnable) { + if (Looper.getMainLooper().thread === Thread.currentThread()) { + r.run() + } else { + mainHandler.post(r) + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/activities/livedata/EqualtableLiveData.kt b/app/src/main/java/org/breezyweather/common/activities/livedata/EqualtableLiveData.kt new file mode 100644 index 0000000..40680eb --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/activities/livedata/EqualtableLiveData.kt @@ -0,0 +1,39 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.activities.livedata + +import androidx.lifecycle.MutableLiveData + +class EqualtableLiveData( + value: T? = null, +) : MutableLiveData(value) { + + override fun setValue(value: T) { + if (value == this.value) { + return + } + super.setValue(value) + } + + override fun postValue(value: T) { + // this.value is a volatile value. + if (value == this.value) { + return + } + super.postValue(value) + } +} diff --git a/app/src/main/java/org/breezyweather/common/bus/EventBus.kt b/app/src/main/java/org/breezyweather/common/bus/EventBus.kt new file mode 100644 index 0000000..102f5bb --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/bus/EventBus.kt @@ -0,0 +1,49 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.bus + +import android.os.Handler +import android.os.Looper +import org.breezyweather.common.activities.livedata.BusLiveData + +class EventBus private constructor() { + + companion object { + + val instance: EventBus by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + EventBus() + } + } + + private val liveDataMap = HashMap>() + private val mainHandler = Handler(Looper.getMainLooper()) + + fun with(type: Class): BusLiveData { + val key = key(type = type) + + if (!liveDataMap.containsKey(key)) { + liveDataMap[key] = BusLiveData(mainHandler) + } + return liveDataMap[key] as BusLiveData + } + + fun remove(type: Class<*>) { + liveDataMap.remove(key(type)) + } + + private fun key(type: Class) = type.name +} diff --git a/app/src/main/java/org/breezyweather/common/bus/MyObserverWrapper.kt b/app/src/main/java/org/breezyweather/common/bus/MyObserverWrapper.kt new file mode 100644 index 0000000..473927c --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/bus/MyObserverWrapper.kt @@ -0,0 +1,40 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.bus + +import androidx.lifecycle.Observer +import org.breezyweather.common.activities.livedata.BusLiveData +import java.lang.ref.WeakReference + +internal class MyObserverWrapper internal constructor( + host: BusLiveData, + internal val observer: Observer, + private var version: Int, +) : Observer { + + private val host = WeakReference(host) + + override fun onChanged(value: T) { + host.get()?.let { + if (version >= it.version) { + return + } + version = it.version + observer.onChanged(value) + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/di/DbModule.kt b/app/src/main/java/org/breezyweather/common/di/DbModule.kt new file mode 100644 index 0000000..a8f7cdd --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/di/DbModule.kt @@ -0,0 +1,275 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.di + +import android.content.Context +import android.os.Build +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import breezyweather.data.AlertSeverityColumnAdapter +import breezyweather.data.Alerts +import breezyweather.data.AndroidDatabaseHandler +import breezyweather.data.Dailys +import breezyweather.data.Database +import breezyweather.data.DatabaseHandler +import breezyweather.data.DistanceColumnAdapter +import breezyweather.data.DurationColumnAdapter +import breezyweather.data.Hourlys +import breezyweather.data.Locations +import breezyweather.data.Minutelys +import breezyweather.data.Normals +import breezyweather.data.PollenConcentrationColumnAdapter +import breezyweather.data.PollutantConcentrationColumnAdapter +import breezyweather.data.PrecipitationColumnAdapter +import breezyweather.data.PressureColumnAdapter +import breezyweather.data.RatioColumnAdapter +import breezyweather.data.SpeedColumnAdapter +import breezyweather.data.TemperatureColumnAdapter +import breezyweather.data.TimeZoneColumnAdapter +import breezyweather.data.WeatherCodeColumnAdapter +import breezyweather.data.Weathers +import breezyweather.data.location.LocationRepository +import breezyweather.data.weather.WeatherRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory +import org.breezyweather.BuildConfig +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class DbModule { + + @Provides + @Singleton + fun provideSqlDriver(@ApplicationContext context: Context): SqlDriver { + return AndroidSqliteDriver( + schema = Database.Schema, + context = context, + name = "breezyweather.db", + factory = if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Support database inspector in Android Studio + FrameworkSQLiteOpenHelperFactory() + } else { + RequerySQLiteOpenHelperFactory() + }, + callback = object : AndroidSqliteDriver.Callback(Database.Schema) { + override fun onOpen(db: SupportSQLiteDatabase) { + super.onOpen(db) + setPragma(db, "foreign_keys = ON") + setPragma(db, "journal_mode = WAL") + setPragma(db, "synchronous = NORMAL") + } + private fun setPragma(db: SupportSQLiteDatabase, pragma: String) { + val cursor = db.query("PRAGMA $pragma") + cursor.moveToFirst() + cursor.close() + } + } + ) + } + + @Provides + @Singleton + fun provideDatabase(driver: SqlDriver): Database { + return Database( + driver, + locationsAdapter = Locations.Adapter( + timezoneAdapter = TimeZoneColumnAdapter + ), + weathersAdapter = Weathers.Adapter( + weather_codeAdapter = WeatherCodeColumnAdapter, + temperatureAdapter = TemperatureColumnAdapter, + temperature_source_feels_likeAdapter = TemperatureColumnAdapter, + temperature_apparentAdapter = TemperatureColumnAdapter, + temperature_wind_chillAdapter = TemperatureColumnAdapter, + humidexAdapter = TemperatureColumnAdapter, + wind_speedAdapter = SpeedColumnAdapter, + wind_gustsAdapter = SpeedColumnAdapter, + pm25Adapter = PollutantConcentrationColumnAdapter, + pm10Adapter = PollutantConcentrationColumnAdapter, + so2Adapter = PollutantConcentrationColumnAdapter, + no2Adapter = PollutantConcentrationColumnAdapter, + o3Adapter = PollutantConcentrationColumnAdapter, + coAdapter = PollutantConcentrationColumnAdapter, + relative_humidityAdapter = RatioColumnAdapter, + dew_pointAdapter = TemperatureColumnAdapter, + pressureAdapter = PressureColumnAdapter, + visibilityAdapter = DistanceColumnAdapter, + cloud_coverAdapter = RatioColumnAdapter, + ceilingAdapter = DistanceColumnAdapter + ), + dailysAdapter = Dailys.Adapter( + daytime_weather_codeAdapter = WeatherCodeColumnAdapter, + daytime_temperatureAdapter = TemperatureColumnAdapter, + daytime_temperature_source_feels_likeAdapter = TemperatureColumnAdapter, + daytime_temperature_apparentAdapter = TemperatureColumnAdapter, + daytime_temperature_wind_chillAdapter = TemperatureColumnAdapter, + daytime_humidexAdapter = TemperatureColumnAdapter, + daytime_total_precipitationAdapter = PrecipitationColumnAdapter, + daytime_thunderstorm_precipitationAdapter = PrecipitationColumnAdapter, + daytime_rain_precipitationAdapter = PrecipitationColumnAdapter, + daytime_snow_precipitationAdapter = PrecipitationColumnAdapter, + daytime_ice_precipitationAdapter = PrecipitationColumnAdapter, + daytime_total_precipitation_probabilityAdapter = RatioColumnAdapter, + daytime_thunderstorm_precipitation_probabilityAdapter = RatioColumnAdapter, + daytime_rain_precipitation_probabilityAdapter = RatioColumnAdapter, + daytime_snow_precipitation_probabilityAdapter = RatioColumnAdapter, + daytime_ice_precipitation_probabilityAdapter = RatioColumnAdapter, + daytime_total_precipitation_durationAdapter = DurationColumnAdapter, + daytime_thunderstorm_precipitation_durationAdapter = DurationColumnAdapter, + daytime_rain_precipitation_durationAdapter = DurationColumnAdapter, + daytime_snow_precipitation_durationAdapter = DurationColumnAdapter, + daytime_ice_precipitation_durationAdapter = DurationColumnAdapter, + daytime_wind_speedAdapter = SpeedColumnAdapter, + daytime_wind_gustsAdapter = SpeedColumnAdapter, + nighttime_temperatureAdapter = TemperatureColumnAdapter, + nighttime_temperature_source_feels_likeAdapter = TemperatureColumnAdapter, + nighttime_temperature_apparentAdapter = TemperatureColumnAdapter, + nighttime_temperature_wind_chillAdapter = TemperatureColumnAdapter, + nighttime_humidexAdapter = TemperatureColumnAdapter, + nighttime_weather_codeAdapter = WeatherCodeColumnAdapter, + nighttime_total_precipitationAdapter = PrecipitationColumnAdapter, + nighttime_thunderstorm_precipitationAdapter = PrecipitationColumnAdapter, + nighttime_rain_precipitationAdapter = PrecipitationColumnAdapter, + nighttime_snow_precipitationAdapter = PrecipitationColumnAdapter, + nighttime_ice_precipitationAdapter = PrecipitationColumnAdapter, + nighttime_total_precipitation_probabilityAdapter = RatioColumnAdapter, + nighttime_thunderstorm_precipitation_probabilityAdapter = RatioColumnAdapter, + nighttime_rain_precipitation_probabilityAdapter = RatioColumnAdapter, + nighttime_snow_precipitation_probabilityAdapter = RatioColumnAdapter, + nighttime_ice_precipitation_probabilityAdapter = RatioColumnAdapter, + nighttime_total_precipitation_durationAdapter = DurationColumnAdapter, + nighttime_thunderstorm_precipitation_durationAdapter = DurationColumnAdapter, + nighttime_rain_precipitation_durationAdapter = DurationColumnAdapter, + nighttime_snow_precipitation_durationAdapter = DurationColumnAdapter, + nighttime_ice_precipitation_durationAdapter = DurationColumnAdapter, + nighttime_wind_speedAdapter = SpeedColumnAdapter, + nighttime_wind_gustsAdapter = SpeedColumnAdapter, + degree_day_heatingAdapter = TemperatureColumnAdapter, + degree_day_coolingAdapter = TemperatureColumnAdapter, + pm25Adapter = PollutantConcentrationColumnAdapter, + pm10Adapter = PollutantConcentrationColumnAdapter, + so2Adapter = PollutantConcentrationColumnAdapter, + no2Adapter = PollutantConcentrationColumnAdapter, + o3Adapter = PollutantConcentrationColumnAdapter, + coAdapter = PollutantConcentrationColumnAdapter, + alderAdapter = PollenConcentrationColumnAdapter, + ashAdapter = PollenConcentrationColumnAdapter, + birchAdapter = PollenConcentrationColumnAdapter, + chestnutAdapter = PollenConcentrationColumnAdapter, + cypressAdapter = PollenConcentrationColumnAdapter, + grassAdapter = PollenConcentrationColumnAdapter, + hazelAdapter = PollenConcentrationColumnAdapter, + hornbeamAdapter = PollenConcentrationColumnAdapter, + lindenAdapter = PollenConcentrationColumnAdapter, + moldAdapter = PollenConcentrationColumnAdapter, + mugwortAdapter = PollenConcentrationColumnAdapter, + oakAdapter = PollenConcentrationColumnAdapter, + oliveAdapter = PollenConcentrationColumnAdapter, + planeAdapter = PollenConcentrationColumnAdapter, + plantainAdapter = PollenConcentrationColumnAdapter, + poplarAdapter = PollenConcentrationColumnAdapter, + ragweedAdapter = PollenConcentrationColumnAdapter, + sorrelAdapter = PollenConcentrationColumnAdapter, + treeAdapter = PollenConcentrationColumnAdapter, + urticaceaeAdapter = PollenConcentrationColumnAdapter, + willowAdapter = PollenConcentrationColumnAdapter, + sunshine_durationAdapter = DurationColumnAdapter, + relative_humidity_averageAdapter = RatioColumnAdapter, + relative_humidity_minAdapter = RatioColumnAdapter, + relative_humidity_maxAdapter = RatioColumnAdapter, + dewpoint_averageAdapter = TemperatureColumnAdapter, + dewpoint_minAdapter = TemperatureColumnAdapter, + dewpoint_maxAdapter = TemperatureColumnAdapter, + pressure_averageAdapter = PressureColumnAdapter, + pressure_maxAdapter = PressureColumnAdapter, + pressure_minAdapter = PressureColumnAdapter, + cloud_cover_averageAdapter = RatioColumnAdapter, + cloud_cover_minAdapter = RatioColumnAdapter, + cloud_cover_maxAdapter = RatioColumnAdapter, + visibility_averageAdapter = DistanceColumnAdapter, + visibility_maxAdapter = DistanceColumnAdapter, + visibility_minAdapter = DistanceColumnAdapter + ), + hourlysAdapter = Hourlys.Adapter( + weather_codeAdapter = WeatherCodeColumnAdapter, + temperatureAdapter = TemperatureColumnAdapter, + temperature_source_feels_likeAdapter = TemperatureColumnAdapter, + temperature_apparentAdapter = TemperatureColumnAdapter, + temperature_wind_chillAdapter = TemperatureColumnAdapter, + humidexAdapter = TemperatureColumnAdapter, + total_precipitationAdapter = PrecipitationColumnAdapter, + thunderstorm_precipitationAdapter = PrecipitationColumnAdapter, + rain_precipitationAdapter = PrecipitationColumnAdapter, + snow_precipitationAdapter = PrecipitationColumnAdapter, + ice_precipitationAdapter = PrecipitationColumnAdapter, + total_precipitation_probabilityAdapter = RatioColumnAdapter, + thunderstorm_precipitation_probabilityAdapter = RatioColumnAdapter, + rain_precipitation_probabilityAdapter = RatioColumnAdapter, + snow_precipitation_probabilityAdapter = RatioColumnAdapter, + ice_precipitation_probabilityAdapter = RatioColumnAdapter, + wind_speedAdapter = SpeedColumnAdapter, + wind_gustsAdapter = SpeedColumnAdapter, + pm25Adapter = PollutantConcentrationColumnAdapter, + pm10Adapter = PollutantConcentrationColumnAdapter, + so2Adapter = PollutantConcentrationColumnAdapter, + no2Adapter = PollutantConcentrationColumnAdapter, + o3Adapter = PollutantConcentrationColumnAdapter, + coAdapter = PollutantConcentrationColumnAdapter, + relative_humidityAdapter = RatioColumnAdapter, + dew_pointAdapter = TemperatureColumnAdapter, + pressureAdapter = PressureColumnAdapter, + cloud_coverAdapter = RatioColumnAdapter, + visibilityAdapter = DistanceColumnAdapter + ), + minutelysAdapter = Minutelys.Adapter( + precipitation_intensityAdapter = PrecipitationColumnAdapter + ), + alertsAdapter = Alerts.Adapter( + severityAdapter = AlertSeverityColumnAdapter + ), + normalsAdapter = Normals.Adapter( + temperature_max_averageAdapter = TemperatureColumnAdapter, + temperature_min_averageAdapter = TemperatureColumnAdapter + ) + ) + } + + @Provides + @Singleton + fun provideDatabaseHandler(db: Database, driver: SqlDriver): DatabaseHandler { + return AndroidDatabaseHandler(db, driver) + } + + @Provides + @Singleton + fun provideLocationRepository(databaseHandler: DatabaseHandler): LocationRepository { + return LocationRepository(databaseHandler) + } + + @Provides + @Singleton + fun provideWeatherRepository(databaseHandler: DatabaseHandler): WeatherRepository { + return WeatherRepository(databaseHandler) + } +} diff --git a/app/src/main/java/org/breezyweather/common/di/HttpModule.kt b/app/src/main/java/org/breezyweather/common/di/HttpModule.kt new file mode 100644 index 0000000..d4d48c9 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/di/HttpModule.kt @@ -0,0 +1,195 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.di + +import android.app.Application +import android.os.Build +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import nl.adaptivity.xmlutil.serialization.XML +import okhttp3.Cache +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.tls.HandshakeCertificates +import org.breezyweather.BreezyWeather +import org.breezyweather.R +import retrofit2.Converter +import retrofit2.Retrofit +import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.io.File +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.inject.Named +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class HttpModule { + @Provides + @Singleton + fun provideOkHttpClient(app: Application, loggingInterceptor: HttpLoggingInterceptor): OkHttpClient { + val client = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + /** + * Add support for Let’s encrypt certificate authority on Android < 7.0 + */ + try { + val certificateFactory = CertificateFactory.getInstance("X.509") + val certificateIsrgRootX1 = certificateFactory + .generateCertificates(app.resources.openRawResource(R.raw.isrg_root_x1)) + .single() as X509Certificate + val certificateIsrgRootX2 = certificateFactory + .generateCertificates(app.resources.openRawResource(R.raw.isrg_root_x2)) + .single() as X509Certificate + val certificates = HandshakeCertificates.Builder() + .addTrustedCertificate(certificateIsrgRootX1) + .addTrustedCertificate(certificateIsrgRootX2) + .addPlatformTrustedCertificates() + .build() + + OkHttpClient.Builder() + .sslSocketFactory(certificates.sslSocketFactory(), certificates.trustManager) + } catch (ignored: Exception) { + OkHttpClient.Builder() + } + } else { + OkHttpClient.Builder() + } + + return client + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(45, TimeUnit.SECONDS) + .cache( + Cache( + File(app.cacheDir, "http_cache"), // $0.05 worth of phone storage in 2020 + 50L * 1024L * 1024L // 50 MiB + ) + ) + .addInterceptor(loggingInterceptor) + .build() + } + + @Provides + @Singleton + fun provideRxJava3CallAdapterFactory(): RxJava3CallAdapterFactory { + return RxJava3CallAdapterFactory.create() + } + + @Provides + @Singleton + fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor { + return HttpLoggingInterceptor().apply { + level = if (BreezyWeather.instance.debugMode) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + } + + @Provides + @Singleton + @Named("JsonSerializer") + fun provideKotlinxJsonSerializationConverterFactory(): Converter.Factory { + val contentType = "application/json".toMediaType() + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + isLenient = !BreezyWeather.instance.debugMode + } + return json.asConverterFactory(contentType) + } + + @Provides + @Named("JsonClient") + fun provideJsonRetrofitBuilder( + client: OkHttpClient, + @Named("JsonSerializer") jsonConverterFactory: Converter.Factory, + callAdapterFactory: RxJava3CallAdapterFactory, + ): Retrofit.Builder { + return Retrofit.Builder() + .client(client) + .addConverterFactory(jsonConverterFactory) + // TODO: We should probably migrate to suspend + // https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-260-2019-06-05 + .addCallAdapterFactory(callAdapterFactory) + } + + @Provides + @Singleton + @Named("XmlSerializer") + fun provideKotlinxXmlSerializationConverterFactory(): Converter.Factory { + val contentType = "application/xml".toMediaType() + return XML { + defaultPolicy { + pedantic = false + ignoreUnknownChildren() + } + autoPolymorphic = true + }.asConverterFactory(contentType) + } + + @Provides + @Named("XmlClient") + fun provideXmlRetrofitBuilder( + client: OkHttpClient, + @Named("XmlSerializer") xmlConverterFactory: Converter.Factory, + callAdapterFactory: RxJava3CallAdapterFactory, + ): Retrofit.Builder { + return Retrofit.Builder() + .client(client) + .addConverterFactory(xmlConverterFactory) + // TODO: We should probably migrate to suspend + // https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-260-2019-06-05 + .addCallAdapterFactory(callAdapterFactory) + } + + /*@Provides + @Singleton + @Named("CsvSerializer") + fun provideKotlinxCsvSerializationConverterFactory(): Converter.Factory { + val contentType = "text/csv".toMediaType() // RFC 7111 + val csv = Csv { + hasHeaderRecord = true + delimiter = ';' + recordSeparator = "\r\n" + ignoreUnknownColumns = true + } + return csv.asConverterFactory(contentType) + } + + @Provides + @Named("CsvClient") + fun provideCsvRetrofitBuilder( + client: OkHttpClient, + @Named("CsvSerializer") csvConverterFactory: Converter.Factory, + callAdapterFactory: RxJava3CallAdapterFactory, + ): Retrofit.Builder { + return Retrofit.Builder() + .client(client) + .addConverterFactory(csvConverterFactory) + // TODO: We should probably migrate to suspend + // https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-260-2019-06-05 + .addCallAdapterFactory(callAdapterFactory) + }*/ +} diff --git a/app/src/main/java/org/breezyweather/common/di/RxModule.kt b/app/src/main/java/org/breezyweather/common/di/RxModule.kt new file mode 100644 index 0000000..c11c3cc --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/di/RxModule.kt @@ -0,0 +1,32 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.reactivex.rxjava3.disposables.CompositeDisposable + +@InstallIn(SingletonComponent::class) +@Module +class RxModule { + @Provides + fun provideCompositeDisposable(): CompositeDisposable { + return CompositeDisposable() + } +} diff --git a/app/src/main/java/org/breezyweather/common/exceptions/ApiKeyMissingException.kt b/app/src/main/java/org/breezyweather/common/exceptions/ApiKeyMissingException.kt new file mode 100644 index 0000000..3f4c5e8 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/ApiKeyMissingException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class ApiKeyMissingException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/ApiLimitReachedException.kt b/app/src/main/java/org/breezyweather/common/exceptions/ApiLimitReachedException.kt new file mode 100644 index 0000000..19bebe6 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/ApiLimitReachedException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class ApiLimitReachedException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/ApiUnauthorizedException.kt b/app/src/main/java/org/breezyweather/common/exceptions/ApiUnauthorizedException.kt new file mode 100644 index 0000000..a6a84ed --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/ApiUnauthorizedException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class ApiUnauthorizedException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/InvalidLocationException.kt b/app/src/main/java/org/breezyweather/common/exceptions/InvalidLocationException.kt new file mode 100644 index 0000000..cf73512 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/InvalidLocationException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class InvalidLocationException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/InvalidOrIncompleteDataException.kt b/app/src/main/java/org/breezyweather/common/exceptions/InvalidOrIncompleteDataException.kt new file mode 100644 index 0000000..775b7cf --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/InvalidOrIncompleteDataException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class InvalidOrIncompleteDataException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/LocationAccessOffException.kt b/app/src/main/java/org/breezyweather/common/exceptions/LocationAccessOffException.kt new file mode 100644 index 0000000..6a8879b --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/LocationAccessOffException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class LocationAccessOffException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/LocationException.kt b/app/src/main/java/org/breezyweather/common/exceptions/LocationException.kt new file mode 100644 index 0000000..e61f314 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/LocationException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class LocationException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/LocationSearchException.kt b/app/src/main/java/org/breezyweather/common/exceptions/LocationSearchException.kt new file mode 100644 index 0000000..b58ae88 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/LocationSearchException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class LocationSearchException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/MissingPermissionLocationBackgroundException.kt b/app/src/main/java/org/breezyweather/common/exceptions/MissingPermissionLocationBackgroundException.kt new file mode 100644 index 0000000..8947ca0 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/MissingPermissionLocationBackgroundException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class MissingPermissionLocationBackgroundException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/MissingPermissionLocationException.kt b/app/src/main/java/org/breezyweather/common/exceptions/MissingPermissionLocationException.kt new file mode 100644 index 0000000..fa82e53 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/MissingPermissionLocationException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class MissingPermissionLocationException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/NoNetworkException.kt b/app/src/main/java/org/breezyweather/common/exceptions/NoNetworkException.kt new file mode 100644 index 0000000..a9a46d6 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/NoNetworkException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class NoNetworkException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/NonFreeNetSourceException.kt b/app/src/main/java/org/breezyweather/common/exceptions/NonFreeNetSourceException.kt new file mode 100644 index 0000000..f42c4fd --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/NonFreeNetSourceException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class NonFreeNetSourceException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/OutdatedServerDataException.kt b/app/src/main/java/org/breezyweather/common/exceptions/OutdatedServerDataException.kt new file mode 100644 index 0000000..90113b3 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/OutdatedServerDataException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class OutdatedServerDataException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/ParsingException.kt b/app/src/main/java/org/breezyweather/common/exceptions/ParsingException.kt new file mode 100644 index 0000000..4fcd4e0 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/ParsingException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class ParsingException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/ReverseGeocodingException.kt b/app/src/main/java/org/breezyweather/common/exceptions/ReverseGeocodingException.kt new file mode 100644 index 0000000..c2fab1d --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/ReverseGeocodingException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class ReverseGeocodingException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/SourceNotInstalledException.kt b/app/src/main/java/org/breezyweather/common/exceptions/SourceNotInstalledException.kt new file mode 100644 index 0000000..c9a1123 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/SourceNotInstalledException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class SourceNotInstalledException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/UnsupportedFeatureException.kt b/app/src/main/java/org/breezyweather/common/exceptions/UnsupportedFeatureException.kt new file mode 100644 index 0000000..52e7800 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/UnsupportedFeatureException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class UnsupportedFeatureException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/exceptions/WeatherException.kt b/app/src/main/java/org/breezyweather/common/exceptions/WeatherException.kt new file mode 100644 index 0000000..91af77d --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/exceptions/WeatherException.kt @@ -0,0 +1,19 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.exceptions + +class WeatherException : Exception() diff --git a/app/src/main/java/org/breezyweather/common/extensions/ContextExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/ContextExtensions.kt new file mode 100644 index 0000000..8173d95 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/ContextExtensions.kt @@ -0,0 +1,116 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.Manifest +import android.app.UiModeManager +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ShortcutManager +import android.hardware.SensorManager +import android.location.LocationManager +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import androidx.annotation.RawRes +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import com.google.maps.android.data.geojson.GeoJsonParser +import org.breezyweather.domain.settings.SettingsManager +import org.json.JSONObject +import java.io.File + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/162b6397050e1577c113a88e7b7cfe9f98e6a45c/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt + */ + +/** + * Checks if the give permission is granted. + * + * @param permission the permission to check. + * @return true if it has permissions. + */ +fun Context.hasPermission( + permission: String, +) = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + +/** + * Checks if the notification permission is granted. + * + * @return true if the permission is granted. Always returns true on Android 12 and lower. + */ +val Context.hasNotificationPermission + get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + hasPermission(Manifest.permission.POST_NOTIFICATIONS) + +val Context.clipboardManager: ClipboardManager + get() = getSystemService()!! + +val Context.inputMethodManager: InputMethodManager + get() = getSystemService()!! + +val Context.locationManager: LocationManager + get() = getSystemService()!! + +val Context.powerManager: PowerManager + get() = getSystemService()!! + +val Context.sensorManager: SensorManager? + get() = if (SettingsManager.getInstance(this).isGravitySensorEnabled) { + getSystemService() + } else { + null + } + +val Context.windowManager: WindowManager? + get() = getSystemService() + +val Context.shortcutManager: ShortcutManager? + get() = getSystemService() + +val Context.uiModeManager: UiModeManager? + get() = getSystemService() + +fun Context.createFileInCacheDir(name: String): File { + val file = File(externalCacheDir, name) + if (file.exists()) { + file.delete() + } + file.createNewFile() + return file +} + +fun Context.parseRawGeoJson(@RawRes rawFile: Int): GeoJsonParser { + val text = resources.openRawResource(rawFile).bufferedReader().use { it.readText() } + return GeoJsonParser(JSONObject(text)) +} + +fun Context.openApplicationDetailsSettings() { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).setData( + Uri.fromParts("package", packageName, null) + ) + ) +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/CoroutinesExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/CoroutinesExtensions.kt new file mode 100644 index 0000000..f7ed6ca --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/CoroutinesExtensions.kt @@ -0,0 +1,37 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +fun CoroutineScope.launchUI(block: suspend CoroutineScope.() -> Unit): Job = + launch(Dispatchers.Main, block = block) + +fun CoroutineScope.launchIO(block: suspend CoroutineScope.() -> Unit): Job = + launch(Dispatchers.IO, block = block) + +suspend fun withUIContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.Main, block) + +suspend fun withIOContext(block: suspend CoroutineScope.() -> T) = withContext(Dispatchers.IO, block) + +suspend fun withNonCancellableContext(block: suspend CoroutineScope.() -> T) = + withContext(NonCancellable, block) diff --git a/app/src/main/java/org/breezyweather/common/extensions/DataSharingExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/DataSharingExtensions.kt new file mode 100644 index 0000000..5d34560 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/DataSharingExtensions.kt @@ -0,0 +1,43 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.os.Bundle +import android.os.Parcel +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream + +val Bundle.sizeInBytes: Int + get() { + val parcel = Parcel.obtain() + parcel.writeBundle(this) + + return parcel.dataSize().also { + parcel.recycle() + } + } + +/** + * Compress a string using GZIP. + * + * @return an UTF-8 encoded byte array. + */ +fun String.gzipCompress(): ByteArray { + val bos = ByteArrayOutputStream() + GZIPOutputStream(bos).bufferedWriter(Charsets.UTF_8).use { it.write(this) } + return bos.toByteArray() +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/DateExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/DateExtensions.kt new file mode 100644 index 0000000..35122c2 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/DateExtensions.kt @@ -0,0 +1,286 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.annotation.SuppressLint +import android.content.Context +import android.icu.text.DateTimePatternGenerator +import android.icu.text.SimpleDateFormat +import android.icu.util.TimeZone +import android.icu.util.ULocale +import android.os.Build +import android.text.format.DateFormat +import android.text.format.DateUtils +import androidx.annotation.RequiresApi +import breezyweather.domain.location.model.Location +import breezyweather.domain.weather.reference.Month +import org.breezyweather.BreezyWeather +import org.breezyweather.common.options.appearance.CalendarHelper +import org.breezyweather.common.utils.helpers.LogHelper +import org.chickenhook.restrictionbypass.RestrictionBypass +import java.lang.reflect.Method +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.Date +import java.util.Locale + +val Context.is12Hour: Boolean + get() = !DateFormat.is24HourFormat(this) + +@SuppressLint("PrivateApi") +fun Date.getRelativeTime(context: Context): String { + try { + // Reflection allows us to specify the locale + // If we don't, we always have system locale instead of per-app language preference + val getRelativeTimeSpanStringMethod: Method = RestrictionBypass.getMethod( + Class.forName("android.text.format.RelativeDateTimeFormatter"), + "getRelativeTimeSpanString", + Locale::class.java, + java.util.TimeZone::class.java, + Long::class.javaPrimitiveType, + Long::class.javaPrimitiveType, + Long::class.javaPrimitiveType, + Int::class.javaPrimitiveType + ) + return getRelativeTimeSpanStringMethod.invoke( + null, + context.currentLocale, + java.util.TimeZone.getDefault(), + time, + Date().time, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) as String + } catch (_: Exception) { + if (BreezyWeather.instance.debugMode) { + LogHelper.log(msg = "Reflection of relative time failed") + } + return DateUtils.getRelativeTimeSpanString( + time, + Date().time, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ) as String + } +} + +// Makes the code more readable by not having to do a null check condition +fun Long.toDate(): Date { + return Date(this) +} + +fun Date.getFormattedDate( + pattern: String, + location: Location? = null, + context: Context? = null, + withBestPattern: Boolean = false, +): String { + val locale = context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + SimpleDateFormat( + if (withBestPattern) { + DateTimePatternGenerator.getInstance(locale).getBestPattern(pattern) + } else { + pattern + }, + locale + ).apply { + timeZone = location?.timeZone?.let { TimeZone.getTimeZone(it.id) } ?: TimeZone.getDefault() + }.format(this) + } else { + @Suppress("DEPRECATION") + getFormattedDate(pattern, location?.timeZone, locale) + } +} + +fun Date.getFormattedTime( + location: Location? = null, + context: Context?, + twelveHour: Boolean, +): String { + return if (twelveHour) { + getFormattedDate("h:mm a", location, context, withBestPattern = true) + } else { + getFormattedDate("HH:mm", location, context) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +fun LocalTime.getFormattedTime( + locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build(), + twelveHour: Boolean, +): String { + return if (twelveHour) { + format( + DateTimeFormatter.ofPattern( + DateTimePatternGenerator.getInstance(locale).getBestPattern("h:mm a") + ).withLocale(locale) + ) + } else { + format(DateTimeFormatter.ofPattern("HH:mm").withLocale(locale)) + } +} + +fun Date.getFormattedShortDayAndMonth( + location: Location, + context: Context?, +): String { + return getFormattedDate("MM-dd", location, context, withBestPattern = true) +} + +fun Date.getFormattedDayOfTheMonth( + location: Location, + context: Context?, +): String { + return getFormattedDate("dd", location, context, withBestPattern = true) +} + +fun Date.getFormattedMediumDayAndMonth( + location: Location, + context: Context?, +): String { + val locale = context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build() + return getFormattedDate("d MMM", location, context, withBestPattern = true).capitalize(locale) +} + +fun Date.getFormattedFullDayAndMonth( + location: Location, + context: Context?, +): String { + val locale = context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build() + return getFormattedDate("d MMMM", location, context, withBestPattern = true).capitalize(locale) +} + +fun getShortWeekdayDayMonth( + context: Context?, +): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + DateTimePatternGenerator.getInstance( + context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build() + ).getBestPattern("EEE d MMM") + } else { + "EEE d MMM" + } +} + +fun getLongWeekdayDayMonth( + context: Context?, +): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + DateTimePatternGenerator.getInstance( + context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build() + ).getBestPattern("EEEE d MMMM") + } else { + "EEEE d MMMM" + } +} + +fun Date.getWeek(location: Location, context: Context?, full: Boolean = false): String { + val locale = context?.currentLocale ?: Locale.Builder().setLanguage("en").setRegion("001").build() + return getFormattedDate(if (full) "EEEE" else "E", location, context).capitalize(locale) +} + +fun Date.getHour(location: Location, context: Context): String { + return getFormattedDate( + if (context.is12Hour) "h a" else "H:mm", + location, + context, + withBestPattern = context.is12Hour + ) +} + +fun Date.getHourIn24Format(location: Location): String { + return getFormattedDate("H", location) +} + +/** + * See CalendarHelper.supportedCalendars for full list of supported calendars + */ +fun Date.getFormattedMediumDayAndMonthInAdditionalCalendar( + location: Location? = null, + context: Context, +): String? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val calendarId = CalendarHelper.getAlternateCalendarSetting(context) + if (calendarId != null) { + val alternateCalendar = CalendarHelper.getCalendars(context).firstOrNull { it.id == calendarId } + if (alternateCalendar != null) { + val locale = context.currentLocale + val uLocale = ULocale.Builder().apply { + setLanguageTag(locale.toLanguageTag()) + setUnicodeLocaleKeyword(CalendarHelper.CALENDAR_EXTENSION_TYPE, calendarId) + alternateCalendar.additionalParams?.forEach { + setUnicodeLocaleKeyword(it.key, it.value) + } + }.build() + SimpleDateFormat( + if (!alternateCalendar.specificPattern.isNullOrEmpty()) { + alternateCalendar.specificPattern + } else { + DateTimePatternGenerator.getInstance(uLocale).getBestPattern("d MMM") + }, + uLocale + ).apply { + timeZone = location?.timeZone?.let { TimeZone.getTimeZone(it.id) } ?: TimeZone.getDefault() + }.format(this) + } else { + null + } + } else { + null + } + } else { + null + } +} + +fun Date.toCalendar(location: Location): Calendar { + return Calendar.getInstance().also { + it.time = this + it.timeZone = location.timeZone + } +} + +/** + * Optimized function to get yyyy-MM-dd formatted date + * Takes 0 ms on my device compared to 2-3 ms for getFormattedDate() (which uses SimpleDateFormat) + * Saves about 1 second when looping through 24 hourly over a 16 day period + */ +fun Calendar.getIsoFormattedDate(): String { + return "${this[Calendar.YEAR]}-${getMonth(twoDigits = true)}-${getDayOfMonth(twoDigits = true)}" +} + +fun Calendar.getMonth(twoDigits: Boolean = false): String { + return "${(this[Calendar.MONTH] + 1).let { month -> + if (twoDigits && month.toString().length < 2) "0$month" else month + }}" +} + +fun Calendar.getDayOfMonth(twoDigits: Boolean = false): String { + return "${this[Calendar.DAY_OF_MONTH].let { day -> + if (twoDigits && day.toString().length < 2) "0$day" else day + }}" +} + +fun Date.getIsoFormattedDate(location: Location): String { + return toCalendar(location).getIsoFormattedDate() +} + +fun Date.getCalendarMonth(location: Location): Month { + return Month.fromCalendarMonth(toCalendarWithTimeZone(location.timeZone)[Calendar.MONTH]) +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/DateOldExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/DateOldExtensions.kt new file mode 100644 index 0000000..c04c455 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/DateOldExtensions.kt @@ -0,0 +1,99 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * The functions below make use of old java.util.* that should be replaced with android.icu + * counterparts, introduced in Android SDK 24 + */ + +fun Date.toCalendarWithTimeZone(zone: TimeZone): Calendar { + return Calendar.getInstance().also { + it.time = this + it.timeZone = zone + } +} + +/** + * Get a date at midnight on a specific timezone from a formatted date + * @this formattedDate in yyyy-MM-dd format + * @param timeZoneP + * @return Date + */ +fun String.toDateNoHour(timeZoneP: TimeZone = TimeZone.getDefault()): Date? { + if (isEmpty() || length < 10) return null + return Calendar.getInstance().also { + it.timeZone = timeZoneP + it.set(Calendar.YEAR, substring(0, 4).toInt()) + it.set(Calendar.MONTH, substring(5, 7).toInt() - 1) + it.set(Calendar.DAY_OF_MONTH, substring(8, 10).toInt()) + it.set(Calendar.HOUR_OF_DAY, 0) + it.set(Calendar.MINUTE, 0) + it.set(Calendar.SECOND, 0) + it.set(Calendar.MILLISECOND, 0) + }.time +} + +@Deprecated("Makes no sense, must be replaced") +fun Date.toTimezone(timeZone: TimeZone = TimeZone.getDefault()): Date { + val calendarWithTimeZone = toCalendarWithTimeZone(timeZone) + return Date( + calendarWithTimeZone[Calendar.YEAR] - 1900, + calendarWithTimeZone[Calendar.MONTH], + calendarWithTimeZone[Calendar.DAY_OF_MONTH], + calendarWithTimeZone[Calendar.HOUR_OF_DAY], + calendarWithTimeZone[Calendar.MINUTE], + calendarWithTimeZone[Calendar.SECOND] + ) +} + +@Deprecated("Use toTimezoneSpecificHour instead") +fun Date.toTimezoneNoHour(timeZone: TimeZone = TimeZone.getDefault()): Date { + return toTimezoneSpecificHour(timeZone) +} + +fun Date.toTimezoneSpecificHour( + timeZone: TimeZone = TimeZone.getDefault(), + specificHour: Int = 0, +): Date { + return toCalendarWithTimeZone(timeZone).apply { + set(Calendar.YEAR, get(Calendar.YEAR)) + set(Calendar.MONTH, get(Calendar.MONTH)) + set(Calendar.DAY_OF_MONTH, get(Calendar.DAY_OF_MONTH)) + set(Calendar.HOUR_OF_DAY, specificHour) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.time +} + +@Deprecated("Use ICU functions instead") +fun Date.getFormattedDate( + pattern: String, + timeZone: TimeZone?, + locale: Locale, +): String { + return SimpleDateFormat(pattern, locale).apply { + setTimeZone(timeZone ?: TimeZone.getDefault()) + }.format(this) +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/DisplayExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/DisplayExtensions.kt new file mode 100644 index 0000000..35fc3ac --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/DisplayExtensions.kt @@ -0,0 +1,281 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.animation.Animator +import android.animation.ObjectAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.os.Build +import android.provider.Settings +import android.provider.Settings.SettingNotFoundException +import android.util.TypedValue +import android.view.View +import android.view.Window +import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator +import android.view.animation.OvershootInterpolator +import androidx.annotation.AttrRes +import androidx.annotation.ColorRes +import androidx.annotation.Px +import androidx.annotation.Size +import androidx.annotation.StyleRes +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.createBitmap +import androidx.core.view.WindowInsetsControllerCompat +import com.google.android.material.resources.TextAppearance +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt + +private const val MAX_TABLET_ADAPTIVE_LIST_WIDTH_DIP_PHONE = 512 +private const val MAX_TABLET_ADAPTIVE_LIST_WIDTH_DIP_TABLET = 600 +val FLOATING_DECELERATE_INTERPOLATOR: Interpolator = DecelerateInterpolator(1f) +const val DEFAULT_CARD_LIST_ITEM_ELEVATION_DP = 2f +private const val SQUISHED_BLOCK_FACTOR = 1.1f + +val Context.isTabletDevice: Boolean + get() = ( + resources.configuration.screenLayout + and Configuration.SCREENLAYOUT_SIZE_MASK + ) >= Configuration.SCREENLAYOUT_SIZE_LARGE + +val Context.isLandscape: Boolean + get() = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + +/** + * Minimum size is adjusted from font scale: + * - At 1.0 font scale, minimum size for a block is 1.0 (160 dp) + * - At 2.0 font scale, minimum size for a block is 2.0 (320 dp) + */ +val Context.minBlockWidth: Float + get() = 0.5f + fontScale.div(2f) + +/** + * @param widthInDp available width in which blocks will be displayed + * @return a number of blocks between 1 and 5 that can fit + */ +fun Context.getBlocksPerRow( + widthInDp: Float = windowWidth.toFloat().div(density), +): Int { + val potentialResult = floor(widthInDp.div(minBlockWidth)).roundToInt().coerceIn(1..5) + return if (potentialResult > 2) { + // if more than 2 blocks can fit, we prefer displaying less blocks and have a bit more room + // rather than having squished blocks + floor(widthInDp.div(minBlockWidth * SQUISHED_BLOCK_FACTOR)).roundToInt().coerceIn(1..5) + } else { + potentialResult + } +} + +/** + * Simplified estimation by taking into account more than 2 blocks are never squished, + * and that devices with drawer layout always have space for at least 2 non-squished blocks + */ +val Context.areBlocksSquished: Boolean + get() = getBlocksPerRow().let { blocksPerRow -> + if (blocksPerRow > 2) { + false + } else { + windowWidth.toFloat().div(density).div(minBlockWidth * SQUISHED_BLOCK_FACTOR) < blocksPerRow + } + } + +val Context.isRtl: Boolean + get() = resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL + +val Context.isDarkMode: Boolean + get() = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + +val Context.isMotionReduced: Boolean + get() { + return try { + Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE) == 0f + } catch (e: SettingNotFoundException) { + false + } + } + +val Context.density: Int + get() { + return resources.displayMetrics.densityDpi + } + +val Context.fontScale: Float + get() { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + resources.configuration.fontScale * + resources.displayMetrics.densityDpi.div(android.util.DisplayMetrics.DENSITY_DEVICE_STABLE.toFloat()) + } else { + 1f // Let’s just ignore it on old Android versions + } + } + +// Take into account font scale, but not as much +// For example a font scale of 1.6 makes the width 1.3 times larger +val Context.fontScaleToApply: Float + get() { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + fontScale.let { + if (it != 1f) 1f + abs(it - 1f).div(2f).times(if (it > 1f) 1f else -1f) else it + } + } else { + 1f // Let’s just ignore it on old Android versions + } + } + +val Context.windowHeightInDp: Float + get() { + return pxToDp(resources.displayMetrics.heightPixels) + } + +val Context.windowWidthInDp: Float + get() { + return pxToDp(resources.displayMetrics.widthPixels) + } + +val Context.windowWidth: Int + @Px + get() { + return resources.displayMetrics.widthPixels + } + +fun Context.dpToPx(dp: Float): Float { + return dp * (resources.displayMetrics.densityDpi / 160f) +} + +fun Context.spToPx(sp: Int): Float { + return sp * TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 1.0f, resources.displayMetrics) +} + +@Suppress("unused") +fun Context.pxToDp(@Px px: Int): Float { + return px / (resources.displayMetrics.densityDpi / 160f) +} + +@Px +fun Context.getTabletListAdaptiveWidth(@Px width: Int): Int { + return if (!isTabletDevice && !isLandscape) { + width + } else { + min( + width.toFloat(), + dpToPx( + if (isTabletDevice) { + MAX_TABLET_ADAPTIVE_LIST_WIDTH_DIP_TABLET + } else { + MAX_TABLET_ADAPTIVE_LIST_WIDTH_DIP_PHONE + }.toFloat() + ) + ).toInt() + } +} + +@SuppressLint("RestrictedApi", "VisibleForTests") +fun Context.getTypefaceFromTextAppearance( + @StyleRes textAppearanceId: Int, +): Typeface { + return TextAppearance(this, textAppearanceId).getFont(this) +} + +fun Context.getThemeColor( + @AttrRes id: Int, +): Int { + val typedValue = TypedValue() + theme.resolveAttribute(id, typedValue, true) + return typedValue.data +} + +fun Context.getColorResource(@ColorRes id: Int): androidx.compose.ui.graphics.Color { + return androidx.compose.ui.graphics.Color(ResourcesCompat.getColor(resources, id, theme)) +} + +@Suppress("DEPRECATION") +fun Window.setSystemBarStyle( + lightStatus: Boolean, +) { + var newLightStatus = lightStatus + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + // Use default dark and light platform colors from EdgeToEdge + val colorSystemBarDark = Color.argb(0x80, 0x1b, 0x1b, 0x1b) + val colorSystemBarLight = Color.argb(0xe6, 0xFF, 0xFF, 0xFF) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // Always apply a dark shader as a light or transparent status bar is not supported + newLightStatus = false + } + statusBarColor = Color.TRANSPARENT + + navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && lightStatus) { + colorSystemBarLight + } else { + colorSystemBarDark + } + } else { + isStatusBarContrastEnforced = false + isNavigationBarContrastEnforced = true + } + + // Contrary to the documentation FALSE applies a light foreground color and TRUE a dark foreground color + WindowInsetsControllerCompat(this, decorView).run { + isAppearanceLightStatusBars = newLightStatus + isAppearanceLightNavigationBars = lightStatus + } +} + +fun Drawable.toBitmap(): Bitmap { + val bitmap = createBitmap(intrinsicWidth, intrinsicHeight) + val canvas = Canvas(bitmap) + setBounds(0, 0, intrinsicWidth, intrinsicHeight) + draw(canvas) + return bitmap +} + +// translationY, scaleX, scaleY +@Size(3) +fun View.getFloatingOvershotEnterAnimators(): Array { + return getFloatingOvershotEnterAnimators(1.5f) +} + +@Size(3) +fun View.getFloatingOvershotEnterAnimators(overshootFactor: Float): Array { + return getFloatingOvershotEnterAnimators(overshootFactor, translationY, scaleX, scaleY) +} + +@Size(3) +fun View.getFloatingOvershotEnterAnimators( + overshootFactor: Float, + translationYFrom: Float, + scaleXFrom: Float, + scaleYFrom: Float, +): Array { + val translation: Animator = ObjectAnimator.ofFloat(this, "translationY", translationYFrom, 0f) + translation.interpolator = OvershootInterpolator(overshootFactor) + val scaleX: Animator = ObjectAnimator.ofFloat(this, "scaleX", scaleXFrom, 1f) + scaleX.interpolator = FLOATING_DECELERATE_INTERPOLATOR + val scaleY: Animator = ObjectAnimator.ofFloat(this, "scaleY", scaleYFrom, 1f) + scaleY.interpolator = FLOATING_DECELERATE_INTERPOLATOR + return arrayOf(translation, scaleX, scaleY) +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/FileExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/FileExtensions.kt new file mode 100644 index 0000000..a6bbafe --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/FileExtensions.kt @@ -0,0 +1,43 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import org.breezyweather.BuildConfig +import java.io.File + +/** + * Returns the uri of a file + * + * @param context context of application + */ +fun File.getUriCompat(context: Context): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", this) + } else { + toUri() + } +} + +fun fileFromAsset(resource: Int, context: Context): File = + File("${context.cacheDir}/$resource").apply { + writeBytes(context.resources.openRawResource(resource).readBytes()) + } diff --git a/app/src/main/java/org/breezyweather/common/extensions/LanguageExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/LanguageExtensions.kt new file mode 100644 index 0000000..0b70741 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/LanguageExtensions.kt @@ -0,0 +1,135 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import java.util.Locale + +val Context.currentLocale: Locale + get() { + return AppCompatDelegate.getApplicationLocales().get(0) + ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + resources.configuration.locales[0] + } else { + @Suppress("DEPRECATION") + resources.configuration.locale + } + } + +// TODO: Review this use vs toLanguageTag() +val Locale.code: String + get() { + val language = language + val country = country + return if (isTraditionalChinese) { + language.lowercase() + "-" + country.lowercase() + } else { + language.lowercase() + } + } + +// TODO: Review this use vs toLanguageTag() +val Locale.codeWithCountry: String + get() { + val language = language + val country = country + return if (!country.isNullOrEmpty()) { + language.lowercase() + "-" + country.lowercase() + } else { + language.lowercase() + } + } + +// Accepts "Hant" for traditional chinese but no country code otherwise +val Locale.codeForGeonames: String + get() { + return if (isTraditionalChinese) { + language.lowercase() + "-Hant" + } else { + language.lowercase() + } + } + +// Everything in uppercase + "ZHT" for traditional Chinese +val Locale.codeForNaturalEarth: String + get() { + return if (isTraditionalChinese) { + language.uppercase() + "T" + } else { + language.uppercase() + } + } + +val Locale.isChinese: Boolean + get() = language.equals("zh", ignoreCase = true) + +// There is no way to access the script used, so assume Taiwan, Hong Kong and Macao +val Locale.isTraditionalChinese: Boolean + get() = isChinese && + arrayOf("TW", "HK", "MO").any { country.equals(it, ignoreCase = true) } + +fun Locale.getCountryName(countryCode: String): String { + return Locale.Builder() + .setLanguage(language) + .setRegion(countryCode) + .build() + .displayCountry +} + +/** + * Replaces the given string to have at most [count] characters using [replacement] at its end. + * If [replacement] is longer than [count] an exception will be thrown when `length > count`. + */ +fun String.chop(count: Int, replacement: String = "…"): String { + return if (length > count) { + take(count - replacement.length) + replacement + } else { + this + } +} + +fun String.capitalize(locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build()): String { + return replaceFirstChar { firstChar -> + if (firstChar.isLowerCase()) { + firstChar.titlecase(locale) + } else { + firstChar.toString() + } + } +} + +fun String.uncapitalize(locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build()): String { + return replaceFirstChar { firstChar -> + if (firstChar.isUpperCase()) { + firstChar.lowercase(locale) + } else { + firstChar.toString() + } + } +} + +fun Context.getStringByLocale( + id: Int, + locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build(), +): String { + val configuration = Configuration(resources.configuration) + configuration.setLocale(locale) + return createConfigurationContext(configuration).resources.getString(id) +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/ModifierExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/ModifierExtensions.kt new file mode 100644 index 0000000..c090cbe --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/ModifierExtensions.kt @@ -0,0 +1,33 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import androidx.compose.ui.Modifier + +/** + * Source: ProAndroidDev + * https://proandroiddev.com/jetpack-compose-tricks-conditionally-applying-modifiers-for-dynamic-uis-e3fe5a119f45 + */ +inline fun Modifier.conditional( + condition: Boolean, + ifTrue: Modifier.() -> Modifier, + ifFalse: Modifier.() -> Modifier = { this }, +): Modifier = if (condition) { + then(ifTrue(Modifier)) +} else { + then(ifFalse(Modifier)) +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/NetworkExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/NetworkExtensions.kt new file mode 100644 index 0000000..1e65b88 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/NetworkExtensions.kt @@ -0,0 +1,49 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import androidx.core.content.getSystemService + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/c5e8c9f01fa6b54425675ee3ebdc6f735aee7ba9/app/src/main/java/eu/kanade/tachiyomi/util/system/NetworkExtensions.kt + */ +val Context.connectivityManager: ConnectivityManager + get() = getSystemService()!! + +fun Context.isOnline(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val activeNetwork = connectivityManager.activeNetwork ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + val maxTransport = NetworkCapabilities.TRANSPORT_LOWPAN + return if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { + // If VPN is enabled, but there is no other transport enabled, we are actually offline + (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).count(networkCapabilities::hasTransport) > 1 + } else { + (NetworkCapabilities.TRANSPORT_CELLULAR..maxTransport).any(networkCapabilities::hasTransport) + } + } else { + @Suppress("DEPRECATION") + return connectivityManager.activeNetworkInfo?.isConnected ?: false + } +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/NotificationExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/NotificationExtensions.kt new file mode 100644 index 0000000..af93b81 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/NotificationExtensions.kt @@ -0,0 +1,118 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.Manifest +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationChannelGroupCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/953f5fb0253879547a94f88231b36ce81a35b48e/app/src/main/java/eu/kanade/tachiyomi/util/system/NotificationExtensions.kt + */ +val Context.notificationManager: NotificationManager + get() = getSystemService()!! + +fun Context.notify( + id: Int, + channelId: String, + block: (NotificationCompat.Builder.() -> Unit)? = null, +) { + val notification = notificationBuilder(channelId, block).build() + notify(id, notification) +} + +fun Context.notify(id: Int, notification: Notification) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + + NotificationManagerCompat.from(this).notify(id, notification) +} + +fun Context.cancelNotification(id: Int) { + NotificationManagerCompat.from(this).cancel(id) +} + +/** + * Helper method to create a notification builder. + * + * @param channelId the channel id. + * @param block the function that will execute inside the builder. + * @return a notification to be displayed or updated. + */ +fun Context.notificationBuilder( + channelId: String, + block: (NotificationCompat.Builder.() -> Unit)? = null, +): NotificationCompat.Builder { + val builder = NotificationCompat.Builder(this, channelId) + if (block != null) { + builder.block() + } + return builder +} + +/** + * Helper method to build a notification channel group. + * + * @param channelId the channel id. + * @param block the function that will execute inside the builder. + * @return a notification channel group to be displayed or updated. + */ +fun buildNotificationChannelGroup( + channelId: String, + block: (NotificationChannelGroupCompat.Builder.() -> Unit), +): NotificationChannelGroupCompat { + val builder = NotificationChannelGroupCompat.Builder(channelId) + builder.block() + return builder.build() +} + +/** + * Helper method to build a notification channel. + * + * @param channelId the channel id. + * @param channelImportance the channel importance. + * @param block the function that will execute inside the builder. + * @return a notification channel to be displayed or updated. + */ +fun buildNotificationChannel( + channelId: String, + channelImportance: Int, + block: (NotificationChannelCompat.Builder.() -> Unit), +): NotificationChannelCompat { + val builder = NotificationChannelCompat.Builder(channelId, channelImportance) + builder.block() + return builder.build() +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/NumberExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/NumberExtensions.kt new file mode 100644 index 0000000..7d87302 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/NumberExtensions.kt @@ -0,0 +1,97 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalLayoutDirection +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.ceil +import kotlin.math.floor + +operator fun Int?.plus(other: Int?): Int? = if (this != null || other != null) { + (this ?: 0) + (other ?: 0) +} else { + null +} + +operator fun Double?.plus(other: Double?): Double? = if (this != null || other != null) { + (this ?: 0.0) + (other ?: 0.0) +} else { + null +} + +operator fun Double?.minus(other: Double?): Double? = if (this != null || other != null) { + (this ?: 0.0) - (other ?: 0.0) +} else { + null +} + +fun Double.ensurePositive(): Double? = if (this >= 0.0) this else null + +fun Double.roundUpToNearestMultiplier(multiplier: Double): Double { + return ceil(div(multiplier)).times(multiplier) +} + +fun Double.roundDownToNearestMultiplier(multiplier: Double): Double { + return floor(div(multiplier)).times(multiplier) +} + +fun Double.roundDecimals(decimals: Int): Double? { + return if (!isNaN()) { + BigDecimal(this).setScale(decimals, RoundingMode.HALF_UP).toDouble() + } else { + null + } +} + +val Array.median: Double? + get() { + if (isEmpty()) return null + + sort() + + return if (size % 2 != 0) { + this[size / 2] + } else { + (this[(size - 1) / 2] + this[size / 2]) / 2.0 + } + } + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/58a0add4f6bd8a5ab1006755035ff1b102355d4a/presentation-core/src/main/java/tachiyomi/presentation/core/util/PaddingValues.kt + */ +@Composable +@ReadOnlyComposable +operator fun PaddingValues.plus(other: PaddingValues): PaddingValues { + val layoutDirection = LocalLayoutDirection.current + return PaddingValues( + start = calculateStartPadding(layoutDirection) + + other.calculateStartPadding(layoutDirection), + end = calculateEndPadding(layoutDirection) + + other.calculateEndPadding(layoutDirection), + top = calculateTopPadding() + other.calculateTopPadding(), + bottom = calculateBottomPadding() + other.calculateBottomPadding() + ) +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/StringExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/StringExtensions.kt new file mode 100644 index 0000000..ba11ad7 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/StringExtensions.kt @@ -0,0 +1,32 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +/** + * Allow to split a string while keeping delimiters + */ +fun String.splitKeeping(str: String): List { + return split(str).flatMap { listOf(it, str) }.dropLast(1).filterNot { it.isEmpty() } +} + +fun String.splitKeeping(vararg strs: String): List { + var res = listOf(this) + strs.forEach { str -> + res = res.flatMap { it.splitKeeping(str) } + } + return res +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/UnitExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/UnitExtensions.kt new file mode 100644 index 0000000..36e725a --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/UnitExtensions.kt @@ -0,0 +1,434 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.content.Context +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import org.breezyweather.R +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.unit.distance.Distance +import org.breezyweather.unit.distance.Distance.Companion.meters +import org.breezyweather.unit.duration.format +import org.breezyweather.unit.formatting.UnitWidth +import org.breezyweather.unit.pollen.PollenConcentrationUnit +import org.breezyweather.unit.pollutant.PollutantConcentrationUnit +import org.breezyweather.unit.precipitation.Precipitation +import org.breezyweather.unit.precipitation.PrecipitationUnit +import org.breezyweather.unit.pressure.Pressure +import org.breezyweather.unit.ratio.Ratio +import org.breezyweather.unit.ratio.RatioUnit +import org.breezyweather.unit.speed.Speed +import org.breezyweather.unit.temperature.Temperature +import org.breezyweather.unit.temperature.TemperatureUnit +import kotlin.time.Duration +import kotlin.time.DurationUnit + +/** + * TODO: Lot of duplicates code in this page + * Technically, we can do a extension, but we need to handle how we are getting the user-preferred + * unit + */ + +/** + * Convenient format function with parameters filled for our app + * Getting the default unit from Settings is terribly slow, so it must be send as parameter + */ +fun Temperature.formatMeasure( + context: Context, + unit: TemperatureUnit, + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, + showSign: Boolean = false, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + unit = unit, + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + showSign = showSign, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + * Getting the default unit from Settings is terribly slow, so it must be send as parameter + */ +fun Temperature.formatValue( + context: Context, + unit: TemperatureUnit, + width: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return formatValue( + unit = unit, + width = width, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Source: https://weather.metoffice.gov.uk/guides/what-does-this-forecast-mean + */ +const val VISIBILITY_VERY_POOR = 1000.0 +const val VISIBILITY_POOR = 4000.0 +const val VISIBILITY_MODERATE = 10000.0 +const val VISIBILITY_GOOD = 20000.0 +const val VISIBILITY_CLEAR = 40000.0 + +val visibilityScaleThresholds = listOf( + 0.meters, + VISIBILITY_VERY_POOR.meters, + VISIBILITY_POOR.meters, + VISIBILITY_MODERATE.meters, + VISIBILITY_GOOD.meters, + VISIBILITY_CLEAR.meters +) + +/** + * @param context + */ +fun Distance.getVisibilityDescription(context: Context): String? { + return when (inMeters) { + in 0.0.. context.getString(R.string.visibility_very_poor) + in VISIBILITY_VERY_POOR.. context.getString(R.string.visibility_poor) + in VISIBILITY_POOR.. context.getString(R.string.visibility_moderate) + in VISIBILITY_MODERATE.. context.getString(R.string.visibility_good) + in VISIBILITY_GOOD.. context.getString(R.string.visibility_clear) + in VISIBILITY_CLEAR..Double.MAX_VALUE -> context.getString(R.string.visibility_perfectly_clear) + else -> null + } +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Distance.formatMeasure( + context: Context, + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + unit = settings.getDistanceUnit(context), + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Distance.formatValue( + context: Context, + width: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return formatValue( + unit = settings.getDistanceUnit(context), + width = width, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +fun Speed.getBeaufortScaleStrength(context: Context): String? { + return context.resources.getStringArray(R.array.wind_strength_descriptions).getOrElse(inBeaufort) { null } +} + +@ColorInt +fun Speed.getBeaufortScaleColor(context: Context): Int { + return context.resources.getIntArray(R.array.wind_strength_colors).getOrNull(inBeaufort) ?: Color.TRANSPARENT +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Speed.formatMeasure( + context: Context, + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + unit = settings.getSpeedUnit(context), + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Speed.formatValue( + context: Context, + width: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return formatValue( + unit = settings.getSpeedUnit(context), + width = width, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Precipitation.formatMeasure( + context: Context, + unit: PrecipitationUnit = SettingsManager.getInstance(context).getPrecipitationUnit(context), + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + unit = unit, + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Precipitation.formatValue( + context: Context, + unit: PrecipitationUnit = SettingsManager.getInstance(context).getPrecipitationUnit(context), + width: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return formatValue( + unit = unit, + width = width, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Precipitation.formatMeasureIntensity( + context: Context, + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return formatIntensity( + context = context, + unit = settings.getPrecipitationUnit(context), + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Pressure.formatMeasure( + context: Context, + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + unit = settings.getPressureUnit(context), + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Pressure.formatValue( + context: Context, + width: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return formatValue( + unit = settings.getPressureUnit(context), + width = width, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun PollutantConcentrationUnit.formatMeasure( + context: Context, + value: Number, + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + value = value, + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun PollenConcentrationUnit.formatMeasure( + context: Context, + value: Number, + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + value = value, + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Duration.formatTime( + context: Context, + smallestUnit: DurationUnit = DurationUnit.HOURS, + valueWidth: UnitWidth = UnitWidth.SHORT, + unitWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + unit = DurationUnit.HOURS, + smallestUnit = smallestUnit, + valueWidth = valueWidth, + unitWidth = unitWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Ratio.formatPercent( + context: Context, + valueWidth: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return format( + context = context, + unit = RatioUnit.PERCENT, + valueWidth = valueWidth, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +/** + * Convenient format function with parameters filled for our app + */ +fun Ratio.formatValue( + context: Context, + width: UnitWidth = UnitWidth.SHORT, +): String { + val settings = SettingsManager.getInstance(context) + return formatValue( + unit = RatioUnit.PERCENT, + width = width, + locale = context.currentLocale, + useNumberFormatter = settings.useNumberFormatter, + useMeasureFormat = settings.useMeasureFormat + ) +} + +// We don't need any cloud cover unit, it's just a percent, but we need some helpers + +/** + * Source: WMO Cloud distribution for aviation + */ +const val CLOUD_COVER_SKC = 12.5 // 1 okta +const val CLOUD_COVER_FEW = 37.5 // 3 okta +const val CLOUD_COVER_SCT = 62.5 // 5 okta +const val CLOUD_COVER_BKN = 87.5 // 7 okta +const val CLOUD_COVER_OVC = 100.0 // 8 okta + +fun Ratio.getCloudCoverColor(context: Context): Int { + return when (inPercent) { + in 0.0.. ContextCompat.getColor(context, R.color.colorLevel_1) + in CLOUD_COVER_FEW..CLOUD_COVER_SCT -> ContextCompat.getColor(context, R.color.colorLevel_2) + in CLOUD_COVER_SCT..100.0 -> ContextCompat.getColor(context, R.color.colorLevel_3) + else -> Color.TRANSPARENT + } +} + +/** + * @param context + */ +fun Ratio.getCloudCoverDescription(context: Context): String? { + return when (inPercent) { + in 0.0.. context.getString(R.string.common_weather_text_clear_sky) + in CLOUD_COVER_SKC.. context.getString(R.string.common_weather_text_mostly_clear) + in CLOUD_COVER_FEW.. context.getString(R.string.common_weather_text_partly_cloudy) + in CLOUD_COVER_SCT.. context.getString(R.string.common_weather_text_mostly_cloudy) + in CLOUD_COVER_BKN..CLOUD_COVER_OVC -> context.getString(R.string.common_weather_text_cloudy) + else -> null + } +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/WindowInsetsExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/WindowInsetsExtensions.kt new file mode 100644 index 0000000..4a46ef6 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/WindowInsetsExtensions.kt @@ -0,0 +1,61 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.view.View +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener +import androidx.core.view.WindowInsetsCompat + +/** + * Source: Android Developers, Chris Banes + * https://medium.com/androiddevelopers/windowinsets-listeners-to-layouts-8f9ccc8fa4d1 + */ + +/** + * Apply window insets (system bars and display cutouts) for a view. + */ +fun View.doOnApplyWindowInsets(f: (View, Insets) -> Unit) { + // Set an actual OnApplyWindowInsetsListener which proxies to the given lambda + setOnApplyWindowInsetsListener(this) { v, insets -> + val i = insets.getInsets( + WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.displayCutout() + ) + f(v, i) + // Always return the insets, so that children can also use them + insets + } + // request some insets + requestApplyInsetsWhenAttached() +} + +fun View.requestApplyInsetsWhenAttached() { + if (isAttachedToWindow) { + // We're already attached, just request as normal + requestApplyInsets() + } else { + // We're not attached to the hierarchy, add a listener to request when we are + addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.removeOnAttachStateChangeListener(this) + v.requestApplyInsets() + } + + override fun onViewDetachedFromWindow(v: View) = Unit + }) + } +} diff --git a/app/src/main/java/org/breezyweather/common/extensions/WorkManagerExtensions.kt b/app/src/main/java/org/breezyweather/common/extensions/WorkManagerExtensions.kt new file mode 100644 index 0000000..b6e486a --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/extensions/WorkManagerExtensions.kt @@ -0,0 +1,56 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.extensions + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkInfo +import androidx.work.WorkManager +import kotlinx.coroutines.delay +import org.breezyweather.common.utils.helpers.LogHelper + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/util/system/WorkManagerExtensions.kt + */ + +val Context.workManager: WorkManager + get() = WorkManager.getInstance(this) + +/** + * Makes this worker run in the context of a foreground service. + * + * Note that this function is a no-op if the process is subject to foreground + * service restrictions. + * + * Moving to foreground service context requires the worker to run a bit longer, + * allowing Service.startForeground() to be called and avoiding system crash. + */ +suspend fun CoroutineWorker.setForegroundSafely() { + try { + setForeground(getForegroundInfo()) + delay(500) + } catch (e: IllegalStateException) { + LogHelper.log(msg = "Not allowed to set foreground job") + } +} +fun WorkManager.isRunning(tag: String): Boolean { + val list = getWorkInfosByTag(tag).get() + return list.any { it.state == WorkInfo.State.RUNNING } +} diff --git a/app/src/main/java/org/breezyweather/common/options/BaseEnum.kt b/app/src/main/java/org/breezyweather/common/options/BaseEnum.kt new file mode 100644 index 0000000..5492abc --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/BaseEnum.kt @@ -0,0 +1,47 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options + +import android.content.Context + +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ +interface BaseEnum { + val id: String + val nameArrayId: Int + val valueArrayId: Int + + /** + * Get the name of the unit + * @param context + * @returns formatted unit, such as “km/h” + */ + fun getName(context: Context): String +} diff --git a/app/src/main/java/org/breezyweather/common/options/DarkMode.kt b/app/src/main/java/org/breezyweather/common/options/DarkMode.kt new file mode 100644 index 0000000..5d4ec7d --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/DarkMode.kt @@ -0,0 +1,47 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options + +import android.content.Context +import androidx.appcompat.app.AppCompatDelegate +import org.breezyweather.R +import org.breezyweather.common.utils.UnitUtils + +enum class DarkMode( + override val id: String, + val value: Int, +) : BaseEnum { + + SYSTEM("system", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM), + LIGHT("light", AppCompatDelegate.MODE_NIGHT_NO), + DARK("dark", AppCompatDelegate.MODE_NIGHT_YES), + ; + + companion object { + + fun getInstance( + value: String, + ) = entries.firstOrNull { + it.id == value + } ?: SYSTEM + } + + override val valueArrayId = R.array.dark_mode_values + override val nameArrayId = R.array.dark_modes + + override fun getName(context: Context) = UnitUtils.getName(context, this) +} diff --git a/app/src/main/java/org/breezyweather/common/options/DarkModeLocation.kt b/app/src/main/java/org/breezyweather/common/options/DarkModeLocation.kt new file mode 100644 index 0000000..e478f16 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/DarkModeLocation.kt @@ -0,0 +1,51 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options + +import android.content.Context +import org.breezyweather.R +import org.breezyweather.common.utils.UnitUtils + +enum class DarkModeLocation( + override val id: String, + val value: Boolean, +) : BaseEnum { + + DAY_NIGHT("day_night", true), + DARK("dark", false), + ; + + companion object { + + fun getInstance( + value: String, + ) = entries.firstOrNull { + it.id == value + } ?: DARK + + fun getInstance( + value: Boolean, + ) = entries.firstOrNull { + it.value == value + } ?: DARK + } + + override val valueArrayId = R.array.dark_mode_location_values + override val nameArrayId = R.array.dark_modes_location + + override fun getName(context: Context) = UnitUtils.getName(context, this) +} diff --git a/app/src/main/java/org/breezyweather/common/options/NotificationStyle.kt b/app/src/main/java/org/breezyweather/common/options/NotificationStyle.kt new file mode 100644 index 0000000..68f4537 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/NotificationStyle.kt @@ -0,0 +1,46 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options + +import android.content.Context +import org.breezyweather.R +import org.breezyweather.common.utils.UnitUtils + +enum class NotificationStyle( + override val id: String, +) : BaseEnum { + + NATIVE("native"), + CITIES("cities"), + DAILY("daily"), + HOURLY("hourly"), + ; + + companion object { + + fun getInstance( + value: String, + ) = entries.firstOrNull { + it.id == value + } ?: DAILY + } + + override val valueArrayId = R.array.notification_style_values + override val nameArrayId = R.array.notification_styles + + override fun getName(context: Context) = UnitUtils.getName(context, this) +} diff --git a/app/src/main/java/org/breezyweather/common/options/NotificationTextColor.kt b/app/src/main/java/org/breezyweather/common/options/NotificationTextColor.kt new file mode 100644 index 0000000..05a4306 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/NotificationTextColor.kt @@ -0,0 +1,45 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options + +import android.content.Context +import org.breezyweather.R +import org.breezyweather.common.utils.UnitUtils + +enum class NotificationTextColor( + override val id: String, +) : BaseEnum { + + DARK("dark"), + GREY("grey"), + LIGHT("light"), + ; + + companion object { + + fun getInstance( + value: String, + ) = entries.firstOrNull { + it.id == value + } ?: DARK + } + + override val valueArrayId = R.array.notification_text_color_values + override val nameArrayId = R.array.notification_text_colors + + override fun getName(context: Context) = UnitUtils.getName(context, this) +} diff --git a/app/src/main/java/org/breezyweather/common/options/UpdateInterval.kt b/app/src/main/java/org/breezyweather/common/options/UpdateInterval.kt new file mode 100644 index 0000000..9009539 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/UpdateInterval.kt @@ -0,0 +1,58 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options + +import android.content.Context +import org.breezyweather.R +import org.breezyweather.common.utils.UnitUtils +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +enum class UpdateInterval( + override val id: String, + val interval: Duration?, +) : BaseEnum { + + INTERVAL_NEVER("never", null), + INTERVAL_0_30("0:30", 30.minutes), + INTERVAL_1_00("1:00", 1.hours), + INTERVAL_1_30("1:30", 1.5.hours), + INTERVAL_2_00("2:00", 2.hours), + INTERVAL_3_00("3:00", 3.hours), + INTERVAL_6_00("6:00", 6.hours), + INTERVAL_12_00("12:00", 12.hours), + INTERVAL_24_00("24:00", 24.hours), + ; + + companion object { + + fun getInstance( + value: String, + ) = entries.firstOrNull { + it.id == value + } ?: INTERVAL_1_30 + } + + override val valueArrayId = R.array.automatic_refresh_rate_values + override val nameArrayId = R.array.automatic_refresh_rates + + override fun getName(context: Context) = UnitUtils.getName(context, this) + + // Makes locations valid for 1.5 hours when background updates are disabled + val validity = interval ?: 1.5.hours +} diff --git a/app/src/main/java/org/breezyweather/common/options/WidgetWeekIconMode.kt b/app/src/main/java/org/breezyweather/common/options/WidgetWeekIconMode.kt new file mode 100644 index 0000000..3555f59 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/WidgetWeekIconMode.kt @@ -0,0 +1,45 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options + +import android.content.Context +import org.breezyweather.R +import org.breezyweather.common.utils.UnitUtils + +enum class WidgetWeekIconMode( + override val id: String, +) : BaseEnum { + + AUTO("auto"), + DAY("day"), + NIGHT("night"), + ; + + companion object { + + fun getInstance( + value: String, + ) = entries.firstOrNull { + it.id == value + } ?: AUTO + } + + override val valueArrayId = R.array.week_icon_mode_values + override val nameArrayId = R.array.week_icon_modes + + override fun getName(context: Context) = UnitUtils.getName(context, this) +} diff --git a/app/src/main/java/org/breezyweather/common/options/appearance/BackgroundAnimationMode.kt b/app/src/main/java/org/breezyweather/common/options/appearance/BackgroundAnimationMode.kt new file mode 100644 index 0000000..844c4b2 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/appearance/BackgroundAnimationMode.kt @@ -0,0 +1,46 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options.appearance + +import android.content.Context +import org.breezyweather.R +import org.breezyweather.common.options.BaseEnum +import org.breezyweather.common.utils.UnitUtils + +enum class BackgroundAnimationMode( + override val id: String, +) : BaseEnum { + + SYSTEM("system"), + ENABLED("enabled"), + DISABLED("disabled"), + ; + + companion object { + + fun getInstance( + value: String, + ) = entries.firstOrNull { + it.id == value + } ?: SYSTEM + } + + override val valueArrayId = R.array.background_animation_values + override val nameArrayId = R.array.background_animation + + override fun getName(context: Context) = UnitUtils.getName(context, this) +} diff --git a/app/src/main/java/org/breezyweather/common/options/appearance/CalendarHelper.kt b/app/src/main/java/org/breezyweather/common/options/appearance/CalendarHelper.kt new file mode 100644 index 0000000..183ac60 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/appearance/CalendarHelper.kt @@ -0,0 +1,171 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options.appearance + +import android.content.Context +import android.icu.util.ULocale +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.text.util.LocalePreferences +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.breezyweather.R +import org.breezyweather.common.extensions.capitalize +import org.breezyweather.common.extensions.currentLocale +import org.breezyweather.common.utils.helpers.LogHelper +import org.breezyweather.domain.settings.SettingsManager +import java.util.Locale + +object CalendarHelper { + + const val CALENDAR_EXTENSION_TYPE = "ca" + private const val NUMBERS_EXTENSION_TYPE = "nu" + private const val DISPLAY_KEYWORD_OF_CALENDAR = "calendar" + + private val supportedCalendars = listOf( + LocalePreferences.CalendarType.CHINESE, + LocalePreferences.CalendarType.DANGI, + LocalePreferences.CalendarType.HEBREW, + LocalePreferences.CalendarType.INDIAN, + LocalePreferences.CalendarType.ISLAMIC, + LocalePreferences.CalendarType.ISLAMIC_CIVIL, + LocalePreferences.CalendarType.ISLAMIC_RGSA, + LocalePreferences.CalendarType.ISLAMIC_TBLA, + LocalePreferences.CalendarType.ISLAMIC_UMALQURA, + LocalePreferences.CalendarType.PERSIAN + ) + + @RequiresApi(Build.VERSION_CODES.N) + private fun getDisplayName( + calendar: String, + locale: Locale = Locale.Builder().setLanguage("en").setRegion("001").build(), + ): String { + val localeWithCalendar = Locale.Builder() + .setUnicodeLocaleKeyword(CALENDAR_EXTENSION_TYPE, calendar) + .build() + + return ULocale.getDisplayKeywordValue( + localeWithCalendar.toLanguageTag(), + DISPLAY_KEYWORD_OF_CALENDAR, + ULocale.forLocale(locale) + ) + } + + @RequiresApi(Build.VERSION_CODES.N) + fun getCalendars(context: Context): ImmutableList { + return supportedCalendars.map { + val displayName = try { + getDisplayName(it, context.currentLocale).let { result -> + if (result.equals(it, ignoreCase = false)) { + // Fallback to English if there is no translation + getDisplayName(it).capitalize(context.currentLocale) + } else { + result.capitalize() + } + } + } catch (_: Exception) { + try { + getDisplayName(it).capitalize() + } catch (_: Exception) { + it.capitalize() + } + } + AlternateCalendar( + id = it, + displayName = displayName, + additionalParams = when (it) { + "chinese" -> mapOf(NUMBERS_EXTENSION_TYPE to "hanidays") + else -> null + }, + specificPattern = when (it) { + "chinese" -> "MMMd" + else -> null + } + ) + }.sortedBy { + it.displayName + }.toMutableList().apply { + add(0, AlternateCalendar("none", context.getString(R.string.settings_none))) + add( + 1, + AlternateCalendar( + "", + context.getString( + R.string.parenthesis, + context.getString(R.string.settings_regional_preference), + getCalendarPreferenceForLocale(context.currentLocale).let { calendarRegionalPref -> + firstOrNull { it.id == calendarRegionalPref }?.displayName + } ?: context.getString(R.string.settings_none) + ) + ) + ) + }.toImmutableList() + } + + private fun getCalendarPreferenceForLocale(locale: Locale): String { + LogHelper.log(msg = "${locale.language}") + return with(locale) { + when { + arrayOf("CN", "HK", "MO", "TW").any { country.equals(it, ignoreCase = true) } || + language.equals("zh", ignoreCase = true) -> { + LocalePreferences.CalendarType.CHINESE + } + arrayOf("KP", "KR").any { country.equals(it, ignoreCase = true) } || + language.equals("ko", ignoreCase = true) -> { + LocalePreferences.CalendarType.DANGI + } + arrayOf("he", "iw").any { language.equals(it, ignoreCase = true) } -> { + LocalePreferences.CalendarType.HEBREW + } + country.equals("IN", ignoreCase = true) -> LocalePreferences.CalendarType.INDIAN + country.equals("SA", ignoreCase = true) -> LocalePreferences.CalendarType.ISLAMIC_RGSA + country.equals("IR", ignoreCase = true) || language.equals("fa", ignoreCase = true) -> { + LocalePreferences.CalendarType.PERSIAN + } + // Looks like all locales defaults to Gregorian calendar: + // https://unicode-org.github.io/icu/userguide/datetime/calendar/#calendar-locale-and-keyword-handling + else -> LocalePreferences.getCalendarType(locale) + } + } + } + + fun getAlternateCalendarSetting(context: Context): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return null + } + + val alternateCalendarSetting = SettingsManager.getInstance(context).alternateCalendar + if (alternateCalendarSetting == "none") { + return null + } + val alternateCalendar = alternateCalendarSetting.ifEmpty { + getCalendarPreferenceForLocale(context.currentLocale) + } + return if (supportedCalendars.contains(alternateCalendar)) { + alternateCalendar + } else { + null + } + } + + data class AlternateCalendar( + val id: String, + val displayName: String, + val additionalParams: Map? = null, + val specificPattern: String? = null, + ) +} diff --git a/app/src/main/java/org/breezyweather/common/options/appearance/CardDisplay.kt b/app/src/main/java/org/breezyweather/common/options/appearance/CardDisplay.kt new file mode 100644 index 0000000..7b9bed4 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/appearance/CardDisplay.kt @@ -0,0 +1,89 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options.appearance + +import android.app.Activity +import android.content.Context +import androidx.annotation.StringRes +import org.breezyweather.R +import org.breezyweather.common.options.BaseEnum +import org.breezyweather.common.utils.helpers.IntentHelper + +enum class CardDisplay( + override val id: String, + @StringRes private val nameId: Int, + val configure: ((Activity) -> Unit)? = null, +) : BaseEnum { + + CARD_NOWCAST("nowcast", R.string.precipitation_nowcasting), + CARD_DAILY_FORECAST( + "daily_forecast", + R.string.daily_forecast, + { activity -> IntentHelper.startDailyTrendDisplayManageActivity(activity) } + ), + CARD_HOURLY_FORECAST( + "hourly_forecast", + R.string.hourly_forecast, + { activity -> IntentHelper.startHourlyTrendDisplayManageActivity(activity) } + ), + CARD_PRECIPITATION("precipitation", R.string.precipitation), + CARD_WIND("wind", R.string.wind), + CARD_AIR_QUALITY("air_quality", R.string.air_quality), + CARD_POLLEN("pollen", R.string.pollen), + CARD_HUMIDITY("humidity", R.string.humidity), + CARD_UV("uv", R.string.uv_index), + CARD_VISIBILITY("visibility", R.string.visibility), + CARD_PRESSURE("pressure", R.string.pressure), + CARD_SUN("sun", R.string.ephemeris_sun), + CARD_MOON("moon", R.string.ephemeris_moon), + CARD_CLOCK("clock", R.string.clock), + ; + + companion object { + + fun toCardDisplayList( + value: String?, + ) = if (value.isNullOrEmpty()) { + mutableListOf() + } else { + try { + value.split("&").toTypedArray().mapNotNull { cardId -> + entries.firstOrNull { it.id == cardId } + } + } catch (e: Exception) { + emptyList() + } + } + + fun toValue(list: List): String { + return list.joinToString("&") { item -> + item.id + } + } + + fun getSummary(context: Context, list: List): String { + return list.joinToString(context.getString(org.breezyweather.unit.R.string.locale_separator)) { item -> + item.getName(context) + } + } + } + + override val valueArrayId = 0 + override val nameArrayId = 0 + + override fun getName(context: Context) = context.getString(nameId) +} diff --git a/app/src/main/java/org/breezyweather/common/options/appearance/DailyTrendDisplay.kt b/app/src/main/java/org/breezyweather/common/options/appearance/DailyTrendDisplay.kt new file mode 100644 index 0000000..c924705 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/appearance/DailyTrendDisplay.kt @@ -0,0 +1,71 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options.appearance + +import android.content.Context +import androidx.annotation.StringRes +import org.breezyweather.R +import org.breezyweather.common.options.BaseEnum + +enum class DailyTrendDisplay( + override val id: String, + @StringRes val nameId: Int, +) : BaseEnum { + + TAG_TEMPERATURE("temperature", R.string.conditions), + TAG_AIR_QUALITY("air_quality", R.string.air_quality), + TAG_WIND("wind", R.string.wind), + TAG_UV_INDEX("uv_index", R.string.uv_index), + TAG_PRECIPITATION("precipitation", R.string.precipitation), + TAG_SUNSHINE("sunshine", R.string.sunshine), + TAG_FEELS_LIKE("feels_like", R.string.temperature_feels_like), + ; + + companion object { + + fun toDailyTrendDisplayList( + value: String?, + ) = if (value.isNullOrEmpty()) { + entries.toMutableList() + } else { + try { + value.split("&").toTypedArray().mapNotNull { cardId -> + entries.firstOrNull { it.id == cardId } + } + } catch (e: Exception) { + entries.toMutableList() + } + } + + fun toValue(list: List): String { + return list.joinToString("&") { item -> + item.id + } + } + + fun getSummary(context: Context, list: List): String { + return list.joinToString(context.getString(org.breezyweather.unit.R.string.locale_separator)) { item -> + item.getName(context) + } + } + } + + override val valueArrayId = 0 + override val nameArrayId = 0 + + override fun getName(context: Context) = context.getString(nameId) +} diff --git a/app/src/main/java/org/breezyweather/common/options/appearance/DetailScreen.kt b/app/src/main/java/org/breezyweather/common/options/appearance/DetailScreen.kt new file mode 100644 index 0000000..5426660 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/appearance/DetailScreen.kt @@ -0,0 +1,121 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options.appearance + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import breezyweather.domain.location.model.Location +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.breezyweather.R +import org.breezyweather.common.options.BaseEnum + +enum class DetailScreen( + override val id: String, + @StringRes val nameId: Int, + @DrawableRes val iconId: Int, +) : BaseEnum { + + TAG_CONDITIONS("conditions", R.string.conditions, R.drawable.ic_device_thermostat), + TAG_FEELS_LIKE("feels_like", R.string.conditions, R.drawable.ic_device_thermostat), + TAG_WIND("wind", R.string.wind, R.drawable.ic_wind), + TAG_AIR_QUALITY("air_quality", R.string.air_quality, R.drawable.weather_haze_mini_xml), + TAG_POLLEN("pollen", R.string.pollen, R.drawable.ic_allergy), + TAG_UV_INDEX("uv_index", R.string.uv_index, R.drawable.ic_uv), + TAG_PRECIPITATION("precipitation", R.string.precipitation, R.drawable.ic_precipitation), + TAG_HUMIDITY("humidity", R.string.humidity, R.drawable.ic_humidity_percentage), + TAG_PRESSURE("pressure", R.string.pressure, R.drawable.ic_gauge), + TAG_CLOUD_COVER("cloud_cover", R.string.cloud_cover, R.drawable.ic_cloud), + TAG_VISIBILITY("visibility", R.string.visibility, R.drawable.ic_eye), + TAG_SUN_MOON("sun_moon", R.string.ephemeris, R.drawable.weather_clear_night_mini_xml), + ; + + companion object { + + const val CHART_MIN_COUNT = 2 + + fun toDetailScreenList( + location: Location, + ): ImmutableList { + return entries + .filter { detailScreen -> + when (detailScreen) { + TAG_CONDITIONS -> true // Always displayed + TAG_FEELS_LIKE -> false // never displayed, it’s actually a sub menu of TAG_CONDITIONS + TAG_PRECIPITATION -> true // Too many conditions + TAG_WIND -> location.weather?.dailyForecast?.any { + it.day?.wind?.speed != null || it.night?.wind?.speed != null + } == true || + location.weather?.hourlyForecast?.any { it.wind?.speed != null } == true + TAG_AIR_QUALITY -> !location.airQualitySource.isNullOrEmpty() + TAG_POLLEN -> !location.pollenSource.isNullOrEmpty() + TAG_UV_INDEX -> location.weather?.dailyForecast?.any { + (it.uV?.index ?: 0.0) > 0.0 + } == true || + location.weather?.hourlyForecast?.any { + (it.uV?.index ?: 0.0) > 0.0 + } == true + TAG_HUMIDITY -> location.weather?.dailyForecast?.any { + it.relativeHumidity?.min != null || it.relativeHumidity?.max != null + } == true || + location.weather?.hourlyForecast?.any { + it.relativeHumidity != null || it.dewPoint != null + } == true + TAG_PRESSURE -> location.weather?.dailyForecast?.any { it.pressure?.average != null } == true || + location.weather?.hourlyForecast?.any { it.pressure != null } == true + TAG_CLOUD_COVER -> location.weather?.dailyForecast?.any { + it.cloudCover?.min != null || it.cloudCover?.max != null + } == true || + location.weather?.hourlyForecast?.any { it.cloudCover != null } == true + TAG_VISIBILITY -> location.weather?.dailyForecast?.any { + it.visibility?.min != null || it.visibility?.max != null + } == true || + location.weather?.hourlyForecast?.any { it.visibility != null } == true + TAG_SUN_MOON -> true // Should always be computed, no need to check + } + }.toImmutableList() + } + + fun toValue(list: List): String { + val builder = StringBuilder() + for (v in list) { + builder.append("&").append(v.id) + } + if (builder.isNotEmpty() && builder[0] == '&') { + builder.deleteCharAt(0) + } + return builder.toString() + } + + fun getSummary(context: Context, list: List): String { + val builder = StringBuilder() + for (item in list) { + builder.append(",").append(item.getName(context)) + } + if (builder.isNotEmpty() && builder[0] == ',') { + builder.deleteCharAt(0) + } + return builder.toString().replace(",", context.getString(org.breezyweather.unit.R.string.locale_separator)) + } + } + + override val valueArrayId = 0 + override val nameArrayId = 0 + + override fun getName(context: Context) = context.getString(nameId) +} diff --git a/app/src/main/java/org/breezyweather/common/options/appearance/HourlyTrendDisplay.kt b/app/src/main/java/org/breezyweather/common/options/appearance/HourlyTrendDisplay.kt new file mode 100644 index 0000000..c69ad80 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/appearance/HourlyTrendDisplay.kt @@ -0,0 +1,74 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options.appearance + +import android.content.Context +import androidx.annotation.StringRes +import org.breezyweather.R +import org.breezyweather.common.options.BaseEnum + +enum class HourlyTrendDisplay( + override val id: String, + @StringRes val nameId: Int, +) : BaseEnum { + + TAG_TEMPERATURE("temperature", R.string.conditions), + TAG_AIR_QUALITY("air_quality", R.string.air_quality), + TAG_WIND("wind", R.string.wind), + TAG_UV_INDEX("uv_index", R.string.uv_index), + TAG_PRECIPITATION("precipitation", R.string.precipitation), + TAG_FEELS_LIKE("feels_like", R.string.temperature_feels_like), + TAG_HUMIDITY("humidity", R.string.humidity_dew_point), + TAG_PRESSURE("pressure", R.string.pressure), + TAG_CLOUD_COVER("cloud_cover", R.string.cloud_cover), + TAG_VISIBILITY("visibility", R.string.visibility), + ; + + companion object { + + fun toHourlyTrendDisplayList( + value: String?, + ) = if (value.isNullOrEmpty()) { + entries.toMutableList() + } else { + try { + value.split("&").toTypedArray().mapNotNull { cardId -> + entries.firstOrNull { it.id == cardId } + } + } catch (e: Exception) { + entries.toMutableList() + } + } + + fun toValue(list: List): String { + return list.joinToString("&") { item -> + item.id + } + } + + fun getSummary(context: Context, list: List): String { + return list.joinToString(context.getString(org.breezyweather.unit.R.string.locale_separator)) { item -> + item.getName(context) + } + } + } + + override val valueArrayId = 0 + override val nameArrayId = 0 + + override fun getName(context: Context) = context.getString(nameId) +} diff --git a/app/src/main/java/org/breezyweather/common/options/appearance/LocaleHelper.kt b/app/src/main/java/org/breezyweather/common/options/appearance/LocaleHelper.kt new file mode 100644 index 0000000..fcc1b26 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/options/appearance/LocaleHelper.kt @@ -0,0 +1,94 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.options.appearance + +import android.content.Context +import androidx.core.os.LocaleListCompat +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.breezyweather.R +import org.breezyweather.common.extensions.capitalize +import org.xmlpull.v1.XmlPullParser +import java.util.Locale + +object LocaleHelper { + + fun getLangs(context: Context): ImmutableList { + val langs = mutableListOf() + val parser = context.resources.getXml(R.xml.locales_config) + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.name == "locale") { + for (i in 0.. LocaleListCompat.getAdjustedDefault()[0] + else -> Locale.forLanguageTag(lang) + } + return locale!!.getDisplayName(locale).capitalize(locale) + } + + data class Language( + val langTag: String, + val displayName: String, + // val localizedDisplayName: String?, + ) +} diff --git a/app/src/main/java/org/breezyweather/common/preference/EditTextPreference.kt b/app/src/main/java/org/breezyweather/common/preference/EditTextPreference.kt new file mode 100644 index 0000000..0102fe6 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/preference/EditTextPreference.kt @@ -0,0 +1,39 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.preference + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.ui.text.input.KeyboardType + +class EditTextPreference( + @StringRes override val titleId: Int, + val summary: ((Context, String) -> String?)? = null, + val content: String?, + val placeholder: String? = null, + val regex: Regex? = null, + val regexError: String? = null, + val keyboardType: KeyboardType? = null, + val onValueChanged: (String) -> Unit, +) : Preference { + + companion object { + val URL_REGEX = Regex( + "^https://(www[.])?[-a-zA-Z0-9@:%._+~#=]{1,256}[.][a-zA-Z0-9()]{1,6}([-a-zA-Z0-9()!@:%_+.~#?&//=]*)/$" + ) + } +} diff --git a/app/src/main/java/org/breezyweather/common/preference/ListPreference.kt b/app/src/main/java/org/breezyweather/common/preference/ListPreference.kt new file mode 100644 index 0000000..a509a0a --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/preference/ListPreference.kt @@ -0,0 +1,28 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.preference + +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes + +class ListPreference( + @StringRes override val titleId: Int, + @ArrayRes val valueArrayId: Int, + @ArrayRes val nameArrayId: Int, + val selectedKey: String, + val onValueChanged: (String) -> Unit, +) : Preference diff --git a/app/src/main/java/org/breezyweather/common/preference/Preference.kt b/app/src/main/java/org/breezyweather/common/preference/Preference.kt new file mode 100644 index 0000000..4f7988a --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/preference/Preference.kt @@ -0,0 +1,23 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.preference + +import androidx.annotation.StringRes + +interface Preference { + @get:StringRes val titleId: Int +} diff --git a/app/src/main/java/org/breezyweather/common/rxjava/ObserverContainer.kt b/app/src/main/java/org/breezyweather/common/rxjava/ObserverContainer.kt new file mode 100644 index 0000000..9b6577f --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/rxjava/ObserverContainer.kt @@ -0,0 +1,45 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.rxjava + +import io.reactivex.rxjava3.core.Observer +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.observers.DisposableObserver + +class ObserverContainer( + private val compositeDisposable: CompositeDisposable, + private val observer: Observer, +) : DisposableObserver() { + override fun onStart() { + compositeDisposable.add(this) + observer.onSubscribe(this) + } + + override fun onNext(t: T) { + observer.onNext(t) + } + + override fun onError(e: Throwable) { + observer.onError(e) + compositeDisposable.remove(this) + } + + override fun onComplete() { + observer.onComplete() + compositeDisposable.remove(this) + } +} diff --git a/app/src/main/java/org/breezyweather/common/rxjava/SchedulerTransformer.kt b/app/src/main/java/org/breezyweather/common/rxjava/SchedulerTransformer.kt new file mode 100644 index 0000000..9b15307 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/rxjava/SchedulerTransformer.kt @@ -0,0 +1,38 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.rxjava + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.ObservableSource +import io.reactivex.rxjava3.core.ObservableTransformer +import io.reactivex.rxjava3.schedulers.Schedulers + +class SchedulerTransformer : ObservableTransformer { + override fun apply(upstream: Observable): ObservableSource { + return upstream + .subscribeOn(Schedulers.io()) + .unsubscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + companion object { + fun create(): SchedulerTransformer { + return SchedulerTransformer() + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/serializer/DateSerializer.kt b/app/src/main/java/org/breezyweather/common/serializer/DateSerializer.kt new file mode 100644 index 0000000..440669b --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/serializer/DateSerializer.kt @@ -0,0 +1,46 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.serializer + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.breezyweather.common.utils.ISO8601Utils +import java.text.ParseException +import java.util.Date + +object DateSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Date) { + val string = ISO8601Utils.format(value) + encoder.encodeString(string) + } + + override fun deserialize(decoder: Decoder): Date { + val string = decoder.decodeString() + return try { + ISO8601Utils.parse(string) + } catch (e: ParseException) { + throw SerializationException("Failed parsing '$string' as Date") + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/serializer/DateUtcSerializer.kt b/app/src/main/java/org/breezyweather/common/serializer/DateUtcSerializer.kt new file mode 100644 index 0000000..c31af50 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/serializer/DateUtcSerializer.kt @@ -0,0 +1,66 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.serializer + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.breezyweather.common.extensions.toCalendarWithTimeZone +import org.breezyweather.common.extensions.toDateNoHour +import org.breezyweather.common.utils.ISO8601Utils +import java.text.ParseException +import java.util.Calendar +import java.util.Date +import java.util.TimeZone + +object DateUtcSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("DateUtc", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Date?) { + value?.let { + encoder.encodeString(ISO8601Utils.format(it)) + } ?: encoder.encodeNull() + } + + override fun deserialize(decoder: Decoder): Date? { + val jsonValue = decoder.decodeString() + try { + if (jsonValue.isEmpty()) return null + if (jsonValue.length < 16 || + !jsonValue.matches( + // Supports dates from 2000 to 2099 + Regex("20[0-9][0-9]-(0[1-9]|1[0-2])-([0-2][0-9]|3[0-1])[ T]([0-1][0-9]|2[0-3]):[0-5][0-9](.*)") + ) + ) { + throw SerializationException("Failed parsing '$jsonValue' as UTC Date") + } else { + val timeZone = TimeZone.getTimeZone("UTC") + return jsonValue.toDateNoHour(timeZone)?.toCalendarWithTimeZone(timeZone)?.apply { + set(Calendar.HOUR_OF_DAY, jsonValue.substring(11, 13).toInt()) + set(Calendar.MINUTE, jsonValue.substring(14, 16).toInt()) + // We could add parsing of second but not really useful, let’s be efficient + }?.time + } + } catch (e: ParseException) { + throw SerializationException("Failed parsing '$jsonValue' as UTC Date") + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/serializer/LatLngSerializer.kt b/app/src/main/java/org/breezyweather/common/serializer/LatLngSerializer.kt new file mode 100644 index 0000000..7e8ebee --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/serializer/LatLngSerializer.kt @@ -0,0 +1,43 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.serializer + +import com.google.maps.android.model.LatLng +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object LatLngSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LatLng", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: LatLng) { + encoder.encodeString("${value.latitude},${value.longitude}") + } + + override fun deserialize(decoder: Decoder): LatLng { + val jsonValue = decoder.decodeString() + try { + return LatLng.parse(jsonValue) + } catch (e: Exception) { + throw SerializationException("Failed parsing '$jsonValue' as LatLng") + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/serializer/StringOrStringListSerializer.kt b/app/src/main/java/org/breezyweather/common/serializer/StringOrStringListSerializer.kt new file mode 100644 index 0000000..175af92 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/serializer/StringOrStringListSerializer.kt @@ -0,0 +1,54 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.serializer + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializer +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive + +@Serializer(forClass = Any::class) +object StringOrStringListSerializer : KSerializer?> { + override fun deserialize(decoder: Decoder): List? { + if (decoder is JsonDecoder) { + val element = decoder.decodeJsonElement() + if (element is JsonNull) { + return null + } + if (element is JsonArray) { + return element.map { + if (it is JsonPrimitive) { + it.content + } else { + "" + } + } + } + if (element is JsonPrimitive) { + if (element.isString) { + return listOf(element.content) + } + } + throw SerializationException("Invalid Json element $element") + } + throw SerializationException("Unknown serialization type") + } +} diff --git a/app/src/main/java/org/breezyweather/common/snackbar/Snackbar.kt b/app/src/main/java/org/breezyweather/common/snackbar/Snackbar.kt new file mode 100644 index 0000000..d7250c4 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/snackbar/Snackbar.kt @@ -0,0 +1,577 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.snackbar + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ObjectAnimator +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Rect +import android.os.Handler +import android.os.Looper +import android.os.Message +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.IntDef +import androidx.annotation.LayoutRes +import androidx.annotation.StringRes +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.content.withStyledAttributes +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import com.google.android.material.behavior.SwipeDismissBehavior +import org.breezyweather.R +import org.breezyweather.common.extensions.doOnApplyWindowInsets + +class Snackbar private constructor( + private val mParent: ViewGroup, + private val mCardStyle: Boolean, +) { + @Retention(AnnotationRetention.SOURCE) + @IntDef(LENGTH_INDEFINITE, LENGTH_SHORT, LENGTH_LONG) + annotation class Duration + + private val mContext: Context = mParent.context + private val mView: SnackbarLayout + + @get:Duration + var duration = 0 + private set + private var mCallback: Callback? = null + private var mAnimator: Animator? = null + + class Callback { + @Retention(AnnotationRetention.SOURCE) + @IntDef( + DISMISS_EVENT_SWIPE, + DISMISS_EVENT_ACTION, + DISMISS_EVENT_TIMEOUT, + DISMISS_EVENT_MANUAL, + DISMISS_EVENT_CONSECUTIVE + ) + annotation class DismissEvent + + fun onDismissed(snackbar: Snackbar?, @DismissEvent event: Int) {} + fun onShown(snackbar: Snackbar?) {} + + companion object { + const val DISMISS_EVENT_SWIPE = 0 + const val DISMISS_EVENT_ACTION = 1 + const val DISMISS_EVENT_TIMEOUT = 2 + const val DISMISS_EVENT_MANUAL = 3 + const val DISMISS_EVENT_CONSECUTIVE = 4 + } + } + + private val mManagerCallback: SnackbarManager.Callback = object : SnackbarManager.Callback { + override fun show() { + sHandler.sendMessage(sHandler.obtainMessage(MSG_SHOW, this@Snackbar)) + } + + override fun dismiss(event: Int) { + sHandler.sendMessage(sHandler.obtainMessage(MSG_DISMISS, event, 0, this@Snackbar)) + } + } + + init { + mView = LayoutInflater.from(mContext).inflate( + if (mCardStyle) R.layout.container_snackbar_layout_card else R.layout.container_snackbar_layout, + mParent, + false + ) as SnackbarLayout + + view.doOnApplyWindowInsets { v, insets -> + v.updatePadding( + left = insets.left, + right = insets.right + ) + } + } + + fun setAction(@StringRes resId: Int, listener: View.OnClickListener?): Snackbar { + return setAction(mContext.getText(resId), listener) + } + + fun setAction(text: CharSequence?, listener: View.OnClickListener?): Snackbar { + return setAction(text, true, listener) + } + + private fun setAction( + text: CharSequence?, + shouldDismissOnClick: Boolean, + listener: View.OnClickListener?, + ): Snackbar { + mView.actionView?.let { tv -> + if (text.isNullOrEmpty() || listener == null) { + tv.visibility = View.GONE + tv.setOnClickListener(null) + } else { + tv.visibility = View.VISIBLE + tv.text = text + tv.setOnClickListener { view: View? -> + listener.onClick(view) + if (shouldDismissOnClick) { + dispatchDismiss(Callback.DISMISS_EVENT_ACTION) + } + } + } + } + return this + } + + fun setActionTextColor(colors: ColorStateList?): Snackbar { + mView.actionView?.setTextColor(colors) + return this + } + + fun setActionTextColor(@ColorInt color: Int): Snackbar { + mView.actionView?.setTextColor(color) + return this + } + + fun setText(message: CharSequence): Snackbar { + mView.messageView?.text = message + return this + } + + fun setText(@StringRes resId: Int): Snackbar { + return setText(mContext.getText(resId)) + } + + fun setDuration(@Duration duration: Int): Snackbar { + this.duration = duration + return this + } + + val view: View + get() = mView + + fun show() { + SnackbarManager.instance.show(duration, mManagerCallback) + } + + fun dismiss() { + dispatchDismiss(Callback.DISMISS_EVENT_MANUAL) + } + + private fun dispatchDismiss(@Callback.DismissEvent event: Int) { + SnackbarManager.instance.dismiss(mManagerCallback, event) + } + + fun setCallback(callback: Callback?): Snackbar { + mCallback = callback + return this + } + + val isShown: Boolean + get() = SnackbarManager.instance.isCurrent(mManagerCallback) + val isShownOrQueued: Boolean + get() = SnackbarManager.instance.isCurrentOrNext(mManagerCallback) + + fun showView() { + if (mView.parent == null) { + val lp = mView.layoutParams + if (lp is CoordinatorLayout.LayoutParams) { + lp.behavior = Behavior().apply { + setStartAlphaSwipeDistance(0.1f) + setEndAlphaSwipeDistance(0.6f) + setSwipeDirection(SwipeDismissBehavior.SWIPE_DIRECTION_START_TO_END) + listener = object : SwipeDismissBehavior.OnDismissListener { + override fun onDismiss(view: View) { + dispatchDismiss(Callback.DISMISS_EVENT_SWIPE) + } + + override fun onDragStateChanged(state: Int) { + when (state) { + SwipeDismissBehavior.STATE_DRAGGING, SwipeDismissBehavior.STATE_SETTLING -> + SnackbarManager.instance.cancelTimeout(mManagerCallback) + + SwipeDismissBehavior.STATE_IDLE -> + SnackbarManager.instance.restoreTimeout(mManagerCallback) + } + } + } + } + } + mParent.addView(mView) + } + mView.setOnAttachStateChangeListener(object : SnackbarLayout.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View?) {} + override fun onViewDetachedFromWindow(v: View?) { + if (isShownOrQueued) { + sHandler.post { onViewHidden(Callback.DISMISS_EVENT_MANUAL) } + } + } + }) + if (mView.isLaidOut) { + animateViewIn() + } else { + mView.setOnLayoutChangeListener { _: View?, _: Int, _: Int, _: Int, _: Int -> + animateViewIn() + mView.setOnLayoutChangeListener(null) + } + } + } + + private fun animateViewIn() { + mAnimator?.cancel() + mAnimator = SnackbarAnimationUtils.getEnterAnimator(mView, mCardStyle).apply { + duration = ANIMATION_DURATION.toLong() + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + mView.animateChildrenIn( + ANIMATION_DURATION - ANIMATION_FADE_DURATION, + ANIMATION_FADE_DURATION + ) + } + + override fun onAnimationEnd(animation: Animator) { + mCallback?.onShown(this@Snackbar) + SnackbarManager.instance.onShown(mManagerCallback) + } + }) + }.also { it.start() } + } + + private fun animateViewOut(event: Int) { + mAnimator?.cancel() + mAnimator = ObjectAnimator.ofFloat( + mView, + "translationY", + mView.translationY, + mView.height.toFloat() + ).apply { + duration = ANIMATION_DURATION.toLong() + interpolator = SnackbarAnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + mView.animateChildrenOut(0, ANIMATION_FADE_DURATION) + } + + override fun onAnimationEnd(animation: Animator) { + onViewHidden(event) + } + }) + }.also { it.start() } + } + + fun hideView(event: Int) { + if (mView.visibility != View.VISIBLE || isBeingDragged) { + onViewHidden(event) + } else { + animateViewOut(event) + } + } + + private fun onViewHidden(event: Int) { + SnackbarManager.instance.onDismissed(mManagerCallback) + mCallback?.onDismissed(this, event) + val parent = mView.parent + if (parent is ViewGroup) { + parent.removeView(mView) + } + } + + private val isBeingDragged: Boolean + get() { + val lp = mView.layoutParams + if (lp is CoordinatorLayout.LayoutParams) { + val behavior = lp.behavior + if (behavior is SwipeDismissBehavior<*>) { + return (behavior.dragState != SwipeDismissBehavior.STATE_IDLE) + } + } + return false + } + + open class SnackbarLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + ) : ViewGroup(context, attrs) { + private val mWindowInsets: Rect = Rect() + var messageView: TextView? = null + private set + var actionView: Button? = null + private set + + interface OnAttachStateChangeListener { + fun onViewAttachedToWindow(v: View?) + fun onViewDetachedFromWindow(v: View?) + } + + private var mOnLayoutChangeListener: ( + (view: View?, left: Int, top: Int, right: Int, bottom: Int) -> Unit + )? = null + private var mOnAttachStateChangeListener: OnAttachStateChangeListener? = null + + init { + context.withStyledAttributes( + attrs, + com.google.android.material.R.styleable.SnackbarLayout + ) {} + isClickable = true + fitsSystemWindows = false + LayoutInflater.from(context).inflate(layoutId, this) + accessibilityLiveRegion = ACCESSIBILITY_LIVE_REGION_POLITE + } + + @Deprecated("Deprecated in Java") + override fun requestFitSystemWindows() { + // Do not apply horizontal insets in home fragment + val isHomeFragment = parent is CoordinatorLayout + val insets = ViewCompat.getRootWindowInsets(this) + val i = insets?.getInsets(WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.displayCutout()) + if (i != null) { + val rInsets = Rect( + if (isHomeFragment) 0 else i.left, + i.top, + if (isHomeFragment) 0 else i.right, + i.bottom + ) + mWindowInsets.set(rInsets) + } + } + + @get:LayoutRes + open val layoutId: Int + get() = R.layout.container_snackbar_layout_inner + + override fun onFinishInflate() { + super.onFinishInflate() + messageView = findViewById(R.id.snackbar_text) + actionView = findViewById(R.id.snackbar_action) + } + + override fun generateLayoutParams(p: LayoutParams): LayoutParams { + return MarginLayoutParams(p) + } + + override fun generateDefaultLayoutParams(): LayoutParams { + return MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) + } + + override fun generateLayoutParams(attrs: AttributeSet): LayoutParams { + return MarginLayoutParams(context, attrs) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width: Int + val height: Int + val child = getChildAt(0) + val widthUsed = mWindowInsets.left + mWindowInsets.right + val heightUsed = mWindowInsets.bottom + measureChildWithMargins( + child, + widthMeasureSpec, + widthUsed, + heightMeasureSpec, + heightUsed + ) + val lp = child.layoutParams as MarginLayoutParams + width = child.measuredWidth + widthUsed + lp.leftMargin + lp.rightMargin + paddingLeft + paddingRight + height = child.measuredHeight + heightUsed + lp.topMargin + lp.bottomMargin + paddingTop + paddingBottom + setMeasuredDimension(width, height) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val child = getChildAt(0) + + // Set left insets for system bars and evenly align the snackbar + // within the safe drawing area. + val x = mWindowInsets.left + + (measuredWidth - child.measuredWidth - mWindowInsets.left - mWindowInsets.right).div(2) + child.layout(x, 0, x + child.measuredWidth, child.measuredHeight) + mOnLayoutChangeListener?.invoke(this, l, t, r, b) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + mOnAttachStateChangeListener?.onViewAttachedToWindow(this) + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + mOnAttachStateChangeListener?.onViewDetachedFromWindow(this) + } + + fun animateChildrenIn(delay: Int, duration: Int) { + messageView?.let { mView -> + mView.alpha = 0f + mView.animate() + .alpha(1f) + .setDuration(duration.toLong()) + .setStartDelay(delay.toLong()) + .start() + + actionView?.let { aView -> + if (aView.isVisible) { + aView.alpha = 0f + aView.animate() + .alpha(1f) + .setDuration(duration.toLong()) + .setStartDelay(delay.toLong()) + .start() + } + } + } + } + + fun animateChildrenOut(delay: Int, duration: Int) { + messageView?.let { mView -> + mView.alpha = 1f + mView.animate() + .alpha(0f) + .setDuration(duration.toLong()) + .setStartDelay(delay.toLong()) + .start() + + actionView?.let { aView -> + if (aView.isVisible) { + aView.alpha = 1f + mView.animate() + .alpha(0f) + .setDuration(duration.toLong()) + .setStartDelay(delay.toLong()) + .start() + } + } + } + } + + fun setOnLayoutChangeListener( + onLayoutChangeListener: ((view: View?, left: Int, top: Int, right: Int, bottom: Int) -> Unit)?, + ) { + mOnLayoutChangeListener = onLayoutChangeListener + } + + fun setOnAttachStateChangeListener(listener: OnAttachStateChangeListener?) { + mOnAttachStateChangeListener = listener + } + } + + class CardSnackbarLayout : SnackbarLayout { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + override val layoutId: Int + get() = R.layout.container_snackbar_layout_inner_card + } + + internal inner class Behavior : SwipeDismissBehavior() { + override fun canSwipeDismissView(child: View): Boolean { + return child is SnackbarLayout + } + + override fun onInterceptTouchEvent( + parent: CoordinatorLayout, + child: SnackbarLayout, + event: MotionEvent, + ): Boolean { + if (parent.isPointInChildBounds(child, event.x.toInt(), event.y.toInt())) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> SnackbarManager.instance.cancelTimeout(mManagerCallback) + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + SnackbarManager.instance.restoreTimeout(mManagerCallback) + } + } + return super.onInterceptTouchEvent(parent, child, event) + } + } + + companion object { + const val LENGTH_INDEFINITE = -2 + const val LENGTH_SHORT = -1 + const val LENGTH_LONG = 0 + const val ANIMATION_DURATION = 450 + const val ANIMATION_FADE_DURATION = 200 + private val sHandler: Handler = Handler( + Looper.getMainLooper(), + Handler.Callback { message: Message -> + when (message.what) { + MSG_SHOW -> { + (message.obj as Snackbar).showView() + return@Callback true + } + + MSG_DISMISS -> { + (message.obj as Snackbar).hideView(message.arg1) + return@Callback true + } + } + false + } + ) + private const val MSG_SHOW = 0 + private const val MSG_DISMISS = 1 + + fun make( + view: View, + text: CharSequence, + @Duration duration: Int, + cardStyle: Boolean, + ): Snackbar { + val snackbar = Snackbar(findSuitableParent(view), cardStyle) + snackbar.setText(text) + snackbar.setDuration(duration) + return snackbar + } + + fun make( + view: View, + @StringRes resId: Int, + @Duration duration: Int, + cardStyle: Boolean, + ): Snackbar { + return make(view, view.resources.getText(resId), duration, cardStyle) + } + + private fun findSuitableParent(viewP: View): ViewGroup { + var view: View? = viewP + do { + if (view is CoordinatorLayout) { + // We've found a CoordinatorLayout, use it + return view + } else if (view is FrameLayout) { + if (view.id == android.R.id.content) { + // If we've hit the decor content view, then we didn't find a CoL in the + // hierarchy, so use it. + return view + } + } + if (view != null) { + // Else, we will loop and crawl up the view hierarchy and try to find a parent + val parent = view.parent + view = if (parent is View) parent else null + } + } while (view != null) + throw IllegalArgumentException( + "No suitable parent found from the given view. Please provide a valid view." + ) + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/snackbar/SnackbarAnimationUtils.kt b/app/src/main/java/org/breezyweather/common/snackbar/SnackbarAnimationUtils.kt new file mode 100644 index 0000000..8ddbff8 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/snackbar/SnackbarAnimationUtils.kt @@ -0,0 +1,43 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.snackbar + +import android.animation.Animator +import android.animation.AnimatorSet +import android.view.View +import android.view.animation.AnimationUtils +import android.view.animation.Interpolator +import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import org.breezyweather.common.extensions.FLOATING_DECELERATE_INTERPOLATOR +import org.breezyweather.common.extensions.getFloatingOvershotEnterAnimators + +object SnackbarAnimationUtils : AnimationUtils() { + val FAST_OUT_SLOW_IN_INTERPOLATOR: Interpolator = FastOutSlowInInterpolator() + + fun getEnterAnimator(view: View, cardStyle: Boolean): Animator { + view.translationY = view.height.toFloat() + view.scaleX = if (cardStyle) 1.1f else 1f + view.scaleY = if (cardStyle) 1.1f else 1f + val animators = view.getFloatingOvershotEnterAnimators() + if (!cardStyle) { + animators[0].interpolator = FLOATING_DECELERATE_INTERPOLATOR + } + return AnimatorSet().apply { + playTogether(animators[0], animators[1], animators[2]) + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/snackbar/SnackbarContainer.kt b/app/src/main/java/org/breezyweather/common/snackbar/SnackbarContainer.kt new file mode 100644 index 0000000..7497bca --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/snackbar/SnackbarContainer.kt @@ -0,0 +1,26 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.snackbar + +import android.view.ViewGroup +import androidx.lifecycle.LifecycleOwner + +class SnackbarContainer( + val owner: LifecycleOwner?, + val container: ViewGroup, + val cardStyle: Boolean, +) diff --git a/app/src/main/java/org/breezyweather/common/snackbar/SnackbarManager.kt b/app/src/main/java/org/breezyweather/common/snackbar/SnackbarManager.kt new file mode 100644 index 0000000..b3b5231 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/snackbar/SnackbarManager.kt @@ -0,0 +1,205 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.snackbar + +import android.os.Handler +import android.os.Looper +import android.os.Message + +internal class SnackbarManager private constructor() { + private val mLock: Any = Any() + private val mHandler: Handler + private var mCurrentRecord: SnackbarRecord? = null + private var mNextRecord: SnackbarRecord? = null + + internal interface Callback { + fun show() + fun dismiss(event: Int) + } + + init { + mHandler = Handler( + Looper.getMainLooper(), + Handler.Callback { message: Message -> + if (message.what == MSG_TIMEOUT) { + handleTimeout(message.obj as SnackbarRecord) + return@Callback true + } + false + } + ) + } + + fun show(duration: Int, callback: Callback) { + synchronized(mLock) { + if (isCurrentSnackbar(callback)) { + // Means that the callback is already in the queue. We'll just update the duration + mCurrentRecord?.mDuration = duration + // If this is the Snackbar currently being shown, call re-schedule it's + // timeout + mHandler.removeCallbacksAndMessages(mCurrentRecord) + scheduleTimeoutLocked(mCurrentRecord) + return + } else if (isNextSnackbar(callback)) { + // We'll just update the duration + mNextRecord?.mDuration = duration + } else { + // Else, we need to create a new record and queue it + mNextRecord = SnackbarRecord(duration, callback) + } + if (mCurrentRecord != null && + cancelSnackbarLocked(mCurrentRecord, Snackbar.Callback.DISMISS_EVENT_CONSECUTIVE) + ) { + // If we currently have a Snackbar, try and cancel it and wait in line + return + } else { + // Clear out the current snackbar + mCurrentRecord = null + // Otherwise, just show it now + showNextSnackbarLocked() + } + } + } + + fun dismiss(callback: Callback, event: Int) { + synchronized(mLock) { + if (isCurrentSnackbar(callback)) { + cancelSnackbarLocked(mCurrentRecord, event) + } else if (isNextSnackbar(callback)) { + cancelSnackbarLocked(mNextRecord, event) + } else {} + } + } + + /** + * Should be called when a Snackbar is no longer displayed. This is after any exit + * animation has finished. + */ + fun onDismissed(callback: Callback) { + synchronized(mLock) { + if (isCurrentSnackbar(callback)) { + // If the callback is from a Snackbar currently show, remove it and show a new one + mCurrentRecord = null + if (mNextRecord != null) { + showNextSnackbarLocked() + } + } + } + } + + /** + * Should be called when a Snackbar is being shown. This is after any entrance animation has + * finished. + */ + fun onShown(callback: Callback) { + synchronized(mLock) { + if (isCurrentSnackbar(callback)) { + scheduleTimeoutLocked(mCurrentRecord) + } + } + } + + fun cancelTimeout(callback: Callback) { + synchronized(mLock) { + if (isCurrentSnackbar(callback)) { + mHandler.removeCallbacksAndMessages(mCurrentRecord) + } + } + } + + fun restoreTimeout(callback: Callback) { + synchronized(mLock) { + if (isCurrentSnackbar(callback)) { + scheduleTimeoutLocked(mCurrentRecord) + } + } + } + + fun isCurrent(callback: Callback): Boolean { + synchronized(mLock) { return isCurrentSnackbar(callback) } + } + + fun isCurrentOrNext(callback: Callback): Boolean { + synchronized(mLock) { return isCurrentSnackbar(callback) || isNextSnackbar(callback) } + } + + private class SnackbarRecord(var mDuration: Int, val mCallback: Callback?) { + fun isSnackbar(callback: Callback?): Boolean { + return callback != null && mCallback === callback + } + } + + private fun showNextSnackbarLocked() { + if (mNextRecord != null) { + mCurrentRecord = mNextRecord + mNextRecord = null + mCurrentRecord?.mCallback?.show() ?: run { + // The callback doesn't exist any more, clear out the Snackbar + mCurrentRecord = null + } + } + } + + private fun cancelSnackbarLocked(record: SnackbarRecord?, event: Int): Boolean { + record?.mCallback?.let { + it.dismiss(event) + return true + } + return false + } + + private fun isCurrentSnackbar(callback: Callback): Boolean { + return mCurrentRecord?.isSnackbar(callback) ?: false + } + + private fun isNextSnackbar(callback: Callback): Boolean { + return mNextRecord?.isSnackbar(callback) ?: false + } + + private fun scheduleTimeoutLocked(r: SnackbarRecord?) { + if (r == null) return + if (r.mDuration == Snackbar.LENGTH_INDEFINITE) { + // If we're set to indefinite, we don't want to set a timeout + return + } + var durationMs = LONG_DURATION_MS + if (r.mDuration > 0) { + durationMs = r.mDuration + } else if (r.mDuration == Snackbar.LENGTH_SHORT) { + durationMs = SHORT_DURATION_MS + } + mHandler.removeCallbacksAndMessages(r) + mHandler.sendMessageDelayed(Message.obtain(mHandler, MSG_TIMEOUT, r), durationMs.toLong()) + } + + private fun handleTimeout(record: SnackbarRecord) { + synchronized(mLock) { + if (mCurrentRecord === record || mNextRecord === record) { + cancelSnackbarLocked(record, Snackbar.Callback.DISMISS_EVENT_TIMEOUT) + } + } + } + + companion object { + val instance: SnackbarManager by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + SnackbarManager() + } + private const val MSG_TIMEOUT = 0 + private const val SHORT_DURATION_MS = 1500 + const val LONG_DURATION_MS = 3000 + } +} diff --git a/app/src/main/java/org/breezyweather/common/source/AddressSource.kt b/app/src/main/java/org/breezyweather/common/source/AddressSource.kt new file mode 100644 index 0000000..e99ce29 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/AddressSource.kt @@ -0,0 +1,137 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +interface AddressSource : Source { + + /** + * Known ambiguous country codes used by the source + * Use `null` to let Breezy Weather process all country codes known to be potentially ambiguous. + * Use this value if you have no idea what to do, or don’t want to bother testing every territory and claim + * Use an empty array if the source respects all known ISO 3166-1 alpha-2 codes + * Specify each ISO 3166-1 alpha-2 code otherwise + * + * Below are cities / coordinates to test each ambiguity. + * You need to test EACH location to validate that a country code is not ambiguous. Don’t assume that because + * the first ones succeed that the following will also. We have many evidences in the existing sources that + * exceptions are common + * Tip: You can copy the following coordinates and make a bulk replace of , with "&lon=" to semi-generate URLs + * AU: + * - CX: Flying Fish Cove -10.421667,105.678056 + * - CC: Bantam -12.1178,96.8975 + * - HM: Mawson Peak -53.1,73.516667 + * - NF: Kingston -29.056,167.961 + * CN: + * - HK: Hong Kong 22.38715,114.19534 + * - MO: Macau 22.19,113.54 + * - TW: Taipei 25.0375,121.5625 + * DK: + * - FO: Tórshavn 62,-6.783333 + * - GL: Nuuk 64.176667,-51.736111 + * FI: + * - AX: Eckerö 60.216667,19.55 + * FR: + * - GP (971): Pointe-à-Pitre 16.2411,-61.5331 + * - MQ (972): Fort-de-France 14.6,-61.066667 + * - GF (973): Cayenne 4.9372,-52.326 + * - RE (974): Le Tampon -21.2781,55.5153 + * - PM (975): Saint-Pierre 46.7778,-56.1778 + * - YT (976): Mamoudzou -12.7806,45.2278 + * - BL (977): Gustavia 17.897908,-62.850556 + * - MF (978): Marigot 18.07,-63.01 + * - TF (984): Port-aux-Français -49.35,70.218889 + * - AQ (984): Dumont D'Urville -66.662778,140.001111 + * - WF (986): Mata Utu -13.283333,-176.183333 + * - PF (987): Papeete -17.566667,-149.6 + * - NC (988): Nouméa -22.266667,166.466667 + * - CP (ignore if you have no way to make it recognize as CP): Clipperton 10.3,-109.216667 + * GB: + * - AI: The Valley 18.220833,-63.051667 + * - BM: Hamilton 32.296111,-64.782778 + * - IO: Diego Garcia -7.313333,72.411111 + * - KY: George Town 19.296389,-81.381667 + * - FK: Stanley -51.695278,-57.849444 + * - GI: Gibraltar 36.14,-5.35 + * - GG: St. Peter Port 49.4555,-2.5368 + * - IM: Douglas 54.15,-4.48 + * - JE: St Helier 49.19,-2.11 + * - MS: Brades 16.792778,-62.210556 + * - PN: Adamstown -25.066667,-130.1 + * - SH: + * -- Ascension: Two Boats -7.937,-14.364 + * -- Saint Helena: Half Tree Hollow -15.933333,-5.72 + * -- Tristan da Cunha: Edinburgh of the Seven Seas -37.0675,-12.311111 + * - GS: King Edward Point -54.283333,-36.5 + * - TC: Grand Turk (Cockburn Town) 21.459,-71.139 + * - VG: Road Town 18.431389,-64.623056 + * IL: + * - PS: Gaza city 31.516667,34.45 + * MA: + * - EH: Tifariti 26.158056,-10.566944 + * NL: + * - AW: Oranjestad (not to be confused with BQ) 12.518611,-70.035833 + * - BQ (old: AN): + * -- Bonaire: Kralendijk 12.144444,-68.265556 + * -- Sint Eustatius: Oranjestad (not to be confused with AW) 17.483333,-62.983333 + * -- Saba: The Bottom 17.626111,-63.249167 + * - CW (old: AN): Willemstad 12.116667,-68.933333 + * - SX (old: AN): Lower Prince's Quarter 18.052778,-63.0425 + * NO: + * - BV: Bouvet Island -54.42,3.36 + * - SJ: + * -- Svalbard: Longyearbyen 78.22,15.65 + * -- Jan Mayen: Olonkinbyen 70.922,-8.715 + * NZ: + * - TK: Atafu -8.557222,-172.470833 + * - CK: Avarua -21.206944,-159.770833 + * - NU: Alofi -19.053889,-169.92 + * RS: + * - XK: Pristina 42.663333,21.162222 + * US: + * - AS: Tāfuna -14.335833,-170.72 + * - GU: Dededo 13.509492,144.836528 + * - MP: Saipan 15.183333,145.75 + * - PR: Arecibo 18.375,-66.625 + * - UM: Baker Island 0.195833,-176.479167 + * - VI: Charlotte Amalie 18.35,-64.933333 + */ + val knownAmbiguousCountryCodes: Array? + + companion object { + /** + * For technical reasons, we need to better identify each territory + * Crimea is not included to let each location search/address lookup source resolves it the way they want + * and we will resolve the timezone as Europe/Simferopol whether identified as UA or RU + * Also ignores Antarctica claims, because even if a source actually supports that claim, no one lives there + */ + val ambiguousCountryCodes = arrayOf( + "AU", // Territories: CX, CC, HM (uninhabited), NF + "CN", // Territories: HK, MO. Claims: TW + "DK", // Territories: FO, GL + "FI", // Territories: AX + "FR", // Territories: GF, PF, TF (uninhabited), GP, MQ, YT, NC, RE, BL, MF, PM, WF, CP. Claims: AQ + "GB", // Territories: AI, BM, IO, KY, FK, GI, GG, IM, JE, MS, PN, SH, GS (uninhabited), TC, VG + "IL", // Claims: PS + "MA", // Claims: EH + "NL", // Territories: AW, BQ, CW, SX + "NO", // Territories: BV, SJ + "NZ", // Territories: TK. Associated states: CK, NU + "RS", // Claims: XK + "US" // Territories: AS, GU, MP, PR, UM (uninhabited), VI + ) + } +} diff --git a/app/src/main/java/org/breezyweather/common/source/BroadcastSource.kt b/app/src/main/java/org/breezyweather/common/source/BroadcastSource.kt new file mode 100644 index 0000000..2cfa72a --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/BroadcastSource.kt @@ -0,0 +1,39 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import android.os.Bundle +import breezyweather.domain.location.model.Location + +/** + * Broadcast services + */ +interface BroadcastSource : Source { + + // Make sure to also add it to the Manifest! + val intentAction: String + + /** + * Return null if anything happens and you no longer want to send any data + */ + fun getExtras( + context: Context, + allLocations: List, + updatedLocationIds: Array?, + ): Bundle? +} diff --git a/app/src/main/java/org/breezyweather/common/source/ConfigurableSource.kt b/app/src/main/java/org/breezyweather/common/source/ConfigurableSource.kt new file mode 100644 index 0000000..ca81c62 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/ConfigurableSource.kt @@ -0,0 +1,32 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import org.breezyweather.common.preference.Preference + +/** + * Implement this if you need a preference screen for all locations + * Use PreferencesParametersSource instead if you need per-location parameters + */ +interface ConfigurableSource : Source { + + val isConfigured: Boolean + val isRestricted: Boolean + + fun getPreferences(context: Context): List +} diff --git a/app/src/main/java/org/breezyweather/common/source/FeatureSource.kt b/app/src/main/java/org/breezyweather/common/source/FeatureSource.kt new file mode 100644 index 0000000..c2d7f79 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/FeatureSource.kt @@ -0,0 +1,78 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import androidx.annotation.DrawableRes +import breezyweather.domain.location.model.Location +import breezyweather.domain.source.SourceFeature + +/** + * Source with features + */ +interface FeatureSource : Source { + + /** + * An optional icon for the attribution page. + * /!\ Only include it if it is mandatory in the attribution, as we don’t want to bundle copyrighted icons which + * we don’t have the right to use! + * Example: return R.drawable.accu_icon + */ + @DrawableRes + fun getAttributionIcon(): Int? { + return null + } + + /** + * List the features by the source as keys + * Values are credits and acknowledgments that will be shown at the bottom of main screen + * Please check terms of the source to be sure to put the correct term here + * Example: + */ + val supportedFeatures: Map + + /** + * May be used when you don't have reverse geocoding implemented and you want to filter + * location results from default location search source to only include some countries + * for example + */ + fun isFeatureSupportedForLocation( + location: Location, + feature: SourceFeature, + ): Boolean = true + + /** + * Used to identify recommended sources by countries, ordered by priority descending. + * Any positive number can be used here, but we recommend using the available constants + * + * For example, worldwide sources will usually return PRIORITY_NONE (not recommended) + * National sources should return PRIORITY_HIGHEST here, unless there are multiple national sources + * and one is better than the other. In that case, sort them using the available preset priorities + */ + fun getFeaturePriorityForLocation( + location: Location, + feature: SourceFeature, + ): Int = PRIORITY_NONE + + companion object { + const val PRIORITY_HIGHEST = 100 + const val PRIORITY_HIGH = 75 + const val PRIORITY_MEDIUM = 50 + const val PRIORITY_LOW = 25 + const val PRIORITY_LOWEST = 0 + const val PRIORITY_NONE = -1 + } +} diff --git a/app/src/main/java/org/breezyweather/common/source/HttpSource.kt b/app/src/main/java/org/breezyweather/common/source/HttpSource.kt new file mode 100644 index 0000000..55d3cae --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/HttpSource.kt @@ -0,0 +1,46 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import breezyweather.domain.source.SourceContinent + +/** + * TODO: We should inject Retrofit.Builder here, however I still haven’t figure out how to do it yet + */ +abstract class HttpSource : Source { + + /** + * Privacy policy of the website, like: https://mysite.com/privacy + */ + abstract val privacyPolicyUrl: String + + /** + * Add a link each time a string appears in an attribution + * Example: <"Open-Meteo", "https://open-meteo.com/"> + */ + open val attributionLinks: Map = emptyMap() + + /** + * The continent the source is mainly based of + * + * Worldwide sources will use `SourceContinent.WORLDWIDE` + * National sources even if supporting worldwide will use the continent their mainland is based on + * E.g. Météo-France will use `SourceContinent.EUROPE` even if it supports oversea territories on other continents + * E.g. Türkiye will use `SourceContinent.ASIA` even if 10% of its territory is technically in Europe + */ + abstract val continent: SourceContinent +} diff --git a/app/src/main/java/org/breezyweather/common/source/LocationParametersSource.kt b/app/src/main/java/org/breezyweather/common/source/LocationParametersSource.kt new file mode 100644 index 0000000..f31cdae --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/LocationParametersSource.kt @@ -0,0 +1,53 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import breezyweather.domain.location.model.Location +import breezyweather.domain.source.SourceFeature +import io.reactivex.rxjava3.core.Observable + +/** + * Implement this if you need parameters such as an ID for the location + * For example, before fetching weather, you need to call an URL with longitude,latitude that + * will then give you the ID that needs to be stored + * ONLY used before fetching main weather OR secondary weather data + */ +interface LocationParametersSource : Source { + + /** + * Parameters: + * - the location + * - if coordinates were changed (only on the current location) + * - list of features requested. Empty if not specific to a feature (main source) + */ + fun needsLocationParametersRefresh( + location: Location, + coordinatesChanged: Boolean, + features: List = emptyList(), + ): Boolean + + /** + * Fetch any parameters you need and then make a map. For example : + * {"gridId": "20", "gridX": "30", "gridY": "25"} + * TODO: Add feature parameter (NWS needs to know if requesting current) + */ + fun requestLocationParameters( + context: Context, + location: Location, + ): Observable> +} diff --git a/app/src/main/java/org/breezyweather/common/source/LocationPositionWrapper.kt b/app/src/main/java/org/breezyweather/common/source/LocationPositionWrapper.kt new file mode 100644 index 0000000..876798d --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/LocationPositionWrapper.kt @@ -0,0 +1,23 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +// location. +data class LocationPositionWrapper( + val latitude: Double, + val longitude: Double, +) diff --git a/app/src/main/java/org/breezyweather/common/source/LocationResult.kt b/app/src/main/java/org/breezyweather/common/source/LocationResult.kt new file mode 100644 index 0000000..c14dba0 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/LocationResult.kt @@ -0,0 +1,24 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import breezyweather.domain.location.model.Location + +class LocationResult( + val location: Location, + val errors: List = emptyList(), +) diff --git a/app/src/main/java/org/breezyweather/common/source/LocationSearchSource.kt b/app/src/main/java/org/breezyweather/common/source/LocationSearchSource.kt new file mode 100644 index 0000000..45921c4 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/LocationSearchSource.kt @@ -0,0 +1,40 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import breezyweather.domain.location.model.LocationAddressInfo +import io.reactivex.rxjava3.core.Observable + +/** + * Location search source + */ +interface LocationSearchSource : AddressSource { + /** + * Credits and acknowledgments that will be shown at the bottom of main screen + * Please check terms of the source to be sure to put the correct term here + * Example: MyGreatApi (CC BY 4.0) + * + * Will not be displayed if identical to weatherAttribution + */ + val locationSearchAttribution: String + + /** + * Returns a list of Breezy Weather Location results from a query + */ + fun requestLocationSearch(context: Context, query: String): Observable> +} diff --git a/app/src/main/java/org/breezyweather/common/source/LocationSource.kt b/app/src/main/java/org/breezyweather/common/source/LocationSource.kt new file mode 100644 index 0000000..04af84c --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/LocationSource.kt @@ -0,0 +1,45 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.Manifest +import android.content.Context +import io.reactivex.rxjava3.core.Observable +import org.breezyweather.common.extensions.hasPermission + +interface LocationSource : Source { + + fun requestLocation(context: Context): Observable + + // permission. + val permissions: Array + fun hasPermissions(context: Context): Boolean { + val permissions = permissions + for (p in permissions) { + if (p == Manifest.permission.ACCESS_COARSE_LOCATION || p == Manifest.permission.ACCESS_FINE_LOCATION) { + continue + } + if (!context.hasPermission(p)) { + return false + } + } + + val coarseLocation = context.hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + val fineLocation = context.hasPermission(Manifest.permission.ACCESS_FINE_LOCATION) + return coarseLocation || fineLocation + } +} diff --git a/app/src/main/java/org/breezyweather/common/source/NonFreeNetSource.kt b/app/src/main/java/org/breezyweather/common/source/NonFreeNetSource.kt new file mode 100644 index 0000000..8b10e01 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/NonFreeNetSource.kt @@ -0,0 +1,22 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +/** + * Interface only used to display an error when trying to use it from the freenet flavor + */ +interface NonFreeNetSource diff --git a/app/src/main/java/org/breezyweather/common/source/PollenIndexSource.kt b/app/src/main/java/org/breezyweather/common/source/PollenIndexSource.kt new file mode 100644 index 0000000..37295e5 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/PollenIndexSource.kt @@ -0,0 +1,36 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +/** + * Interface for sources providing pollen data expressed in a scale or level way + * (for example 0 to 5) + */ +interface PollenIndexSource : Source { + + /** + * Array containing 0 to max level non-translatable labels + * If a data exceed max level, it will fallback to last item + */ + val pollenLabels: Int + + /** + * Array containing 0 to max level colors + * If a data exceed max level, it will fallback to last item + */ + val pollenColors: Int +} diff --git a/app/src/main/java/org/breezyweather/common/source/PreferencesParametersSource.kt b/app/src/main/java/org/breezyweather/common/source/PreferencesParametersSource.kt new file mode 100644 index 0000000..b10f124 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/PreferencesParametersSource.kt @@ -0,0 +1,50 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import androidx.compose.runtime.Composable +import breezyweather.domain.location.model.Location +import breezyweather.domain.source.SourceFeature +import kotlinx.collections.immutable.ImmutableList + +/** + * Implement this if you need parameters specific to each location + * Use ConfigurableSource instead if you need all locations parameters + */ +interface PreferencesParametersSource : Source { + + /** + * Must return true if the preferences screen is enabled for the given parameters + * + * Parameters: + * - the location + * - list of features requested. Empty if not specific to a feature + */ + fun hasPreferencesScreen( + location: Location, + features: List = emptyList(), + ): Boolean + + @Composable + fun PerLocationPreferences( + context: Context, + location: Location, + features: ImmutableList, + onSave: (Map) -> Unit, + ) +} diff --git a/app/src/main/java/org/breezyweather/common/source/RefreshError.kt b/app/src/main/java/org/breezyweather/common/source/RefreshError.kt new file mode 100644 index 0000000..314838b --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/RefreshError.kt @@ -0,0 +1,53 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import breezyweather.domain.source.SourceFeature +import org.breezyweather.R +import org.breezyweather.domain.source.resourceName +import org.breezyweather.sources.SourceManager +import org.breezyweather.ui.main.utils.RefreshErrorType + +class RefreshError( + val error: RefreshErrorType, + val source: String? = null, + val feature: SourceFeature? = null, +) { + fun getSourceWithOptionalFeature(context: Context, sourceManager: SourceManager): String? { + return if (!source.isNullOrEmpty()) { + val sourceName = sourceManager.getSource(source)?.name ?: source + if (feature != null) { + context.getString(R.string.parenthesis, sourceName, context.getString(feature.resourceName)) + } else { + sourceName + } + } else { + null + } + } + + fun getMessage(context: Context, sourceManager: SourceManager): String { + return if (!source.isNullOrEmpty()) { + "${getSourceWithOptionalFeature(context, sourceManager)}${context.getString( + R.string.colon_separator + )}${context.getString(error.shortMessage)}" + } else { + context.getString(error.shortMessage) + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/source/ReverseGeocodingSource.kt b/app/src/main/java/org/breezyweather/common/source/ReverseGeocodingSource.kt new file mode 100644 index 0000000..5bc3087 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/ReverseGeocodingSource.kt @@ -0,0 +1,36 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import breezyweather.domain.location.model.LocationAddressInfo +import io.reactivex.rxjava3.core.Observable + +/** + * Reverse geocoding source + */ +interface ReverseGeocodingSource : FeatureSource, AddressSource { + + /** + * Returns address info for the nearest location for the coordinates in parameter + */ + fun requestNearestLocation( + context: Context, + latitude: Double, + longitude: Double, + ): Observable> +} diff --git a/app/src/main/java/org/breezyweather/common/source/Source.kt b/app/src/main/java/org/breezyweather/common/source/Source.kt new file mode 100644 index 0000000..d361736 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/Source.kt @@ -0,0 +1,55 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import androidx.annotation.StringRes +import breezyweather.domain.location.model.Location +import breezyweather.domain.source.SourceContinent +import breezyweather.domain.source.SourceFeature +import org.breezyweather.R +import org.breezyweather.domain.source.resourceName + +interface Source { + /** + * Id for the source. Must be unique. + */ + val id: String + + /** + * Name of the source. + */ + val name: String + + /** + * How this source should be grouped: + * - Recommended + * - Worldwide + * - Continent + */ + @StringRes + fun getGroup(location: Location, feature: SourceFeature): Int { + return if (this is FeatureSource && + getFeaturePriorityForLocation(location, feature) >= 0 + ) { + R.string.weather_source_recommended + } else if (this is HttpSource) { + continent.resourceName + } else { + SourceContinent.WORLDWIDE.resourceName + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/source/SourceExtensions.kt b/app/src/main/java/org/breezyweather/common/source/SourceExtensions.kt new file mode 100644 index 0000000..1472417 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/SourceExtensions.kt @@ -0,0 +1,39 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import breezyweather.domain.location.model.Location +import breezyweather.domain.source.SourceFeature +import org.breezyweather.BuildConfig +import org.breezyweather.R + +fun Source.getName(context: Context, feature: SourceFeature? = null, location: Location? = null): String { + return if (this is NonFreeNetSource && BuildConfig.FLAVOR == "freenet") { + context.getString(R.string.settings_weather_source_non_freenet, name) + } else if (this is ConfigurableSource && !isConfigured) { + context.getString(R.string.settings_weather_source_not_configured, name) + } else if (this is FeatureSource && + location != null && + feature != null && + (!supportedFeatures.contains(feature) || !isFeatureSupportedForLocation(location, feature)) + ) { + context.getString(R.string.settings_weather_source_unavailable, name) + } else { + name + } +} diff --git a/app/src/main/java/org/breezyweather/common/source/TimeZoneSource.kt b/app/src/main/java/org/breezyweather/common/source/TimeZoneSource.kt new file mode 100644 index 0000000..8c37294 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/TimeZoneSource.kt @@ -0,0 +1,34 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import breezyweather.domain.location.model.Location +import io.reactivex.rxjava3.core.Observable +import java.util.TimeZone + +/** + * Timezone matcher source + */ +interface TimeZoneSource : Source { + + /** + * Returns the timezone for this location + * Returns GMT if failed to find the timezone + */ + fun requestTimezone(context: Context, location: Location): Observable +} diff --git a/app/src/main/java/org/breezyweather/common/source/WeatherResult.kt b/app/src/main/java/org/breezyweather/common/source/WeatherResult.kt new file mode 100644 index 0000000..5f3f693 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/WeatherResult.kt @@ -0,0 +1,24 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import breezyweather.domain.weather.model.Weather + +class WeatherResult( + val weather: Weather? = null, + val errors: List = emptyList(), +) diff --git a/app/src/main/java/org/breezyweather/common/source/WeatherSource.kt b/app/src/main/java/org/breezyweather/common/source/WeatherSource.kt new file mode 100644 index 0000000..9604284 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/source/WeatherSource.kt @@ -0,0 +1,77 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.source + +import android.content.Context +import breezyweather.domain.location.model.Location +import breezyweather.domain.source.SourceFeature +import breezyweather.domain.weather.wrappers.WeatherWrapper +import io.reactivex.rxjava3.core.Observable + +/** + * Weather service. + */ +interface WeatherSource : FeatureSource { + + /** + * One or a few locations that represents use cases you want to test for this source + * They will be available to add in the debug version + * + * Usually, you will need: name, longitude, latitude, timezone, countryCode, xxxxSource + * Don't bother adding things not useful for the tests such as administration levels + * To find coordinates and timezone, go to https://open-meteo.com/en/docs/geocoding-api + * + * Example: + * Location( + * city = "State College", + * latitude = 40.79339, + * longitude = -77.86, + * timeZone = "America/New_York", + * countryCode = "US", + * forecastSource = id, + * currentSource = id, + * airQualitySource = id, + * pollenSource = id, + * minutelySource = id, + * alertSource = id, + * normalsSource = id + * ) + * + * Can be an emptyList(), although we recommend adding at least one + */ + val testingLocations: List + get() = emptyList() + + /** + * Returns weather converted to Breezy Weather Weather object + * @param requestedFeatures List of features requested by the user + */ + fun requestWeather( + context: Context, + location: Location, + requestedFeatures: List, + ): Observable + + companion object { + const val PRIORITY_HIGHEST = 100 + const val PRIORITY_HIGH = 75 + const val PRIORITY_MEDIUM = 50 + const val PRIORITY_LOW = 25 + const val PRIORITY_LOWEST = 0 + const val PRIORITY_NONE = -1 + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/ColorUtils.kt b/app/src/main/java/org/breezyweather/common/utils/ColorUtils.kt new file mode 100644 index 0000000..05fd6f2 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/ColorUtils.kt @@ -0,0 +1,81 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils + +import android.graphics.Bitmap +import android.graphics.Color +import android.media.ThumbnailUtils +import androidx.annotation.ColorInt +import androidx.core.graphics.ColorUtils +import androidx.core.graphics.get +import kotlin.math.ln + +object ColorUtils { + + @ColorInt + fun bitmapToColorInt(bitmap: Bitmap): Int { + return ThumbnailUtils.extractThumbnail(bitmap, 1, 1)[0, 0] + } + + fun isLightColor(@ColorInt color: Int): Boolean { + val alpha = 0xFF shl 24 + var grey = color + val red = grey and 0x00FF0000 shr 16 + val green = grey and 0x0000FF00 shr 8 + val blue = grey and 0x000000FF + grey = (red * 0.3 + green * 0.59 + blue * 0.11).toInt() + grey = alpha or (grey shl 16) or (grey shl 8) or grey + return grey > -0x424243 + } + + fun getDarkerColor(@ColorInt color: Int): Int { + val hsv = FloatArray(3) + Color.colorToHSV(color, hsv) + hsv[1] = hsv[1] + 0.15f + hsv[2] = hsv[2] - 0.15f + return Color.HSVToColor(hsv) + } + + @ColorInt + fun blendColor(@ColorInt foreground: Int, @ColorInt background: Int): Int { + val scr = Color.red(foreground) + val scg = Color.green(foreground) + val scb = Color.blue(foreground) + val sa = foreground ushr 24 + val dcr = Color.red(background) + val dcg = Color.green(background) + val dcb = Color.blue(background) + val colorR = dcr * (0xff - sa) / 0xff + scr * sa / 0xff + val colorG = dcg * (0xff - sa) / 0xff + scg * sa / 0xff + val colorB = dcb * (0xff - sa) / 0xff + scb * sa / 0xff + return (colorR shl 16) + (colorG shl 8) + colorB or -0x1000000 + } + + @ColorInt + fun getWidgetSurfaceColor( + elevationDp: Float, + @ColorInt tintColor: Int, + @ColorInt surfaceColor: Int, + ): Int { + if (elevationDp == 0f) return surfaceColor + val foreground = ColorUtils.setAlphaComponent( + tintColor, + ((4.5f * ln((elevationDp + 1).toDouble()) + 2f) / 100f * 255).toInt() + ) + return blendColor(foreground, surfaceColor) + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/CrashLogUtils.kt b/app/src/main/java/org/breezyweather/common/utils/CrashLogUtils.kt new file mode 100644 index 0000000..db3ef99 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/CrashLogUtils.kt @@ -0,0 +1,83 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils + +import android.content.Context +import android.net.Uri +import android.os.Build +import org.breezyweather.BuildConfig +import org.breezyweather.R +import org.breezyweather.background.receiver.NotificationReceiver +import org.breezyweather.common.extensions.cancelNotification +import org.breezyweather.common.extensions.createFileInCacheDir +import org.breezyweather.common.extensions.getUriCompat +import org.breezyweather.common.extensions.notify +import org.breezyweather.common.extensions.withNonCancellableContext +import org.breezyweather.common.extensions.withUIContext +import org.breezyweather.common.utils.helpers.SnackbarHelper +import org.breezyweather.remoteviews.Notifications + +/** + * Taken from Mihon + * Apache License, Version 2.0 + * + * https://github.com/mihonapp/mihon/blob/aa498360db90350f2642e6320dc55e7d474df1fd/app/src/main/java/eu/kanade/tachiyomi/util/CrashLogUtil.kt + */ + +class CrashLogUtils( + private val context: Context, +) { + + suspend fun dumpLogs() = withNonCancellableContext { + try { + val file = context.createFileInCacheDir("breezyweather_crash_logs.txt") + Runtime.getRuntime().exec("logcat *:E -d -f ${file.absolutePath}").waitFor() + file.appendText(getDebugInfo()) + + showNotification(file.getUriCompat(context)) + } catch (e: Throwable) { + e.printStackTrace() + withUIContext { SnackbarHelper.showSnackbar("Failed to get logs") } + } + } + + fun getDebugInfo(): String { + return """ + App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.FLAVOR}, ${BuildConfig.COMMIT_SHA}, ${BuildConfig.VERSION_CODE} + Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}); build ${Build.DISPLAY} + Device brand: ${Build.BRAND} + Device manufacturer: ${Build.MANUFACTURER} + Device name: ${Build.DEVICE} (${Build.PRODUCT}) + Device model: ${Build.MODEL} + """.trimIndent() + } + + private fun showNotification(uri: Uri) { + context.cancelNotification(Notifications.ID_CRASH_LOGS) + + context.notify( + Notifications.ID_CRASH_LOGS, + Notifications.CHANNEL_CRASH_LOGS + ) { + setContentTitle(context.getString(R.string.settings_debug_dump_crash_logs_saved)) + setContentText(context.getString(R.string.settings_debug_dump_crash_logs_tap_to_open)) + setSmallIcon(R.drawable.ic_alert) + + setContentIntent(NotificationReceiver.openErrorLogPendingActivity(context, uri)) + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/ISO8601Utils.kt b/app/src/main/java/org/breezyweather/common/utils/ISO8601Utils.kt new file mode 100644 index 0000000..e6aae9d --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/ISO8601Utils.kt @@ -0,0 +1,390 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ +package org.breezyweather.common.utils + +import java.text.ParseException +import java.text.ParsePosition +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import java.util.Locale +import java.util.TimeZone +import kotlin.math.abs +import kotlin.math.min + +/** + * Utilities methods for manipulating dates in iso8601 format. This is much faster and GC friendly than using SimpleDateFormat so + * highly suitable if you (un)serialize lots of date objects. + * + * Supported parse format: [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]] + * + * @see [this specification](http://www.w3.org/TR/NOTE-datetime) + */ +// Date parsing code from Jackson databind ISO8601Utils.java +// https://github.com/FasterXML/jackson-databind/blob/2.8/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java +object ISO8601Utils { + /** + * ID to represent the 'UTC' string, default timezone since Jackson 2.7 + * + * @since 2.7 + */ + private const val UTC_ID = "UTC" + + /** + * The UTC timezone, prefetched to avoid more lookups. + * + * @since 2.7 + */ + private val TIMEZONE_UTC = TimeZone.getTimeZone(UTC_ID) + + /* + / ********************************************************** + / * Formatting + / ********************************************************** + */ + + /** + * Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] + * + * @param date the date to format + * @param millis true to include millis precision otherwise false + * @param tz timezone to use for the formatting (UTC will produce 'Z') + * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] + */ + fun format(date: Date, millis: Boolean = false, tz: TimeZone = TIMEZONE_UTC): String { + val calendar: Calendar = GregorianCalendar(tz, Locale.US).apply { + time = date + } + + // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) + var capacity = "yyyy-MM-ddThh:mm:ss".length + capacity += if (millis) ".sss".length else 0 + capacity += if (tz.rawOffset == 0) "Z".length else "+hh:mm".length + val formatted = StringBuilder(capacity) + + padInt(formatted, calendar[Calendar.YEAR], "yyyy".length) + formatted.append('-') + padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length) + formatted.append('-') + padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length) + formatted.append('T') + padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length) + formatted.append(':') + padInt(formatted, calendar[Calendar.MINUTE], "mm".length) + formatted.append(':') + padInt(formatted, calendar[Calendar.SECOND], "ss".length) + if (millis) { + formatted.append('.') + padInt(formatted, calendar[Calendar.MILLISECOND], "sss".length) + } + + val offset = tz.getOffset(calendar.timeInMillis) + if (offset != 0) { + val hours = abs(offset / (60 * 1000) / 60) + val minutes = abs(offset / (60 * 1000) % 60) + formatted.append(if (offset < 0) '-' else '+') + padInt(formatted, hours, "hh".length) + formatted.append(':') + padInt(formatted, minutes, "mm".length) + } else { + formatted.append('Z') + } + + return formatted.toString() + } + + /* + / ********************************************************** + / * Parsing + / ********************************************************** + */ + + /** + * Parse a date from ISO-8601 formatted string. It expects a format + * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:mm]]] + * + * @param date ISO string to parse in the appropriate format. + * @return the parsed date + * @throws ParseException if the date is not in the appropriate format + */ + @Throws(ParseException::class) + fun parse(date: String?): Date { + val pos = ParsePosition(0) + var fail: Exception? = null + try { + var offset = pos.index + + // extract year + val year = parseInt( + date, + offset, + 4.let { + offset += it + offset + } + ) + if (checkOffset(date, offset, '-')) { + offset += 1 + } + + // extract month + val month = parseInt( + date, + offset, + 2.let { + offset += it + offset + } + ) + if (checkOffset(date, offset, '-')) { + offset += 1 + } + + // extract day + val day = parseInt( + date, + offset, + 2.let { + offset += it + offset + } + ) + // default time value + var hour = 0 + var minutes = 0 + var seconds = 0 + var milliseconds = 0 // always use 0 otherwise returned date will include millis of current time + + // if the value has no time component (and no time zone), we are done + val hasT = checkOffset(date, offset, 'T') + + if (!hasT && date!!.length <= offset) { + val calendar: Calendar = GregorianCalendar(year, month - 1, day).apply { + isLenient = false + } + pos.index = offset + return calendar.time + } + + if (hasT) { + // extract hours, minutes, seconds and milliseconds + hour = parseInt( + date, + 1.let { + offset += it + offset + }, + 2.let { + offset += it + offset + } + ) + if (checkOffset(date, offset, ':')) { + offset += 1 + } + + minutes = parseInt( + date, + offset, + 2.let { + offset += it + offset + } + ) + if (checkOffset(date, offset, ':')) { + offset += 1 + } + // second and milliseconds can be optional + if (date!!.length > offset) { + val c = date[offset] + if (c != 'Z' && c != '+' && c != '-') { + seconds = parseInt( + date, + offset, + 2.let { + offset += it + offset + } + ) + if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds + // milliseconds can be optional in the format + if (checkOffset(date, offset, '.')) { + offset += 1 + val endOffset = indexOfNonDigit(date, offset + 1) // assume at least one digit + val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits + val fraction = parseInt(date, offset, parseEndOffset) + milliseconds = when (parseEndOffset - offset) { + 2 -> fraction * 10 + 1 -> fraction * 100 + else -> fraction + } + offset = endOffset + } + } + } + } + + // extract timezone + require(date!!.length > offset) { "No time zone indicator" } + + var timezone: TimeZone? = null + val timezoneIndicator = date[offset] + + if (timezoneIndicator == 'Z') { + timezone = TIMEZONE_UTC + offset += 1 + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + var timezoneOffset = date.substring(offset) + + // When timezone has no minutes, we should append it, valid timezones are, + // for example: +00:00, +0000 and +00 + timezoneOffset = if (timezoneOffset.length >= 5) timezoneOffset else timezoneOffset + "00" + + offset += timezoneOffset.length + // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" + if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) { + timezone = TIMEZONE_UTC + } else { + // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC… + // not sure why, but that's the way it looks. Further, Javadocs for + // `java.util.TimeZone` specifically instruct use of GMT as base for + // custom timezones… odd. + val timezoneId = "GMT$timezoneOffset" + // val timezoneId = "UTC$timezoneOffset; + + timezone = TimeZone.getTimeZone(timezoneId) + + val act = timezone.id + if (act != timezoneId) { + /* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given + * one without. If so, don't sweat. + * Yes, very inefficient. Hopefully not hit often. + * If it becomes a perf problem, add 'loose' comparison instead. + */ + val cleaned = act.replace(":", "") + if (cleaned != timezoneId) { + throw IndexOutOfBoundsException( + "Mismatching time zone indicator: " + timezoneId + " given, resolves to " + timezone.id + ) + } + } + } + } else { + throw IndexOutOfBoundsException("Invalid time zone indicator '$timezoneIndicator'") + } + val calendar: Calendar = GregorianCalendar(timezone).apply { + isLenient = false + set(Calendar.YEAR, year) + set(Calendar.MONTH, month - 1) + set(Calendar.DAY_OF_MONTH, day) + set(Calendar.HOUR_OF_DAY, hour) + set(Calendar.MINUTE, minutes) + set(Calendar.SECOND, seconds) + set(Calendar.MILLISECOND, milliseconds) + } + pos.index = offset + return calendar.time + // If we get a ParseException it'll already have the right message/offset. + // Other exception types can convert here. + } catch (e: IndexOutOfBoundsException) { + fail = e + } catch (e: IllegalArgumentException) { + fail = e + } + val input = if (date == null) null else '"'.toString() + date + '"' + var msg = fail!!.message + if (msg.isNullOrEmpty()) { + msg = "(" + fail.javaClass.name + ")" + } + val ex = ParseException("Failed to parse date [$input]: $msg", pos.index) + ex.initCause(fail) + throw ex + } + + /** + * Check if the expected character exist at the given offset in the value. + * + * @param value the string to check at the specified offset + * @param offset the offset to look for the expected character + * @param expected the expected character + * @return true if the expected character exist at the given offset + */ + private fun checkOffset(value: String?, offset: Int, expected: Char): Boolean { + return offset < value!!.length && value[offset] == expected + } + + /** + * Parse an integer located between 2 given offsets in a string + * + * @param value the string to parse + * @param beginIndex the start index for the integer in the string + * @param endIndex the end index for the integer in the string + * @return the int + * @throws NumberFormatException if the value is not a number + */ + @Throws(NumberFormatException::class) + private fun parseInt(value: String?, beginIndex: Int, endIndex: Int): Int { + if (beginIndex < 0 || endIndex > value!!.length || beginIndex > endIndex) { + throw NumberFormatException(value) + } + // use same logic as in Integer.parseInt() but less generic we're not supporting negative values + var i = beginIndex + var result = 0 + var digit: Int + if (i < endIndex) { + digit = value[i++].digitToIntOrNull() ?: -1 + if (digit < 0) { + throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) + } + result = -digit + } + while (i < endIndex) { + digit = value[i++].digitToIntOrNull() ?: -1 + if (digit < 0) { + throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) + } + result *= 10 + result -= digit + } + return -result + } + + /** + * Zero pad a number to a specified length + * + * @param buffer buffer to use for padding + * @param value the integer value to pad if necessary. + * @param length the length of the string we should zero pad + */ + private fun padInt(buffer: StringBuilder, value: Int, length: Int) { + val strValue = value.toString() + for (i in length - strValue.length downTo 1) { + buffer.append('0') + } + buffer.append(strValue) + } + + /** + * Returns the index of the first character in the string that is not a digit, starting at offset. + */ + private fun indexOfNonDigit(string: String?, offset: Int): Int { + for (i in offset until string!!.length) { + val c = string[i] + if (c < '0' || c > '9') return i + } + return string.length + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/UnitUtils.kt b/app/src/main/java/org/breezyweather/common/utils/UnitUtils.kt new file mode 100644 index 0000000..e1e7d38 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/UnitUtils.kt @@ -0,0 +1,212 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils + +import android.content.Context +import android.content.res.Resources +import android.text.SpannableString +import android.text.Spanned +import android.text.style.RelativeSizeSpan +import androidx.annotation.ArrayRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.em +import org.breezyweather.common.extensions.currentLocale +import org.breezyweather.common.options.BaseEnum +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.unit.formatting.format + +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ +object UnitUtils { + + fun getName( + context: Context, + enum: BaseEnum, + ) = getNameByValue( + res = context.resources, + value = enum.id, + nameArrayId = enum.nameArrayId, + valueArrayId = enum.valueArrayId + )!! + + fun getNameByValue( + res: Resources, + value: String, + @ArrayRes nameArrayId: Int, + @ArrayRes valueArrayId: Int, + ): String? { + val names = res.getStringArray(nameArrayId) + val values = res.getStringArray(valueArrayId) + return getNameByValue(value, names, values) + } + + private fun getNameByValue( + value: String, + names: Array, + values: Array, + ) = values.zip(names).firstOrNull { it.first == value }?.second + + @Deprecated("Use Number.format() extension") + fun formatDouble( + context: Context, + value: Double, + precision: Int = 2, + showSign: Boolean = false, + ): String { + return value.format( + decimals = precision, + locale = context.currentLocale, + showSign = showSign, + useNumberFormatter = SettingsManager.Companion.getInstance(context).useNumberFormatter, + useNumberFormat = SettingsManager.Companion.getInstance(context).useMeasureFormat + ) + } + + @Deprecated("Use Number.format() extension") + fun formatInt( + context: Context, + value: Int, + showSign: Boolean = false, + ): String { + return value.format( + decimals = 0, + locale = context.currentLocale, + showSign = showSign, + useNumberFormatter = SettingsManager.Companion.getInstance(context).useNumberFormatter, + useNumberFormat = SettingsManager.Companion.getInstance(context).useMeasureFormat + ) + } + + /** + * Units will stay at the same size if it somehow fails to parse + */ + fun formatUnitsHalfSize(formattedMeasure: String): CharSequence { + val firstIndexOfADigit = formattedMeasure.indexOfAny(DIGITS, 0) + val lastIndexOfADigit = formattedMeasure.lastIndexOfAny(DIGITS, formattedMeasure.length - 1) + if (firstIndexOfADigit < 0 || lastIndexOfADigit < 0 || lastIndexOfADigit > formattedMeasure.length) { + return formattedMeasure + } + return SpannableString(formattedMeasure) + .apply { + if (firstIndexOfADigit > 0) { + setSpan( + RelativeSizeSpan(0.5f), + 0, + firstIndexOfADigit, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + if (lastIndexOfADigit < formattedMeasure.length) { + setSpan( + RelativeSizeSpan(0.5f), + lastIndexOfADigit + 1, + formattedMeasure.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + } + + /** + * Units will stay at the same size if it somehow fails to parse + */ + @Composable + fun formatUnitsDifferentFontSize( + formattedMeasure: String, + fontSize: TextUnit, + ): AnnotatedString { + val firstIndexOfADigit = formattedMeasure.indexOfAny(DIGITS, 0) + val lastIndexOfADigit = formattedMeasure.lastIndexOfAny(DIGITS, formattedMeasure.length - 1) + return buildAnnotatedString { + if (firstIndexOfADigit < 0 || lastIndexOfADigit < 0 || lastIndexOfADigit > formattedMeasure.length) { + append(formattedMeasure) + } else { + if (firstIndexOfADigit > 0) { + withStyle(style = SpanStyle(fontSize = fontSize)) { + append(formattedMeasure.substring(0, firstIndexOfADigit)) + } + } + append(formattedMeasure.substring(firstIndexOfADigit, lastIndexOfADigit + 1)) + if (lastIndexOfADigit < formattedMeasure.length) { + withStyle(style = SpanStyle(fontSize = fontSize)) { + append(formattedMeasure.substring(lastIndexOfADigit + 1)) + } + } + } + } + } + + /** + * Format a pollutant name so that the number are subscript + * Units will stay at the same size if it somehow fails to parse + */ + @Composable + fun formatPollutantName( + formattedMeasure: String, + ): AnnotatedString { + val firstIndexOfADigit = formattedMeasure.indexOfAny(DIGITS, 0) + val lastIndexOfADigit = formattedMeasure.lastIndexOfAny(DIGITS, formattedMeasure.length - 1) + return buildAnnotatedString { + if (firstIndexOfADigit < 0 || lastIndexOfADigit < 0 || lastIndexOfADigit > formattedMeasure.length) { + append(formattedMeasure) + } else { + if (firstIndexOfADigit > 0) { + append(formattedMeasure.substring(0, firstIndexOfADigit)) + } + withStyle( + style = SpanStyle( + baselineShift = BaselineShift.Companion.Subscript, + fontSize = 0.8.em + ) + ) { + append(formattedMeasure.substring(firstIndexOfADigit, lastIndexOfADigit + 1)) + } + if (lastIndexOfADigit < formattedMeasure.length) { + append(formattedMeasure.substring(lastIndexOfADigit + 1)) + } + } + } + } + + private val ARABIC_DIGITS = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9') + private val ARABIC_INDIC_DIGITS = charArrayOf('٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩') + private val BENGALI_DIGITS = charArrayOf('০', '১', '২', '৩', '৪', '৫', '৬', '৭', '৮', '৯') + private val DEVANAGARI_DIGITS = charArrayOf('०', '१', '२', '३', '४', '५', '६', '७', '८', '९') + private val PERSIAN_DIGITS = charArrayOf('۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹') + private val TAMIL_DIGITS = charArrayOf('௦', '௧', '௨', '௩', '௪', '௫', '௬', '௭', '௮', '௯') + internal val DIGITS = + ARABIC_DIGITS + ARABIC_INDIC_DIGITS + BENGALI_DIGITS + DEVANAGARI_DIGITS + PERSIAN_DIGITS + TAMIL_DIGITS +} diff --git a/app/src/main/java/org/breezyweather/common/utils/helpers/AsyncHelper.kt b/app/src/main/java/org/breezyweather/common/utils/helpers/AsyncHelper.kt new file mode 100644 index 0000000..c004a95 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/helpers/AsyncHelper.kt @@ -0,0 +1,96 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils.helpers + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.ObservableEmitter +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import java.util.concurrent.TimeUnit + +object AsyncHelper { + fun runOnIO( + task: (emitter: Emitter) -> Unit, + callback: (t: T, done: Boolean) -> Unit, + ): Controller { + return Controller( + Observable.create { emitter: ObservableEmitter> -> + task(Emitter(emitter)) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { data: Data -> callback(data.t, data.done) } + .subscribe() + ) + } + + fun runOnIO(runnable: Runnable): Controller { + return Controller( + Observable.create { _: ObservableEmitter? -> runnable.run() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + ) + } + + fun delayRunOnIO(runnable: Runnable, milliSeconds: Long): Controller { + return Controller( + Observable.timer(milliSeconds, TimeUnit.MILLISECONDS) + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(Schedulers.io()) + .doOnComplete { runnable.run() } + .subscribe() + ) + } + + fun delayRunOnUI(runnable: Runnable, milliSeconds: Long): Controller { + return Controller( + Observable.timer(milliSeconds, TimeUnit.MILLISECONDS) + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnComplete { runnable.run() } + .subscribe() + ) + } + + fun intervalRunOnUI( + runnable: Runnable, + intervalMilliSeconds: Long, + initDelayMilliSeconds: Long, + ): Controller { + return Controller( + Observable.interval(initDelayMilliSeconds, intervalMilliSeconds, TimeUnit.MILLISECONDS) + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { runnable.run() } + ) + } + + class Controller internal constructor(val inner: Disposable) { + fun cancel() { + inner.dispose() + } + } + + class Data internal constructor(val t: T, val done: Boolean) + class Emitter internal constructor(val inner: ObservableEmitter>) { + fun send(t: T, done: Boolean) { + inner.onNext(Data(t, done)) + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/helpers/IntentHelper.kt b/app/src/main/java/org/breezyweather/common/utils/helpers/IntentHelper.kt new file mode 100644 index 0000000..34f1db7 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/helpers/IntentHelper.kt @@ -0,0 +1,241 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils.helpers + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.annotation.RequiresApi +import androidx.core.net.toUri +import breezyweather.domain.location.model.Location +import org.breezyweather.common.options.appearance.DetailScreen +import org.breezyweather.ui.about.AboutActivity +import org.breezyweather.ui.alert.AlertActivity +import org.breezyweather.ui.details.DetailsActivity +import org.breezyweather.ui.main.MainActivity +import org.breezyweather.ui.search.SearchActivity +import org.breezyweather.ui.settings.activities.CardDisplayManageActivity +import org.breezyweather.ui.settings.activities.DailyTrendDisplayManageActivity +import org.breezyweather.ui.settings.activities.DependenciesActivity +import org.breezyweather.ui.settings.activities.HourlyTrendDisplayManageActivity +import org.breezyweather.ui.settings.activities.PreviewIconActivity +import org.breezyweather.ui.settings.activities.PrivacyPolicyActivity +import org.breezyweather.ui.settings.activities.SettingsActivity +import org.breezyweather.ui.settings.compose.SettingsScreenRouter + +/** + * Intent helper. + */ +object IntentHelper { + fun startMainActivityForManagement(activity: Activity) { + activity.startActivity( + Intent(MainActivity.ACTION_MANAGEMENT).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + ) + } + + fun buildMainActivityIntent(location: Location?): Intent { + var formattedId: String? = null + if (location != null) { + formattedId = location.formattedId + } + return Intent(MainActivity.ACTION_MAIN).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(MainActivity.KEY_MAIN_ACTIVITY_LOCATION_FORMATTED_ID, formattedId) + } + } + + fun buildMainActivityShowAlertsIntent(location: Location?, alertId: String? = null): Intent { + var formattedId: String? = null + if (location != null) { + formattedId = location.formattedId + } + return Intent(MainActivity.ACTION_SHOW_ALERTS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(MainActivity.KEY_MAIN_ACTIVITY_LOCATION_FORMATTED_ID, formattedId) + if (alertId != null) { + putExtra(MainActivity.KEY_MAIN_ACTIVITY_ALERT_ID, alertId) + } + } + } + + fun buildMainActivityShowDailyForecastIntent( + location: Location?, + index: Int, + ): Intent { + var formattedId: String? = null + if (location != null) { + formattedId = location.formattedId + } + return Intent(MainActivity.ACTION_SHOW_DAILY_FORECAST).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + putExtra(MainActivity.KEY_MAIN_ACTIVITY_LOCATION_FORMATTED_ID, formattedId) + putExtra(MainActivity.KEY_DAILY_INDEX, index) + } + } + + fun startDailyWeatherActivity( + activity: Activity, + formattedId: String?, + index: Int? = null, + chart: DetailScreen? = null, + ) { + activity.startActivity( + Intent(activity, DetailsActivity::class.java).apply { + putExtra(DetailsActivity.KEY_FORMATTED_LOCATION_ID, formattedId) + if (index != null) { + putExtra(DetailsActivity.KEY_CURRENT_DAILY_INDEX, index) + } + if (chart != null) { + putExtra(DetailsActivity.KEY_CURRENT_PAGE, chart.id) + } + } + ) + } + + fun startAlertActivity(activity: Activity, formattedId: String?, alertId: String? = null) { + activity.startActivity( + Intent(activity, AlertActivity::class.java).apply { + putExtra(AlertActivity.KEY_FORMATTED_ID, formattedId) + if (alertId != null) { + putExtra(AlertActivity.KEY_ALERT_ID, alertId) + } + } + ) + } + + fun buildSearchActivityIntent(activity: Activity): Intent { + return Intent(activity, SearchActivity::class.java) + } + + fun startSettingsActivity(activity: Activity) { + activity.startActivity(Intent(activity, SettingsActivity::class.java)) + } + + fun startCardDisplayManageActivity(activity: Activity) { + activity.startActivity(Intent(activity, CardDisplayManageActivity::class.java)) + } + + fun startDailyTrendDisplayManageActivity(activity: Activity) { + activity.startActivity(Intent(activity, DailyTrendDisplayManageActivity::class.java)) + } + + fun startHourlyTrendDisplayManageActivity(activity: Activity) { + activity.startActivity(Intent(activity, HourlyTrendDisplayManageActivity::class.java)) + } + + fun startMainScreenSettingsActivity(activity: Activity) { + activity.startActivity( + Intent(activity, SettingsActivity::class.java).apply { + putExtra( + SettingsActivity.KEY_SETTINGS_ACTIVITY_START_DESTINATION, + SettingsScreenRouter.MainScreen.route + ) + } + ) + } + + fun startLocationProviderSettingsActivity(activity: Activity) { + activity.startActivity( + Intent(activity, SettingsActivity::class.java).apply { + putExtra( + SettingsActivity.KEY_SETTINGS_ACTIVITY_START_DESTINATION, + SettingsScreenRouter.Location.route + ) + } + ) + } + + fun startWeatherProviderSettingsActivity(activity: Activity) { + activity.startActivity( + Intent(activity, SettingsActivity::class.java).apply { + putExtra( + SettingsActivity.KEY_SETTINGS_ACTIVITY_START_DESTINATION, + SettingsScreenRouter.WeatherProviders.route + ) + } + ) + } + + fun startPreviewIconActivity(activity: Activity, packageName: String?) { + activity.startActivity( + Intent(activity, PreviewIconActivity::class.java).apply { + putExtra( + PreviewIconActivity.KEY_ICON_PREVIEW_ACTIVITY_PACKAGE_NAME, + packageName + ) + } + ) + } + + fun startAboutActivity(context: Context) { + context.startActivity(Intent(context, AboutActivity::class.java)) + } + + fun startDependenciesActivity(activity: Activity) { + activity.startActivity(Intent(activity, DependenciesActivity::class.java)) + } + + fun startPrivacyPolicyActivity(activity: Activity) { + activity.startActivity(Intent(activity, PrivacyPolicyActivity::class.java)) + } + + fun startApplicationDetailsActivity(context: Context, pkgName: String? = context.packageName) { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", pkgName, null) + } + ) + } + + fun startLocationSettingsActivity(context: Context) { + context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + } + + fun startBreezyActivity(activity: Activity, location: Location) { + activity.startActivity( + Intent( + Intent.ACTION_VIEW, + "geo:${location.latitude},${location.longitude}".toUri() + ) + ) + } + + @RequiresApi(Build.VERSION_CODES.O) + fun startNotificationSettingsActivity(context: Context, pkgName: String? = context.packageName) { + context.startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, pkgName) + } + ) + } + + private fun isIntentAvailable(context: Context, intent: Intent): Boolean { + return context.packageManager + .queryIntentActivities(intent, PackageManager.GET_ACTIVITIES) + .size > 0 + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/helpers/LogHelper.kt b/app/src/main/java/org/breezyweather/common/utils/helpers/LogHelper.kt new file mode 100644 index 0000000..f64ff29 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/helpers/LogHelper.kt @@ -0,0 +1,27 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils.helpers + +import android.util.Log + +object LogHelper { + private const val TAG = "BreezyWeather" + + fun log(tag: String? = TAG, msg: String) { + Log.d(tag, msg) + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/helpers/PermissionHelper.kt b/app/src/main/java/org/breezyweather/common/utils/helpers/PermissionHelper.kt new file mode 100644 index 0000000..76f2db1 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/helpers/PermissionHelper.kt @@ -0,0 +1,56 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils.helpers + +import android.app.Activity +import androidx.core.app.ActivityCompat.requestPermissions +import androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale +import org.breezyweather.ui.main.utils.StatementManager + +object PermissionHelper { + + /** + * Requests a permission via the default permission dialog and falls back to a custom action if the default + * dialog cannot be launched because it was previously denied by the user. + * + * Source: https://stackoverflow.com/a/50639402 + */ + fun requestPermissionWithFallback( + activity: Activity, + permission: String, + requestCode: Int = 0, + fallback: () -> Unit, + ) { + val statementManager = StatementManager(activity) + val showRationale = shouldShowRequestPermissionRationale(activity, permission) + val permissionDenied = statementManager.isPermissionDenied(permission) + + if (!showRationale && permissionDenied) { + fallback() + } else { + requestPermissions( + activity, + arrayOf(permission), + requestCode + ) + + if (showRationale && !permissionDenied) { + statementManager.setPermissionDenied(permission) + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/helpers/ShortcutsHelper.kt b/app/src/main/java/org/breezyweather/common/utils/helpers/ShortcutsHelper.kt new file mode 100644 index 0000000..f68a962 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/helpers/ShortcutsHelper.kt @@ -0,0 +1,64 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils.helpers + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap +import breezyweather.domain.weather.reference.WeatherCode +import org.breezyweather.ui.theme.resource.ResourceHelper +import org.breezyweather.ui.theme.resource.providers.ResourceProvider + +/** + * Shortcuts manager. + */ +@RequiresApi(Build.VERSION_CODES.N_MR1) +object ShortcutsHelper { + + private fun drawableToBitmap(drawable: Drawable): Bitmap { + val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) + drawable.draw(canvas) + return bitmap + } + + @RequiresApi(Build.VERSION_CODES.O) + fun getAdaptiveIcon( + provider: ResourceProvider, + code: WeatherCode, + daytime: Boolean, + ): Icon { + return Icon.createWithAdaptiveBitmap( + drawableToBitmap( + ResourceHelper.getShortcutsForegroundIcon(provider, code, daytime) + ) + ) + } + + fun getIcon(provider: ResourceProvider, code: WeatherCode, daytime: Boolean): Icon { + return Icon.createWithBitmap( + drawableToBitmap( + ResourceHelper.getShortcutsIcon(provider, code, daytime) + ) + ) + } +} diff --git a/app/src/main/java/org/breezyweather/common/utils/helpers/SnackbarHelper.kt b/app/src/main/java/org/breezyweather/common/utils/helpers/SnackbarHelper.kt new file mode 100644 index 0000000..01f5664 --- /dev/null +++ b/app/src/main/java/org/breezyweather/common/utils/helpers/SnackbarHelper.kt @@ -0,0 +1,41 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.common.utils.helpers + +import android.view.View +import org.breezyweather.BreezyWeather +import org.breezyweather.common.activities.BreezyActivity +import org.breezyweather.common.snackbar.Snackbar + +object SnackbarHelper { + + fun showSnackbar( + content: String, + action: String? = null, + activity: BreezyActivity? = null, + listener: View.OnClickListener? = null, + ) { + if (action != null && listener == null) { + throw RuntimeException("Must send a non null listener as parameter.") + } + val container = (activity ?: BreezyWeather.instance.topActivity ?: return).provideSnackbarContainer() + Snackbar.make(container.container, content, Snackbar.LENGTH_LONG, container.cardStyle) + .setAction(action, listener) + .setCallback(Snackbar.Callback()) + .show() + } +} diff --git a/app/src/main/java/org/breezyweather/data/Contributors.kt b/app/src/main/java/org/breezyweather/data/Contributors.kt new file mode 100644 index 0000000..d8b9cdd --- /dev/null +++ b/app/src/main/java/org/breezyweather/data/Contributors.kt @@ -0,0 +1,439 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.data + +import androidx.annotation.StringRes +import org.breezyweather.R + +class ContributorItem( + val name: String, + val github: String? = null, + val weblate: String? = null, + val mail: String? = null, + val url: String? = null, + @StringRes val contribution: Int? = null, +) { + val link = when { + !github.isNullOrEmpty() -> "https://github.com/$github" + !weblate.isNullOrEmpty() -> "https://hosted.weblate.org/user/$weblate/" + !mail.isNullOrEmpty() -> "mailto:$mail" + !url.isNullOrEmpty() -> url + else -> "" + } +} + +class TranslatorItem( + val lang: Array = emptyArray(), + val name: String, + val github: String? = null, + val weblate: String? = null, + val mail: String? = null, + val url: String? = null, +) + +val appContributors: Array = arrayOf( + ContributorItem("Julien Papasian", github = "papjul"), + ContributorItem( + "WangDaYeeeeee", + github = "WangDaYeeeeee", + contribution = R.string.about_contribution_WangDaYeeeeee + ), + /** + * Many contributions + */ + ContributorItem("min7-i", github = "min7-i"), + /** + * Many contributions + */ + ContributorItem("chunshek", github = "chunshek"), + /** + * - Fix daily shift in widgets + * - Improvements to the Android location source + * - Many other contributions that I can no longer find + */ + ContributorItem("Cod3d.", github = "Cod3dDOT"), + /** + * - Disable animation settings when disabled at system level + * - Fix location dialog opening twice + * - Fix location dialog being duplicated on certain screens + * - Fix colors in composables + * - Exclude live wallpaper preview from recent apps list + */ + ContributorItem("ecawthorne", github = "ecawthorne"), + /** + * - Add cache support to OkHttp + * - Added method to compute hourly/current UV based on daily UV + */ + ContributorItem("Romain Théry", github = "rthery"), + /** + * Clean up / Documentation + */ + ContributorItem("Mark Bestavros", github = "mbestavros"), + /** + * Dark mode fixes + */ + ContributorItem("Suyash Gupta", github = "suyashgupta25"), + /** + * Fix Chinese calendar days + */ + ContributorItem("Coelacanthus", github = "CoelacanthusHex"), + /** + * Show full multiline forecast in expanded notification + */ + ContributorItem("danielzhang130", github = "danielzhang130"), + /** + * Added spacing between quantity and unit according to ISO. + */ + ContributorItem("majjejjam", github = "majjejjam"), + /** + * MET Office source + */ + ContributorItem("bunburya", github = "bunburya"), + /** + * Fix lag in refresh + */ + ContributorItem("jayyuz", github = "jayyuz"), + /** + * Fix hourly forecast hours in China source + */ + ContributorItem("JiunnTarn", github = "JiunnTarn"), + /** + * Remove trailing spaces in search + */ + ContributorItem("mags0ft", github = "mags0ft"), + /** + * Make the app seen as a weather app for the system + */ + ContributorItem("Devy Ballard", github = "devycarol"), + /** + * Fix dark mode of the Edit location dialog + */ + ContributorItem("Mushfiq1060", github = "Mushfiq1060"), + /** + * Fix hourly tabs disappearing + */ + ContributorItem("ccyybn", github = "ccyybn"), + /** + * Don’t open location list in landscape by default when there is only 1 location + */ + ContributorItem("Doğaç Tanrıverdi", github = "DogacTanriverdi"), + /** + * Memory leak + */ + ContributorItem("Nero Nguyen", github = "neronguyenvn"), + /** + * UX of custom subtitle documentation + */ + ContributorItem("Dipesh Pal", github = "codewithdipesh"), + /** + * Logo + */ + ContributorItem( + "Anthony Dégrange", + url = "https://anthony-degrange-design.fr/", + contribution = R.string.about_contribution_designer + ) +) + +// Please keep them ordered by the main language translated so that we can easily sort translators by % contributed +// Here, we want to sort by language code, which is a different order than in Language.kt +// If you significantly contributed more than other translators, and you would like to appear +// first in the list, please open a GitHub issue +val appTranslators = arrayOf( + TranslatorItem(arrayOf("ar"), "sodqe muhammad", mail = "sodqe.younes@gmail.com"), + TranslatorItem(arrayOf("ar"), "Rex_sa", github = "rex07"), + TranslatorItem(arrayOf("ar"), "TomatoScriptCPP", github = "TomatoScriptCPP"), + TranslatorItem(arrayOf("ar"), "jonnysemon", weblate = "jonnysemon"), + TranslatorItem(arrayOf("be"), "Yauhen", weblate = "Bugomol"), + TranslatorItem(arrayOf("be"), "Drugi Sapog", weblate = "DinDrugi"), + TranslatorItem(arrayOf("be"), "Ding User", weblate = "DeNGus"), + TranslatorItem(arrayOf("bg"), "elgratea", weblate = "flantito"), + TranslatorItem(arrayOf("bg"), "StoyanDimitrov", github = "StoyanDimitrov"), + TranslatorItem(arrayOf("bg"), "srmihnev", github = "srmihnev"), + TranslatorItem(arrayOf("bn"), "Manab Ray", github = "manabray"), + TranslatorItem(arrayOf("bn"), "The Contributor", weblate = "another_user"), + TranslatorItem(arrayOf("bn"), "Fahim Ahmed", github = "fahim-ahmed05"), + TranslatorItem(arrayOf("bn"), "Dipyaman Roy", github = "dipyamanroy"), + TranslatorItem(arrayOf("bn"), "ferus3", weblate = "ferus3"), + TranslatorItem(arrayOf("bn"), "Abhishek Dasgupta", github = "abdasgupta"), + TranslatorItem(arrayOf("bs"), "Erudaro", github = "Erudaro"), + TranslatorItem(arrayOf("bs"), "SecularSteve", github = "SecularSteve"), + TranslatorItem(arrayOf("ca"), "Álvaro Martínez Majado", github = "alvaromartinezmajado"), + TranslatorItem(arrayOf("ca"), "Arnau Mora", github = "ArnyminerZ"), + TranslatorItem(arrayOf("ca"), "Sabrina Khan", weblate = "khansabrina594"), + TranslatorItem(arrayOf("ca"), "Pere Orga", github = "pereorga"), + TranslatorItem(arrayOf("ca"), "Jaime Muñoz Martín", github = "kayron8"), + TranslatorItem(arrayOf("ca"), "John Doe", weblate = "healthyburrito"), + TranslatorItem(arrayOf("ca"), "gReventos", github = "gReventos"), + TranslatorItem(arrayOf("ca"), "BennyBeat", github = "BennyBeat"), + TranslatorItem(arrayOf("ckb", "ar"), "anyone00", weblate = "anyone00"), + TranslatorItem(arrayOf("cs"), "Jiří Král", mail = "jirkakral978@gmail.com"), + TranslatorItem(arrayOf("cs"), "ikanakova", github = "ikanakova"), + TranslatorItem(arrayOf("cs"), "esszed", github = "esszed"), + TranslatorItem(arrayOf("cs"), "Vojta", github = "vojta-dev"), + TranslatorItem(arrayOf("cs"), "Jiří Král", github = "FrameXX"), + TranslatorItem(arrayOf("cs"), "Fjuro", github = "Fjuro"), + TranslatorItem(arrayOf("da"), "Rasmus", weblate = "Grooty"), + TranslatorItem(arrayOf("da"), "Peter", github = "peetabix"), + TranslatorItem(arrayOf("da"), "Grooty12", weblate = "Grooty12"), + TranslatorItem(arrayOf("da"), "Benjamin Nielsen", weblate = "devjam1n"), + TranslatorItem(arrayOf("da"), "Michael Millet", weblate = "mrMillet"), + TranslatorItem(arrayOf("de"), "Ken Berns", mail = "ken.berns@yahoo.de"), + TranslatorItem(arrayOf("de"), "Jörg Meinhardt", mail = "jorime@web.de"), + TranslatorItem(arrayOf("de"), "Thorsten Eckerlein", mail = "thorsten.eckerlein@gmx.de"), + TranslatorItem(arrayOf("de"), "Pascal Dietrich", github = "Cameo007"), + TranslatorItem(arrayOf("de"), "min7-i", github = "min7-i"), + TranslatorItem(arrayOf("de"), "Ettore Atalan", github = "Atalanttore"), + TranslatorItem(arrayOf("de"), "FineFindus", github = "FineFindus"), + TranslatorItem(arrayOf("de"), "elea11", github = "elea11"), + TranslatorItem(arrayOf("de"), "Ulion", weblate = "ulion"), + TranslatorItem(arrayOf("de"), "ColorfulRhino", weblate = "ColorfulRhino"), + TranslatorItem(arrayOf("de"), "Lacey Anaya", weblate = "lanAYA"), + TranslatorItem(arrayOf("de"), "Kachelkaiser", github = "Kachelkaiser"), + TranslatorItem(arrayOf("de"), "Lenny Angst", github = "Lezurex"), + TranslatorItem(arrayOf("de"), "ich Bin May", weblate = "Maxterdf"), + TranslatorItem(arrayOf("de"), "Nonni", github = "nonni-heere"), + TranslatorItem(arrayOf("el"), "Μιχάλης Καζώνης", mail = "istrios@gmail.com"), + TranslatorItem(arrayOf("el"), "Kostas Giapis", github = "tsiflimagas"), + TranslatorItem(arrayOf("el"), "giwrgosmant", github = "giwrgosmant"), + TranslatorItem(arrayOf("el"), "Steven Shehata", weblate = "Stidon"), + TranslatorItem(arrayOf("el"), "Lefteris T.", github = "trlef19"), + TranslatorItem(arrayOf("el"), "Makis", github = "80-svg"), + TranslatorItem(arrayOf("eo"), "phlostically", weblate = "phlostically"), + TranslatorItem(arrayOf("eo"), "Oasis Tri", weblate = "Oasis3"), + TranslatorItem(arrayOf("eo", "cs"), "Valentin Lluba", weblate = "circulate"), + TranslatorItem(arrayOf("es"), "dylan", github = "d-l-n"), + TranslatorItem(arrayOf("es"), "Miguel Torrijos", mail = "migueltg352340@gmail.com"), + TranslatorItem(arrayOf("es"), "Julio Martínez Ródenas", github = "juliomartinezrodenas"), + TranslatorItem(arrayOf("es"), "Hin Weisner", weblate = "Hinweis"), + TranslatorItem(arrayOf("es"), "gallegonovato", weblate = "gallegonovato"), + TranslatorItem(arrayOf("es"), "Jose", github = "AzagraMac"), + TranslatorItem(arrayOf("es"), "Yayi23", github = "Yayi23"), + TranslatorItem(arrayOf("es"), "Eraorahan", weblate = "eraorahan"), + TranslatorItem(arrayOf("es"), "Jose l. Azagra", github = "azagramac"), + TranslatorItem(arrayOf("es"), "Traductor", github = "cyphra"), + TranslatorItem(arrayOf("es"), "Richa371", github = "Richa371"), + TranslatorItem(arrayOf("es"), "No name", weblate = "CertainBot"), + TranslatorItem(arrayOf("et"), "kovabait12", github = "kovabait12"), + TranslatorItem(arrayOf("et"), "Priit Jõerüüt", weblate = "jrthwlate"), + TranslatorItem(arrayOf("et"), "Gert Lutter", weblate = "ruut.103"), + TranslatorItem(arrayOf("et"), "Theodor Põlluste", github = "theodor373"), + TranslatorItem(arrayOf("et"), "rimasx", github = "rimasx"), + TranslatorItem(arrayOf("et"), "Priit Jõerüüt", weblate = "jrthwlate"), + TranslatorItem(arrayOf("eu"), "Dabid", github = "desertorea"), + TranslatorItem(arrayOf("eu"), "beriain", github = "beriain"), + TranslatorItem(arrayOf("eu"), "xabiliza", github = "xabiliza"), + TranslatorItem(arrayOf("eu"), "Isolus", weblate = "isolus"), + TranslatorItem(arrayOf("fa"), "Aspen", weblate = "olden"), + TranslatorItem(arrayOf("fa"), "Armin Bashizade", github = "arminbashizade"), + TranslatorItem(arrayOf("fa"), "Alireza Rashidi", github = "alr86"), + TranslatorItem(arrayOf("fa"), "Monirzadeh", github = "Monirzadeh"), + TranslatorItem(arrayOf("fa"), "hulse", weblate = "hulse"), + TranslatorItem(arrayOf("fr", "en", "eo"), "Julien Papasian", github = "papjul"), + TranslatorItem(arrayOf("fr"), "Benjamin Tourrel", mail = "polo_naref@hotmail.fr"), + TranslatorItem(arrayOf("fr"), "Nam", github = "ldmpub"), + TranslatorItem(arrayOf("fi"), "huuhaa", github = "huuhaa"), + TranslatorItem(arrayOf("fi"), "nimxaa", github = "nimxaa"), + TranslatorItem(arrayOf("fi"), "MillionsToOne", github = "MillionsToOne"), + TranslatorItem(arrayOf("fi"), "Jane Doe", weblate = "Decaf3683"), + TranslatorItem(arrayOf("fi"), "Ricky-Tigg", github = "Ricky-Tigg"), + TranslatorItem(arrayOf("fi"), "Juli", weblate = "Julimiro"), + TranslatorItem(arrayOf("ga"), "Aindriú Mac Giolla Eoin", github = "aindriu80"), + TranslatorItem(arrayOf("gl"), "Adrian Hermida Baloira", github = "adrianhermida"), + TranslatorItem(arrayOf("gl"), "xcomesana", github = "xcomesana"), + TranslatorItem(arrayOf("gl"), "Roi", weblate = "roicou"), + TranslatorItem(arrayOf("he", "iw"), "nick", github = "nvurgaft"), + TranslatorItem(arrayOf("he", "iw"), "Doge", weblate = "Doge"), + TranslatorItem(arrayOf("he", "iw"), "Kurpaph", github = "Kurpaph"), + TranslatorItem(arrayOf("he", "iw"), "Arthur Zamarin", github = "arthurzam"), + TranslatorItem(arrayOf("hi", "mr"), "Sapate Vaibhav", github = "sapatevaibhav"), + TranslatorItem(arrayOf("hi"), "Chandra Mohan Jha", github = "ChAJ07"), + TranslatorItem(arrayOf("hi"), "Deepesh Singh Chauhan", github = "master2619"), + TranslatorItem(arrayOf("hi"), "ShareASmile", weblate = "ShareASmile"), + TranslatorItem(arrayOf("hi"), "Akshat", weblate = "Akshat-Projects"), + TranslatorItem(arrayOf("hr"), "Mateo Spajić", github = "Spajki001"), + TranslatorItem(arrayOf("hr"), "Milo Ivir", github = "milotype"), + TranslatorItem(arrayOf("hr"), "ggdorman", github = "ggdorman"), + TranslatorItem(arrayOf("hu"), "Viktor Blaskó", github = "blaskoviktor"), + TranslatorItem(arrayOf("hu"), "Olivér Paróczai", github = "OliverParoczai"), + TranslatorItem(arrayOf("hu"), "summoner001", github = "summoner001"), + TranslatorItem(arrayOf("hu"), "NBencee", github = "NBencee"), + TranslatorItem(arrayOf("ia"), "softinterlingua", github = "softinterlingua"), + TranslatorItem(arrayOf("in"), "MDP43140", github = "MDP43140"), + TranslatorItem(arrayOf("in"), "Reza", github = "rezaalmanda"), + TranslatorItem(arrayOf("in"), "Christian Elbrianno", github = "crse"), + TranslatorItem(arrayOf("in"), "Linerly", github = "Linerly"), + TranslatorItem(arrayOf("in"), "Adrien N", weblate = "adriennathaniel1999"), + TranslatorItem(arrayOf("it"), "Andrea Carulli", mail = "rctandrew100@gmail.com"), + TranslatorItem(arrayOf("it"), "Giovanni Donisi", github = "gdonisi"), + TranslatorItem(arrayOf("it"), "Henry The Mole", weblate = "htmole"), + TranslatorItem(arrayOf("it"), "Lorenzo J. Lucchini", github = "LuccoJ"), + TranslatorItem(arrayOf("it"), "Gabriele Monaco", github = "glemco"), + TranslatorItem(arrayOf("it"), "Manuel Tassi", github = "Mannivu"), + TranslatorItem(arrayOf("it"), "Ulisse Perusin", github = "ulipo"), + TranslatorItem(arrayOf("it"), "Lorenzo Romano", weblate = "lloranmorenzio"), + TranslatorItem(arrayOf("it"), "Innominatapersona", github = "Innominatapersona"), + TranslatorItem(arrayOf("it"), "bryce-lynch", weblate = "bryce-lynch"), + TranslatorItem(arrayOf("it"), "Giorgio", github = "dimeglio98"), + TranslatorItem(arrayOf("it"), "mapi68", github = "mapi68"), + TranslatorItem(arrayOf("it"), "Nicola Cesarini", weblate = "Cianfrugo"), + TranslatorItem(arrayOf("ja"), "rikupin1105", github = "rikupin1105"), + TranslatorItem(arrayOf("ja"), "Suguru Hirahara", weblate = "shirahara"), + TranslatorItem(arrayOf("ja"), "Meiru", weblate = "Tenbin"), + TranslatorItem(arrayOf("ja"), "若林 さち", weblate = "05e82918ec434690"), + TranslatorItem(arrayOf("ja"), "しいたけ", github = "Shiitakeeeee"), + TranslatorItem(arrayOf("kab"), "ButterflyOfFire", weblate = "boffire"), + TranslatorItem(arrayOf("kab"), "Ziri Sut", github = "ZiriSut"), + TranslatorItem(arrayOf("ko"), "이서경", mail = "ng0972@naver.com"), + TranslatorItem(arrayOf("ko"), "Yurical", github = "yurical"), + TranslatorItem(arrayOf("ko"), "ID J", weblate = "tabby4442"), + TranslatorItem(arrayOf("ko"), "Alex", github = "whatthesamuel"), + TranslatorItem(arrayOf("ko"), "agw76638", github = "agw76638"), + TranslatorItem(arrayOf("ko"), "tabby", weblate = "tabby"), + TranslatorItem(arrayOf("ko"), "multivac", github = "Centurion-Mk2"), + TranslatorItem(arrayOf("lt"), "Deividas Paukštė", weblate = "RustyOperator"), + TranslatorItem(arrayOf("lt"), "D221", github = "D221"), + TranslatorItem(arrayOf("lt"), "splice11", github = "splice11"), + TranslatorItem(arrayOf("lt"), "Oliveinparis", github = "Oliveinparis"), + TranslatorItem(arrayOf("lv"), "Niks Rodžers", weblate = "niks.rodzers.auzins"), + TranslatorItem(arrayOf("lv"), "Eduards Lusts", weblate = "eduardslu"), + TranslatorItem(arrayOf("lv"), "Edgars Andersons", weblate = "Edgarsons"), + TranslatorItem(arrayOf("lv"), "09pulse", weblate = "09pulse"), + TranslatorItem(arrayOf("lv"), "Coool", github = "Coool"), + TranslatorItem(arrayOf("mk"), "ikocevski7", github = "ikocevski7"), + TranslatorItem(arrayOf("mk"), "Rijolo", weblate = "rijolo4790"), + TranslatorItem(arrayOf("nb_rNO"), "Even Bull-Tornøe", github = "bt0rne"), + TranslatorItem(arrayOf("nb_rNO"), "Visnes", github = "Visnes"), + TranslatorItem(arrayOf("nb_rNO"), "Simen", weblate = "sien"), + TranslatorItem(arrayOf("nl"), "BabyBenefactor", github = "BabyBenefactor"), + TranslatorItem(arrayOf("nl"), "Jurre Tas", mail = "jurretas@gmail.com"), + TranslatorItem(arrayOf("nl"), "trend", github = "trend-1"), + TranslatorItem(arrayOf("nl"), "programpro2005", github = "programpro2005"), + TranslatorItem(arrayOf("nl"), "OliNau", github = "OliNau"), + TranslatorItem(arrayOf("nl"), "CouldBeMathijs", github = "JustPassingBy06"), + TranslatorItem(arrayOf("nl"), "that translator", weblate = "Translate"), + TranslatorItem(arrayOf("nl"), "Stef Smeets", github = "stefsmeets"), + TranslatorItem(arrayOf("nl"), "Roan-V", github = "Roan-V"), + TranslatorItem(arrayOf("nl"), "kyrawertho", github = "kyrawertho"), + TranslatorItem(arrayOf("nl"), "Brecht", github = "brecht6"), + TranslatorItem(arrayOf("nl"), "Stephan Paternotte", github = "Stephan-P"), + TranslatorItem(arrayOf("oc"), "Quentin PAGÈS", weblate = "Quenti"), + TranslatorItem(arrayOf("pl"), "Kamil", mail = "invisiblehype@gmail.com"), + TranslatorItem(arrayOf("pl"), "nid", github = "nidmb"), + TranslatorItem(arrayOf("pl"), "Eryk Michalak", github = "gnu-ewm"), + TranslatorItem(arrayOf("pl"), "HackZy01", github = "HackZy01"), + TranslatorItem(arrayOf("pl"), "GGORG", github = "GGORG0"), + TranslatorItem(arrayOf("pl"), "maksskorka", github = "maksskorka"), + TranslatorItem(arrayOf("pl"), "bitzy", weblate = "bitzy"), + TranslatorItem(arrayOf("pl"), "Daniel Misiarek", weblate = "daniel8f54446d1f224098"), + TranslatorItem(arrayOf("pl"), "r5jyhte", weblate = "trewtdj"), + TranslatorItem(arrayOf("pl"), "diskacz", github = "diskacz"), + TranslatorItem(arrayOf("pt"), "Silvério Santos", github = "SantosSi"), + TranslatorItem(arrayOf("pt"), "TiagoAryan", github = "TiagoAryan"), + TranslatorItem(arrayOf("pt"), "Pedro", github = "pdafv"), + TranslatorItem(arrayOf("pt"), "Manuela Silva", github = "mansil"), + TranslatorItem(arrayOf("pt", "de", "es"), "Murcielago", weblate = "MRCLG"), + TranslatorItem(arrayOf("pt", "pt_rBR"), "Kirakaze", github = "Kirazake"), + TranslatorItem(arrayOf("pt_rBR"), "Fabio Raitz", mail = "fabioraitz@outlook.com"), + TranslatorItem(arrayOf("pt_rBR"), "Washington Luiz Candido dos Santos Neto", weblate = "Netocon"), + TranslatorItem(arrayOf("pt_rBR"), "mf", weblate = "marfS2"), + TranslatorItem(arrayOf("pt_rBR"), "jucasagr", github = "jucasagr"), + TranslatorItem(arrayOf("pt_rBR"), "Lucas Fernandes Vitor", weblate = "luc4sfv"), + TranslatorItem(arrayOf("pt_rBR"), "tetify", github = "tetify"), + TranslatorItem(arrayOf("pt_rBR"), "DanGLES3", github = "DanGLES3"), + TranslatorItem(arrayOf("pt_rBR"), "OlliesGudh", github = "OlliesGudh"), + TranslatorItem(arrayOf("pt_rBR"), "burns", github = "alvaroburns"), + TranslatorItem(arrayOf("pt_rBR"), "Lucas", github = "lucasmz-dev"), + TranslatorItem(arrayOf("ro"), "Igor Sorocean", github = "ygorigor"), + TranslatorItem(arrayOf("ro"), "alexandru l", mail = "sandu.lulu@gmail.com"), + TranslatorItem(arrayOf("ro"), "sas", weblate = "sas33"), + TranslatorItem(arrayOf("ro"), "Alexandru51", github = "Alexandru51"), + TranslatorItem(arrayOf("ro"), "Glassto", github = "Glassto"), + TranslatorItem(arrayOf("ro"), "Renko", github = "Renko"), + TranslatorItem(arrayOf("ro"), "David", weblate = "David7e16baa08f0b4658"), + TranslatorItem(arrayOf("ru"), "Roman Adadurov", mail = "orelars53@gmail.com"), + TranslatorItem(arrayOf("ru"), "Denio", mail = "deniosens@yandex.ru"), + TranslatorItem(arrayOf("ru"), "Егор Ермаков", weblate = "creepen"), + TranslatorItem(arrayOf("ru"), "TenchMaviatorius2759", github = "TenchMaviatorius2759"), + TranslatorItem(arrayOf("ru"), "mak7im01", github = "mak7im01"), + TranslatorItem(arrayOf("ru"), "Tim", weblate = "dlee"), + TranslatorItem(arrayOf("ru"), "Yurt Page", github = "yurtpage"), + TranslatorItem(arrayOf("sk"), "Kuko", weblate = "kuko7"), + TranslatorItem(arrayOf("sk", "cs"), "Viliam Geffert", github = "vgeffer"), + TranslatorItem(arrayOf("sk"), "aasami", weblate = "aasami"), + TranslatorItem(arrayOf("sl_rSI"), "Gregor", mail = "glakner@gmail.com"), + TranslatorItem(arrayOf("sl_rSI"), "Kristijan Tkalec", github = "lapor-kris"), + TranslatorItem(arrayOf("sl_rSI"), "Marko", weblate = "horvat.marko1993"), + TranslatorItem(arrayOf("sl_rSI"), "BorKajin", github = "BorKajin"), + TranslatorItem(arrayOf("sr"), "NEXI", github = "nexiRS"), + TranslatorItem(arrayOf("sr"), "Milan Andrejić", mail = "amikia@hotmail.com"), + TranslatorItem(arrayOf("sv"), "P.O", weblate = "mxvWhxCebxjnmLQxcIr"), + TranslatorItem(arrayOf("sv"), "Peter Ericson", github = "noscirep"), + TranslatorItem(arrayOf("sv"), "Luna Jernberg", github = "bittin"), + TranslatorItem(arrayOf("sv"), "Victor Zamanian", github = "victorz"), + TranslatorItem(arrayOf("sv"), "Innocentius0", github = "Innocentius0"), + TranslatorItem(arrayOf("ta"), "தமிழ் நேரம்", github = "TamilNeram"), + TranslatorItem(arrayOf("ta"), "Naveen", weblate = "naveen"), + TranslatorItem(arrayOf("ta"), "Yogeshwar Bala", github = "Blend3rman"), + TranslatorItem(arrayOf("th"), "Wari", github = "wwwwwwari"), + TranslatorItem(arrayOf("th"), "ACHN SYPS", github = "achn-syps"), + TranslatorItem(arrayOf("th", "ja"), "ikkue", github = "ikkue"), + TranslatorItem(arrayOf("tr"), "Mehmet Saygin Yilmaz", mail = "memcos@gmail.com"), + TranslatorItem(arrayOf("tr"), "Ali D.", mail = "siyaha@gmail.com"), + TranslatorItem(arrayOf("tr"), "metezd", weblate = "metezd"), + TranslatorItem(arrayOf("tr"), "Furkan Karcıoğlu", github = "frknkrc44"), + TranslatorItem(arrayOf("tr"), "abfreeman", weblate = "abfreeman"), + TranslatorItem(arrayOf("tr"), "Oğuz Ersen", github = "oersen"), + TranslatorItem(arrayOf("tr"), "Önder Nuray", github = "ondern"), + TranslatorItem(arrayOf("tr"), "AbdullahManaz", github = "AbdullahManaz"), + TranslatorItem(arrayOf("tr"), "ODK", weblate = "odk0160"), + TranslatorItem(arrayOf("tr"), "polarwood", weblate = "polarwood"), + TranslatorItem(arrayOf("tr"), "Salih Efe Ergür", github = "salihefee"), + TranslatorItem(arrayOf("uk"), "Cod3d.", github = "Cod3dDOT"), + TranslatorItem(arrayOf("uk"), "Skrripy", weblate = "Skrripy"), + TranslatorItem(arrayOf("uk"), "Fqwe1", weblate = "Fqwe1"), + TranslatorItem(arrayOf("uk"), "Сергій", github = "Serega124"), + TranslatorItem(arrayOf("uk"), "Максим Горпиніч", weblate = "Maksim2005UKR"), + TranslatorItem(arrayOf("uk"), "Do you know my name?", weblate = "Anonymous2676"), + TranslatorItem(arrayOf("uk", "be", "ru"), "vertekplus", github = "vertekplus"), + TranslatorItem(arrayOf("vi"), "minb", weblate = "minbe"), + TranslatorItem(arrayOf("vi"), "Fairy", weblate = "Fairy"), + TranslatorItem(arrayOf("vi"), "ngocanhtve", github = "ngocanhtve"), + TranslatorItem(arrayOf("vi"), "minh3339", github = "minh3339"), + TranslatorItem(arrayOf("vi"), "Hoang-Ender", github = "Hoang-Ender"), + TranslatorItem(arrayOf("zh_rCN", "zh_rHK", "zh_rTW", "en"), "WangDaYeeeeee", github = "WangDaYeeeeee"), + TranslatorItem(arrayOf("zh_rCN"), "Coelacanthus", github = "CoelacanthusHex"), + TranslatorItem(arrayOf("zh_rCN"), "御坂13766号", github = "misaka-13766"), + TranslatorItem(arrayOf("zh_rCN"), "losky2987", github = "losky2987"), + TranslatorItem(arrayOf("zh_rCN"), "thdcloud", github = "thdcloud"), + TranslatorItem(arrayOf("zh_rCN", "zh_rTW"), "thaumiel9", github = "thaumiel9"), + TranslatorItem(arrayOf("zh_rCN"), "tomac4t", github = "tomac4t"), + TranslatorItem(arrayOf("zh_rHK", "zh_rTW"), "abc0922001", github = "abc0922001"), + TranslatorItem(arrayOf("zh_rCN"), "大王叫我来巡山", weblate = "hamburger2048"), + TranslatorItem(arrayOf("zh_rCN"), "hugoalh", github = "hugoalh"), + TranslatorItem(arrayOf("zh_rCN"), "cloudfish", github = "cloudfish"), + TranslatorItem(arrayOf("zh_rCN"), "WorldNulptr", github = "WorldNulptr"), + TranslatorItem(arrayOf("zh_rCN"), "狼四星", github = "Fostar-Awoo"), + TranslatorItem(arrayOf("zh_rHK", "zh_rTW", "is"), "chunshek", github = "chunshek"), + TranslatorItem(arrayOf("zh_rCN", "zh_rHK", "zh_rTW"), "Frz", github = "FrzMtrsprt"), + TranslatorItem(arrayOf("ja", "zh_rCN", "zh_rHK", "zh_rTW"), "天ツ風", github = "Yibuki"), + TranslatorItem( + arrayOf("zh_rHK", "zh_rTW", "be", "bg", "bs", "de", "el", "en", "eu", "it", "ja", "mk", "pl", "ru", "uk", "vi"), + "kilimov25", + github = "kilimov25" + ) +) diff --git a/app/src/main/java/org/breezyweather/domain/location/model/Location.kt b/app/src/main/java/org/breezyweather/domain/location/model/Location.kt new file mode 100644 index 0000000..0558c4f --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/location/model/Location.kt @@ -0,0 +1,81 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.location.model + +import android.content.Context +import breezyweather.domain.location.model.Location +import breezyweather.domain.location.model.Location.Companion.CLOSE_DISTANCE +import breezyweather.domain.source.SourceFeature +import com.google.maps.android.SphericalUtil +import com.google.maps.android.model.LatLng +import org.breezyweather.R +import org.breezyweather.domain.weather.model.getRiseProgress +import org.breezyweather.sources.SourceManager + +fun Location.getPlace(context: Context, showCurrentPositionInPriority: Boolean = false): String { + if (showCurrentPositionInPriority && isCurrentPosition) { + return context.getString(R.string.location_current) + } + if (!customName.isNullOrEmpty()) { + return customName!! + } + if (cityAndDistrict.isNotEmpty()) { + return cityAndDistrict + } + if (cityAndDistrict.isEmpty() && isCurrentPosition) { + return context.getString(R.string.location_current) + } + return "$latitude, $longitude" +} + +val Location.isDaylight: Boolean + get() { + val sunRiseProgress = getRiseProgress( + astro = this.weather?.today?.sun, + location = this + ) + return 0 < sunRiseProgress && sunRiseProgress < 1 + } + +fun Location.applyDefaultPreset(sourceManager: SourceManager): Location { + val forecastSource = sourceManager.getBestSourceForFeatureOrDefault(this, SourceFeature.FORECAST)!!.id + + return copy( + forecastSource = forecastSource, + currentSource = sourceManager.getBestSourceForFeature(this, SourceFeature.CURRENT)?.id + ?: sourceManager.getDefaultSourceForFeature(this, SourceFeature.CURRENT)?.id?.let { + // If current source is the default (Open-Meteo), let it be null to fallback to forecast, + // instead of using Open-Meteo "forecast" as current, which would make it inconsistent + if (it != forecastSource) null else it + }, + airQualitySource = sourceManager.getBestSourceForFeatureOrDefault(this, SourceFeature.AIR_QUALITY)?.id, + pollenSource = sourceManager.getBestSourceForFeatureOrDefault(this, SourceFeature.POLLEN)?.id, + minutelySource = sourceManager.getBestSourceForFeatureOrDefault(this, SourceFeature.MINUTELY)?.id, + alertSource = sourceManager.getBestSourceForFeatureOrDefault(this, SourceFeature.ALERT)?.id, + normalsSource = sourceManager.getBestSourceForFeatureOrDefault(this, SourceFeature.NORMALS)?.id, + reverseGeocodingSource = sourceManager + .getBestSourceForFeatureOrDefault(this, SourceFeature.REVERSE_GEOCODING)?.id + ) +} + +fun Location.isCloseTo(location: Location): Boolean { + return cityId == location.cityId || + SphericalUtil.computeDistanceBetween( + LatLng(location.latitude, location.longitude), + LatLng(latitude, longitude) + ) < CLOSE_DISTANCE +} diff --git a/app/src/main/java/org/breezyweather/domain/settings/ConfigStore.kt b/app/src/main/java/org/breezyweather/domain/settings/ConfigStore.kt new file mode 100644 index 0000000..367b979 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/settings/ConfigStore.kt @@ -0,0 +1,113 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.settings + +import android.content.Context + +/** + * TODO: When migrating to extensions, we should make this class read only + * and only give main app write access + * TODO: Should we migrate to Android DataStore? + */ +open class ConfigStore( + context: Context, + name: String = context.packageName + "_preferences", +) { + + private val preferences = context.getSharedPreferences(name, Context.MODE_PRIVATE) + + fun getString(key: String, defValue: String?): String? { + return preferences.getString(key, defValue) + } + + fun getStringSet(key: String, defValues: Set?): Set? { + return preferences.getStringSet(key, defValues) + } + + fun getInt(key: String, defValue: Int): Int { + return preferences.getInt(key, defValue) + } + + fun getLong(key: String, defValue: Long): Long { + return preferences.getLong(key, defValue) + } + + fun getFloat(key: String, defValue: Float): Float { + return preferences.getFloat(key, defValue) + } + + fun getBoolean(key: String, defValue: Boolean): Boolean { + return preferences.getBoolean(key, defValue) + } + + fun contains(key: String): Boolean { + return preferences.contains(key) + } + + fun edit(): Editor { + return Editor(this) + } + + class Editor internal constructor(host: ConfigStore) { + + private val editor = host.preferences.edit() + + fun putString(key: String, value: String?): Editor { + editor.putString(key, value) + return this + } + + fun putStringSet(key: String, values: Set?): Editor { + editor.putStringSet(key, values) + return this + } + + fun putInt(key: String, value: Int): Editor { + editor.putInt(key, value) + return this + } + + fun putLong(key: String, value: Long): Editor { + editor.putLong(key, value) + return this + } + + fun putFloat(key: String, value: Float): Editor { + editor.putFloat(key, value) + return this + } + + fun putBoolean(key: String, value: Boolean): Editor { + editor.putBoolean(key, value) + return this + } + + fun remove(key: String): Editor { + editor.remove(key) + return this + } + + fun clear(): Editor { + editor.clear() + return this + } + + fun apply() { + editor.apply() + } + } +} diff --git a/app/src/main/java/org/breezyweather/domain/settings/CurrentLocationStore.kt b/app/src/main/java/org/breezyweather/domain/settings/CurrentLocationStore.kt new file mode 100644 index 0000000..91b78c3 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/settings/CurrentLocationStore.kt @@ -0,0 +1,81 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.settings + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Date +import javax.inject.Inject + +/** + * Store the current location independently from the current location, so that in a future update, we could have + * multiple current locations with different sources + */ +class CurrentLocationStore @Inject constructor( + @ApplicationContext context: Context, +) { + private val config: ConfigStore = ConfigStore(context, CURRENT_LOCATION_STORE) + + var lastKnownLongitude: Float + get() = config.getFloat(KEY_LAST_KNOWN_LONGITUDE, 0f) + private set(value) { + config.edit().putFloat(KEY_LAST_KNOWN_LONGITUDE, value).apply() + } + + var lastKnownLatitude: Float + get() = config.getFloat(KEY_LAST_KNOWN_LATITUDE, 0f) + private set(value) { + config.edit().putFloat(KEY_LAST_KNOWN_LATITUDE, value).apply() + } + + var lastSuccessfulRefresh: Long + get() = config.getLong(KEY_LAST_SUCCESSFUL_REFRESH, 0) + private set(value) { + config.edit().putLong(KEY_LAST_SUCCESSFUL_REFRESH, value).apply() + } + + val isUsable: Boolean + get() = lastKnownLatitude != 0f || lastKnownLongitude != 0f + + /** + * Store an updated current location + */ + fun updateCurrentLocation( + longitude: Float, + latitude: Float, + ) { + lastKnownLongitude = longitude + lastKnownLatitude = latitude + lastSuccessfulRefresh = Date().time + } + + /** + * Call this when you no longer have any need for current location + */ + fun clearCurrentLocation() { + lastKnownLongitude = 0f + lastKnownLatitude = 0f + lastSuccessfulRefresh = 0 + } + + companion object { + private const val CURRENT_LOCATION_STORE = "current_location" + private const val KEY_LAST_SUCCESSFUL_REFRESH = "last_successful_refresh" + private const val KEY_LAST_KNOWN_LONGITUDE = "last_known_longitude" + private const val KEY_LAST_KNOWN_LATITUDE = "last_known_latitude" + } +} diff --git a/app/src/main/java/org/breezyweather/domain/settings/SettingsManager.kt b/app/src/main/java/org/breezyweather/domain/settings/SettingsManager.kt new file mode 100644 index 0000000..5d48bbd --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/settings/SettingsManager.kt @@ -0,0 +1,477 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.settings + +import android.content.Context +import android.os.Build +import org.breezyweather.BreezyWeather +import org.breezyweather.BuildConfig +import org.breezyweather.common.bus.EventBus +import org.breezyweather.common.extensions.currentLocale +import org.breezyweather.common.options.DarkMode +import org.breezyweather.common.options.NotificationStyle +import org.breezyweather.common.options.UpdateInterval +import org.breezyweather.common.options.WidgetWeekIconMode +import org.breezyweather.common.options.appearance.BackgroundAnimationMode +import org.breezyweather.common.options.appearance.CardDisplay +import org.breezyweather.common.options.appearance.DailyTrendDisplay +import org.breezyweather.common.options.appearance.HourlyTrendDisplay +import org.breezyweather.unit.distance.DistanceUnit +import org.breezyweather.unit.precipitation.PrecipitationUnit +import org.breezyweather.unit.pressure.PressureUnit +import org.breezyweather.unit.speed.SpeedUnit +import org.breezyweather.unit.temperature.TemperatureUnit + +class SettingsChangedMessage + +class SettingsManager private constructor( + context: Context, +) { + + companion object { + + @Volatile + private var instance: SettingsManager? = null + + fun getInstance(context: Context): SettingsManager { + if (instance == null) { + synchronized(SettingsManager::class) { + if (instance == null) { + instance = SettingsManager(context) + } + } + } + return instance!! + } + + const val DEFAULT_CARD_DISPLAY = "nowcast" + + "&daily_forecast" + + "&hourly_forecast" + + "&precipitation" + + "&wind" + + "&air_quality" + + "&pollen" + + "&humidity" + + "&uv" + + "&visibility" + + "&pressure" + + "&sun" + + "&moon" + const val DEFAULT_DAILY_TREND_DISPLAY = "temperature" + + "&air_quality" + + "&wind" + + "&uv_index" + + "&precipitation" + + "&sunshine" + + "&feels_like" + const val DEFAULT_HOURLY_TREND_DISPLAY = "temperature" + + "&air_quality" + + "&wind" + + "&uv_index" + + "&precipitation" + + "&feels_like" + + "&humidity" + + "&pressure" + + "&cloud_cover" + + "&visibility" + + const val DEFAULT_TODAY_FORECAST_TIME = "07:00" + const val DEFAULT_TOMORROW_FORECAST_TIME = "21:00" + } + + private val config = ConfigStore(context) + + // App updates + var lastVersionCode: Int + set(value) { + config.edit().putInt("last_version_code", value).apply() + } + get() = config.getInt("last_version_code", 0) + + var isAppUpdateCheckEnabled: Boolean + set(value) { + config.edit().putBoolean("app_update_check_switch", value).apply() + } + get() = config.getBoolean("app_update_check_switch", false) + + var isAppUpdateCheckPromptAlreadyAsked: Boolean + set(value) { + config.edit().putBoolean("app_update_check_prompt", value).apply() + } + get() = config.getBoolean("app_update_check_prompt", false) + + var appUpdateCheckLastTimestamp: Long + set(value) { + config.edit().putLong("app_update_check_last_timestamp", value).apply() + } + get() = config.getLong("app_update_check_last_timestamp", 0) + + // Weather updates + var weatherUpdateLastTimestamp: Long + set(value) { + config.edit().putLong("weather_update_last_timestamp", value).apply() + } + get() = config.getLong("weather_update_last_timestamp", 0) + + var weatherManualUpdateLastTimestamp: Long + set(value) { + config.edit().putLong("weather_manual_update_last_timestamp", value).apply() + } + get() = config.getLong("weather_manual_update_last_timestamp", 0) + + var weatherManualUpdateLastLocationId: String + set(value) { + config.edit().putString("weather_manual_update_last_location_id", value).apply() + } + get() = config.getString("weather_manual_update_last_location_id", null) ?: "" + + // basic. + var isAlertPushEnabled: Boolean + set(value) { + config.edit().putBoolean("alert_notification_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("alert_notification_switch", true) + + var isPrecipitationPushEnabled: Boolean + set(value) { + config.edit().putBoolean("precipitation_notification_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("precipitation_notification_switch", false) + + var updateInterval: UpdateInterval + set(value) { + config.edit().putString("refresh_rate", value.id).apply() + notifySettingsChanged() + } + get() = UpdateInterval.getInstance( + config.getString("refresh_rate", null) + ?: (if (BreezyWeather.instance.debugMode) "never" else "1:30") + ) + + var ignoreUpdatesWhenBatteryLow: Boolean + set(value) { + config.edit().putBoolean("refresh_ignore_battery_low", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("refresh_ignore_battery_low", true) + + var darkMode: DarkMode + set(value) { + config.edit().putString("dark_mode", value.id).apply() + notifySettingsChanged() + } + get() = DarkMode.getInstance( + config.getString("dark_mode", null) ?: "system" + ) + + // Default config is: follow system on Android 10+ (disabled), automatic day/night switch (enabled) + // on Android < 10 where dark mode doesn’t exist natively + var dayNightModeForLocations: Boolean + set(value) { + config.edit().putBoolean("day_night_mode_locations", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("day_night_mode_locations", Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + + // service providers. + var locationSource: String + set(value) { + config.edit().putString("location_service", value).apply() + notifySettingsChanged() + } + get() = config.getString("location_service", null) ?: BuildConfig.DEFAULT_LOCATION_SOURCE + + var defaultForecastSource: String + set(value) { + config.edit().putString("default_weather_source", value).apply() + notifySettingsChanged() + } + get() = config.getString("default_weather_source", null) ?: BuildConfig.DEFAULT_FORECAST_SOURCE + + // unit. + var temperatureUnit: TemperatureUnit? + set(value) { + config.edit().putString("temperature_unit", value?.id ?: "auto").apply() + notifySettingsChanged() + } + get() = TemperatureUnit.getUnit(config.getString("temperature_unit", "auto") ?: "auto") + + fun getTemperatureUnit(context: Context): TemperatureUnit { + return temperatureUnit ?: TemperatureUnit.getDefaultUnit(context.currentLocale) + } + + var distanceUnit: DistanceUnit? + set(value) { + config.edit().putString("distance_unit", value?.id ?: "auto").apply() + notifySettingsChanged() + } + get() = DistanceUnit.getUnit(config.getString("distance_unit", "auto") ?: "auto") + + fun getDistanceUnit(context: Context): DistanceUnit { + return distanceUnit ?: DistanceUnit.getDefaultUnit(context.currentLocale) + } + + var precipitationUnit: PrecipitationUnit? + set(value) { + config.edit().putString("precipitation_unit", value?.id ?: "auto").apply() + notifySettingsChanged() + } + get() = PrecipitationUnit.getUnit(config.getString("precipitation_unit", "auto") ?: "auto") + + fun getPrecipitationUnit(context: Context): PrecipitationUnit { + return precipitationUnit ?: PrecipitationUnit.getDefaultUnit(context.currentLocale) + } + + fun getSnowfallUnit(context: Context): PrecipitationUnit { + return precipitationUnit ?: PrecipitationUnit.getDefaultSnowfallUnit(context.currentLocale) + } + + var speedUnit: SpeedUnit? + set(value) { + config.edit().putString("speed_unit", value?.id ?: "auto").apply() + notifySettingsChanged() + } + get() = SpeedUnit.getUnit(config.getString("speed_unit", "auto") ?: "auto") + + fun getSpeedUnit(context: Context): SpeedUnit { + return speedUnit ?: SpeedUnit.getDefaultUnit(context.currentLocale) + } + + var pressureUnit: PressureUnit? + set(value) { + config.edit().putString("pressure_unit", value?.id ?: "auto").apply() + notifySettingsChanged() + } + get() = PressureUnit.getUnit(config.getString("pressure_unit", "auto") ?: "auto") + + fun getPressureUnit(context: Context): PressureUnit { + return pressureUnit ?: PressureUnit.getDefaultUnit(context.currentLocale) + } + + // appearance. + var iconProvider: String + set(value) { + config + .edit() + .putString("iconProvider", value) + .apply() + notifySettingsChanged() + } + get() = config.getString("iconProvider", BreezyWeather.instance.packageName) ?: "" + + var cardDisplayList: List + set(value) { + config + .edit() + .putString("card_display", CardDisplay.toValue(value)) + .apply() + } + get() = CardDisplay + .toCardDisplayList( + config.getString("card_display", DEFAULT_CARD_DISPLAY) + ) + .toMutableList() + + var dailyTrendDisplayList: List + set(value) { + config + .edit() + .putString("daily_trend_display", DailyTrendDisplay.toValue(value)) + .apply() + notifySettingsChanged() + } + get() = DailyTrendDisplay + .toDailyTrendDisplayList( + config.getString("daily_trend_display", DEFAULT_DAILY_TREND_DISPLAY) + ) + .toMutableList() + + var hourlyTrendDisplayList: List + set(value) { + config + .edit() + .putString("hourly_trend_display", HourlyTrendDisplay.toValue(value)) + .apply() + notifySettingsChanged() + } + get() = HourlyTrendDisplay + .toHourlyTrendDisplayList( + config.getString("hourly_trend_display", DEFAULT_HOURLY_TREND_DISPLAY) + ) + .toMutableList() + + var isTrendHorizontalLinesEnabled: Boolean + set(value) { + config.edit().putBoolean("trend_horizontal_line_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("trend_horizontal_line_switch", true) + + var backgroundAnimationMode: BackgroundAnimationMode + set(value) { + config.edit().putString("background_animation_mode", value.id).apply() + notifySettingsChanged() + } + get() = BackgroundAnimationMode.getInstance( + config.getString("background_animation_mode", "system") ?: "" + ) + + var isGravitySensorEnabled: Boolean + set(value) { + config.edit().putBoolean("gravity_sensor_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("gravity_sensor_switch", true) + + var isCardsFadeInEnabled: Boolean + set(value) { + config.edit().putBoolean("list_animation_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("list_animation_switch", true) + + var isElementsAnimationEnabled: Boolean + set(value) { + config.edit().putBoolean("item_animation_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("item_animation_switch", true) + + var languageUpdateLastTimestamp: Long + set(value) { + config.edit().putLong("language_update_last_timestamp", value).apply() + } + get() = config.getLong("language_update_last_timestamp", 0) + + var alternateCalendar: String + set(value) { + config.edit().putString("calendar_alternate", value).apply() + } + get() = config.getString("calendar_alternate", null) ?: "" + + // forecast. + var isTodayForecastEnabled: Boolean + set(value) { + config.edit().putBoolean("timing_forecast_switch_today", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("timing_forecast_switch_today", false) + + var todayForecastTime: String + set(value) { + config.edit().putString("forecast_time_today", value).apply() + notifySettingsChanged() + } + get() = config + .getString("forecast_time_today", DEFAULT_TODAY_FORECAST_TIME) + ?: DEFAULT_TODAY_FORECAST_TIME + + var isTomorrowForecastEnabled: Boolean + set(value) { + config.edit().putBoolean("timing_forecast_switch_tomorrow", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("timing_forecast_switch_tomorrow", false) + + var tomorrowForecastTime: String + set(value) { + config.edit().putString("forecast_time_tomorrow", value).apply() + notifySettingsChanged() + } + get() = config + .getString("forecast_time_tomorrow", DEFAULT_TOMORROW_FORECAST_TIME) + ?: DEFAULT_TOMORROW_FORECAST_TIME + + // widget. + + var widgetWeekIconMode: WidgetWeekIconMode + set(value) { + config.edit().putString("widget_week_icon_mode", value.id).apply() + notifySettingsChanged() + } + get() = WidgetWeekIconMode.getInstance( + config.getString("widget_week_icon_mode", "auto") ?: "" + ) + + var isWidgetUsingMonochromeIcons: Boolean + set(value) { + config.edit().putBoolean("widget_monochrome_icons", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("widget_monochrome_icons", false) + + // notification widget + var isWidgetNotificationEnabled: Boolean + set(value) { + config.edit().putBoolean("notification_widget_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("notification_widget_switch", false) + + var isWidgetNotificationPersistent: Boolean + set(value) { + config.edit().putBoolean("notification_widget_persistent_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("notification_widget_persistent_switch", true) + + var widgetNotificationStyle: NotificationStyle + set(value) { + config.edit().putString("notification_widget_style", value.id).apply() + notifySettingsChanged() + } + get() = NotificationStyle.getInstance( + config.getString("notification_widget_style", "daily") ?: "" + ) + + var isWidgetNotificationTemperatureIconEnabled: Boolean + set(value) { + config.edit().putBoolean("notification_widget_temp_icon_switch", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("notification_widget_temp_icon_switch", false) + + var isWidgetNotificationUsingFeelsLike: Boolean + set(value) { + config.edit().putBoolean("notification_widget_feelslike", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("notification_widget_feelslike", false) + + var useNumberFormatter: Boolean + set(value) { + config.edit().putBoolean("use_number_formatter", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("use_number_formatter", true) + + var useMeasureFormat: Boolean + set(value) { + config.edit().putBoolean("use_measure_format", value).apply() + notifySettingsChanged() + } + get() = config.getBoolean("use_measure_format", true) + + private fun notifySettingsChanged() { + EventBus + .instance + .with(SettingsChangedMessage::class.java) + .postValue(SettingsChangedMessage()) + } +} diff --git a/app/src/main/java/org/breezyweather/domain/settings/SourceConfigStore.kt b/app/src/main/java/org/breezyweather/domain/settings/SourceConfigStore.kt new file mode 100644 index 0000000..45a5da5 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/settings/SourceConfigStore.kt @@ -0,0 +1,24 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.settings + +import android.content.Context + +class SourceConfigStore( + context: Context, + sourceId: String, +) : ConfigStore(context, "source_" + sourceId + "_preferences") diff --git a/app/src/main/java/org/breezyweather/domain/source/SourceContinent.kt b/app/src/main/java/org/breezyweather/domain/source/SourceContinent.kt new file mode 100644 index 0000000..4600a6b --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/source/SourceContinent.kt @@ -0,0 +1,31 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.source + +import breezyweather.domain.source.SourceContinent +import org.breezyweather.R + +val SourceContinent.resourceName: Int + get() = when (this) { + SourceContinent.WORLDWIDE -> R.string.weather_source_continent_worldwide + SourceContinent.AFRICA -> R.string.weather_source_continent_africa + SourceContinent.ASIA -> R.string.weather_source_continent_asia + SourceContinent.EUROPE -> R.string.weather_source_continent_europe + SourceContinent.NORTH_AMERICA -> R.string.weather_source_continent_north_america + SourceContinent.OCEANIA -> R.string.weather_source_continent_oceania + SourceContinent.SOUTH_AMERICA -> R.string.weather_source_continent_south_america + } diff --git a/app/src/main/java/org/breezyweather/domain/source/SourceFeature.kt b/app/src/main/java/org/breezyweather/domain/source/SourceFeature.kt new file mode 100644 index 0000000..b29f6c7 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/source/SourceFeature.kt @@ -0,0 +1,32 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.source + +import breezyweather.domain.source.SourceFeature +import org.breezyweather.R + +val SourceFeature.resourceName: Int + get() = when (this) { + SourceFeature.FORECAST -> R.string.forecast + SourceFeature.CURRENT -> R.string.current_weather + SourceFeature.AIR_QUALITY -> R.string.air_quality + SourceFeature.POLLEN -> R.string.pollen + SourceFeature.MINUTELY -> R.string.precipitation_nowcasting + SourceFeature.ALERT -> R.string.alerts + SourceFeature.NORMALS -> R.string.temperature_normals + SourceFeature.REVERSE_GEOCODING -> R.string.location_reverse_geocoding + } diff --git a/app/src/main/java/org/breezyweather/domain/weather/index/PollenIndex.kt b/app/src/main/java/org/breezyweather/domain/weather/index/PollenIndex.kt new file mode 100644 index 0000000..967490e --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/index/PollenIndex.kt @@ -0,0 +1,157 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.index + +import android.content.Context +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import org.breezyweather.R +import org.breezyweather.common.source.PollenIndexSource +import kotlin.math.roundToInt + +@Suppress("ktlint") +enum class PollenIndex( + val id: String, + @StringRes val pollenName: Int, + val thresholds: List, +) { + ALDER("alder", R.string.pollen_alnus, listOf(0, 10, 60, 100, 500)), // Atmo France + ASH("ash", R.string.pollen_fraxinus, listOf(0, 30, 100, 200, 400)), + BIRCH("birch", R.string.pollen_betula, listOf(0, 10, 60, 100, 500)), // Atmo France + CHESTNUT("chestnut", R.string.pollen_castanea, listOf(0, 1, 2, 3, 4)), // TODO + // COTTONWOOD("cottonwood", R.string.pollen_cottonwood, listOf(0, 50, 200, 400, 800)), + CYPRESS("cypress", R.string.pollen_cupressaceae_taxaceae, listOf(0, 1, 2, 3, 4)), // TODO + // ELM("elm", R.string.pollen_elm, listOf(0, 30, 50, 100, 200)), + GRASS("grass", R.string.pollen_poaeceae, listOf(0, 3, 30, 50, 250)), // Atmo France + HAZEL("hazel", R.string.pollen_corylus, listOf(0, 1, 2, 3, 4)), // TODO + HORNBEAM("hornbeam", R.string.pollen_carpinus, listOf(0, 1, 2, 3, 4)), // TODO + // JAPANESE_CYPRESS("cypress", R.string.pollen_japanese_cypress, listOf(0, 3, 11, 19, 39)), + // JUNIPER("juniper", R.string.pollen_juniper, listOf(0, 10, 50, 140, 280)), + LINDEN("linden", R.string.pollen_tilia, listOf(0, 1, 2, 3, 4)), // TODO + // MAPLE("maple", R.string.pollen_maple, listOf(0, 30, 50, 100, 200)), + MOLD("mold", R.string.pollen_mold, listOf(0, 6500, 13000, 50000, 65000)), + MUGWORT("mugwort", R.string.pollen_artemisia, listOf(0, 3, 30, 50, 250)), // Atmo France + OAK("oak", R.string.pollen_quercus, listOf(0, 50, 100, 200, 400)), + OLIVE("olive", R.string.pollen_olea, listOf(0, 20, 100, 200, 500)), // Atmo France + // PINE("pine", R.string.pollen_platanus, listOf(0, 50, 200, 500, 1000)), + PLANE("plane", R.string.pollen_platanus, listOf(0, 1, 2, 3, 4)), // TODO + PLANTAIN("plantain", R.string.pollen_plantaginaceae, listOf(0, 1, 2, 3, 4)), // TODO + POPLAR("poplar", R.string.pollen_populus, listOf(0, 1, 2, 3, 4)), // TODO + RAGWEED("ragweed", R.string.pollen_ambrosia, listOf(0, 3, 30, 50, 250)), // Atmo France + SORREL("sorrel", R.string.pollen_rumex, listOf(0, 1, 2, 3, 4)), // TODO + TREE("tree", R.string.pollen_tree, listOf(0, 10, 50, 100, 300)), + URTICACEAE("urticaceae", R.string.pollen_urticaceae, listOf(0, 1, 2, 3, 4)), // TODO + WILLOW("willow", R.string.pollen_salix, listOf(0, 1, 2, 3, 4)), + ; // TODO + + companion object { + // No index exists, but let’s make a fake one to help with graphics + val pollenIndexThresholds = listOf(0, 25, 50, 75, 100) + val namesArrayId = R.array.pollen_levels + val colorsArrayId = R.array.pollen_level_colors + + fun getPollenIndexToLevel(pollenIndex: Int?): Int? { + if (pollenIndex == null) return null + val level = pollenIndexThresholds.indexOfLast { pollenIndex >= it } + return if (level >= 0) level else null + } + + @ColorInt + fun getPollenIndexToColor(context: Context, pollenIndex: Int?): Int { + if (pollenIndex == null) return Color.TRANSPARENT + if (pollenIndex == 0) return ContextCompat.getColor(context, R.color.pollenLevel_0) + val level = getPollenIndexToLevel(pollenIndex) + return if (level != null) { + context.resources.getIntArray(colorsArrayId).getOrNull(level) ?: Color.TRANSPARENT + } else { + Color.TRANSPARENT + } + } + + fun getPollenIndexToName(context: Context, pollenIndex: Int?): String? { + if (pollenIndex == null) return null + if (pollenIndex == 0) return context.getString(R.string.pollen_level_0) + val level = getPollenIndexToLevel(pollenIndex) + return if (level != null) context.resources.getStringArray(namesArrayId).getOrNull(level) else null + } + + @ColorInt + fun getNegligiblePollenColor( + context: Context, + source: PollenIndexSource? = null, + ): Int { + return if (source != null) { + context.resources.getIntArray(source.pollenColors).getOrElse(0) { Color.TRANSPARENT } + } else { + getPollenIndexToColor(context, 0) + } + } + + fun getNegligiblePollenText( + context: Context, + source: PollenIndexSource? = null, + ): String? { + return if (source != null) { + context.resources.getStringArray(source.pollenLabels).getOrElse(0) { null } + } else { + getPollenIndexToName(context, 0) + } + } + } + + private fun getIndex(cp: Double, bpLo: Int, bpHi: Int, inLo: Int, inHi: Int): Int { + // Result will be incorrect if we don’t cast to double + return ( + (inHi.toDouble() - inLo.toDouble()) / (bpHi.toDouble() - bpLo.toDouble()) * (cp - bpLo.toDouble()) + + inLo.toDouble() + ).roundToInt() + } + + private fun getIndex(cp: Double, level: Int): Int { + return if (level < thresholds.lastIndex) { + getIndex( + cp, + thresholds[level], + thresholds[level + 1], + pollenIndexThresholds[level], + pollenIndexThresholds[level + 1] + ) + } else { + // Continue producing a linear index above lastIndex + ((cp * pollenIndexThresholds.last()) / thresholds.last()).roundToInt() + } + } + + fun getIndex(cp: Double?): Int? { + if (cp == null) return null + val level = thresholds.indexOfLast { cp >= it } + return if (level >= 0) getIndex(cp, level) else 0 + } + + fun getLevel(cp: Double?): Int? { + if (cp == null) return null + val level = thresholds.indexOfLast { cp >= it } + return if (level >= 0) level else null + } + + fun getName(context: Context, cp: Double?): String? = getPollenIndexToName(context, getIndex(cp)) + + @ColorInt + fun getColor(context: Context, cp: Double?): Int = getPollenIndexToColor(context, getIndex(cp)) +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/index/PollutantIndex.kt b/app/src/main/java/org/breezyweather/domain/weather/index/PollutantIndex.kt new file mode 100644 index 0000000..d5e4a88 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/index/PollutantIndex.kt @@ -0,0 +1,253 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.index + +import android.content.Context +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.StringRes +import org.breezyweather.R +import org.breezyweather.common.extensions.currentLocale +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.unit.formatting.UnitWidth +import org.breezyweather.unit.pollutant.PollutantConcentrationUnit +import org.breezyweather.unit.precipitation.PrecipitationUnit +import kotlin.math.roundToInt + +enum class PollutantIndex( + val id: String, + val thresholds: List, + val maxY: Int, + val molecularMass: Double?, + @StringRes val shortName: Int, + @StringRes val voicedName: Int, + @StringRes val fullName: Int, + @StringRes val aboutPollutant: Int, + @StringRes val aboutIndex: Int, + @StringRes val sources: Int, +) { + PM25( + "pm25", + listOf(0, 5, 15, 30, 60, 150), // Plume 2023 + 60, + null, + R.string.air_quality_pm25, + R.string.air_quality_pm25_voice, + R.string.air_quality_pm25_full, + R.string.air_quality_pm25_about, + R.string.air_quality_pm25_index, + R.string.air_quality_pm_sources + ), + PM10( + "pm10", + listOf(0, 15, 45, 80, 160, 400), // Plume 2023 + 160, + null, + R.string.air_quality_pm10, + R.string.air_quality_pm10_voice, + R.string.air_quality_pm10_full, + R.string.air_quality_pm10_about, + R.string.air_quality_pm10_index, + R.string.air_quality_pm_sources + ), + O3( + "o3", + listOf(0, 50, 100, 160, 240, 480), // Plume 2023 + 240, + 48.0, + R.string.air_quality_o3, + R.string.air_quality_o3_voice, + R.string.air_quality_o3_full, + R.string.air_quality_o3_about, + R.string.air_quality_o3_index, + R.string.air_quality_o3_sources + ), + NO2( + "no2", + listOf(0, 10, 25, 200, 400, 1000), // Plume 2023 + 200, + 46.0055, + R.string.air_quality_no2, + R.string.air_quality_no2_voice, + R.string.air_quality_no2_full, + R.string.air_quality_no2_about, + R.string.air_quality_no2_index, + R.string.air_quality_no2_sources + ), + SO2( + "so2", + listOf( + 0, + 20, + 40, // daily + 270, + 500, // 10 min + 960 // linear prolongation + ), // WHO 2021 + 270, + 64.066, + R.string.air_quality_so2, + R.string.air_quality_so2_voice, + R.string.air_quality_so2_full, + R.string.air_quality_so2_about, + R.string.air_quality_so2_index, + R.string.air_quality_so2_sources + ), + CO( + "co", + listOf( + 0, + 2, + 4, // daily + 35, // hourly + 100, // 15 min + 230 // linear prolongation + ), // WHO 2021 + 35, + 28.01, + R.string.air_quality_co, + R.string.air_quality_co_voice, + R.string.air_quality_co_full, + R.string.air_quality_co_about, + R.string.air_quality_co_index, + R.string.air_quality_co_sources + ), + ; + + companion object { + // Plume 2023 + val aqiThresholds = listOf(0, 20, 50, 100, 150, 250) + val namesArrayId = R.array.air_quality_levels + val descriptionsArrayId = R.array.air_quality_level_descriptions + val harmlessExposuresArrayId = R.array.air_quality_level_harmless_exposures + val colorsArrayId = R.array.air_quality_level_colors + + val indexFreshAir = aqiThresholds[1] + val indexHighPollution = aqiThresholds[3] + val indexExcessivePollution = aqiThresholds.last() + + fun getAqiToLevel(aqi: Int?): Int? { + if (aqi == null) return null + val level = aqiThresholds.indexOfLast { aqi >= it } + return if (level >= 0) level else null + } + + @ColorInt + fun getAqiToColor(context: Context, aqi: Int?): Int { + if (aqi == null) return Color.TRANSPARENT + val level = getAqiToLevel(aqi) + return if (level != null) { + context.resources.getIntArray(colorsArrayId).getOrNull(level) ?: Color.TRANSPARENT + } else { + Color.TRANSPARENT + } + } + + fun getAqiToName(context: Context, aqi: Int?): String? { + if (aqi == null) return null + val level = getAqiToLevel(aqi) + return if (level != null) context.resources.getStringArray(namesArrayId).getOrNull(level) else null + } + + fun getAqiToDescription(context: Context, aqi: Int?): String? { + if (aqi == null) return null + val level = getAqiToLevel(aqi) + return if (level != null) context.resources.getStringArray(descriptionsArrayId).getOrNull(level) else null + } + + fun getAqiToHarmlessExposure(context: Context, aqi: Int?): String? { + if (aqi == null) return null + val level = getAqiToLevel(aqi) + return if (level != null) { + context.resources.getStringArray(harmlessExposuresArrayId).getOrNull(level) + } else { + null + } + } + + fun getUnit(pollutantIndex: PollutantIndex): PollutantConcentrationUnit { + return if (pollutantIndex == CO) { + PollutantConcentrationUnit.MILLIGRAM_PER_CUBIC_METER + } else { + PollutantConcentrationUnit.MICROGRAM_PER_CUBIC_METER + } + } + } + + private fun getIndex(cp: Double, bpLo: Int, bpHi: Int, inLo: Int, inHi: Int): Int { + // Result will be incorrect if we don’t cast to double + return ( + (inHi.toDouble() - inLo.toDouble()) / + (bpHi.toDouble() - bpLo.toDouble()) * + (cp - bpLo.toDouble()) + + inLo.toDouble() + ).roundToInt() + } + + private fun getIndex(cp: Double, level: Int): Int { + return if (level < thresholds.lastIndex) { + getIndex( + cp, + thresholds[level], + thresholds[level + 1], + aqiThresholds[level], + aqiThresholds[level + 1] + ) + } else { + // Continue producing a linear index above lastIndex + ((cp * aqiThresholds.last()) / thresholds.last()).roundToInt() + } + } + + fun getFullName(context: Context): String { + return context.getString( + fullName, + if (this == PM10 || this == PM25) { // Cheating a little by using the precipitation unit + PrecipitationUnit.MICROMETER.format( + context = context, + value = if (this == PM10) 10.0 else 2.5, + valueWidth = UnitWidth.LONG, + locale = context.currentLocale, + useNumberFormatter = SettingsManager.getInstance(context).useNumberFormatter, + useMeasureFormat = SettingsManager.getInstance(context).useMeasureFormat + ) + } else { + context.getString(shortName) + } + ) + } + + fun getIndex(cp: Double?): Int? { + if (cp == null) return null + val level = thresholds.indexOfLast { cp >= it } + return if (level >= 0) getIndex(cp, level) else null + } + + fun getLevel(cp: Double?): Int? { + if (cp == null) return null + val level = thresholds.indexOfLast { cp >= it } + return if (level >= 0) level else null + } + + val excessivePollution = thresholds.last() + + fun getName(context: Context, cp: Double?): String? = getAqiToName(context, getIndex(cp)) + fun getDescription(context: Context, cp: Double?): String? = getAqiToDescription(context, getIndex(cp)) + + @ColorInt + fun getColor(context: Context, cp: Double?): Int = getAqiToColor(context, getIndex(cp)) +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/AirQuality.kt b/app/src/main/java/org/breezyweather/domain/weather/model/AirQuality.kt new file mode 100644 index 0000000..0bb319b --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/AirQuality.kt @@ -0,0 +1,82 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import androidx.annotation.ColorInt +import breezyweather.domain.weather.model.AirQuality +import org.breezyweather.domain.weather.index.PollutantIndex + +val AirQuality.validPollutants: List + get() { + return listOf( + PollutantIndex.NO2, + PollutantIndex.O3, + PollutantIndex.PM10, + PollutantIndex.PM25, + PollutantIndex.SO2, + PollutantIndex.CO + ).filter { getConcentration(it) != null } + } + +fun AirQuality.getIndex(pollutant: PollutantIndex? = null): Int? { + return if (pollutant == null) { // Air Quality + val pollutantsAqi: List = listOfNotNull( + getIndex(PollutantIndex.O3), + getIndex(PollutantIndex.NO2), + getIndex(PollutantIndex.PM10), + getIndex(PollutantIndex.PM25) + ) + if (pollutantsAqi.isNotEmpty()) pollutantsAqi.max() else null + } else { // Specific pollutant + pollutant.getIndex(getConcentration(pollutant)) + } +} + +fun AirQuality.getConcentration(pollutant: PollutantIndex) = when (pollutant) { + PollutantIndex.PM25 -> pM25?.inMicrogramsPerCubicMeter + PollutantIndex.PM10 -> pM10?.inMicrogramsPerCubicMeter + PollutantIndex.O3 -> o3?.inMicrogramsPerCubicMeter + PollutantIndex.NO2 -> nO2?.inMicrogramsPerCubicMeter + PollutantIndex.SO2 -> sO2?.inMicrogramsPerCubicMeter + PollutantIndex.CO -> cO?.inMilligramsPerCubicMeter +} + +fun AirQuality.getName(context: Context, pollutant: PollutantIndex? = null): String? { + return if (pollutant == null) { // Air Quality + PollutantIndex.getAqiToName(context, getIndex()) + } else { // Specific pollutant + pollutant.getName(context, getConcentration(pollutant)) + } +} + +fun AirQuality.getDescription(context: Context, pollutant: PollutantIndex? = null): String? { + return if (pollutant == null) { // Air Quality + PollutantIndex.getAqiToDescription(context, getIndex()) + } else { // Specific pollutant + pollutant.getDescription(context, getConcentration(pollutant)) + } +} + +@ColorInt +fun AirQuality.getColor(context: Context, pollutant: PollutantIndex? = null): Int { + return if (pollutant == null) { + PollutantIndex.getAqiToColor(context, getIndex()) + } else { // Specific pollutant + pollutant.getColor(context, getConcentration(pollutant)) + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/Alert.kt b/app/src/main/java/org/breezyweather/domain/weather/model/Alert.kt new file mode 100644 index 0000000..30016e7 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/Alert.kt @@ -0,0 +1,57 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.location.model.Location +import breezyweather.domain.weather.model.Alert +import org.breezyweather.R +import org.breezyweather.common.extensions.getFormattedFullDayAndMonth +import org.breezyweather.common.extensions.getFormattedMediumDayAndMonth +import org.breezyweather.common.extensions.getFormattedTime +import org.breezyweather.common.extensions.is12Hour + +fun Alert.getFormattedDates( + location: Location, + context: Context, + full: Boolean = false, +): String { + val builder = StringBuilder() + startDate?.let { startDate -> + val startDateDay = if (full) { + startDate.getFormattedFullDayAndMonth(location, context) + } else { + startDate.getFormattedMediumDayAndMonth(location, context) + } + builder.append(startDateDay) + .append(context.getString(org.breezyweather.unit.R.string.locale_separator)) + .append(startDate.getFormattedTime(location, context, context.is12Hour)) + endDate?.let { endDate -> + builder.append(" — ") + val endDateDay = if (full) { + startDate.getFormattedFullDayAndMonth(location, context) + } else { + endDate.getFormattedMediumDayAndMonth(location, context) + } + if (startDateDay != endDateDay) { + builder.append(endDateDay).append(context.getString(org.breezyweather.unit.R.string.locale_separator)) + } + builder.append(endDate.getFormattedTime(location, context, context.is12Hour)) + } + } + return builder.toString() +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/Astro.kt b/app/src/main/java/org/breezyweather/domain/weather/model/Astro.kt new file mode 100644 index 0000000..56c37ad --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/Astro.kt @@ -0,0 +1,68 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import breezyweather.domain.location.model.Location +import breezyweather.domain.weather.model.Astro +import org.breezyweather.common.extensions.toTimezone +import java.util.Calendar +import java.util.Date +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** + * (-inf, 0] -> not yet rise. + * (0, 1) -> has risen, not yet set. + * [1, inf) -> has gone down. + * TODO: Works but the way timezones are handled is wrong + * */ +fun getRiseProgress( + astro: Astro?, + location: Location, +): Double { + val defaultRiseHour = 6 + val defaultDurationHour = 12 + + val timezoneCalendar = Calendar.getInstance(location.timeZone) + val currentTime = (timezoneCalendar[Calendar.HOUR_OF_DAY].hours + timezoneCalendar[Calendar.MINUTE].minutes) + .inWholeMilliseconds + + val riseTime = astro?.riseDate?.toTimezone(location.timeZone)?.time + val setTime = astro?.setDate?.toTimezone(location.timeZone)?.time + if (riseTime == null || setTime == null) { + val riseHourMinuteTime = defaultRiseHour.hours.inWholeMilliseconds + val setHourMinuteTime = riseHourMinuteTime + defaultDurationHour.hours.inWholeMilliseconds + + if (setHourMinuteTime == riseHourMinuteTime) { + return -1.0 + } + return (currentTime - riseHourMinuteTime).toDouble() / (setHourMinuteTime - riseHourMinuteTime).toDouble() + } + + val riseCalendar = Calendar.getInstance().apply { time = Date(riseTime) } + val riseHourMinuteTime = (riseCalendar[Calendar.HOUR_OF_DAY].hours + riseCalendar[Calendar.MINUTE].minutes) + .inWholeMilliseconds + + var safeSetTime = setTime + while (safeSetTime <= riseTime) { + safeSetTime += 1.days.inWholeMilliseconds + } + val setHourMinuteTime = riseHourMinuteTime + (safeSetTime - riseTime) + + return (currentTime - riseHourMinuteTime).toDouble() / (setHourMinuteTime - riseHourMinuteTime).toDouble() +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/Daily.kt b/app/src/main/java/org/breezyweather/domain/weather/model/Daily.kt new file mode 100644 index 0000000..252400c --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/Daily.kt @@ -0,0 +1,117 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.location.model.Location +import breezyweather.domain.weather.model.Daily +import org.breezyweather.R +import org.breezyweather.common.extensions.capitalize +import org.breezyweather.common.extensions.currentLocale +import org.breezyweather.common.extensions.formatMeasure +import org.breezyweather.common.extensions.getFormattedDate +import org.breezyweather.common.extensions.getLongWeekdayDayMonth +import org.breezyweather.common.extensions.getWeek +import org.breezyweather.unit.temperature.TemperatureUnit +import java.util.Calendar +import kotlin.time.Duration.Companion.days + +/** + * Shows one of the following valid label: + * - Yesterday + * - Today + * - Tomorrow + * - Monday, Tuesday, etc + * - Monday DD MMMM, Tuesday DD MMMM, etc + */ +fun Daily.getFullLabel(location: Location, context: Context): String { + val current = Calendar.getInstance(location.timeZone) + + // In more than a week? Show full weekday + day + month + if ((date.time > current.time.time.plus(6.days.inWholeMilliseconds))) { + return date.getFormattedDate(getLongWeekdayDayMonth(context), location, context) + .capitalize(context.currentLocale) + } + + val thisDay = Calendar.getInstance(location.timeZone) + thisDay.time = date + + return if (current[Calendar.YEAR] == thisDay[Calendar.YEAR] && + current[Calendar.DAY_OF_YEAR] == thisDay[Calendar.DAY_OF_YEAR] + ) { + context.getString(R.string.daily_today) + } else if ( + ( + current[Calendar.YEAR] == thisDay[Calendar.YEAR] && + current[Calendar.DAY_OF_YEAR] - 1 == thisDay[Calendar.DAY_OF_YEAR] + ) || + ( // Special new year case + (current[Calendar.YEAR] - 1 == thisDay[Calendar.YEAR]) && + current[Calendar.DAY_OF_YEAR] == 1 && + thisDay[Calendar.DAY_OF_YEAR] in 365..366 + ) + ) { + context.getString(R.string.daily_yesterday) + } else if ( + ( + current[Calendar.YEAR] == thisDay[Calendar.YEAR] && + current[Calendar.DAY_OF_YEAR] + 1 == thisDay[Calendar.DAY_OF_YEAR] + ) || + ( // Special new year case + (current[Calendar.YEAR] + 1 == thisDay[Calendar.YEAR]) && + thisDay[Calendar.DAY_OF_YEAR] == 1 && + current[Calendar.DAY_OF_YEAR] in 365..366 + ) + ) { + context.getString(R.string.daily_tomorrow) + } else if (date < current.time) { // In the past? Show full date + date.getFormattedDate(getLongWeekdayDayMonth(context), location, context) + } else { + date.getWeek(location, context, full = true) + }.capitalize(context.currentLocale) +} + +fun Daily.getWeek(location: Location, context: Context?, full: Boolean = false): String { + return date.getWeek(location, context, full) +} + +fun Daily.isToday(location: Location): Boolean { + val current = Calendar.getInstance(location.timeZone) + val thisDay = Calendar.getInstance(location.timeZone) + thisDay.time = date + return current[Calendar.YEAR] == thisDay[Calendar.YEAR] && + current[Calendar.DAY_OF_YEAR] == thisDay[Calendar.DAY_OF_YEAR] +} + +fun Daily.getTrendTemperature(context: Context, temperatureUnit: TemperatureUnit): String? { + if (day?.temperature?.temperature == null || night?.temperature?.temperature == null) { + return null + } + return day!!.temperature!!.temperature!!.formatMeasure( + context, + temperatureUnit, + valueWidth = org.breezyweather.unit.formatting.UnitWidth.NARROW, + unitWidth = org.breezyweather.unit.formatting.UnitWidth.NARROW + ) + + "/" + + night!!.temperature!!.temperature!!.formatMeasure( + context, + temperatureUnit, + valueWidth = org.breezyweather.unit.formatting.UnitWidth.NARROW, + unitWidth = org.breezyweather.unit.formatting.UnitWidth.NARROW + ) +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/DailyCloudCover.kt b/app/src/main/java/org/breezyweather/domain/weather/model/DailyCloudCover.kt new file mode 100644 index 0000000..af3c75d --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/DailyCloudCover.kt @@ -0,0 +1,57 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.weather.model.DailyCloudCover +import org.breezyweather.R +import org.breezyweather.common.extensions.formatPercent +import org.breezyweather.common.extensions.formatValue +import org.breezyweather.common.extensions.getCloudCoverDescription + +fun DailyCloudCover.getRangeSummary(context: Context): String? { + return if (min == null || max == null) { + null + } else if (min == max) { + max!!.formatPercent(context) + } else { + context.getString( + R.string.cloud_cover_from_to_number, + min!!.formatValue(context), + max!!.formatPercent(context) + ) + } +} + +fun DailyCloudCover.getRangeDescriptionSummary(context: Context): String? { + return if (min == null || max == null) { + null + } else { + val minDescription = min!!.getCloudCoverDescription(context) + val maxDescription = max!!.getCloudCoverDescription(context) + + if (minDescription == maxDescription) { + maxDescription + } else { + context.getString( + R.string.cloud_cover_from_to_description, + minDescription, + maxDescription + ) + } + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/DailyDewPoint.kt b/app/src/main/java/org/breezyweather/domain/weather/model/DailyDewPoint.kt new file mode 100644 index 0000000..9434034 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/DailyDewPoint.kt @@ -0,0 +1,53 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.weather.model.DailyDewPoint +import org.breezyweather.R +import org.breezyweather.common.extensions.formatMeasure +import org.breezyweather.common.extensions.formatValue +import org.breezyweather.unit.formatting.UnitWidth +import org.breezyweather.unit.temperature.TemperatureUnit + +fun DailyDewPoint.getRangeSummary(context: Context, temperatureUnit: TemperatureUnit): String? { + return if (min == null || max == null) { + null + } else if (min == max) { + max!!.formatMeasure(context, temperatureUnit, unitWidth = UnitWidth.NARROW) + } else { + context.getString( + R.string.dew_point_from_to_number, + min!!.formatValue(context, temperatureUnit), + max!!.formatMeasure(context, temperatureUnit, unitWidth = UnitWidth.NARROW) + ) + } +} + +fun DailyDewPoint.getRangeContentDescriptionSummary(context: Context, temperatureUnit: TemperatureUnit): String? { + return if (min == null || max == null) { + null + } else if (min == max) { + max!!.formatMeasure(context, temperatureUnit, unitWidth = UnitWidth.LONG) + } else { + context.getString( + R.string.dew_point_from_to_number, + min!!.formatValue(context, temperatureUnit), + max!!.formatMeasure(context, temperatureUnit, unitWidth = UnitWidth.LONG) + ) + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/DailyRelativeHumidity.kt b/app/src/main/java/org/breezyweather/domain/weather/model/DailyRelativeHumidity.kt new file mode 100644 index 0000000..324a3b7 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/DailyRelativeHumidity.kt @@ -0,0 +1,37 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.weather.model.DailyRelativeHumidity +import org.breezyweather.R +import org.breezyweather.common.extensions.formatPercent +import org.breezyweather.common.extensions.formatValue + +fun DailyRelativeHumidity.getRangeSummary(context: Context): String? { + return if (min == null || max == null) { + null + } else if (min == max) { + max!!.formatPercent(context) + } else { + context.getString( + R.string.humidity_from_to_number, + min!!.formatValue(context), + max!!.formatPercent(context) + ) + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/DailyVisibility.kt b/app/src/main/java/org/breezyweather/domain/weather/model/DailyVisibility.kt new file mode 100644 index 0000000..a773b60 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/DailyVisibility.kt @@ -0,0 +1,72 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.weather.model.DailyVisibility +import org.breezyweather.R +import org.breezyweather.common.extensions.formatMeasure +import org.breezyweather.common.extensions.formatValue +import org.breezyweather.common.extensions.getVisibilityDescription +import org.breezyweather.unit.formatting.UnitWidth + +fun DailyVisibility.getRangeSummary(context: Context): String? { + return if (min == null || max == null) { + null + } else if (min == max) { + max!!.formatMeasure(context) + } else { + context.getString( + R.string.visibility_from_to_number, + min!!.formatValue(context), + max!!.formatMeasure(context) + ) + } +} + +fun DailyVisibility.getRangeContentDescriptionSummary(context: Context): String? { + return if (min == null || max == null) { + null + } else if (min == max) { + max!!.formatMeasure(context, unitWidth = UnitWidth.LONG) + } else { + context.getString( + R.string.visibility_from_to_number, + min!!.formatValue(context), + max!!.formatMeasure(context, unitWidth = UnitWidth.LONG) + ) + } +} + +fun DailyVisibility.getRangeDescriptionSummary(context: Context): String? { + return if (min == null || max == null) { + null + } else { + val minDescription = min?.getVisibilityDescription(context) + val maxDescription = max?.getVisibilityDescription(context) + + if (minDescription == maxDescription) { + maxDescription + } else { + context.getString( + R.string.visibility_from_to_description, + minDescription, + maxDescription + ) + } + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/Minutely.kt b/app/src/main/java/org/breezyweather/domain/weather/model/Minutely.kt new file mode 100644 index 0000000..d398638 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/Minutely.kt @@ -0,0 +1,100 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.location.model.Location +import breezyweather.domain.weather.model.Minutely +import breezyweather.domain.weather.model.Precipitation +import org.breezyweather.R +import org.breezyweather.common.extensions.getFormattedTime +import org.breezyweather.common.extensions.is12Hour +import org.breezyweather.unit.precipitation.Precipitation.Companion.millimeters + +fun Minutely.getLevel(context: Context): String { + return context.getString( + if (precipitationIntensity == null) { + R.string.precipitation_none + } else { + with(precipitationIntensity!!) { + when { + this == 0.0.millimeters -> R.string.precipitation_none + this in 0.0.millimeters..Precipitation.PRECIPITATION_HOURLY_LIGHT.millimeters -> { + R.string.precipitation_intensity_light + } + this in Precipitation.PRECIPITATION_HOURLY_LIGHT.millimeters + .rangeTo(Precipitation.PRECIPITATION_HOURLY_MEDIUM.millimeters) -> { + R.string.precipitation_intensity_medium + } + this >= Precipitation.PRECIPITATION_HOURLY_MEDIUM.millimeters -> { + R.string.precipitation_intensity_heavy + } + else -> R.string.precipitation_none + } + } + } + ) +} + +fun List.getContentDescription(context: Context, location: Location): String { + val contentDescription = StringBuilder() + + var startingIndex: Int? = null + forEachIndexed { index, minutely -> + if (minutely.precipitationIntensity != null && minutely.precipitationIntensity!!.inMicrometers > 0) { + if (startingIndex == null) { + startingIndex = index + } + } else { + if (startingIndex != null) { + if (contentDescription.toString().isNotEmpty()) { + contentDescription.append(context.getString(org.breezyweather.unit.R.string.locale_separator)) + } + + val slice = subList(startingIndex, index) + contentDescription.append( + context.getString( + R.string.precipitation_between_time, + slice.first().date.getFormattedTime(location, context, context.is12Hour), + slice.last().endingDate.getFormattedTime(location, context, context.is12Hour) + ) + ) + contentDescription.append(context.getString(R.string.colon_separator)) + contentDescription.append(slice.maxBy { it.precipitationIntensity!! }.getLevel(context)) + startingIndex = null + } + } + } + + if (startingIndex != null) { + val slice = subList(startingIndex, size) + if (contentDescription.toString().isNotEmpty()) { + contentDescription.append(context.getString(org.breezyweather.unit.R.string.locale_separator)) + } + contentDescription.append( + context.getString( + R.string.precipitation_between_time, + slice.first().date.getFormattedTime(location, context, context.is12Hour), + slice.last().endingDate.getFormattedTime(location, context, context.is12Hour) + ) + ) + contentDescription.append(context.getString(R.string.colon_separator)) + contentDescription.append(slice.maxBy { it.precipitationIntensity!! }.getLevel(context)) + } + + return contentDescription.toString() +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/MoonPhase.kt b/app/src/main/java/org/breezyweather/domain/weather/model/MoonPhase.kt new file mode 100644 index 0000000..f5a5530 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/MoonPhase.kt @@ -0,0 +1,38 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.weather.model.MoonPhase +import org.breezyweather.R +import org.shredzone.commons.suncalc.MoonPhase.Phase + +fun MoonPhase.getDescription(context: Context): String? { + if (angle == null) return null + + return when (Phase.toPhase(angle!!.toDouble())) { + Phase.NEW_MOON -> context.getString(R.string.ephemeris_moon_phase_new_moon) + Phase.WAXING_CRESCENT -> context.getString(R.string.ephemeris_moon_phase_waxing_crescent) + Phase.FIRST_QUARTER -> context.getString(R.string.ephemeris_moon_phase_first_quarter) + Phase.WAXING_GIBBOUS -> context.getString(R.string.ephemeris_moon_phase_waxing_gibbous) + Phase.FULL_MOON -> context.getString(R.string.ephemeris_moon_phase_full_moon) + Phase.WANING_GIBBOUS -> context.getString(R.string.ephemeris_moon_phase_waning_gibbous) + Phase.LAST_QUARTER -> context.getString(R.string.ephemeris_moon_phase_last_quarter) + Phase.WANING_CRESCENT -> context.getString(R.string.ephemeris_moon_phase_waning_crescent) + else -> null + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/Pollen.kt b/app/src/main/java/org/breezyweather/domain/weather/model/Pollen.kt new file mode 100644 index 0000000..bbd17ee --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/Pollen.kt @@ -0,0 +1,149 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import android.graphics.Color +import androidx.annotation.ColorInt +import breezyweather.domain.weather.model.Pollen +import org.breezyweather.R +import org.breezyweather.common.source.PollenIndexSource +import org.breezyweather.domain.weather.index.PollenIndex + +val Pollen.validPollens: List + get() { + return PollenIndex.entries.filter { getConcentration(it) != null } + } + +val Pollen.pollensWithConcentration: List + get() { + return PollenIndex.entries.filter { pollenIndex -> + val concentration = getConcentration(pollenIndex) + concentration != null && concentration.value > 0L + } + } + +fun Pollen.getIndex(pollen: PollenIndex? = null): Int? { + return if (pollen == null) { // Global pollen index + val pollensIndex: List = PollenIndex.entries.mapNotNull { getIndex(it) } + if (pollensIndex.isNotEmpty()) pollensIndex.max() else null + } else { // Specific pollen + pollen.getIndex(getConcentration(pollen)?.inPerCubicMeter) + } +} + +fun Pollen.getPollenWithMaxIndex(): PollenIndex? { + val pollensIndex: Map = PollenIndex.entries + .filter { it != PollenIndex.MOLD } + .mapNotNull { pollenIndex -> + getIndex(pollenIndex)?.let { + if (it > 0) pollenIndex to it else null + } + }.toMap() + return if (pollensIndex.isNotEmpty()) { + pollensIndex.maxBy { it.value }.key + } else { + null + } +} + +fun Pollen.getConcentration(pollen: PollenIndex) = when (pollen) { + PollenIndex.ALDER -> alder + PollenIndex.ASH -> ash + PollenIndex.BIRCH -> birch + PollenIndex.CHESTNUT -> chestnut + PollenIndex.CYPRESS -> cypress + PollenIndex.GRASS -> grass + PollenIndex.HAZEL -> hazel + PollenIndex.HORNBEAM -> hornbeam + PollenIndex.LINDEN -> linden + PollenIndex.MOLD -> mold + PollenIndex.MUGWORT -> mugwort + PollenIndex.OAK -> oak + PollenIndex.OLIVE -> olive + PollenIndex.PLANE -> plane + PollenIndex.PLANTAIN -> plantain + PollenIndex.POPLAR -> poplar + PollenIndex.RAGWEED -> ragweed + PollenIndex.SORREL -> sorrel + PollenIndex.TREE -> tree + PollenIndex.URTICACEAE -> urticaceae + PollenIndex.WILLOW -> willow +} + +fun Pollen.getIndexName( + context: Context, + pollen: PollenIndex? = null, + source: PollenIndexSource? = null, +): String? { + return if (source != null) { + if (pollen != null) { + getConcentration(pollen)?.let { + context.resources.getStringArray(source.pollenLabels).getOrElse(it.inPollenIndex) { null } + } + } else { + null + } + } else { + if (pollen == null) { // Global pollen risk + PollenIndex.getPollenIndexToName(context, getIndex()) + } else { // Specific pollen + pollen.getName(context, getConcentration(pollen)?.inPerCubicMeter) + } + } +} + +fun Pollen.getSummary( + context: Context, + source: PollenIndexSource? = null, +): String { + return pollensWithConcentration.joinToString(context.getString(org.breezyweather.unit.R.string.locale_separator)) { + getName(context, it) + + context.getString(R.string.colon_separator) + + getIndexName(context, it, source) + } +} + +fun Pollen.getName(context: Context, pollen: PollenIndex): String { + return context.getString(pollen.pollenName) +} + +@ColorInt +fun Pollen.getColor( + context: Context, + pollen: PollenIndex? = null, + source: PollenIndexSource? = null, +): Int { + return if (source != null) { + if (pollen != null) { + getConcentration(pollen)?.let { + context.resources.getIntArray(source.pollenColors).getOrElse(it.inPollenIndex) { Color.TRANSPARENT } + } ?: Color.TRANSPARENT + } else { + Color.TRANSPARENT + } + } else { + if (pollen == null) { + PollenIndex.getPollenIndexToColor(context, getIndex()) + } else { // Specific pollen + pollen.getColor(context, getConcentration(pollen)?.inPerCubicMeter) + } + } +} + +val Pollen.isIndexValid: Boolean + get() = getIndex() != null diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/Precipitation.kt b/app/src/main/java/org/breezyweather/domain/weather/model/Precipitation.kt new file mode 100644 index 0000000..682e591 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/Precipitation.kt @@ -0,0 +1,96 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.core.content.ContextCompat +import breezyweather.domain.weather.model.Precipitation +import org.breezyweather.R +import org.breezyweather.unit.precipitation.Precipitation.Companion.millimeters + +@ColorInt +fun Precipitation.getHalfDayPrecipitationColor(context: Context): Int { + return if (total == null) { + Color.TRANSPARENT + } else { + with(total!!) { + when { + this in 0.0.millimeters..Precipitation.PRECIPITATION_HALF_DAY_LIGHT.millimeters -> { + ContextCompat.getColor(context, R.color.colorLevel_1) + } + + this in Precipitation.PRECIPITATION_HALF_DAY_LIGHT.millimeters + .rangeTo(Precipitation.PRECIPITATION_HALF_DAY_MEDIUM.millimeters) -> + ContextCompat.getColor(context, R.color.colorLevel_2) + + this in Precipitation.PRECIPITATION_HALF_DAY_MEDIUM.millimeters + .rangeTo(Precipitation.PRECIPITATION_HALF_DAY_HEAVY.millimeters) -> { + ContextCompat.getColor(context, R.color.colorLevel_3) + } + + this in Precipitation.PRECIPITATION_HALF_DAY_HEAVY.millimeters + .rangeTo(Precipitation.PRECIPITATION_HALF_DAY_RAINSTORM.millimeters) -> { + ContextCompat.getColor(context, R.color.colorLevel_4) + } + + this >= Precipitation.PRECIPITATION_HALF_DAY_RAINSTORM.millimeters -> { + ContextCompat.getColor(context, R.color.colorLevel_5) + } + + else -> Color.TRANSPARENT + } + } + } +} + +@ColorInt +fun Precipitation.getHourlyPrecipitationColor(context: Context): Int { + return if (total == null) { + Color.TRANSPARENT + } else { + with(total!!) { + when { + this in 0.0.millimeters..Precipitation.PRECIPITATION_HOURLY_LIGHT.millimeters -> { + ContextCompat.getColor(context, R.color.colorLevel_1) + } + + this in Precipitation.PRECIPITATION_HOURLY_LIGHT.millimeters + .rangeTo(Precipitation.PRECIPITATION_HOURLY_MEDIUM.millimeters) -> { + ContextCompat.getColor(context, R.color.colorLevel_2) + } + + this in Precipitation.PRECIPITATION_HOURLY_MEDIUM.millimeters + .rangeTo(Precipitation.PRECIPITATION_HOURLY_HEAVY.millimeters) -> { + ContextCompat.getColor(context, R.color.colorLevel_3) + } + + this in Precipitation.PRECIPITATION_HOURLY_HEAVY.millimeters + .rangeTo(Precipitation.PRECIPITATION_HOURLY_RAINSTORM.millimeters) -> { + ContextCompat.getColor(context, R.color.colorLevel_4) + } + + this >= Precipitation.PRECIPITATION_HOURLY_RAINSTORM.millimeters -> { + ContextCompat.getColor(context, R.color.colorLevel_5) + } + + else -> Color.TRANSPARENT + } + } + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/UV.kt b/app/src/main/java/org/breezyweather/domain/weather/model/UV.kt new file mode 100644 index 0000000..fb73f46 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/UV.kt @@ -0,0 +1,90 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import breezyweather.domain.weather.model.UV +import org.breezyweather.R +import org.breezyweather.common.utils.UnitUtils + +fun UV.getLevel(context: Context): String? { + if (index == null) return null + return when (index!!) { + in 0.0.. context.getString(R.string.uv_index_0_2) + in UV.UV_INDEX_LOW.. context.getString(R.string.uv_index_3_5) + in UV.UV_INDEX_MIDDLE.. context.getString(R.string.uv_index_6_7) + in UV.UV_INDEX_HIGH.. context.getString(R.string.uv_index_8_10) + in UV.UV_INDEX_EXCESSIVE..Double.MAX_VALUE -> context.getString(R.string.uv_index_11) + else -> null + } +} + +fun UV.getContentDescription(context: Context): String { + val builder = StringBuilder() + index?.let { + builder.append(UnitUtils.formatDouble(context, it, 0)) + } + getLevel(context)?.let { + if (builder.toString().isNotEmpty()) { + builder.append(context.getString(org.breezyweather.unit.R.string.locale_separator)) + } + builder.append(it) + } + return builder.toString() +} + +fun UV.getShortDescription(context: Context): String { + val builder = StringBuilder() + index?.let { + builder.append(UnitUtils.formatDouble(context, it, 0)) + } + getLevel(context)?.let { + if (builder.toString().isNotEmpty()) builder.append(" ") + builder.append(it) + } + return builder.toString() +} + +@ColorInt +fun UV.getUVColor(context: Context): Int { + if (index == null) return Color.TRANSPARENT + return when (index!!) { + in 0.0.. ContextCompat.getColor(context, R.color.colorLevel_1) + in UV.UV_INDEX_LOW.. ContextCompat.getColor(context, R.color.colorLevel_2) + in UV.UV_INDEX_MIDDLE.. ContextCompat.getColor(context, R.color.colorLevel_3) + in UV.UV_INDEX_HIGH.. ContextCompat.getColor(context, R.color.colorLevel_4) + in UV.UV_INDEX_EXCESSIVE..Double.MAX_VALUE -> ContextCompat.getColor(context, R.color.colorLevel_5) + else -> Color.TRANSPARENT + } +} + +@DrawableRes +fun UV.getShape(): Int { + if (index == null) return R.drawable.uv_unknown + return when (index!!) { + in 0.0.. R.drawable.uv_low + in UV.UV_INDEX_LOW.. R.drawable.uv_moderate + in UV.UV_INDEX_MIDDLE.. R.drawable.uv_high + in UV.UV_INDEX_HIGH.. R.drawable.uv_very_high + in UV.UV_INDEX_EXCESSIVE..Double.MAX_VALUE -> R.drawable.uv_extreme + else -> R.drawable.uv_unknown + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/Weather.kt b/app/src/main/java/org/breezyweather/domain/weather/model/Weather.kt new file mode 100644 index 0000000..7ffc314 --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/Weather.kt @@ -0,0 +1,160 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import breezyweather.domain.location.model.Location +import breezyweather.domain.weather.model.AirQuality +import breezyweather.domain.weather.model.Weather +import org.breezyweather.R +import org.breezyweather.common.extensions.formatMeasure +import org.breezyweather.common.extensions.getFormattedTime +import org.breezyweather.common.extensions.is12Hour +import org.breezyweather.common.extensions.toCalendarWithTimeZone +import org.breezyweather.unit.formatting.UnitWidth +import org.breezyweather.unit.temperature.Temperature +import org.breezyweather.unit.temperature.TemperatureUnit +import java.util.Calendar +import java.util.Date + +val Weather.validAirQuality: AirQuality? + get() = if (current?.airQuality != null && current!!.airQuality!!.isIndexValid) { + current!!.airQuality + } else if (today?.airQuality != null && today!!.airQuality!!.isIndexValid) { + today!!.airQuality + } else { + null + } + +val Weather.hasMinutelyPrecipitation: Boolean + get() = minutelyForecast.any { (it.dbz ?: 0) > 0 } + +fun Weather.getMinutelyTitle(context: Context): String { + return if (hasMinutelyPrecipitation) { + // 1 = soon, 2 = continue, 3 = end + val case = if (minutelyForecast.first().dbz != null && minutelyForecast.first().dbz!! > 0) { + if (minutelyForecast.last().dbz != null && minutelyForecast.last().dbz!! > 0) 2 else 3 + } else { + 1 + } + + when (case) { + 1 -> context.getString(R.string.notification_precipitation_starting) + 2 -> context.getString(R.string.notification_precipitation_continuing) + 3 -> context.getString(R.string.notification_precipitation_stopping) + else -> context.getString(R.string.precipitation) + } + } else { + context.getString(R.string.precipitation) + } +} + +fun Weather.getMinutelyDescription(context: Context, location: Location): String { + return if (hasMinutelyPrecipitation) { + // 1 = soon, 2 = continue, 3 = end + val case = if (minutelyForecast.first().dbz != null && minutelyForecast.first().dbz!! > 0) { + if (minutelyForecast.last().dbz != null && minutelyForecast.last().dbz!! > 0) 2 else 3 + } else { + 1 + } + + context.getString( + when (case) { + 1 -> R.string.notification_precipitation_starting_desc + 2 -> R.string.notification_precipitation_continuing_desc + 3 -> R.string.notification_precipitation_stopping_desc + else -> R.string.notification_precipitation_continuing_desc + }, + when (case) { + 1 -> minutelyForecast.first { (it.dbz ?: 0) > 0 }.date + .getFormattedTime(location, context, context.is12Hour) + else -> minutelyForecast.last { (it.dbz ?: 0) > 0 }.endingDate + .getFormattedTime(location, context, context.is12Hour) + } + ) + } else { + context.getString(R.string.precipitation_none) + } +} + +fun Weather.getTemperatureRangeSummary( + context: Context, + location: Location, + temperatureUnit: TemperatureUnit, +): Pair? { + if (today == null) return null + + val cal = Date().toCalendarWithTimeZone(location.timeZone) + val currentHour = cal[Calendar.HOUR_OF_DAY] + + val isDayFirst: Boolean + val temperatures = mutableListOf() + + val halfDayTemperatureRange = mutableListOf() + val halfDayTemperatureRangeVoice = mutableListOf() + + // Early morning + if (currentHour < 6) { + val yesterday = dailyForecast.getOrElse(todayIndex!!.minus(1)) { null } + isDayFirst = false + temperatures.add(yesterday?.night?.temperature?.temperature) + temperatures.add(today!!.day?.temperature?.temperature) + } else if (currentHour < 18) { + isDayFirst = true + temperatures.add(today!!.day?.temperature?.temperature) + temperatures.add(today!!.night?.temperature?.temperature) + } else { + isDayFirst = false + temperatures.add(today!!.night?.temperature?.temperature) + temperatures.add(tomorrow?.day?.temperature?.temperature) + } + + temperatures.getOrElse(0) { null }?.let { + halfDayTemperatureRange.add( + context.getString(if (isDayFirst) R.string.daytime_short else R.string.nighttime_short) + + context.getString(R.string.colon_separator) + + it.formatMeasure(context, temperatureUnit, valueWidth = UnitWidth.NARROW, unitWidth = UnitWidth.NARROW) + ) + halfDayTemperatureRangeVoice.add( + context.getString(if (isDayFirst) R.string.daytime_short else R.string.nighttime_short) + + context.getString(R.string.colon_separator) + + it.formatMeasure(context, temperatureUnit, valueWidth = UnitWidth.NARROW, unitWidth = UnitWidth.LONG) + ) + } + + temperatures.getOrElse(1) { null }?.let { + halfDayTemperatureRange.add( + context.getString(if (isDayFirst) R.string.nighttime_short else R.string.daytime_short) + + context.getString(R.string.colon_separator) + + it.formatMeasure(context, temperatureUnit, valueWidth = UnitWidth.NARROW, unitWidth = UnitWidth.NARROW) + ) + halfDayTemperatureRangeVoice.add( + context.getString(if (isDayFirst) R.string.nighttime_short else R.string.daytime_short) + + context.getString(R.string.colon_separator) + + it.formatMeasure(context, temperatureUnit, valueWidth = UnitWidth.NARROW, unitWidth = UnitWidth.LONG) + ) + } + + return if (halfDayTemperatureRange.isNotEmpty()) { + Pair( + halfDayTemperatureRange.joinToString(context.getString(R.string.dot_separator)), + halfDayTemperatureRangeVoice.joinToString(context.getString(R.string.dot_separator)) + ) + } else { + null + } +} diff --git a/app/src/main/java/org/breezyweather/domain/weather/model/Wind.kt b/app/src/main/java/org/breezyweather/domain/weather/model/Wind.kt new file mode 100644 index 0000000..c498fca --- /dev/null +++ b/app/src/main/java/org/breezyweather/domain/weather/model/Wind.kt @@ -0,0 +1,156 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.domain.weather.model + +import android.content.Context +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import breezyweather.domain.weather.model.Wind +import org.breezyweather.R +import org.breezyweather.common.extensions.formatMeasure +import org.breezyweather.common.extensions.getBeaufortScaleStrength +import org.breezyweather.unit.formatting.UnitWidth +import org.breezyweather.unit.speed.Speed.Companion.centimetersPerSecond + +fun Wind.validate(): Wind { + return copy( + degree = Wind.validateDegree(degree), + speed = speed?.toValidOrNull(), + gusts = gusts?.toValidOrNull() + ) +} + +@ColorInt +fun Wind.getColor(context: Context): Int { + if (speed == null) return Color.TRANSPARENT + return when (speed!!.inBeaufort) { + in 0..<4 -> ContextCompat.getColor(context, R.color.colorLevel_1) + in 4..<6 -> ContextCompat.getColor(context, R.color.colorLevel_2) + in 6..<8 -> ContextCompat.getColor(context, R.color.colorLevel_3) + in 8..<10 -> ContextCompat.getColor(context, R.color.colorLevel_4) + in 10..<12 -> ContextCompat.getColor(context, R.color.colorLevel_5) + in 12..Integer.MAX_VALUE -> ContextCompat.getColor(context, R.color.colorLevel_6) + else -> Color.TRANSPARENT + } +} + +fun Wind.getDirection(context: Context, short: Boolean = true): String? { + if (degree == null) return null + return when (degree!!) { + in 0.0..22.5 -> context.getString( + if (short) R.string.wind_direction_N_short else R.string.wind_direction_N + ) + in 22.5..67.5 -> context.getString( + if (short) R.string.wind_direction_NE_short else R.string.wind_direction_NE + ) + in 67.5..112.5 -> context.getString( + if (short) R.string.wind_direction_E_short else R.string.wind_direction_E + ) + in 112.5..157.5 -> context.getString( + if (short) R.string.wind_direction_SE_short else R.string.wind_direction_SE + ) + in 157.5..202.5 -> context.getString( + if (short) R.string.wind_direction_S_short else R.string.wind_direction_S + ) + in 202.5..247.5 -> context.getString( + if (short) R.string.wind_direction_SW_short else R.string.wind_direction_SW + ) + in 247.5..292.5 -> context.getString( + if (short) R.string.wind_direction_W_short else R.string.wind_direction_W + ) + in 292.5..337.5 -> context.getString( + if (short) R.string.wind_direction_NW_short else R.string.wind_direction_NW + ) + in 337.5..360.0 -> context.getString( + if (short) R.string.wind_direction_N_short else R.string.wind_direction_N + ) + else -> context.getString(R.string.wind_direction_variable) + } +} + +val Wind.drawableArrow: Int? + @DrawableRes + get() { + if (degree == null) return null + return when (degree!!) { + in 0.0..22.5 -> R.drawable.arrow_north + in 22.5..67.5 -> R.drawable.arrow_north_east + in 67.5..112.5 -> R.drawable.arrow_east + in 112.5..157.5 -> R.drawable.arrow_south_east + in 157.5..202.5 -> R.drawable.arrow_south + in 202.5..247.5 -> R.drawable.arrow_south_west + in 247.5..292.5 -> R.drawable.arrow_west + in 292.5..337.5 -> R.drawable.arrow_north_west + in 337.5..360.0 -> R.drawable.arrow_north + else -> R.drawable.ic_replay + } + } + +fun Wind.getStrength(context: Context): String? { + return speed?.getBeaufortScaleStrength(context) +} + +fun Wind.getShortDescription(context: Context): String? { + val builder = StringBuilder() + arrow?.let { + builder.append(it) + } + speed?.let { + if (builder.toString().isNotEmpty()) builder.append(" ") + builder.append(it.formatMeasure(context)) + } + return builder.toString().ifEmpty { null } +} + +fun Wind.getContentDescription( + context: Context, + withGusts: Boolean = false, +): String { + val builder = StringBuilder() + speed?.let { + builder.append(it.formatMeasure(context, unitWidth = UnitWidth.LONG)) + if (!getStrength(context).isNullOrEmpty()) { + builder.append(context.getString(org.breezyweather.unit.R.string.locale_separator)) + builder.append(getStrength(context)) + } + } + if (!getDirection(context).isNullOrEmpty()) { + if (builder.toString().isNotEmpty()) { + builder.append(context.getString(org.breezyweather.unit.R.string.locale_separator)) + } + if (degree!! in 0.0..360.0) { + builder.append(context.getString(R.string.wind_origin, getDirection(context, short = false))) + } else { + builder.append(getDirection(context, short = false)) + } + } + if (withGusts) { + gusts?.let { + if (it > (speed ?: 0.centimetersPerSecond)) { + if (builder.toString().isNotEmpty()) { + builder.append(context.getString(org.breezyweather.unit.R.string.locale_separator)) + } + builder.append(context.getString(R.string.wind_gusts_short)) + builder.append(context.getString(R.string.colon_separator)) + builder.append(it.formatMeasure(context, unitWidth = UnitWidth.LONG)) + } + } + } + return builder.toString() +} diff --git a/app/src/main/java/org/breezyweather/remoteviews/Notifications.kt b/app/src/main/java/org/breezyweather/remoteviews/Notifications.kt new file mode 100644 index 0000000..05eb98b --- /dev/null +++ b/app/src/main/java/org/breezyweather/remoteviews/Notifications.kt @@ -0,0 +1,355 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.remoteviews + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.NotificationManagerCompat.IMPORTANCE_DEFAULT +import androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH +import androidx.core.app.NotificationManagerCompat.IMPORTANCE_MIN +import androidx.core.content.ContextCompat +import androidx.core.text.parseAsHtml +import breezyweather.domain.location.model.Location +import breezyweather.domain.weather.model.Alert +import breezyweather.domain.weather.model.Weather +import breezyweather.domain.weather.reference.AlertSeverity +import org.breezyweather.R +import org.breezyweather.common.extensions.buildNotificationChannel +import org.breezyweather.common.extensions.buildNotificationChannelGroup +import org.breezyweather.common.extensions.notificationBuilder +import org.breezyweather.common.extensions.notify +import org.breezyweather.common.utils.helpers.IntentHelper +import org.breezyweather.domain.location.model.getPlace +import org.breezyweather.domain.location.model.isDaylight +import org.breezyweather.domain.settings.ConfigStore +import org.breezyweather.domain.settings.SettingsManager +import org.breezyweather.domain.weather.model.getMinutelyDescription +import org.breezyweather.domain.weather.model.getMinutelyTitle +import org.breezyweather.domain.weather.model.hasMinutelyPrecipitation +import java.text.DateFormat + +object Notifications { + + // We only have one group as we don’t have many channels + // We name it “Breezy Weather” as LeakCanary also has its own group + private const val GROUP_BREEZY_WEATHER = "group_breezy_weather" + + private const val CHANNEL_ALERT = "alert" + private const val ID_ALERT_MIN = 1000 + private const val ID_ALERT_MAX = 1999 + private const val ID_ALERT_GROUP = 2000 + private const val ID_PRECIPITATION = 3000 + + const val CHANNEL_FORECAST = "forecast" + const val ID_TODAY_FORECAST = 2 + const val ID_TOMORROW_FORECAST = 3 + const val ID_UPDATING_TODAY_FORECAST = 7 + const val ID_UPDATING_TOMORROW_FORECAST = 8 + + const val CHANNEL_WIDGET = "widget" + const val ID_WIDGET = 1 + const val ID_UPDATING_WIDGET = 6 + + const val CHANNEL_BACKGROUND = "background" + const val ID_RUNNING_IN_BACKGROUND = 5 + const val ID_UPDATING_AWAKE = 9 + const val ID_WEATHER_PROGRESS = -101 + const val ID_WEATHER_ERROR = -102 + + const val CHANNEL_APP_UPDATE = "app_apk_update_channel" + const val ID_APP_UPDATER = 11 + const val ID_APP_UPDATE_PROMPT = 12 + + /** + * Notification channel used for crash log file sharing. + */ + const val CHANNEL_CRASH_LOGS = "crash_logs" + const val ID_CRASH_LOGS = -201 + + private const val ALERT_GROUP_KEY = "breezy_weather_alert_notification_group" + private const val PREFERENCE_NOTIFICATION = "NOTIFICATION_PREFERENCE" + private const val KEY_NOTIFICATION_ID = "NOTIFICATION_ID" + // private const val PREFERENCE_SHORT_TERM_PRECIPITATION_ALERT = "SHORT_TERM_PRECIPITATION_ALERT_PREFERENCE" + // private const val KEY_PRECIPITATION_LOCATION_KEY = "PRECIPITATION_LOCATION_KEY" + // private const val KEY_PRECIPITATION_DATE = "PRECIPITATION_DATE" + + private val deprecatedChannels = listOf( + "normally" + ) + + /** + * Initialize channels so that the user can disable them, even if didn’t receive a notification yet + */ + fun createChannels(context: Context) { + val notificationManager = NotificationManagerCompat.from(context) + + // Delete old notification channels + deprecatedChannels.forEach(notificationManager::deleteNotificationChannel) + + notificationManager.createNotificationChannelGroupsCompat( + listOf( + buildNotificationChannelGroup(GROUP_BREEZY_WEATHER) { + setName(context.getString(R.string.breezy_weather)) + } + ) + ) + + notificationManager.createNotificationChannelsCompat( + listOf( + buildNotificationChannel(CHANNEL_ALERT, IMPORTANCE_HIGH) { + setName(context.getString(R.string.notification_channel_alerts)) + setGroup(GROUP_BREEZY_WEATHER) + }, + buildNotificationChannel(CHANNEL_FORECAST, IMPORTANCE_DEFAULT) { + setName(context.getString(R.string.notification_channel_forecast)) + setGroup(GROUP_BREEZY_WEATHER) + }, + buildNotificationChannel(CHANNEL_WIDGET, IMPORTANCE_DEFAULT) { + setName(context.getString(R.string.notification_channel_widget)) + setGroup(GROUP_BREEZY_WEATHER) + setShowBadge(false) + }, + buildNotificationChannel(CHANNEL_BACKGROUND, IMPORTANCE_MIN) { + setName(context.getString(R.string.notification_channel_background_services)) + setGroup(GROUP_BREEZY_WEATHER) + setShowBadge(false) + }, + buildNotificationChannel(CHANNEL_CRASH_LOGS, IMPORTANCE_HIGH) { + setName(context.getString(R.string.notification_channel_crash_logs)) + }, + buildNotificationChannel(CHANNEL_APP_UPDATE, IMPORTANCE_DEFAULT) { + setName(context.getString(R.string.notification_channel_app_updates)) + } + ) + ) + } + + private fun getNotificationBuilder( + context: Context, + @DrawableRes iconId: Int, + title: String, + subtitle: String, + content: CharSequence?, + intent: PendingIntent, + ): NotificationCompat.Builder { + return context.notificationBuilder(CHANNEL_ALERT).apply { + setSmallIcon(iconId) + setContentTitle(title) + setSubText(subtitle) + setContentText(content) + setAutoCancel(true) + setOnlyAlertOnce(true) + setBadgeIconType(NotificationCompat.BADGE_ICON_SMALL) + setContentIntent(intent) + } + } + + fun checkAndSendAlert( + context: Context, + location: Location, + oldResult: Weather?, + ) { + val weather = location.weather + if (weather == null || !SettingsManager.getInstance(context).isAlertPushEnabled) return + + val alertList = mutableListOf() + if (oldResult == null) { + alertList.addAll(weather.alertList) + } else { + val idSet = mutableSetOf() + val desSet = mutableSetOf() + for (alert in oldResult.alertList.filter { + it.severity != AlertSeverity.MINOR + }) { + idSet.add(alert.alertId) + desSet.add( + alert.headline?.ifEmpty { + context.getString(R.string.alert) + } ?: context.getString(R.string.alert) + ) + } + for (alert in weather.alertList.filter { + it.severity != AlertSeverity.MINOR + }) { + if (!idSet.contains(alert.alertId) && + !desSet.contains( + alert.headline?.ifEmpty { + context.getString(R.string.alert) + } ?: context.getString(R.string.alert) + ) + ) { + alertList.add(alert) + } + } + } + alertList.forEach { alert -> + sendAlertNotification(context, location, alert, alertList.size > 1) + } + } + + private fun sendAlertNotification( + context: Context, + location: Location, + alert: Alert, + inGroup: Boolean, + ) { + val notificationId = getAlertNotificationId(context) + context.notify( + notificationId, + buildSingleAlertNotification(context, location, alert, inGroup, notificationId) + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && inGroup) { + context.notify( + ID_ALERT_GROUP, + buildAlertGroupSummaryNotification(context, location, alert, notificationId) + ) + } + } + + @SuppressLint("InlinedApi") + private fun buildSingleAlertNotification( + context: Context, + location: Location, + alert: Alert, + inGroup: Boolean, + notificationId: Int, + ): Notification { + val time = alert.startDate?.let { + // FIXME: Timezone + DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.DEFAULT).format(it) + } + val description = alert.description?.replace("\n", "
")?.parseAsHtml() ?: "" + val builder = getNotificationBuilder( + context, + R.drawable.ic_alert, + alert.headline?.ifEmpty { + context.getString(R.string.alert) + } ?: context.getString(R.string.alert), + time ?: "", + description, + PendingIntent.getActivity( + context, + notificationId, + IntentHelper.buildMainActivityShowAlertsIntent(location, alert.alertId), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ).setStyle( + NotificationCompat.BigTextStyle() + .setBigContentTitle( + alert.headline?.ifEmpty { + context.getString(R.string.alert) + } ?: context.getString(R.string.alert) + ) + .setSummaryText(time) + .bigText(description) + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && inGroup) { + builder.setGroup(ALERT_GROUP_KEY) + } + return builder.build() + } + + @SuppressLint("InlinedApi") + private fun buildAlertGroupSummaryNotification( + context: Context, + location: Location, + alert: Alert, + notificationId: Int, + ): Notification { + return context.notificationBuilder(CHANNEL_ALERT).apply { + setSmallIcon(R.drawable.ic_alert) + setContentTitle( + alert.headline?.ifEmpty { + context.getString(R.string.alert) + } ?: context.getString(R.string.alert) + ) + setGroup(ALERT_GROUP_KEY) + color = getColor(context, location) + setGroupSummary(true) + setOnlyAlertOnce(true) + setContentIntent( + PendingIntent.getActivity( + context, + notificationId, + IntentHelper.buildMainActivityShowAlertsIntent(location, alert.alertId), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + }.build() + } + + private fun getAlertNotificationId(context: Context): Int { + val config = ConfigStore(context, PREFERENCE_NOTIFICATION) + var id = config.getInt(KEY_NOTIFICATION_ID, ID_ALERT_MIN) + 1 + if (id > ID_ALERT_MAX) { + id = ID_ALERT_MIN + } + config.edit() + .putInt(KEY_NOTIFICATION_ID, id) + .apply() + return id + } + + // precipitation. + fun checkAndSendPrecipitation(context: Context, location: Location) { + if (!SettingsManager.getInstance(context).isPrecipitationPushEnabled || + location.weather?.minutelyForecast.isNullOrEmpty() + ) { + return + } + // val config = ConfigStore(context, PREFERENCE_SHORT_TERM_PRECIPITATION_ALERT) + // val timestamp = config.getLong(KEY_PRECIPITATION_DATE, 0) + + if (location.weather!!.hasMinutelyPrecipitation) { + context.notify( + ID_PRECIPITATION, + getNotificationBuilder( + context, + R.drawable.ic_precipitation, + location.weather!!.getMinutelyTitle(context), + location.getPlace(context), + location.weather!!.getMinutelyDescription(context, location), + PendingIntent.getActivity( + context, + ID_PRECIPITATION, + IntentHelper.buildMainActivityIntent(location), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ).build() + ) + /*config.edit() + .putString(KEY_PRECIPITATION_LOCATION_KEY, location.formattedId) + .putLong(KEY_PRECIPITATION_DATE, System.currentTimeMillis()) + .apply()*/ + } + } + + @ColorInt + private fun getColor(context: Context, location: Location): Int { + return ContextCompat.getColor( + context, + if (location.isDaylight) R.color.lightPrimary_5 else R.color.darkPrimary_5 + ) + } +} diff --git a/app/src/main/java/org/breezyweather/remoteviews/Widgets.kt b/app/src/main/java/org/breezyweather/remoteviews/Widgets.kt new file mode 100644 index 0000000..6daa256 --- /dev/null +++ b/app/src/main/java/org/breezyweather/remoteviews/Widgets.kt @@ -0,0 +1,172 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.remoteviews + +import android.content.Context +import android.text.TextPaint +import breezyweather.domain.weather.model.Weather +import org.breezyweather.common.extensions.formatMeasure +import org.breezyweather.unit.formatting.UnitWidth +import org.breezyweather.unit.temperature.TemperatureUnit + +object Widgets { + + // day. + const val DAY_PENDING_INTENT_CODE_WEATHER = 11 + const val DAY_PENDING_INTENT_CODE_CALENDAR = 13 + + // week. + const val WEEK_PENDING_INTENT_CODE_WEATHER = 21 + const val WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_1 = 211 + const val WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_2 = 212 + const val WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_3 = 213 + const val WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_4 = 214 + const val WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_5 = 215 + + // day + week. + const val DAY_WEEK_PENDING_INTENT_CODE_WEATHER = 31 + const val DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_1 = 311 + const val DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_2 = 312 + const val DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_3 = 313 + const val DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_4 = 314 + const val DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_5 = 315 + const val DAY_WEEK_PENDING_INTENT_CODE_CALENDAR = 33 + + // clock + day (vertical). + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_WEATHER = 41 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_LIGHT = 43 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_NORMAL = 44 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_BLACK = 45 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_1_LIGHT = 46 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_2_LIGHT = 47 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_1_NORMAL = 48 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_2_NORMAL = 49 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_1_BLACK = 50 + const val CLOCK_DAY_VERTICAL_PENDING_INTENT_CODE_CLOCK_2_BLACK = 51 + + // clock + day (horizontal). + const val CLOCK_DAY_HORIZONTAL_PENDING_INTENT_CODE_WEATHER = 61 + const val CLOCK_DAY_HORIZONTAL_PENDING_INTENT_CODE_CALENDAR = 63 + const val CLOCK_DAY_HORIZONTAL_PENDING_INTENT_CODE_CLOCK_LIGHT = 64 + const val CLOCK_DAY_HORIZONTAL_PENDING_INTENT_CODE_CLOCK_NORMAL = 65 + const val CLOCK_DAY_HORIZONTAL_PENDING_INTENT_CODE_CLOCK_BLACK = 66 + + // clock + day + details. + const val CLOCK_DAY_DETAILS_PENDING_INTENT_CODE_WEATHER = 71 + const val CLOCK_DAY_DETAILS_PENDING_INTENT_CODE_CALENDAR = 73 + const val CLOCK_DAY_DETAILS_PENDING_INTENT_CODE_CLOCK_LIGHT = 74 + const val CLOCK_DAY_DETAILS_PENDING_INTENT_CODE_CLOCK_NORMAL = 75 + const val CLOCK_DAY_DETAILS_PENDING_INTENT_CODE_CLOCK_BLACK = 76 + + // clock + day + week. + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_WEATHER = 81 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_1 = 821 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_2 = 822 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_3 = 823 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_4 = 824 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_DAILY_FORECAST_5 = 825 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_CALENDAR = 83 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_CLOCK_LIGHT = 84 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_CLOCK_NORMAL = 85 + const val CLOCK_DAY_WEEK_PENDING_INTENT_CODE_CLOCK_BLACK = 86 + + // text. + const val TEXT_PENDING_INTENT_CODE_WEATHER = 91 + const val TEXT_PENDING_INTENT_CODE_CALENDAR = 93 + + // trend daily. + const val TREND_DAILY_PENDING_INTENT_CODE_WEATHER = 101 + + // trend hourly. + const val TREND_HOURLY_PENDING_INTENT_CODE_WEATHER = 111 + + // multi city. + const val MULTI_CITY_PENDING_INTENT_CODE_WEATHER_1 = 121 + const val MULTI_CITY_PENDING_INTENT_CODE_WEATHER_2 = 123 + const val MULTI_CITY_PENDING_INTENT_CODE_WEATHER_3 = 125 + + // material you. + const val MATERIAL_YOU_FORECAST_PENDING_INTENT_CODE_WEATHER = 131 + const val MATERIAL_YOU_CURRENT_PENDING_INTENT_CODE_WEATHER = 132 + + fun buildWidgetDayStyleText(context: Context, weather: Weather, temperatureUnit: TemperatureUnit): Array { + val texts = arrayOf( + weather.current?.weatherText ?: "", + weather.current?.temperature?.temperature?.formatMeasure( + context, + temperatureUnit, + valueWidth = UnitWidth.NARROW, + unitWidth = UnitWidth.NARROW + ) ?: "", + weather.today?.day?.temperature?.temperature?.formatMeasure( + context, + temperatureUnit, + valueWidth = UnitWidth.NARROW, + unitWidth = UnitWidth.NARROW + ) ?: "", + weather.today?.night?.temperature?.temperature?.formatMeasure( + context, + temperatureUnit, + valueWidth = UnitWidth.NARROW, + unitWidth = UnitWidth.NARROW + ) ?: "" + ) + val paint = TextPaint() + val widths = FloatArray(4) + for (i in widths.indices) { + widths[i] = paint.measureText(texts[i]) + } + var maxiWidth = widths[0] + for (w in widths) { + if (w > maxiWidth) { + maxiWidth = w + } + } + while (true) { + val flags = booleanArrayOf(false, false, false, false) + for (i in 0..1) { + if (widths[i] < maxiWidth) { + texts[i] = texts[i] + " " + widths[i] = paint.measureText(texts[i]) + } else { + flags[i] = true + } + } + for (i in 2..3) { + if (widths[i] < maxiWidth) { + texts[i] = " " + texts[i] + widths[i] = paint.measureText(texts[i]) + } else { + flags[i] = true + } + } + var n = 0 + for (flag in flags) { + if (flag) { + n++ + } + } + if (n == 4) { + break + } + } + return arrayOf( + texts[0] + "\n" + texts[1], + texts[2] + "\n" + texts[3] + ) + } +} diff --git a/app/src/main/java/org/breezyweather/remoteviews/common/MaterialYouWidgetShape.kt b/app/src/main/java/org/breezyweather/remoteviews/common/MaterialYouWidgetShape.kt new file mode 100644 index 0000000..acbe07d --- /dev/null +++ b/app/src/main/java/org/breezyweather/remoteviews/common/MaterialYouWidgetShape.kt @@ -0,0 +1,60 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.remoteviews.common + +import org.breezyweather.R + +/** + * Not yet used + * Contains the shapes of the Material You - Forecast + */ +enum class MaterialYouWidgetShape( + val defaultWidth: Int, + val defaultHeight: Int, + val miniWidth: Int, + val miniHeight: Int, + // val layout: Int, +) { + MINI( + R.dimen.widget_material_you_mini_default_width, + R.dimen.widget_material_you_mini_default_height, + R.dimen.widget_material_you_minimum_width_for_square_mini, + R.dimen.widget_material_you_minimum_height_for_square_mini + // R.layout.widget_material_you_mini + ), + SQUARE( + R.dimen.widget_material_you_default_size, + R.dimen.widget_material_you_default_size, + R.dimen.widget_material_you_minimum_size_for_square, + R.dimen.widget_material_you_minimum_size_for_square + // R.layout.widget_material_you + ), + MEDIUM( + R.dimen.widget_material_you_medium_default_width, + R.dimen.widget_material_you_medium_default_height, + R.dimen.widget_material_you_minimum_width_for_square_medium, + R.dimen.widget_material_you_minimum_height_for_square_medium + // R.layout.widget_material_you_medium + ), + LARGE( + R.dimen.widget_material_you_large_default_width, + R.dimen.widget_material_you_large_default_height, + R.dimen.widget_material_you_minimum_width_for_square_large, + R.dimen.widget_material_you_minimum_height_for_square_large + // R.layout.widget_material_you_medium + ), +} diff --git a/app/src/main/java/org/breezyweather/remoteviews/common/WidgetSize.kt b/app/src/main/java/org/breezyweather/remoteviews/common/WidgetSize.kt new file mode 100644 index 0000000..7f2c1b7 --- /dev/null +++ b/app/src/main/java/org/breezyweather/remoteviews/common/WidgetSize.kt @@ -0,0 +1,28 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.remoteviews.common + +import android.content.res.Resources +import kotlin.math.roundToInt + +data class WidgetSize( + private val widthDp: Float, + private val heightDp: Float, +) { + val widthPx: Int = (widthDp * Resources.getSystem().displayMetrics.density).roundToInt() + val heightPx: Int = (heightDp * Resources.getSystem().displayMetrics.density).roundToInt() +} diff --git a/app/src/main/java/org/breezyweather/remoteviews/common/WidgetSizeUtils.kt b/app/src/main/java/org/breezyweather/remoteviews/common/WidgetSizeUtils.kt new file mode 100644 index 0000000..0254216 --- /dev/null +++ b/app/src/main/java/org/breezyweather/remoteviews/common/WidgetSizeUtils.kt @@ -0,0 +1,71 @@ +/* + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.remoteviews.common + +import android.appwidget.AppWidgetManager +import android.os.Build +import android.os.Bundle +import android.util.SizeF +import android.widget.RemoteViews +import androidx.core.os.BundleCompat +import kotlin.math.roundToInt + +object WidgetSizeUtils { + + fun initializeRemoteViews( + appWidgetManager: AppWidgetManager, + widgetId: Int, + getRemoteViews: (widgetSize: WidgetSize) -> RemoteViews, + ): RemoteViews { + val appWidgetOptions = appWidgetManager.getAppWidgetOptions(widgetId) + val parcelableArrayList: ArrayList<*>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + BundleCompat.getParcelableArrayList( + appWidgetOptions, + AppWidgetManager.OPTION_APPWIDGET_SIZES, + SizeF::class.java + ) + } else { + null + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + parcelableArrayList == null || + parcelableArrayList.isEmpty() + ) { + return RemoteViews( + getRemoteViews(getWidgetSize(appWidgetOptions, portrait = false)), + getRemoteViews(getWidgetSize(appWidgetOptions, portrait = true)) + ) + } + val linkedHashMap = LinkedHashMap() + for (obj in parcelableArrayList) { + val sizeF = obj as SizeF + linkedHashMap[sizeF] = getRemoteViews( + WidgetSize(sizeF.width.roundToInt().toFloat(), sizeF.height.roundToInt().toFloat()) + ) + } + return RemoteViews(linkedHashMap) + } + + private fun getWidgetSize(bundle: Bundle, portrait: Boolean): WidgetSize { + val pair = if (portrait) { + Pair(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) + } else { + Pair(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) + } + return WidgetSize(bundle.getInt(pair.first).toFloat(), bundle.getInt(pair.second).toFloat()) + } +} diff --git a/app/src/main/java/org/breezyweather/remoteviews/config/AbstractWidgetConfigActivity.kt b/app/src/main/java/org/breezyweather/remoteviews/config/AbstractWidgetConfigActivity.kt new file mode 100644 index 0000000..d20b4e5 --- /dev/null +++ b/app/src/main/java/org/breezyweather/remoteviews/config/AbstractWidgetConfigActivity.kt @@ -0,0 +1,809 @@ +/** + * This file is part of Breezy Weather. + * + * Breezy Weather is free software: you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by the + * Free Software Foundation, version 3 of the License. + * + * Breezy Weather is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public + * License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Breezy Weather. If not, see . + */ + +package org.breezyweather.remoteviews.config + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.app.WallpaperManager +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.os.Build +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.AttributeSet +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.CompoundButton +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.RelativeLayout +import android.widget.RemoteViews +import android.widget.Switch +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.annotation.CallSuper +import androidx.annotation.RequiresApi +import androidx.appcompat.widget.AppCompatSpinner +import androidx.core.view.updatePadding +import androidx.core.widget.NestedScrollView +import androidx.lifecycle.lifecycleScope +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import org.breezyweather.R +import org.breezyweather.common.activities.BreezyActivity +import org.breezyweather.common.extensions.doOnApplyWindowInsets +import org.breezyweather.common.extensions.formatPercent +import org.breezyweather.common.extensions.getTabletListAdaptiveWidth +import org.breezyweather.common.extensions.hasPermission +import org.breezyweather.common.extensions.launchUI +import org.breezyweather.common.options.appearance.CalendarHelper +import org.breezyweather.common.snackbar.Snackbar +import org.breezyweather.common.snackbar.SnackbarManager +import org.breezyweather.common.utils.helpers.SnackbarHelper +import org.breezyweather.domain.settings.ConfigStore +import org.breezyweather.unit.formatting.UnitWidth +import org.breezyweather.unit.ratio.Ratio.Companion.percent +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Abstract widget config activity. + */ +abstract class AbstractWidgetConfigActivity : BreezyActivity() { + protected var mTopContainer: FrameLayout? = null + protected var mWallpaper: ImageView? = null + protected var mWidgetContainer: FrameLayout? = null + protected var mScrollView: NestedScrollView? = null + protected var mViewTypeContainer: RelativeLayout? = null + protected var mCardStyleContainer: RelativeLayout? = null + protected var mCardAlphaContainer: RelativeLayout? = null + protected var mHideSubtitleContainer: RelativeLayout? = null + protected var mHideSubtitleTitle: TextView? = null + protected var mSubtitleDataContainer: RelativeLayout? = null + protected var mTextColorContainer: RelativeLayout? = null + protected var mTextSizeContainer: RelativeLayout? = null + protected var mClockFontContainer: RelativeLayout? = null + protected var mHideAlternateCalendarContainer: RelativeLayout? = null + protected var mAlignEndContainer: RelativeLayout? = null + private var mBottomSheetBehavior: BottomSheetBehavior<*>? = null + private var mBottomSheetScrollView: NestedScrollView? = null + private var mSubtitleInputLayout: TextInputLayout? = null + private var mSubtitleEditText: TextInputEditText? = null + + protected var destroyed = false + protected var viewTypeValueNow: String? = null + protected var viewTypes: Array = emptyArray() + protected var viewTypeValues: Array = emptyArray() + protected var cardStyleValueNow: String? = null + protected var cardStyles: Array = emptyArray() + protected var cardStyleValues: Array = emptyArray() + protected var cardAlpha = 0 + protected var hideSubtitle = false + protected var subtitleDataValueNow: String? = null + protected var subtitleData: Array = emptyArray() + protected var subtitleDataValues: Array = emptyArray() + protected var textColorValueNow: String? = null + protected var textColors: Array = emptyArray() + protected var textColorValues: Array = emptyArray() + protected var textSize = 0 + protected var clockFontValueNow: String? = null + protected var clockFonts: Array = emptyArray() + protected var clockFontValues: Array = emptyArray() + protected var hideAlternateCalendar = false + protected var alignEnd = false + private var mLastBackPressedTime: Long = -1 + + // Workaround to properly resize layout and keep text input field visible when IME is open + // For more information, see https://issuetracker.google.com/issues/36911528#comment100 + private class KeyboardResizeBugWorkaround private constructor(activity: Activity) { + + private val mChildOfContent: View + private var usableHeightPrevious = 0 + private val frameLayoutParams: FrameLayout.LayoutParams + + init { + val content = activity.findViewById(android.R.id.content) as FrameLayout + mChildOfContent = content.getChildAt(0) + mChildOfContent.viewTreeObserver.addOnGlobalLayoutListener { possiblyResizeChildOfContent() } + frameLayoutParams = mChildOfContent.layoutParams as FrameLayout.LayoutParams + } + + private fun possiblyResizeChildOfContent() { + val usableHeightNow = computeUsableHeight() + if (usableHeightNow != usableHeightPrevious) { + val usableHeightSansKeyboard = mChildOfContent.rootView.height + val heightDifference = usableHeightSansKeyboard - usableHeightNow + if (heightDifference > usableHeightSansKeyboard / 4) { + // keyboard probably just became visible + frameLayoutParams.height = usableHeightSansKeyboard - heightDifference + } else { + // keyboard probably just became hidden + frameLayoutParams.height = + usableHeightSansKeyboard - getNavigationBarHeight(mChildOfContent.context) + } + mChildOfContent.requestLayout() + usableHeightPrevious = usableHeightNow + } + } + + private fun computeUsableHeight(): Int { + val r = Rect() + mChildOfContent.getWindowVisibleDisplayFrame(r) + return r.bottom + } + + private fun getNavigationBarHeight(context: Context): Int { + val hasMenuKey = ViewConfiguration.get(context).hasPermanentMenuKey() + if (!hasMenuKey) { + // This device has a navigation bar + val resourceId = context.resources.getIdentifier("navigation_bar_height", "dimen", "android") + return if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0 + } + return 0 + } + + companion object { + // To use this class, simply invoke assistActivity() on an Activity that already has its content view set. + fun assistActivity(activity: Activity) { + KeyboardResizeBugWorkaround(activity) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_widget_config) + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + lifecycleScope.launchUI { + initLocations() + initData() + readConfig() + initView() + updateHostView() + } + KeyboardResizeBugWorkaround.assistActivity(this) + } + + private val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if ( + (System.currentTimeMillis() - mLastBackPressedTime) < + (Snackbar.ANIMATION_DURATION + Snackbar.ANIMATION_FADE_DURATION + SnackbarManager.LONG_DURATION_MS) + ) { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } else { + mLastBackPressedTime = System.currentTimeMillis() + SnackbarHelper.showSnackbar(getString(R.string.message_tap_again_to_exit)) + } + } + } + + override fun onCreateView( + parent: View?, + name: String, + context: Context, + attrs: AttributeSet, + ): View? { + return if (name == "ImageView") { + ImageView(context, attrs) + } else { + super.onCreateView(parent, name, context, attrs) + } + } + + override fun onCreateView( + name: String, + context: Context, + attrs: AttributeSet, + ): View? { + return if (name == "ImageView") { + ImageView(context, attrs) + } else { + super.onCreateView(name, context, attrs) + } + } + + override fun onDestroy() { + super.onDestroy() + destroyed = true + } + + @SuppressLint("MissingSuperCall") + override fun onSaveInstanceState(outState: Bundle) { + // do nothing. + } + + abstract suspend fun initLocations() + + @CallSuper + open fun initData() { + val res = resources + viewTypeValueNow = "rectangle" + viewTypes = res.getStringArray(R.array.widget_styles) + viewTypeValues = res.getStringArray(R.array.widget_style_values) + cardStyleValueNow = "none" + cardStyles = res.getStringArray(R.array.widget_card_styles) + cardStyleValues = res.getStringArray(R.array.widget_card_style_values) + cardAlpha = 100 + hideSubtitle = false + subtitleDataValueNow = "time" + val data = res.getStringArray(R.array.widget_subtitle_data) + val dataValues = res.getStringArray(R.array.widget_subtitle_data_values) + if (CalendarHelper.getAlternateCalendarSetting(this) != null) { + subtitleData = arrayOf(data[0], data[1], data[2], data[3], data[4], data[5]) + subtitleDataValues = arrayOf( + dataValues[0], + dataValues[1], + dataValues[2], + dataValues[3], + dataValues[4], + dataValues[5] + ) + } else { + subtitleData = arrayOf(data[0], data[1], data[2], data[3], data[5]) + subtitleDataValues = arrayOf(dataValues[0], dataValues[1], dataValues[2], dataValues[3], dataValues[5]) + } + textColorValueNow = "light" + textColors = res.getStringArray(R.array.widget_text_colors) + textColorValues = res.getStringArray(R.array.widget_text_color_values) + textSize = 100 + clockFontValueNow = "light" + clockFonts = res.getStringArray(R.array.widget_clock_fonts) + clockFontValues = res.getStringArray(R.array.widget_clock_font_values) + hideAlternateCalendar = false + alignEnd = false + } + + private fun readConfig() { + val config = ConfigStore(this, configStoreName!!) + viewTypeValueNow = config.getString(getString(R.string.key_view_type), viewTypeValueNow) + cardStyleValueNow = config.getString(getString(R.string.key_card_style), cardStyleValueNow) + cardAlpha = config.getInt(getString(R.string.key_card_alpha), cardAlpha) + hideSubtitle = config.getBoolean(getString(R.string.key_hide_subtitle), hideSubtitle) + subtitleDataValueNow = config.getString(getString(R.string.key_subtitle_data), subtitleDataValueNow) + textColorValueNow = config.getString(getString(R.string.key_text_color), textColorValueNow) + textSize = config.getInt(getString(R.string.key_text_size), textSize) + clockFontValueNow = config.getString(getString(R.string.key_clock_font), clockFontValueNow) + hideAlternateCalendar = config.getBoolean( + getString(R.string.key_hide_alternate_calendar), + hideAlternateCalendar + ) + alignEnd = config.getBoolean(getString(R.string.key_align_end), alignEnd) + } + + @SuppressLint("UseSwitchCompatOrMaterialCode") + @CallSuper + open fun initView() { + mWallpaper = findViewById(R.id.activity_widget_config_wall) + bindWallpaper(true) + mWidgetContainer = findViewById(R.id.activity_widget_config_widgetContainer) + val screenWidth = resources.displayMetrics.widthPixels + val adaptiveWidth = this.getTabletListAdaptiveWidth(screenWidth) + val paddingHorizontal = (screenWidth - adaptiveWidth) / 2 + mTopContainer = findViewById(R.id.activity_widget_config_top).apply { + mWidgetContainer!!.doOnApplyWindowInsets { view, insets -> + view.updatePadding( + top = insets.top, + left = insets.left + paddingHorizontal, + right = insets.right + paddingHorizontal + ) + } + } + mScrollView = findViewById(R.id.activity_widget_config_scrollView).also { + it.doOnApplyWindowInsets { view, insets -> + view.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom + ) + } + } + + mViewTypeContainer = findViewById(R.id.activity_widget_config_viewStyleContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_styleSpinner).also { + it.onItemSelectedListener = ViewTypeSpinnerSelectedListener() + it.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, viewTypes) + it.setSelection(indexValue(viewTypeValues, viewTypeValueNow), true) + } + + mCardStyleContainer = findViewById(R.id.activity_widget_config_showCardContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_showCardSpinner).also { + it.onItemSelectedListener = CardStyleSpinnerSelectedListener() + it.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, cardStyles) + it.setSelection(indexValue(cardStyleValues, cardStyleValueNow), true) + } + + mCardAlphaContainer = findViewById(R.id.activity_widget_config_cardAlphaContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_cardAlphaSlider).apply { + valueFrom = 0f + stepSize = 10f + valueTo = 100f + value = ((cardAlpha.toDouble() / 10.0).roundToInt() * 10.0).toFloat() + setLabelFormatter { value: Float -> + value.toDouble().percent.formatPercent(context, UnitWidth.NARROW) + } + addOnChangeListener { _, value, _ -> + if (cardAlpha != value.roundToInt()) { + cardAlpha = value.roundToInt() + updateHostView() + } + } + } + + mHideSubtitleTitle = findViewById(R.id.activity_widget_config_hideSubtitleTitle).apply { + text = getString(R.string.widget_label_hide_subtitle) + } + mHideSubtitleContainer = findViewById(R.id.activity_widget_config_hideSubtitleContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_hideSubtitleSwitch).apply { + setOnCheckedChangeListener(HideSubtitleSwitchCheckListener()) + isChecked = hideSubtitle + } + + mSubtitleDataContainer = findViewById(R.id.activity_widget_config_subtitleDataContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_subtitleDataSpinner).also { + it.onItemSelectedListener = SubtitleDataSpinnerSelectedListener() + it.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, subtitleData) + it.setSelection( + indexValue(subtitleDataValues, if (isCustomSubtitle) "custom" else subtitleDataValueNow), + true + ) + } + + mTextColorContainer = findViewById(R.id.activity_widget_config_blackTextContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_blackTextSpinner).also { + it.onItemSelectedListener = TextColorSpinnerSelectedListener() + it.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, textColors) + it.setSelection(indexValue(textColorValues, textColorValueNow), true) + } + + mTextSizeContainer = findViewById(R.id.activity_widget_config_textSizeContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_textSizeSlider).apply { + valueFrom = 50f + stepSize = 10f + valueTo = 250f + value = max( + 50f, + min( + 250f, + ((((textSize - 50).toDouble() / 10.0).roundToInt() * 10.0) + 50.0).toFloat() + ) + ) + addOnChangeListener { _, value, _ -> + if (textSize != value.roundToInt()) { + textSize = value.roundToInt() + updateHostView() + } + } + setLabelFormatter { value: Float -> + value.toDouble().percent.formatPercent(context, UnitWidth.NARROW) + } + } + + mClockFontContainer = findViewById(R.id.activity_widget_config_clockFontContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_clockFontSpinner).also { + it.onItemSelectedListener = ClockFontSpinnerSelectedListener() + it.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, clockFonts) + it.setSelection(indexValue(clockFontValues, clockFontValueNow), true) + } + + mHideAlternateCalendarContainer = + findViewById(R.id.activity_widget_config_hideAlternateCalendarContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_hideAlternateCalendarSwitch).apply { + setOnCheckedChangeListener(HideAlternateCalendarSwitchCheckListener()) + isChecked = hideAlternateCalendar + } + + mAlignEndContainer = findViewById(R.id.activity_widget_config_alignEndContainer).apply { + visibility = View.GONE + } + findViewById(R.id.activity_widget_config_alignEndSwitch).apply { + setOnCheckedChangeListener(AlignEndSwitchCheckListener()) + isChecked = alignEnd + } + + val doneButton = findViewById